From d29ef569b3b24b55e920379572d3190972cd714b Mon Sep 17 00:00:00 2001 From: Jan Racek Date: Fri, 27 Mar 2026 13:47:18 +0100 Subject: [PATCH] Broken RUntime --- Editor/Core/MkpsxisoDownloader.cs | 230 ++++++++++ Editor/Core/MkpsxisoDownloader.cs.meta | 2 + Editor/Core/PSXAudioConverter.cs | 7 +- Editor/Core/SplashBuildPaths.cs | 18 + Editor/Core/SplashControlPanel.cs | 411 +++++++++++++++-- Editor/Core/SplashSettings.cs | 30 +- Editor/PSXMenuItems.cs | 2 +- Editor/PSXObjectExporterEditor.cs | 601 +++++++------------------ Editor/PSXSceneExporterEditor.cs | 239 +++++++--- Editor/PSXSceneValidatorWindow.cs | 496 -------------------- Editor/PSXSceneValidatorWindow.cs.meta | 2 - Editor/PSXSplashInstaller.cs | 3 +- Runtime/PSXCollisionExporter.cs | 8 +- Runtime/PSXObjectExporter.cs | 197 ++------ Runtime/PSXSceneExporter.cs | 11 +- Runtime/PSXSceneWriter.cs | 282 ++++++------ 16 files changed, 1168 insertions(+), 1371 deletions(-) create mode 100644 Editor/Core/MkpsxisoDownloader.cs create mode 100644 Editor/Core/MkpsxisoDownloader.cs.meta delete mode 100644 Editor/PSXSceneValidatorWindow.cs delete mode 100644 Editor/PSXSceneValidatorWindow.cs.meta 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(" "); + + // License file (optional) + string licensePath = SplashSettings.LicenseFilePath; + if (!string.IsNullOrEmpty(licensePath) && File.Exists(licensePath)) + { + xml.AppendLine($" "); + } + + xml.AppendLine(" "); + + // SYSTEM.CNF — must be first for BIOS to find it + string cnfPath = SplashBuildPaths.SystemCnfPath; + xml.AppendLine($" "); + + // The executable — renamed to match what SYSTEM.CNF points to + string exePath = SplashBuildPaths.CompiledExePath; + string isoExeName = GetISOExeName(); + xml.AppendLine($" "); + + // Manifest + string manifestPath = SplashBuildPaths.ManifestPath; + if (File.Exists(manifestPath)) + { + xml.AppendLine($" "); + } + + // Scene splashpacks and loading packs + for (int i = 0; i < _sceneList.Count; i++) + { + string splashpack = SplashBuildPaths.GetSceneSplashpackPath(i, _sceneList[i].name); + if (File.Exists(splashpack)) + { + string isoName = $"SCENE_{i}.SPK"; + xml.AppendLine($" "); + } + + string loadingPack = SplashBuildPaths.GetSceneLoaderPackPath(i, _sceneList[i].name); + if (File.Exists(loadingPack)) + { + string isoName = $"SCENE_{i}.LDG"; + xml.AppendLine($" "); + } + } + + // Trailing dummy sectors to prevent drive runaway + 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);