diff --git a/Editor/Core/MkpsxisoDownloader.cs b/Editor/Core/MkpsxisoDownloader.cs
new file mode 100644
index 0000000..a13878b
--- /dev/null
+++ b/Editor/Core/MkpsxisoDownloader.cs
@@ -0,0 +1,230 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Net.Http;
+using System.Threading.Tasks;
+using UnityEditor;
+using UnityEngine;
+using Debug = UnityEngine.Debug;
+
+namespace SplashEdit.EditorCode
+{
+ ///
+ /// Downloads and manages mkpsxiso — the tool that builds PlayStation CD images
+ /// from an XML catalog. Used for the ISO build target.
+ /// https://github.com/Lameguy64/mkpsxiso
+ ///
+ public static class MkpsxisoDownloader
+ {
+ private const string MKPSXISO_VERSION = "2.20";
+ private const string MKPSXISO_RELEASE_BASE =
+ "https://github.com/Lameguy64/mkpsxiso/releases/download/v" + MKPSXISO_VERSION + "/";
+
+ private static readonly HttpClient _http = new HttpClient();
+
+ ///
+ /// Install directory for mkpsxiso inside .tools/
+ ///
+ public static string MkpsxisoDir =>
+ Path.Combine(SplashBuildPaths.ToolsDir, "mkpsxiso");
+
+ ///
+ /// Path to the mkpsxiso binary.
+ ///
+ public static string MkpsxisoBinary
+ {
+ get
+ {
+ if (Application.platform == RuntimePlatform.WindowsEditor)
+ return Path.Combine(MkpsxisoDir, "mkpsxiso.exe");
+ return Path.Combine(MkpsxisoDir, "mkpsxiso");
+ }
+ }
+
+ ///
+ /// Returns true if mkpsxiso is installed and ready to use.
+ ///
+ public static bool IsInstalled() => File.Exists(MkpsxisoBinary);
+
+ ///
+ /// Downloads and installs mkpsxiso from the official GitHub releases.
+ ///
+ public static async Task DownloadAndInstall(Action log = null)
+ {
+ string archiveName;
+ switch (Application.platform)
+ {
+ case RuntimePlatform.WindowsEditor:
+ archiveName = $"mkpsxiso-{MKPSXISO_VERSION}-win64.zip";
+ break;
+ case RuntimePlatform.LinuxEditor:
+ archiveName = $"mkpsxiso-{MKPSXISO_VERSION}-Linux.zip";
+ break;
+ case RuntimePlatform.OSXEditor:
+ archiveName = $"mkpsxiso-{MKPSXISO_VERSION}-Darwin.zip";
+ break;
+ default:
+ log?.Invoke("Unsupported platform for mkpsxiso.");
+ return false;
+ }
+
+ string downloadUrl = $"{MKPSXISO_RELEASE_BASE}{archiveName}";
+ log?.Invoke($"Downloading mkpsxiso: {downloadUrl}");
+
+ try
+ {
+ string tempFile = Path.Combine(Path.GetTempPath(), archiveName);
+ EditorUtility.DisplayProgressBar("Downloading mkpsxiso", "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 mkpsxiso",
+ $"{downloaded / 1024}/{totalBytes.Value / 1024} KB", progress);
+ }
+ }
+ }
+ }
+
+ log?.Invoke("Extracting...");
+ EditorUtility.DisplayProgressBar("Installing mkpsxiso", "Extracting...", 0.9f);
+
+ string installDir = MkpsxisoDir;
+ if (Directory.Exists(installDir))
+ Directory.Delete(installDir, true);
+ Directory.CreateDirectory(installDir);
+
+ System.IO.Compression.ZipFile.ExtractToDirectory(tempFile, installDir);
+
+ // Fix nested directory (archives often have one extra level)
+ FixNestedDirectory(installDir);
+
+ try { File.Delete(tempFile); } catch { }
+
+ EditorUtility.ClearProgressBar();
+
+ if (IsInstalled())
+ {
+ // Make executable on Linux/Mac
+ if (Application.platform != RuntimePlatform.WindowsEditor)
+ {
+ var chmod = Process.Start("chmod", $"+x \"{MkpsxisoBinary}\"");
+ chmod?.WaitForExit();
+ }
+ log?.Invoke("mkpsxiso installed successfully!");
+ return true;
+ }
+
+ log?.Invoke($"mkpsxiso binary not found at: {MkpsxisoBinary}");
+ return false;
+ }
+ catch (Exception ex)
+ {
+ log?.Invoke($"mkpsxiso 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 { }
+ }
+ }
+
+ ///
+ /// Runs mkpsxiso with the given XML catalog to produce a BIN/CUE image.
+ ///
+ /// Path to the mkpsxiso XML catalog.
+ /// Override output .bin path (optional, uses XML default if null).
+ /// Override output .cue path (optional, uses XML default if null).
+ /// Logging callback.
+ /// True if mkpsxiso succeeded.
+ public static bool BuildISO(string xmlPath, string outputBin = null,
+ string outputCue = null, Action log = null)
+ {
+ if (!IsInstalled())
+ {
+ log?.Invoke("mkpsxiso is not installed.");
+ return false;
+ }
+
+ // Build arguments
+ string args = $"-y \"{xmlPath}\"";
+ if (!string.IsNullOrEmpty(outputBin))
+ args += $" -o \"{outputBin}\"";
+ if (!string.IsNullOrEmpty(outputCue))
+ args += $" -c \"{outputCue}\"";
+
+ log?.Invoke($"Running: mkpsxiso {args}");
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = MkpsxisoBinary,
+ Arguments = args,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ };
+
+ try
+ {
+ var process = Process.Start(psi);
+ string stdout = process.StandardOutput.ReadToEnd();
+ string stderr = process.StandardError.ReadToEnd();
+ process.WaitForExit();
+
+ if (!string.IsNullOrEmpty(stdout))
+ log?.Invoke(stdout.Trim());
+
+ if (process.ExitCode != 0)
+ {
+ if (!string.IsNullOrEmpty(stderr))
+ log?.Invoke($"mkpsxiso error: {stderr.Trim()}");
+ log?.Invoke($"mkpsxiso exited with code {process.ExitCode}");
+ return false;
+ }
+
+ log?.Invoke("ISO image built successfully.");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ log?.Invoke($"mkpsxiso execution failed: {ex.Message}");
+ return false;
+ }
+ }
+ }
+}
diff --git a/Editor/Core/MkpsxisoDownloader.cs.meta b/Editor/Core/MkpsxisoDownloader.cs.meta
new file mode 100644
index 0000000..6e65dc5
--- /dev/null
+++ b/Editor/Core/MkpsxisoDownloader.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 45aea686b641c474dba05b83956d8947
\ No newline at end of file
diff --git a/Editor/Core/PSXAudioConverter.cs b/Editor/Core/PSXAudioConverter.cs
index bd7a340..f47e6ac 100644
--- a/Editor/Core/PSXAudioConverter.cs
+++ b/Editor/Core/PSXAudioConverter.cs
@@ -52,17 +52,14 @@ namespace SplashEdit.EditorCode
///
public static async Task DownloadAndInstall(Action log = null)
{
- string platformSuffix;
string archiveName;
switch (Application.platform)
{
case RuntimePlatform.WindowsEditor:
- platformSuffix = "x86_64-pc-windows-msvc";
- archiveName = $"psxavenc-{PSXAVENC_VERSION}-{platformSuffix}.zip";
+ archiveName = $"psxavenc-windows.zip";
break;
case RuntimePlatform.LinuxEditor:
- platformSuffix = "x86_64-unknown-linux-gnu";
- archiveName = $"psxavenc-{PSXAVENC_VERSION}-{platformSuffix}.tar.gz";
+ archiveName = $"psxavenc-linux.zip";
break;
default:
log?.Invoke("Only Windows and Linux are supported.");
diff --git a/Editor/Core/SplashBuildPaths.cs b/Editor/Core/SplashBuildPaths.cs
index 7ec302f..bf3bc15 100644
--- a/Editor/Core/SplashBuildPaths.cs
+++ b/Editor/Core/SplashBuildPaths.cs
@@ -130,6 +130,24 @@ namespace SplashEdit.EditorCode
public static string CUEOutputPath =>
Path.Combine(BuildOutputDir, "psxsplash.cue");
+ ///
+ /// XML catalog path used by mkpsxiso to build the ISO image.
+ ///
+ public static string ISOCatalogPath =>
+ Path.Combine(BuildOutputDir, "psxsplash.xml");
+
+ ///
+ /// SYSTEM.CNF file path generated for the ISO image.
+ /// The PS1 BIOS reads this to find and launch the executable.
+ ///
+ public static string SystemCnfPath =>
+ Path.Combine(BuildOutputDir, "SYSTEM.CNF");
+
+ ///
+ /// Checks if mkpsxiso is installed in the tools directory.
+ ///
+ public static bool IsMkpsxisoInstalled() => MkpsxisoDownloader.IsInstalled();
+
///
/// Ensures the build output and tools directories exist.
/// Also appends entries to the project .gitignore if not present.
diff --git a/Editor/Core/SplashControlPanel.cs b/Editor/Core/SplashControlPanel.cs
index 7b8c31f..5e0a6b9 100644
--- a/Editor/Core/SplashControlPanel.cs
+++ b/Editor/Core/SplashControlPanel.cs
@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.IO;
using System.IO.Ports;
using System.Linq;
+using System.Threading.Tasks;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
@@ -46,6 +47,7 @@ namespace SplashEdit.EditorCode
private bool _hasRedux;
private bool _hasNativeProject;
private bool _hasPsxavenc;
+ private bool _hasMkpsxiso;
private string _reduxVersion = "";
// ───── Native project installer ─────
@@ -486,6 +488,22 @@ namespace SplashEdit.EditorCode
}
EditorGUILayout.EndHorizontal();
+ // mkpsxiso (ISO builder)
+ EditorGUILayout.BeginHorizontal();
+ DrawStatusIcon(_hasMkpsxiso);
+ GUILayout.Label("mkpsxiso (ISO)", GUILayout.Width(160));
+ GUILayout.FlexibleSpace();
+ if (!_hasMkpsxiso)
+ {
+ if (GUILayout.Button("Download", GUILayout.Width(80)))
+ DownloadMkpsxiso();
+ }
+ else
+ {
+ GUILayout.Label("Installed", EditorStyles.miniLabel);
+ }
+ EditorGUILayout.EndHorizontal();
+
// Refresh button
EditorGUILayout.Space(2);
EditorGUILayout.BeginHorizontal();
@@ -653,14 +671,11 @@ namespace SplashEdit.EditorCode
EditorGUILayout.Space(4);
- // GTE Scaling
EditorGUILayout.LabelField("Export Settings", EditorStyles.boldLabel);
SplashSettings.DefaultGTEScaling = EditorGUILayout.FloatField("Default GTE Scaling", SplashSettings.DefaultGTEScaling);
- SplashSettings.AutoValidateOnExport = EditorGUILayout.Toggle("Auto-Validate on Export", SplashSettings.AutoValidateOnExport);
EditorGUILayout.Space(6);
- // Open dedicated VRAM windows
EditorGUILayout.LabelField("Advanced Tools", EditorStyles.boldLabel);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Open VRAM Editor", GUILayout.Height(24)))
@@ -673,11 +688,6 @@ namespace SplashEdit.EditorCode
}
EditorGUILayout.EndHorizontal();
- if (GUILayout.Button("Open Scene Validator", EditorStyles.miniButton))
- {
- PSXSceneValidatorWindow.ShowWindow();
- }
-
EditorGUILayout.EndVertical();
}
@@ -711,6 +721,34 @@ namespace SplashEdit.EditorCode
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();
+ }
+
EditorGUILayout.Space(8);
// Big Build & Run button
@@ -759,7 +797,7 @@ namespace SplashEdit.EditorCode
}
if (GUILayout.Button("Compile Only", EditorStyles.miniButton, GUILayout.Width(100)))
{
- CompileNative();
+ CompileOnly();
}
GUILayout.FlexibleSpace();
EditorGUILayout.EndHorizontal();
@@ -774,12 +812,11 @@ namespace SplashEdit.EditorCode
///
/// The main pipeline: Validate → Export all scenes → Compile → Launch.
///
- public void BuildAndRun()
+ public async void BuildAndRun()
{
if (_isBuilding) return;
_isBuilding = true;
- // Open the PSX Console so build output is visible immediately
var console = EditorWindow.GetWindow();
console.titleContent = new GUIContent("PSX Console", EditorGUIUtility.IconContent("d_UnityEditor.ConsoleWindow").image);
console.minSize = new Vector2(400, 200);
@@ -787,7 +824,6 @@ namespace SplashEdit.EditorCode
try
{
- // Step 1: Validate
Log("Validating toolchain...", LogType.Log);
if (!ValidateToolchain())
{
@@ -796,7 +832,6 @@ namespace SplashEdit.EditorCode
}
Log("Toolchain OK.", LogType.Log);
- // Step 2: Export all scenes
Log("Exporting scenes...", LogType.Log);
if (!ExportAllScenes())
{
@@ -805,16 +840,15 @@ namespace SplashEdit.EditorCode
}
Log($"Exported {_sceneList.Count} scene(s).", LogType.Log);
- // Step 3: Compile native
Log("Compiling native code...", LogType.Log);
- if (!CompileNative())
+ 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);
- // Step 4: Launch
Log("Launching...", LogType.Log);
Launch();
}
@@ -852,6 +886,11 @@ namespace SplashEdit.EditorCode
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))
@@ -902,7 +941,7 @@ namespace SplashEdit.EditorCode
EditorSceneManager.OpenScene(scene.path, OpenSceneMode.Single);
// Find the exporter
- var exporter = UnityEngine.Object.FindObjectOfType();
+ var exporter = UnityEngine.Object.FindFirstObjectByType();
if (exporter == null)
{
Log($"Scene '{scene.name}' has no PSXSceneExporter. Skipping.", LogType.Warning);
@@ -1051,10 +1090,29 @@ namespace SplashEdit.EditorCode
// ───── Step 3: Compile ─────
- ///
- /// Runs make in the native project directory.
- ///
- public bool CompileNative()
+ 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 CompileNativeAsync()
{
string nativeDir = SplashBuildPaths.NativeSourceDir;
if (string.IsNullOrEmpty(nativeDir))
@@ -1064,9 +1122,11 @@ namespace SplashEdit.EditorCode
}
string buildArg = SplashSettings.Mode == BuildMode.Debug ? "BUILD=Debug" : "";
- // Run clean first, THEN build — "make clean all -jN" races clean vs build in sub-makes
- string makeCmd = $"make clean && make all -j{SystemInfo.processorCount} {buildArg}".Trim();
+ if (SplashSettings.Target == BuildTarget.ISO)
+ buildArg += " LOADER=cdrom";
+
+ string makeCmd = $"make clean && make all -j{SystemInfo.processorCount} {buildArg}".Trim();
Log($"Running: {makeCmd}", LogType.Log);
var psi = new ProcessStartInfo
@@ -1084,45 +1144,57 @@ namespace SplashEdit.EditorCode
try
{
- var process = Process.Start(psi);
- string stdout = process.StandardOutput.ReadToEnd();
- string stderr = process.StandardError.ReadToEnd();
- process.WaitForExit();
+ var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
+ var stdoutBuf = new System.Text.StringBuilder();
+ var stderrBuf = new System.Text.StringBuilder();
- // Log output to panel only (no Unity console spam)
- if (!string.IsNullOrEmpty(stdout))
+ process.OutputDataReceived += (s, e) =>
{
- foreach (string line in stdout.Split('\n'))
- {
- if (!string.IsNullOrWhiteSpace(line))
- LogToPanel(line.Trim(), LogType.Log);
- }
+ if (e.Data != null) stdoutBuf.AppendLine(e.Data);
+ };
+ process.ErrorDataReceived += (s, e) =>
+ {
+ if (e.Data != null) stderrBuf.AppendLine(e.Data);
+ };
+
+ var tcs = new TaskCompletionSource();
+ 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 (process.ExitCode != 0)
+ if (exitCode != 0)
{
- if (!string.IsNullOrEmpty(stderr))
+ foreach (string line in stderr.Split('\n'))
{
- foreach (string line in stderr.Split('\n'))
- {
- if (!string.IsNullOrWhiteSpace(line))
- LogToPanel(line.Trim(), LogType.Error);
- }
+ if (!string.IsNullOrWhiteSpace(line))
+ LogToPanel(line.Trim(), LogType.Error);
}
- Log($"Make exited with code {process.ExitCode}", LogType.Error);
+ Log($"Make exited with code {exitCode}", LogType.Error);
- // Write build log file
File.WriteAllText(SplashBuildPaths.BuildLogPath,
$"=== STDOUT ===\n{stdout}\n=== STDERR ===\n{stderr}");
return false;
}
- // Copy the compiled exe to PSXBuild/
string exeSource = FindCompiledExe(nativeDir);
if (!string.IsNullOrEmpty(exeSource))
{
File.Copy(exeSource, SplashBuildPaths.CompiledExePath, true);
- Log($"Copied .ps-exe to PSXBuild/", LogType.Log);
+ Log("Copied .ps-exe to PSXBuild/", LogType.Log);
}
else
{
@@ -1172,7 +1244,7 @@ namespace SplashEdit.EditorCode
LaunchToHardware();
break;
case BuildTarget.ISO:
- Log("ISO build not yet implemented.", LogType.Warning);
+ BuildAndLaunchISO();
break;
}
}
@@ -1274,6 +1346,235 @@ namespace SplashEdit.EditorCode
}
}
+ // ───── 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);
+ }
+ }
+
+ ///
+ /// Derive the executable name on disc from the volume label.
+ /// Uppercase, no extension, trimmed to 12 characters (ISO9660 limit).
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// Generates the mkpsxiso XML catalog describing the ISO filesystem layout.
+ /// Includes SYSTEM.CNF, the executable, all splashpacks, loading packs, and manifest.
+ ///
+ 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.AppendLine("");
+ xml.AppendLine(" ");
+ xml.AppendLine("");
+
+ 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("&", "&").Replace("<", "<")
+ .Replace(">", ">").Replace("\"", """);
+ }
+
private void StopPCdrvHost()
{
if (_pcdrvHost != null)
@@ -1339,6 +1640,8 @@ namespace SplashEdit.EditorCode
_hasPsxavenc = PSXAudioConverter.IsInstalled();
+ _hasMkpsxiso = MkpsxisoDownloader.IsInstalled();
+
string nativeDir = SplashBuildPaths.NativeSourceDir;
_hasNativeProject = !string.IsNullOrEmpty(nativeDir) && Directory.Exists(nativeDir);
}
@@ -1418,6 +1721,22 @@ namespace SplashEdit.EditorCode
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
diff --git a/Editor/Core/SplashSettings.cs b/Editor/Core/SplashSettings.cs
index 374eaf2..e7974db 100644
--- a/Editor/Core/SplashSettings.cs
+++ b/Editor/Core/SplashSettings.cs
@@ -124,12 +124,6 @@ namespace SplashEdit.EditorCode
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
{
@@ -137,6 +131,27 @@ namespace SplashEdit.EditorCode
set => EditorPrefs.SetBool(Prefix + "InterceptPlayMode", value);
}
+ // --- ISO Build ---
+ ///
+ /// Optional path to a Sony license file (.dat) for the ISO image.
+ /// If empty, the ISO will be built without license data (homebrew-only).
+ /// The file must be in raw 2336-byte sector format (from PsyQ SDK LCNSFILE).
+ ///
+ public static string LicenseFilePath
+ {
+ get => EditorPrefs.GetString(Prefix + "LicenseFilePath", "");
+ set => EditorPrefs.SetString(Prefix + "LicenseFilePath", value);
+ }
+
+ ///
+ /// Volume label for the ISO image (up to 31 characters, uppercase).
+ ///
+ public static string ISOVolumeLabel
+ {
+ get => EditorPrefs.GetString(Prefix + "ISOVolumeLabel", "PSXSPLASH");
+ set => EditorPrefs.SetString(Prefix + "ISOVolumeLabel", value);
+ }
+
///
/// Resets all settings to defaults by deleting all prefixed keys.
///
@@ -147,7 +162,8 @@ namespace SplashEdit.EditorCode
"Target", "Mode", "NativeProjectPath", "MIPSToolchainPath",
"PCSXReduxPath", "PCSXReduxPCdrvBase", "SerialPort", "SerialBaudRate",
"ResWidth", "ResHeight", "DualBuffering", "VerticalLayout",
- "GTEScaling", "AutoValidate", "InterceptPlayMode"
+ "GTEScaling", "AutoValidate", "InterceptPlayMode",
+ "LicenseFilePath", "ISOVolumeLabel"
};
foreach (string key in keys)
diff --git a/Editor/PSXMenuItems.cs b/Editor/PSXMenuItems.cs
index 70c6122..e7c5305 100644
--- a/Editor/PSXMenuItems.cs
+++ b/Editor/PSXMenuItems.cs
@@ -26,7 +26,7 @@ namespace SplashEdit.EditorCode
[MenuItem("GameObject/PlayStation 1/Scene Exporter", false, 10)]
public static void CreateSceneExporter(MenuCommand menuCommand)
{
- var existing = Object.FindObjectOfType();
+ var existing = Object.FindFirstObjectByType();
if (existing != null)
{
EditorUtility.DisplayDialog(
diff --git a/Editor/PSXObjectExporterEditor.cs b/Editor/PSXObjectExporterEditor.cs
index 0dafbda..ba3797f 100644
--- a/Editor/PSXObjectExporterEditor.cs
+++ b/Editor/PSXObjectExporterEditor.cs
@@ -2,512 +2,233 @@ using UnityEngine;
using UnityEditor;
using SplashEdit.RuntimeCode;
using System.Linq;
-using System.Collections.Generic;
namespace SplashEdit.EditorCode
{
- ///
- /// Custom inspector for PSXObjectExporter with enhanced UX.
- /// Shows mesh info, texture preview, collision visualization, and validation.
- ///
[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 staticColliderProp;
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 SerializedProperty generateNavigationProp;
+
private MeshFilter meshFilter;
private MeshRenderer meshRenderer;
private int triangleCount;
private int vertexCount;
- private Bounds meshBounds;
- private List validationErrors = new List();
- private List validationWarnings = new List();
-
- // Styles
- private GUIStyle headerStyle;
- private GUIStyle errorStyle;
- private GUIStyle warningStyle;
-
- // Validation
- private bool _validationDirty = true;
-
+
+ private bool showExport = true;
+ private bool showCollision = 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");
+ staticColliderProp = serializedObject.FindProperty("staticCollider");
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
+ generateNavigationProp = serializedObject.FindProperty("generateNavigation");
+
CacheMeshInfo();
-
- // Defer validation to first inspector draw
- _validationDirty = true;
}
-
+
private void CacheMeshInfo()
{
var exporter = target as PSXObjectExporter;
if (exporter == null) return;
-
meshFilter = exporter.GetComponent();
meshRenderer = exporter.GetComponent();
-
if (meshFilter != null && meshFilter.sharedMesh != null)
{
- var mesh = meshFilter.sharedMesh;
- triangleCount = mesh.triangles.Length / 3;
- vertexCount = mesh.vertexCount;
- meshBounds = mesh.bounds;
+ triangleCount = meshFilter.sharedMesh.triangles.Length / 3;
+ vertexCount = meshFilter.sharedMesh.vertexCount;
}
}
-
- 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"));
-
+
+ DrawHeader();
+ EditorGUILayout.Space(4);
+
if (!isActiveProp.boolValue)
{
- EditorGUILayout.HelpBox("This object will be skipped during export.", MessageType.Info);
+ EditorGUILayout.LabelField("Object will be skipped during export.", PSXEditorStyles.InfoBox);
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;
- }
+
+ DrawMeshSummary();
+ PSXEditorStyles.DrawSeparator(6, 6);
+ DrawExportSection();
+ PSXEditorStyles.DrawSeparator(6, 6);
+ DrawCollisionSection();
+ PSXEditorStyles.DrawSeparator(6, 6);
+ DrawActions();
+
+ serializedObject.ApplyModifiedProperties();
}
-
- private void InitStyles()
+
+ private new void DrawHeader()
{
- 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);
- }
+ EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle);
+
+ EditorGUILayout.BeginHorizontal();
+ EditorGUILayout.PropertyField(isActiveProp, GUIContent.none, GUILayout.Width(18));
+ var exporter = target as PSXObjectExporter;
+ EditorGUILayout.LabelField(exporter.gameObject.name, PSXEditorStyles.CardHeaderStyle);
+ EditorGUILayout.EndHorizontal();
+
+ EditorGUILayout.EndVertical();
}
-
- private void DrawMeshInfoSection()
+
+ private void DrawMeshSummary()
{
- showMeshInfo = EditorGUILayout.BeginFoldoutHeaderGroup(showMeshInfo, "Mesh Information");
- if (showMeshInfo)
+ if (meshFilter == null || meshFilter.sharedMesh == null)
{
- 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)
+ EditorGUILayout.HelpBox("No mesh on this object.", MessageType.Warning);
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();
+
+ EditorGUILayout.BeginHorizontal();
+ EditorGUILayout.LabelField($"{triangleCount} tris", PSXEditorStyles.RichLabel, GUILayout.Width(60));
+ EditorGUILayout.LabelField($"{vertexCount} verts", PSXEditorStyles.RichLabel, GUILayout.Width(70));
+
+ int subMeshCount = meshFilter.sharedMesh.subMeshCount;
+ if (subMeshCount > 1)
+ EditorGUILayout.LabelField($"{subMeshCount} submeshes", PSXEditorStyles.RichLabel, GUILayout.Width(90));
+
+ int matCount = meshRenderer != null ? meshRenderer.sharedMaterials.Length : 0;
+ int textured = meshRenderer != null
+ ? meshRenderer.sharedMaterials.Count(m => m != null && m.mainTexture != null)
+ : 0;
+ if (textured > 0)
+ EditorGUILayout.LabelField($"{textured}/{matCount} textured", PSXEditorStyles.RichLabel);
+ else
+ EditorGUILayout.LabelField("untextured", PSXEditorStyles.RichLabel);
+
+ EditorGUILayout.EndHorizontal();
}
-
- private void DrawActionButtons()
+
+ private void DrawExportSection()
{
- EditorGUILayout.Space(10);
-
- using (new EditorGUILayout.HorizontalScope())
+ showExport = EditorGUILayout.Foldout(showExport, "Export", true, PSXEditorStyles.FoldoutHeader);
+ if (!showExport) return;
+
+ EditorGUI.indentLevel++;
+
+ EditorGUILayout.PropertyField(bitDepthProp, new GUIContent("Bit Depth"));
+ EditorGUILayout.PropertyField(luaFileProp, new GUIContent("Lua Script"));
+
+ if (luaFileProp.objectReferenceValue != null)
{
- if (GUILayout.Button("Select Scene Exporter"))
- {
- var exporter = FindObjectOfType();
- 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();
- }
+ EditorGUILayout.BeginHorizontal();
+ GUILayout.Space(EditorGUI.indentLevel * 15);
+ if (GUILayout.Button("Edit", EditorStyles.miniButtonLeft, GUILayout.Width(50)))
+ AssetDatabase.OpenAsset(luaFileProp.objectReferenceValue);
+ if (GUILayout.Button("Clear", EditorStyles.miniButtonRight, GUILayout.Width(50)))
+ luaFileProp.objectReferenceValue = null;
+ GUILayout.FlexibleSpace();
+ EditorGUILayout.EndHorizontal();
}
+ else
+ {
+ EditorGUILayout.BeginHorizontal();
+ GUILayout.Space(EditorGUI.indentLevel * 15);
+ if (GUILayout.Button("Create Lua Script", EditorStyles.miniButton, GUILayout.Width(130)))
+ CreateNewLuaScript();
+ GUILayout.FlexibleSpace();
+ EditorGUILayout.EndHorizontal();
+ }
+
+ EditorGUI.indentLevel--;
}
-
+
+ private void DrawCollisionSection()
+ {
+ showCollision = EditorGUILayout.Foldout(showCollision, "Collision", true, PSXEditorStyles.FoldoutHeader);
+ if (!showCollision) return;
+
+ EditorGUI.indentLevel++;
+
+ EditorGUILayout.PropertyField(collisionTypeProp, new GUIContent("Type"));
+
+ var collType = (PSXCollisionType)collisionTypeProp.enumValueIndex;
+ if (collType != PSXCollisionType.None)
+ {
+ EditorGUILayout.PropertyField(staticColliderProp, new GUIContent("Static"));
+
+ bool isStatic = staticColliderProp.boolValue;
+ if (isStatic)
+ {
+ EditorGUILayout.LabelField(
+ "Baked into world collision mesh. No runtime cost.",
+ PSXEditorStyles.RichLabel);
+ }
+ else
+ {
+ EditorGUILayout.LabelField(
+ "Runtime AABB collider. Fires Lua collision events.",
+ PSXEditorStyles.RichLabel);
+ }
+
+ EditorGUILayout.Space(2);
+ EditorGUILayout.PropertyField(exportCollisionMeshProp, new GUIContent("Export Collision Mesh"));
+ EditorGUILayout.PropertyField(customCollisionMeshProp, new GUIContent("Custom Mesh"));
+ EditorGUILayout.PropertyField(collisionLayerProp, new GUIContent("Layer"));
+ }
+
+ EditorGUILayout.Space(4);
+ EditorGUILayout.PropertyField(generateNavigationProp, new GUIContent("Generate Navigation"));
+
+ EditorGUI.indentLevel--;
+ }
+
+ private void DrawActions()
+ {
+ EditorGUILayout.BeginHorizontal();
+ if (GUILayout.Button("Select Scene Exporter", EditorStyles.miniButton))
+ {
+ var se = FindFirstObjectByType();
+ if (se != null)
+ Selection.activeGameObject = se.gameObject;
+ else
+ EditorUtility.DisplayDialog("Not Found", "No PSXSceneExporter in scene.", "OK");
+ }
+ EditorGUILayout.EndHorizontal();
+ }
+
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 Lua Script", defaultName + ".lua", "lua",
"Create a new Lua script for this object");
-
- if (!string.IsNullOrEmpty(path))
+
+ if (string.IsNullOrEmpty(path)) return;
+
+ string template =
+ $"function onCreate(self)\nend\n\nfunction onUpdate(self, dt)\nend\n";
+ System.IO.File.WriteAllText(path, template);
+ AssetDatabase.Refresh();
+
+ var luaFile = AssetDatabase.LoadAssetAtPath(path);
+ if (luaFile != null)
{
- 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(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();
- if (target != null && target != source)
- {
- Undo.RecordObject(target, "Copy PSX Settings");
- // Copy via serialized object
- EditorUtility.CopySerialized(source, target);
- }
+ luaFileProp.objectReferenceValue = luaFile;
+ serializedObject.ApplyModifiedProperties();
}
}
}
diff --git a/Editor/PSXSceneExporterEditor.cs b/Editor/PSXSceneExporterEditor.cs
index b408b41..e91b51e 100644
--- a/Editor/PSXSceneExporterEditor.cs
+++ b/Editor/PSXSceneExporterEditor.cs
@@ -1,38 +1,49 @@
using UnityEngine;
using UnityEditor;
using SplashEdit.RuntimeCode;
+using System.Linq;
namespace SplashEdit.EditorCode
{
- ///
- /// 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
- ///
[CustomEditor(typeof(PSXSceneExporter))]
public class PSXSceneExporterEditor : UnityEditor.Editor
{
- // Saved RenderSettings state so we can restore it on deselect.
+ private SerializedProperty gteScalingProp;
+ private SerializedProperty sceneLuaProp;
+ private SerializedProperty fogEnabledProp;
+ private SerializedProperty fogColorProp;
+ private SerializedProperty fogDensityProp;
+ private SerializedProperty sceneTypeProp;
+ private SerializedProperty cutscenesProp;
+ private SerializedProperty loadingScreenProp;
+ private SerializedProperty previewBVHProp;
+ private SerializedProperty bvhDepthProp;
+
private bool _savedFog;
private Color _savedFogColor;
private FogMode _savedFogMode;
private float _savedFogStart;
private float _savedFogEnd;
-
private bool _previewActive = false;
+ private bool showFog = true;
+ private bool showCutscenes = true;
+ private bool showDebug = false;
+
private void OnEnable()
{
+ gteScalingProp = serializedObject.FindProperty("GTEScaling");
+ sceneLuaProp = serializedObject.FindProperty("SceneLuaFile");
+ fogEnabledProp = serializedObject.FindProperty("FogEnabled");
+ fogColorProp = serializedObject.FindProperty("FogColor");
+ fogDensityProp = serializedObject.FindProperty("FogDensity");
+ sceneTypeProp = serializedObject.FindProperty("SceneType");
+ cutscenesProp = serializedObject.FindProperty("Cutscenes");
+ loadingScreenProp = serializedObject.FindProperty("LoadingScreenPrefab");
+ previewBVHProp = serializedObject.FindProperty("PreviewBVH");
+ bvhDepthProp = serializedObject.FindProperty("BVHPreviewDepth");
+
SaveAndApplyFogPreview();
- // Re-apply whenever the scene is repainted (handles inspector value changes).
EditorApplication.update += OnEditorUpdate;
}
@@ -44,11 +55,155 @@ namespace SplashEdit.EditorCode
private void OnEditorUpdate()
{
- // Keep the preview in sync when the user tweaks values in the inspector.
if (_previewActive)
ApplyFogPreview();
}
+ public override void OnInspectorGUI()
+ {
+ serializedObject.Update();
+ var exporter = (PSXSceneExporter)target;
+
+ DrawHeader();
+ EditorGUILayout.Space(4);
+
+ DrawSceneSettings();
+ PSXEditorStyles.DrawSeparator(6, 6);
+ DrawFogSection(exporter);
+ PSXEditorStyles.DrawSeparator(6, 6);
+ DrawCutscenesSection();
+ PSXEditorStyles.DrawSeparator(6, 6);
+ DrawLoadingSection();
+ PSXEditorStyles.DrawSeparator(6, 6);
+ DrawDebugSection();
+ PSXEditorStyles.DrawSeparator(6, 6);
+ DrawSceneStats();
+
+ serializedObject.ApplyModifiedProperties();
+ }
+
+ private void DrawHeader()
+ {
+ EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle);
+ EditorGUILayout.LabelField("Scene Exporter", PSXEditorStyles.CardHeaderStyle);
+ EditorGUILayout.EndVertical();
+ }
+
+ private void DrawSceneSettings()
+ {
+ EditorGUILayout.PropertyField(sceneTypeProp, new GUIContent("Scene Type"));
+
+ bool isInterior = (PSXSceneType)sceneTypeProp.enumValueIndex == PSXSceneType.Interior;
+ EditorGUILayout.LabelField(
+ isInterior
+ ? "Room/portal occlusion culling."
+ : "BVH frustum culling.",
+ PSXEditorStyles.RichLabel);
+
+ EditorGUILayout.Space(4);
+ EditorGUILayout.PropertyField(gteScalingProp, new GUIContent("GTE Scaling"));
+ EditorGUILayout.PropertyField(sceneLuaProp, new GUIContent("Scene Lua"));
+
+ if (sceneLuaProp.objectReferenceValue != null)
+ {
+ EditorGUILayout.BeginHorizontal();
+ GUILayout.Space(EditorGUI.indentLevel * 15);
+ if (GUILayout.Button("Edit", EditorStyles.miniButtonLeft, GUILayout.Width(50)))
+ AssetDatabase.OpenAsset(sceneLuaProp.objectReferenceValue);
+ if (GUILayout.Button("Clear", EditorStyles.miniButtonRight, GUILayout.Width(50)))
+ sceneLuaProp.objectReferenceValue = null;
+ GUILayout.FlexibleSpace();
+ EditorGUILayout.EndHorizontal();
+ }
+ }
+
+ private void DrawFogSection(PSXSceneExporter exporter)
+ {
+ showFog = EditorGUILayout.Foldout(showFog, "Fog", true, PSXEditorStyles.FoldoutHeader);
+ if (!showFog) return;
+
+ EditorGUI.indentLevel++;
+
+ EditorGUILayout.PropertyField(fogEnabledProp, new GUIContent("Enabled"));
+
+ if (fogEnabledProp.boolValue)
+ {
+ EditorGUILayout.PropertyField(fogColorProp, new GUIContent("Color"));
+ EditorGUILayout.PropertyField(fogDensityProp, new GUIContent("Density"));
+
+ float gteScale = exporter.GTEScaling;
+ int density = Mathf.Clamp(exporter.FogDensity, 1, 10);
+ float fogFarUnity = (8000f / density) * gteScale / 4096f;
+ float fogNearUnity = fogFarUnity / 3f;
+
+ EditorGUILayout.Space(2);
+ EditorGUILayout.LabelField(
+ $"Preview: {fogNearUnity:F1} – {fogFarUnity:F1} units | " +
+ $"GTE: {8000f / (density * 3f):F0} – {8000f / density:F0} SZ",
+ PSXEditorStyles.RichLabel);
+
+ ApplyFogPreview();
+ }
+ else
+ {
+ RenderSettings.fog = false;
+ }
+
+ EditorGUI.indentLevel--;
+ }
+
+ private void DrawCutscenesSection()
+ {
+ showCutscenes = EditorGUILayout.Foldout(showCutscenes, "Cutscenes", true, PSXEditorStyles.FoldoutHeader);
+ if (!showCutscenes) return;
+
+ EditorGUI.indentLevel++;
+ EditorGUILayout.PropertyField(cutscenesProp, new GUIContent("Clips"), true);
+ EditorGUI.indentLevel--;
+ }
+
+ private void DrawLoadingSection()
+ {
+ EditorGUILayout.PropertyField(loadingScreenProp, new GUIContent("Loading Screen Prefab"));
+ if (loadingScreenProp.objectReferenceValue != null)
+ {
+ var go = loadingScreenProp.objectReferenceValue as GameObject;
+ if (go != null && go.GetComponentInChildren() == null)
+ {
+ EditorGUILayout.LabelField(
+ "Prefab has no PSXCanvas component.",
+ PSXEditorStyles.RichLabel);
+ }
+ }
+ }
+
+ private void DrawDebugSection()
+ {
+ showDebug = EditorGUILayout.Foldout(showDebug, "Debug", true, PSXEditorStyles.FoldoutHeader);
+ if (!showDebug) return;
+
+ EditorGUI.indentLevel++;
+ EditorGUILayout.PropertyField(previewBVHProp, new GUIContent("Preview BVH"));
+ if (previewBVHProp.boolValue)
+ EditorGUILayout.PropertyField(bvhDepthProp, new GUIContent("BVH Depth"));
+ EditorGUI.indentLevel--;
+ }
+
+ private void DrawSceneStats()
+ {
+ var exporters = FindObjectsOfType();
+ int total = exporters.Length;
+ int active = exporters.Count(e => e.IsActive);
+ int withCollision = exporters.Count(e => e.CollisionType != PSXCollisionType.None);
+ int staticCol = exporters.Count(e => e.StaticCollider && e.CollisionType != PSXCollisionType.None);
+
+ EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle);
+ EditorGUILayout.LabelField(
+ $"{active}/{total} objects | {withCollision} colliders ({staticCol} static)",
+ PSXEditorStyles.RichLabel);
+ EditorGUILayout.EndVertical();
+ }
+
private void SaveAndApplyFogPreview()
{
_savedFog = RenderSettings.fog;
@@ -56,7 +211,6 @@ namespace SplashEdit.EditorCode
_savedFogMode = RenderSettings.fogMode;
_savedFogStart = RenderSettings.fogStartDistance;
_savedFogEnd = RenderSettings.fogEndDistance;
-
_previewActive = true;
ApplyFogPreview();
}
@@ -68,76 +222,31 @@ namespace SplashEdit.EditorCode
if (!exporter.FogEnabled)
{
- // 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;
+ RenderSettings.fogStartDistance = fogNearSZ * gteScale / 4096f;
+ RenderSettings.fogEndDistance = fogFarSZ * gteScale / 4096f;
}
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();
- }
}
}
diff --git a/Editor/PSXSceneValidatorWindow.cs b/Editor/PSXSceneValidatorWindow.cs
deleted file mode 100644
index f90411d..0000000
--- a/Editor/PSXSceneValidatorWindow.cs
+++ /dev/null
@@ -1,496 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-using UnityEditor;
-using UnityEngine;
-using SplashEdit.RuntimeCode;
-
-namespace SplashEdit.EditorCode
-{
- ///
- /// Scene Validator Window - Validates the current scene for PS1 compatibility.
- /// Checks for common issues that would cause problems on real hardware.
- ///
- public class PSXSceneValidatorWindow : EditorWindow
- {
- private Vector2 scrollPosition;
- private List validationResults = new List();
- 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("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();
-
- 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();
-
- 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();
- 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();
- 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();
- 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();
-
- 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();
- int totalTris = 0;
-
- foreach (var exporter in exporters)
- {
- var mf = exporter.GetComponent();
- 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();
- 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;
- }
- }
-}
diff --git a/Editor/PSXSceneValidatorWindow.cs.meta b/Editor/PSXSceneValidatorWindow.cs.meta
deleted file mode 100644
index 58a8245..0000000
--- a/Editor/PSXSceneValidatorWindow.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: 0a26bf89301a2554ca287b9e28e44906
\ No newline at end of file
diff --git a/Editor/PSXSplashInstaller.cs b/Editor/PSXSplashInstaller.cs
index d21d335..525c2a2 100644
--- a/Editor/PSXSplashInstaller.cs
+++ b/Editor/PSXSplashInstaller.cs
@@ -176,8 +176,7 @@ namespace SplashEdit.EditorCode
process.BeginOutputReadLine();
process.BeginErrorReadLine();
- // Wait for exit with timeout
- var timeout = TimeSpan.FromSeconds(30);
+ var timeout = TimeSpan.FromMinutes(10);
if (await Task.Run(() => process.WaitForExit((int)timeout.TotalMilliseconds)))
{
process.WaitForExit(); // Ensure all output is processed
diff --git a/Runtime/PSXCollisionExporter.cs b/Runtime/PSXCollisionExporter.cs
index db9ef71..5df9af9 100644
--- a/Runtime/PSXCollisionExporter.cs
+++ b/Runtime/PSXCollisionExporter.cs
@@ -84,13 +84,16 @@ namespace SplashEdit.RuntimeCode
foreach (var exporter in exporters)
{
+ // Dynamic objects are handled by the runtime collision system, skip them
+ if (!exporter.StaticCollider && exporter.CollisionType != PSXCollisionType.None)
+ continue;
+
PSXCollisionType effectiveType = exporter.CollisionType;
if (effectiveType == PSXCollisionType.None)
{
if (autoIncludeSolid)
{
- // Auto-include as Solid so all geometry blocks the player
effectiveType = PSXCollisionType.Solid;
autoIncluded++;
}
@@ -146,8 +149,7 @@ namespace SplashEdit.RuntimeCode
flags = (byte)PSXSurfaceFlag.Solid;
// Check if stairs (tagged on exporter or steep-ish)
- if (exporter.ObjectFlags.HasFlag(PSXObjectFlags.Static) &&
- dotUp < 0.95f && dotUp > cosWalkable)
+ if (dotUp < 0.95f && dotUp > cosWalkable)
{
flags |= (byte)PSXSurfaceFlag.Stairs;
}
diff --git a/Runtime/PSXObjectExporter.cs b/Runtime/PSXObjectExporter.cs
index 47f1f96..10510fe 100644
--- a/Runtime/PSXObjectExporter.cs
+++ b/Runtime/PSXObjectExporter.cs
@@ -5,31 +5,12 @@ using UnityEngine.Serialization;
namespace SplashEdit.RuntimeCode
{
- ///
- /// Collision type for PS1 runtime
- ///
public enum PSXCollisionType
- {
- None = 0, // No collision
- Solid = 1, // Solid collision - blocks movement
- Trigger = 2, // Trigger - fires events but doesn't block
- Platform = 3 // Platform - solid from above, passable from below
- }
-
- ///
- /// Object behavior flags for PS1 runtime
- ///
- [System.Flags]
- public enum PSXObjectFlags
{
None = 0,
- Static = 1 << 0, // Object never moves (can be optimized)
- Dynamic = 1 << 1, // Object can move
- Visible = 1 << 2, // Object is rendered
- CastsShadow = 1 << 3, // Object casts shadows (future)
- ReceivesShadow = 1 << 4, // Object receives shadows (future)
- Interactable = 1 << 5, // Player can interact with this
- AlwaysRender = 1 << 6, // Skip frustum culling for this object
+ Solid = 1,
+ Trigger = 2,
+ Platform = 3
}
[RequireComponent(typeof(Renderer))]
@@ -43,184 +24,70 @@ namespace SplashEdit.RuntimeCode
public List Textures { get; set; } = new List();
public PSXMesh Mesh { get; protected set; }
-
- [Header("Export Settings")]
+
[FormerlySerializedAs("BitDepth")]
[SerializeField] private PSXBPP bitDepth = PSXBPP.TEX_8BIT;
[SerializeField] private LuaFile luaFile;
-
- [Header("Object Flags")]
- [SerializeField] private PSXObjectFlags objectFlags = PSXObjectFlags.Static | PSXObjectFlags.Visible;
-
- [Header("Collision Settings")]
+
[SerializeField] private PSXCollisionType collisionType = PSXCollisionType.None;
+ [SerializeField] private bool staticCollider = true;
[SerializeField] private bool exportCollisionMesh = false;
- [SerializeField] private Mesh customCollisionMesh; // Optional simplified collision mesh
- [Tooltip("Layer mask for collision detection (1-8)")]
+ [SerializeField] private Mesh customCollisionMesh;
[Range(1, 8)]
[SerializeField] private int collisionLayer = 1;
- [Header("Navigation")]
- [Tooltip("Include this object's walkable surfaces in nav region generation")]
[SerializeField] private bool generateNavigation = false;
-
- [Header("Gizmo Settings")]
- [FormerlySerializedAs("PreviewNormals")]
- [SerializeField] private bool previewNormals = false;
- [SerializeField] private float normalPreviewLength = 0.5f;
- [SerializeField] private bool showCollisionBounds = true;
- // Public accessors for editor and export
public PSXBPP BitDepth => bitDepth;
public PSXCollisionType CollisionType => collisionType;
+ public bool StaticCollider => staticCollider;
public bool ExportCollisionMesh => exportCollisionMesh;
public Mesh CustomCollisionMesh => customCollisionMesh;
public int CollisionLayer => collisionLayer;
- public PSXObjectFlags ObjectFlags => objectFlags;
public bool GenerateNavigation => generateNavigation;
-
- // For assigning texture from editor
- public Texture2D texture;
private readonly Dictionary<(int, PSXBPP), PSXTexture2D> cache = new();
- private void OnDrawGizmos()
- {
- if (previewNormals)
- {
- MeshFilter filter = GetComponent();
-
- if (filter != null)
- {
- Mesh mesh = filter.sharedMesh;
- Vector3[] vertices = mesh.vertices;
- Vector3[] normals = mesh.normals;
-
- Gizmos.color = Color.green;
-
- for (int i = 0; i < vertices.Length; i++)
- {
- Vector3 worldVertex = transform.TransformPoint(vertices[i]);
- Vector3 worldNormal = transform.TransformDirection(normals[i]);
-
- Gizmos.DrawLine(worldVertex, worldVertex + worldNormal * normalPreviewLength);
- }
- }
- }
- }
-
- private void OnDrawGizmosSelected()
- {
- // Draw collision bounds when object is selected
- if (showCollisionBounds && collisionType != PSXCollisionType.None)
- {
- MeshFilter filter = GetComponent();
- Mesh collisionMesh = customCollisionMesh != null ? customCollisionMesh : (filter?.sharedMesh);
-
- if (collisionMesh != null)
- {
- Bounds bounds = collisionMesh.bounds;
-
- // Choose color based on collision type
- switch (collisionType)
- {
- case PSXCollisionType.Solid:
- Gizmos.color = new Color(1f, 0.3f, 0.3f, 0.5f); // Red
- break;
- case PSXCollisionType.Trigger:
- Gizmos.color = new Color(0.3f, 1f, 0.3f, 0.5f); // Green
- break;
- case PSXCollisionType.Platform:
- Gizmos.color = new Color(0.3f, 0.3f, 1f, 0.5f); // Blue
- break;
- }
-
- // Draw AABB
- Matrix4x4 oldMatrix = Gizmos.matrix;
- Gizmos.matrix = transform.localToWorldMatrix;
- Gizmos.DrawWireCube(bounds.center, bounds.size);
-
- // Draw filled with lower alpha
- Color fillColor = Gizmos.color;
- fillColor.a = 0.1f;
- Gizmos.color = fillColor;
- Gizmos.DrawCube(bounds.center, bounds.size);
-
- Gizmos.matrix = oldMatrix;
- }
- }
- }
-
public void CreatePSXTextures2D()
{
Renderer renderer = GetComponent();
Textures.Clear();
- if (renderer != null)
+ if (renderer == null) return;
+
+ Material[] materials = renderer.sharedMaterials;
+ foreach (Material mat in materials)
{
- // If an override texture is set, use it for all submeshes
- if (texture != null)
+ if (mat == null || mat.mainTexture == null) continue;
+
+ Texture mainTexture = mat.mainTexture;
+ Texture2D tex2D = mainTexture is Texture2D existing
+ ? existing
+ : ConvertToTexture2D(mainTexture);
+
+ if (tex2D == null) continue;
+
+ if (cache.TryGetValue((tex2D.GetInstanceID(), bitDepth), out var cached))
{
- PSXTexture2D tex;
- if (cache.ContainsKey((texture.GetInstanceID(), bitDepth)))
- {
- tex = cache[(texture.GetInstanceID(), bitDepth)];
- }
- else
- {
- tex = PSXTexture2D.CreateFromTexture2D(texture, bitDepth);
- tex.OriginalTexture = texture;
- cache.Add((texture.GetInstanceID(), bitDepth), tex);
- }
- Textures.Add(tex);
- return;
+ Textures.Add(cached);
}
-
- Material[] materials = renderer.sharedMaterials;
-
- foreach (Material mat in materials)
+ else
{
- if (mat != null && mat.mainTexture != null)
- {
- Texture mainTexture = mat.mainTexture;
- Texture2D tex2D = null;
-
- if (mainTexture is Texture2D existingTex2D)
- {
- tex2D = existingTex2D;
- }
- else
- {
- tex2D = ConvertToTexture2D(mainTexture);
- }
-
- if (tex2D != null)
- {
- PSXTexture2D tex;
- if (cache.ContainsKey((tex2D.GetInstanceID(), bitDepth)))
- {
- tex = cache[(tex2D.GetInstanceID(), bitDepth)];
- }
- else
- {
- tex = PSXTexture2D.CreateFromTexture2D(tex2D, bitDepth);
- tex.OriginalTexture = tex2D;
- cache.Add((tex2D.GetInstanceID(), bitDepth), tex);
- }
- Textures.Add(tex);
- }
- }
+ var tex = PSXTexture2D.CreateFromTexture2D(tex2D, bitDepth);
+ tex.OriginalTexture = tex2D;
+ cache.Add((tex2D.GetInstanceID(), bitDepth), tex);
+ Textures.Add(tex);
}
}
}
- private Texture2D ConvertToTexture2D(Texture texture)
+ private static Texture2D ConvertToTexture2D(Texture src)
{
- Texture2D texture2D = new Texture2D(texture.width, texture.height, TextureFormat.RGBA32, false);
+ Texture2D texture2D = new Texture2D(src.width, src.height, TextureFormat.RGBA32, false);
RenderTexture currentActiveRT = RenderTexture.active;
- RenderTexture.active = texture as RenderTexture;
+ RenderTexture.active = src as RenderTexture;
- texture2D.ReadPixels(new Rect(0, 0, texture.width, texture.height), 0, 0);
+ texture2D.ReadPixels(new Rect(0, 0, src.width, src.height), 0, 0);
texture2D.Apply();
RenderTexture.active = currentActiveRT;
diff --git a/Runtime/PSXSceneExporter.cs b/Runtime/PSXSceneExporter.cs
index 125f7c8..a439c30 100644
--- a/Runtime/PSXSceneExporter.cs
+++ b/Runtime/PSXSceneExporter.cs
@@ -9,6 +9,11 @@ using UnityEngine;
namespace SplashEdit.RuntimeCode
{
+ public enum PSXSceneType
+ {
+ Exterior = 0,
+ Interior = 1
+ }
[ExecuteInEditMode]
public class PSXSceneExporter : MonoBehaviour
@@ -35,7 +40,7 @@ namespace SplashEdit.RuntimeCode
[Header("Scene Type")]
[Tooltip("Exterior uses BVH frustum culling. Interior uses room/portal occlusion.")]
- public int SceneType = 0; // 0=exterior, 1=interior
+ public PSXSceneType SceneType = PSXSceneType.Exterior;
[Header("Cutscenes")]
[Tooltip("Cutscene clips to include in this scene's splashpack. Only these will be exported.")]
@@ -166,7 +171,7 @@ namespace SplashEdit.RuntimeCode
// Collect them early so both systems use the same room indices.
PSXRoom[] rooms = null;
PSXPortalLink[] portalLinks = null;
- if (SceneType == 1)
+ if (SceneType == PSXSceneType.Interior)
{
rooms = FindObjectsByType(FindObjectsSortMode.None);
portalLinks = FindObjectsByType(FindObjectsSortMode.None);
@@ -194,7 +199,7 @@ namespace SplashEdit.RuntimeCode
// Phase 5: Build room/portal system (for interior scenes)
_roomBuilder = new PSXRoomBuilder();
- if (SceneType == 1)
+ if (SceneType == PSXSceneType.Interior)
{
if (rooms != null && rooms.Length > 0)
{
diff --git a/Runtime/PSXSceneWriter.cs b/Runtime/PSXSceneWriter.cs
index a33522e..ff2ebcf 100644
--- a/Runtime/PSXSceneWriter.cs
+++ b/Runtime/PSXSceneWriter.cs
@@ -50,7 +50,7 @@ namespace SplashEdit.RuntimeCode
public float gravity;
// Scene configuration (v11)
- public int sceneType; // 0=exterior, 1=interior
+ public PSXSceneType sceneType;
public bool fogEnabled;
public Color fogColor;
public int fogDensity; // 1-10
@@ -111,7 +111,12 @@ namespace SplashEdit.RuntimeCode
int colliderCount = 0;
foreach (var e in scene.exporters)
{
- if (e.CollisionType != PSXCollisionType.None)
+ if (e.CollisionType == PSXCollisionType.None || e.StaticCollider)
+ continue;
+ Mesh cm = e.CustomCollisionMesh != null
+ ? e.CustomCollisionMesh
+ : e.GetComponent()?.sharedMesh;
+ if (cm != null)
colliderCount++;
}
@@ -121,17 +126,17 @@ namespace SplashEdit.RuntimeCode
exporterIndex[scene.exporters[i]] = i;
// ──────────────────────────────────────────────────────
- // Header (72 bytes total — splashpack v8)
+ // Header (104 bytes — splashpack v15)
// ──────────────────────────────────────────────────────
writer.Write('S');
writer.Write('P');
- writer.Write((ushort)13); // version
+ writer.Write((ushort)15);
writer.Write((ushort)luaFiles.Count);
writer.Write((ushort)scene.exporters.Length);
- writer.Write((ushort)0); // navmeshCount (legacy)
writer.Write((ushort)scene.atlases.Length);
writer.Write((ushort)clutCount);
writer.Write((ushort)colliderCount);
+ writer.Write((ushort)scene.interactables.Length);
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerPos.x, gte));
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(-scene.playerPos.y, gte));
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerPos.z, gte));
@@ -142,37 +147,23 @@ namespace SplashEdit.RuntimeCode
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerHeight, gte));
- // Scene Lua index
if (scene.sceneLuaFile != null)
writer.Write((short)luaFiles.IndexOf(scene.sceneLuaFile));
else
writer.Write((short)-1);
- // BVH info
writer.Write((ushort)scene.bvh.NodeCount);
writer.Write((ushort)scene.bvh.TriangleRefCount);
- // Component counts (version 4)
- writer.Write((ushort)scene.interactables.Length);
- writer.Write((ushort)0); // healthCount (removed)
- writer.Write((ushort)0); // timerCount (removed)
- writer.Write((ushort)0); // spawnerCount (removed)
+ writer.Write((ushort)scene.sceneType);
+ writer.Write((ushort)0); // pad0
- // NavGrid (version 5, legacy)
- writer.Write((ushort)0);
- writer.Write((ushort)0);
-
- // Scene type (version 6)
- writer.Write((ushort)scene.sceneType); // 0=exterior, 1=interior
- writer.Write((ushort)0);
-
- // World collision + nav regions (version 7)
writer.Write((ushort)scene.collisionExporter.MeshCount);
writer.Write((ushort)scene.collisionExporter.TriangleCount);
writer.Write((ushort)scene.navRegionBuilder.RegionCount);
writer.Write((ushort)scene.navRegionBuilder.PortalCount);
- // Movement parameters (version 8, 12 bytes)
+ // Movement parameters (12 bytes)
{
const float fps = 30f;
float movePerFrame = scene.moveSpeed / fps / gte;
@@ -187,52 +178,49 @@ namespace SplashEdit.RuntimeCode
writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(gravPsx * 4096f), 0, 65535));
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerRadius, gte));
- writer.Write((ushort)0); // padding
+ writer.Write((ushort)0); // pad1
}
- // Name table offset placeholder (version 9, 4 bytes)
long nameTableOffsetPos = writer.BaseStream.Position;
- writer.Write((uint)0); // placeholder for name table offset
+ writer.Write((uint)0);
- // Audio clip info (version 10, 8 bytes)
int audioClipCount = scene.audioClips?.Length ?? 0;
writer.Write((ushort)audioClipCount);
- writer.Write((ushort)0); // padding
+ writer.Write((ushort)0); // pad2
long audioTableOffsetPos = writer.BaseStream.Position;
- writer.Write((uint)0); // placeholder for audio table offset
+ writer.Write((uint)0);
- // Fog + room/portal header (version 11, 12 bytes)
{
writer.Write((byte)(scene.fogEnabled ? 1 : 0));
writer.Write((byte)Mathf.Clamp(Mathf.RoundToInt(scene.fogColor.r * 255f), 0, 255));
writer.Write((byte)Mathf.Clamp(Mathf.RoundToInt(scene.fogColor.g * 255f), 0, 255));
writer.Write((byte)Mathf.Clamp(Mathf.RoundToInt(scene.fogColor.b * 255f), 0, 255));
writer.Write((byte)Mathf.Clamp(scene.fogDensity, 1, 10));
- writer.Write((byte)0); // reserved
+ writer.Write((byte)0); // pad3
int roomCount = scene.roomBuilder?.RoomCount ?? 0;
int portalCount = scene.roomBuilder?.PortalCount ?? 0;
int roomTriRefCount = scene.roomBuilder?.TotalTriRefCount ?? 0;
- // roomCount is the room count NOT including catch-all; the binary adds +1 for it
writer.Write((ushort)(roomCount > 0 ? roomCount + 1 : 0));
writer.Write((ushort)portalCount);
writer.Write((ushort)roomTriRefCount);
}
- // Cutscene header (version 12, 8 bytes)
int cutsceneCount = scene.cutscenes?.Length ?? 0;
writer.Write((ushort)cutsceneCount);
- writer.Write((ushort)0); // reserved_cs
+ writer.Write((ushort)0); // pad4
long cutsceneTableOffsetPos = writer.BaseStream.Position;
writer.Write((uint)0); // cutsceneTableOffset placeholder
- // UI canvas header (version 13, 8 bytes)
int uiCanvasCount = scene.canvases?.Length ?? 0;
int uiFontCount = scene.fonts?.Length ?? 0;
writer.Write((ushort)uiCanvasCount);
- writer.Write((byte)uiFontCount); // was uiReserved low byte
- writer.Write((byte)0); // was uiReserved high byte
+ writer.Write((byte)uiFontCount);
+ writer.Write((byte)0); // uiPad5
long uiTableOffsetPos = writer.BaseStream.Position;
- writer.Write((uint)0); // uiTableOffset placeholder
+ writer.Write((uint)0);
+
+ long pixelDataOffsetPos = writer.BaseStream.Position;
+ writer.Write((uint)0); // pixelDataOffset placeholder
// ──────────────────────────────────────────────────────
// Lua file metadata
@@ -295,7 +283,7 @@ namespace SplashEdit.RuntimeCode
for (int exporterIdx = 0; exporterIdx < scene.exporters.Length; exporterIdx++)
{
PSXObjectExporter exporter = scene.exporters[exporterIdx];
- if (exporter.CollisionType == PSXCollisionType.None)
+ if (exporter.CollisionType == PSXCollisionType.None || exporter.StaticCollider)
continue;
MeshFilter meshFilter = exporter.GetComponent();
@@ -483,33 +471,6 @@ namespace SplashEdit.RuntimeCode
}
}
- // Atlas pixel data
- foreach (TextureAtlas atlas in scene.atlases)
- {
- AlignToFourBytes(writer);
- atlasOffset.DataOffsets.Add(writer.BaseStream.Position);
-
- for (int y = 0; y < atlas.vramPixels.GetLength(1); y++)
- for (int x = 0; x < atlas.vramPixels.GetLength(0); x++)
- writer.Write(atlas.vramPixels[x, y].Pack());
- }
-
- // CLUT data
- foreach (TextureAtlas atlas in scene.atlases)
- {
- foreach (var texture in atlas.ContainedTextures)
- {
- if (texture.ColorPalette != null)
- {
- AlignToFourBytes(writer);
- clutOffset.DataOffsets.Add(writer.BaseStream.Position);
-
- foreach (VRAMPixel color in texture.ColorPalette)
- writer.Write((ushort)color.Pack());
- }
- }
- }
-
// ──────────────────────────────────────────────────────
// Object name table (version 9)
// ──────────────────────────────────────────────────────
@@ -535,46 +496,50 @@ namespace SplashEdit.RuntimeCode
// ──────────────────────────────────────────────────────
// Audio clip data (version 10)
+ // Metadata entries are 16 bytes each, written contiguously.
+ // Name strings follow the metadata block with backfilled offsets.
+ // ADPCM blobs deferred to dead zone.
// ──────────────────────────────────────────────────────
+ List audioDataOffsetPositions = new List();
if (audioClipCount > 0 && scene.audioClips != null)
{
- // Write audio table: per clip metadata (12 bytes each)
AlignToFourBytes(writer);
long audioTableStart = writer.BaseStream.Position;
- // First pass: write metadata placeholders (16 bytes each)
- List audioDataOffsetPositions = new List();
List audioNameOffsetPositions = new List();
+ List audioClipNames = new List();
+
+ // Phase 1: Write all 16-byte metadata entries contiguously
for (int i = 0; i < audioClipCount; i++)
{
var clip = scene.audioClips[i];
- audioDataOffsetPositions.Add(writer.BaseStream.Position);
- writer.Write((uint)0); // dataOffset placeholder
- writer.Write((uint)(clip.adpcmData?.Length ?? 0)); // sizeBytes
- writer.Write((ushort)clip.sampleRate);
string name = clip.clipName ?? "";
+ if (name.Length > 255) name = name.Substring(0, 255);
+
+ audioDataOffsetPositions.Add(writer.BaseStream.Position);
+ writer.Write((uint)0); // dataOffset placeholder (backfilled in dead zone)
+ writer.Write((uint)(clip.adpcmData?.Length ?? 0));
+ writer.Write((ushort)clip.sampleRate);
writer.Write((byte)(clip.loop ? 1 : 0));
- writer.Write((byte)System.Math.Min(name.Length, 255));
+ writer.Write((byte)name.Length);
audioNameOffsetPositions.Add(writer.BaseStream.Position);
writer.Write((uint)0); // nameOffset placeholder
+ audioClipNames.Add(name);
}
- // Second pass: write ADPCM data and backfill offsets
+ // Phase 2: Write name strings (after all metadata entries)
for (int i = 0; i < audioClipCount; i++)
{
- byte[] data = scene.audioClips[i].adpcmData;
- if (data != null && data.Length > 0)
- {
- AlignToFourBytes(writer);
- long dataPos = writer.BaseStream.Position;
- writer.Write(data);
+ string name = audioClipNames[i];
+ long namePos = writer.BaseStream.Position;
+ byte[] nameBytes = System.Text.Encoding.ASCII.GetBytes(name);
+ writer.Write(nameBytes);
+ writer.Write((byte)0);
- // Backfill data offset
- long curPos = writer.BaseStream.Position;
- writer.Seek((int)audioDataOffsetPositions[i], SeekOrigin.Begin);
- writer.Write((uint)dataPos);
- writer.Seek((int)curPos, SeekOrigin.Begin);
- }
+ long curPos = writer.BaseStream.Position;
+ writer.Seek((int)audioNameOffsetPositions[i], SeekOrigin.Begin);
+ writer.Write((uint)namePos);
+ writer.Seek((int)curPos, SeekOrigin.Begin);
}
// Backfill audio table offset in header
@@ -584,28 +549,6 @@ namespace SplashEdit.RuntimeCode
writer.Write((uint)audioTableStart);
writer.Seek((int)curPos, SeekOrigin.Begin);
}
-
- int totalAudioBytes = 0;
- foreach (var clip in scene.audioClips)
- if (clip.adpcmData != null) totalAudioBytes += clip.adpcmData.Length;
-
- // Third pass: write audio clip names and backfill name offsets
- for (int i = 0; i < audioClipCount; i++)
- {
- string name = scene.audioClips[i].clipName ?? "";
- if (name.Length > 255) name = name.Substring(0, 255);
- long namePos = writer.BaseStream.Position;
- byte[] nameBytes = System.Text.Encoding.ASCII.GetBytes(name);
- writer.Write(nameBytes);
- writer.Write((byte)0); // null terminator
-
- long curPos = writer.BaseStream.Position;
- writer.Seek((int)audioNameOffsetPositions[i], SeekOrigin.Begin);
- writer.Write((uint)namePos);
- writer.Seek((int)curPos, SeekOrigin.Begin);
- }
-
- log?.Invoke($"{audioClipCount} audio clips ({totalAudioBytes / 1024}KB ADPCM) written.", LogType.Log);
}
// ──────────────────────────────────────────────────────
@@ -635,11 +578,12 @@ namespace SplashEdit.RuntimeCode
// ──────────────────────────────────────────────────────
// UI canvas + font data (version 13)
// Font descriptors: 112 bytes each (before canvas data)
- // Font pixel data: raw 4bpp (after font descriptors)
// Canvas descriptor table: 12 bytes per canvas
// Element records: 48 bytes each
// Name and text strings follow with offset backfill
+ // Font pixel data is deferred to the dead zone.
// ──────────────────────────────────────────────────────
+ List fontDataOffsetPositions = new List();
if ((uiCanvasCount > 0 && scene.canvases != null) || uiFontCount > 0)
{
AlignToFourBytes(writer);
@@ -648,7 +592,6 @@ namespace SplashEdit.RuntimeCode
// ── Font descriptors (112 bytes each) ──
// Layout: glyphW(1) glyphH(1) vramX(2) vramY(2) textureH(2)
// dataOffset(4) dataSize(4)
- List fontDataOffsetPositions = new List();
if (scene.fonts != null)
{
foreach (var font in scene.fonts)
@@ -669,32 +612,9 @@ namespace SplashEdit.RuntimeCode
}
}
- // ── Font pixel data (raw 4bpp) ──
- if (scene.fonts != null)
- {
- for (int fi = 0; fi < scene.fonts.Length; fi++)
- {
- var font = scene.fonts[fi];
- if (font.PixelData == null || font.PixelData.Length == 0) continue;
-
- AlignToFourBytes(writer);
- long dataPos = writer.BaseStream.Position;
- writer.Write(font.PixelData);
-
- // Backfill data offset
- long curPos = writer.BaseStream.Position;
- writer.Seek((int)fontDataOffsetPositions[fi], SeekOrigin.Begin);
- writer.Write((uint)dataPos);
- writer.Seek((int)curPos, SeekOrigin.Begin);
- }
-
- if (scene.fonts.Length > 0)
- {
- int totalFontBytes = 0;
- foreach (var f in scene.fonts) totalFontBytes += f.PixelData?.Length ?? 0;
- log?.Invoke($"{scene.fonts.Length} custom font(s) written ({totalFontBytes} bytes 4bpp data).", LogType.Log);
- }
- }
+ // Font pixel data is deferred to the dead zone (after pixelDataOffset).
+ // The C++ loader reads font pixel data via the dataOffset, uploads to VRAM,
+ // then never accesses it again.
// ── Canvas descriptor table (12 bytes each) ──
// Layout per descriptor:
@@ -873,6 +793,96 @@ namespace SplashEdit.RuntimeCode
log?.Invoke($"{uiCanvasCount} UI canvases ({totalElements} elements) written.", LogType.Log);
}
+ // ══════════════════════════════════════════════════════
+ // DEAD ZONE — pixel/audio bulk data (freed after VRAM/SPU upload)
+ // Everything written after this point is not needed at runtime.
+ // ══════════════════════════════════════════════════════
+ AlignToFourBytes(writer);
+ long pixelDataStart = writer.BaseStream.Position;
+
+ // Atlas pixel data
+ foreach (TextureAtlas atlas in scene.atlases)
+ {
+ AlignToFourBytes(writer);
+ atlasOffset.DataOffsets.Add(writer.BaseStream.Position);
+
+ for (int y = 0; y < atlas.vramPixels.GetLength(1); y++)
+ for (int x = 0; x < atlas.vramPixels.GetLength(0); x++)
+ writer.Write(atlas.vramPixels[x, y].Pack());
+ }
+
+ // CLUT data
+ foreach (TextureAtlas atlas in scene.atlases)
+ {
+ foreach (var texture in atlas.ContainedTextures)
+ {
+ if (texture.ColorPalette != null)
+ {
+ AlignToFourBytes(writer);
+ clutOffset.DataOffsets.Add(writer.BaseStream.Position);
+
+ foreach (VRAMPixel color in texture.ColorPalette)
+ writer.Write((ushort)color.Pack());
+ }
+ }
+ }
+
+ // Audio ADPCM data
+ if (audioClipCount > 0 && scene.audioClips != null)
+ {
+ for (int i = 0; i < audioClipCount; i++)
+ {
+ byte[] data = scene.audioClips[i].adpcmData;
+ if (data != null && data.Length > 0)
+ {
+ AlignToFourBytes(writer);
+ long dataPos = writer.BaseStream.Position;
+ writer.Write(data);
+
+ long curPos = writer.BaseStream.Position;
+ writer.Seek((int)audioDataOffsetPositions[i], SeekOrigin.Begin);
+ writer.Write((uint)dataPos);
+ writer.Seek((int)curPos, SeekOrigin.Begin);
+ }
+ }
+
+ int totalAudioBytes = 0;
+ foreach (var clip in scene.audioClips)
+ if (clip.adpcmData != null) totalAudioBytes += clip.adpcmData.Length;
+ log?.Invoke($"{audioClipCount} audio clips ({totalAudioBytes / 1024}KB ADPCM) written.", LogType.Log);
+ }
+
+ // Font pixel data
+ if (scene.fonts != null)
+ {
+ for (int fi = 0; fi < scene.fonts.Length; fi++)
+ {
+ var font = scene.fonts[fi];
+ if (font.PixelData == null || font.PixelData.Length == 0) continue;
+
+ AlignToFourBytes(writer);
+ long dataPos = writer.BaseStream.Position;
+ writer.Write(font.PixelData);
+
+ long curPos = writer.BaseStream.Position;
+ writer.Seek((int)fontDataOffsetPositions[fi], SeekOrigin.Begin);
+ writer.Write((uint)dataPos);
+ writer.Seek((int)curPos, SeekOrigin.Begin);
+ }
+ }
+
+ // Backfill pixelDataOffset in header
+ {
+ long curPos = writer.BaseStream.Position;
+ writer.Seek((int)pixelDataOffsetPos, SeekOrigin.Begin);
+ writer.Write((uint)pixelDataStart);
+ writer.Seek((int)curPos, SeekOrigin.Begin);
+ }
+
+ long totalSize = writer.BaseStream.Position;
+ long deadBytes = totalSize - pixelDataStart;
+ log?.Invoke($"Pixel/audio dead zone: {deadBytes / 1024}KB (freed after VRAM/SPU upload).", LogType.Log);
+
// Backfill offsets
BackfillOffsets(writer, luaOffset, "lua", log);
BackfillOffsets(writer, meshOffset, "mesh", log);