From 4aa4e4942459b4b0f9e281bba5a88220b9839f3e Mon Sep 17 00:00:00 2001 From: Jan Racek Date: Tue, 24 Mar 2026 13:00:54 +0100 Subject: [PATCH] psst --- Debug.unity.meta | 2 +- Editor.meta | 2 +- Editor/Core.meta | 8 + Editor/Core/PCSXReduxDownloader.cs | 263 +++ Editor/Core/PCSXReduxDownloader.cs.meta | 2 + Editor/Core/PCdrvSerialHost.cs | 769 ++++++++ Editor/Core/PCdrvSerialHost.cs.meta | 2 + Editor/Core/PSXAudioConverter.cs | 318 ++++ Editor/Core/PSXAudioConverter.cs.meta | 2 + Editor/Core/PSXConsoleWindow.cs | 418 +++++ Editor/Core/PSXConsoleWindow.cs.meta | 2 + Editor/Core/PSXEditorStyles.cs | 777 ++++++++ Editor/Core/PSXEditorStyles.cs.meta | 2 + Editor/Core/SplashBuildPaths.cs | 178 ++ Editor/Core/SplashBuildPaths.cs.meta | 2 + Editor/Core/SplashControlPanel.cs | 1564 +++++++++++++++++ Editor/Core/SplashControlPanel.cs.meta | 2 + Editor/Core/SplashSettings.cs | 159 ++ Editor/Core/SplashSettings.cs.meta | 2 + Editor/Core/UniromUploader.cs | 549 ++++++ Editor/Core/UniromUploader.cs.meta | 2 + Editor/DependencyCheckInitializer.cs | 44 +- Editor/DependencyCheckInitializer.cs.meta | 2 +- Editor/DependencyInstallerWindow.cs | 522 ------ Editor/DependencyInstallerWindow.cs.meta | 2 - Editor/Inspectors.meta | 8 + Editor/Inspectors/PSXComponentEditors.cs | 136 ++ Editor/Inspectors/PSXComponentEditors.cs.meta | 2 + Editor/LuaFileAssetEditor.cs | 44 +- Editor/LuaFileAssetEditor.cs.meta | 2 +- Editor/LuaImporter.cs | 6 +- Editor/LuaImporter.cs.meta | 2 +- Editor/PSXMenuItems.cs | 80 + Editor/PSXMenuItems.cs.meta | 2 + Editor/PSXNavMeshEditor.cs | 31 - Editor/PSXNavMeshEditor.cs.meta | 2 - Editor/PSXNavRegionEditor.cs | 401 +++++ Editor/PSXNavRegionEditor.cs.meta | 2 + Editor/PSXObjectExporterEditor.cs | 514 ++++++ Editor/PSXObjectExporterEditor.cs.meta | 2 + Editor/PSXSceneExporterEditor.cs | 137 +- Editor/PSXSceneExporterEditor.cs.meta | 2 +- Editor/PSXSceneValidatorWindow.cs | 496 ++++++ Editor/PSXSceneValidatorWindow.cs.meta | 2 + Editor/QuantizedPreviewWindow.cs | 2 +- Editor/SerialConnection.cs | 39 - Editor/SerialConnection.cs.meta | 2 - Editor/ToolchainChecker.cs | 10 +- Editor/ToolchainChecker.cs.meta | 2 +- Editor/ToolchainInstaller.cs | 403 +---- Editor/ToolchainInstaller.cs.meta | 2 +- Editor/UniromConnection.cs | 22 - Editor/UniromConnection.cs.meta | 2 - Editor/UniromConnectionWindow.cs | 145 -- Editor/UniromConnectionWindow.cs.meta | 2 - Editor/VramEditorWindow.cs | 10 +- Editor/net.psxsplash.splashedit.Editor.asmdef | 11 +- ...et.psxsplash.splashedit.Editor.asmdef.meta | 2 +- Icons.meta | 2 +- Icons/PSXNavmesh.png | Bin 129 -> 6837 bytes Icons/PSXNavmesh.png.meta | 29 +- Icons/PSXPlayer.png | Bin 129 -> 9161 bytes Icons/PSXPlayer.png.meta | 29 +- Icons/PSXSObjectExporter.png | Bin 129 -> 6839 bytes Icons/PSXSObjectExporter.png.meta | 29 +- Icons/PSXSceneExporter.png | Bin 129 -> 8210 bytes Icons/PSXSceneExporter.png.meta | 29 +- LICENSE.meta | 2 +- Plugins.meta | 8 + Plugins/DotRecast.meta | 8 + Plugins/DotRecast/DotRecast.Core.dll | 3 + Plugins/DotRecast/DotRecast.Core.dll.meta | 27 + Plugins/DotRecast/DotRecast.Recast.dll | 3 + Plugins/DotRecast/DotRecast.Recast.dll.meta | 27 + README.md | 10 +- README.md.meta | 2 +- Runtime.meta | 2 +- Runtime/BSP.cs | 845 --------- Runtime/BSP.cs.meta | 2 - Runtime/BVH.cs | 393 +++++ Runtime/BVH.cs.meta | 2 + Runtime/Core.meta | 8 + Runtime/IPSXBinaryWritable.cs | 18 + Runtime/IPSXBinaryWritable.cs.meta | 2 + Runtime/IPSXExportable.cs | 20 + Runtime/IPSXExportable.cs.meta | 2 + Runtime/ImageProcessing.cs | 2 +- Runtime/ImageProcessing.cs.meta | 2 +- Runtime/LuaFile.cs | 3 +- Runtime/LuaFile.cs.meta | 2 +- Runtime/PSXAudioSource.cs | 43 + Runtime/PSXAudioSource.cs.meta | 2 + Runtime/PSXCollisionExporter.cs | 357 ++++ Runtime/PSXCollisionExporter.cs.meta | 2 + Runtime/PSXData.cs | 23 +- Runtime/PSXData.cs.meta | 2 +- Runtime/PSXInteractable.cs | 61 + Runtime/PSXInteractable.cs.meta | 2 + Runtime/PSXLightingBaker.cs | 139 +- Runtime/PSXLightingBaker.cs.meta | 2 +- Runtime/PSXMesh.cs | 132 +- Runtime/PSXMesh.cs.meta | 2 +- Runtime/PSXNavMesh.cs | 95 - Runtime/PSXNavMesh.cs.meta | 11 - Runtime/PSXNavRegionBuilder.cs | 522 ++++++ Runtime/PSXNavRegionBuilder.cs.meta | 2 + Runtime/PSXObjectExporter.cs | 156 +- Runtime/PSXObjectExporter.cs.meta | 11 +- Runtime/PSXPlayer.cs | 65 +- Runtime/PSXPlayer.cs.meta | 11 +- Runtime/PSXPortalLink.cs | 59 + Runtime/PSXPortalLink.cs.meta | 2 + Runtime/PSXRoom.cs | 439 +++++ Runtime/PSXRoom.cs.meta | 2 + Runtime/PSXSceneExporter.cs | 549 ++---- Runtime/PSXSceneExporter.cs.meta | 11 +- Runtime/PSXSceneWriter.cs | 693 ++++++++ Runtime/PSXSceneWriter.cs.meta | 2 + Runtime/PSXTexture2D.cs | 10 - Runtime/PSXTexture2D.cs.meta | 2 +- Runtime/TexturePacker.cs | 1 - Runtime/TexturePacker.cs.meta | 2 +- Runtime/Utils.cs | 48 +- Runtime/Utils.cs.meta | 2 +- .../net.psxsplash.splashedit.Runtime.asmdef | 7 +- ...t.psxsplash.splashedit.Runtime.asmdef.meta | 2 +- Sample.meta | 2 +- Sample/Lua.meta | 2 +- Sample/Lua/example.lua | 106 +- Sample/Lua/example.lua.meta | 4 +- Sample/Material.meta | 2 +- Sample/Material/PSXDefault.mat | 2 +- Sample/Material/PSXDefault.mat.meta | 4 +- Sample/Scene.meta | 2 +- Sample/Scene/Demo.unity.meta | 2 +- doc.meta | 2 +- doc/splashbundle.md | 137 -- package.json | 1 - package.json.meta | 2 +- tools.meta | 2 +- tools/LUA_VSCODE_SETUP.md | 49 + .../LUA_VSCODE_SETUP.md.meta | 2 +- tools/imhex.hexproj.meta | 2 +- tools/splash_api.lua | 383 ++++ tools/splash_api.lua.meta | 10 + 145 files changed, 10853 insertions(+), 2965 deletions(-) create mode 100644 Editor/Core.meta create mode 100644 Editor/Core/PCSXReduxDownloader.cs create mode 100644 Editor/Core/PCSXReduxDownloader.cs.meta create mode 100644 Editor/Core/PCdrvSerialHost.cs create mode 100644 Editor/Core/PCdrvSerialHost.cs.meta create mode 100644 Editor/Core/PSXAudioConverter.cs create mode 100644 Editor/Core/PSXAudioConverter.cs.meta create mode 100644 Editor/Core/PSXConsoleWindow.cs create mode 100644 Editor/Core/PSXConsoleWindow.cs.meta create mode 100644 Editor/Core/PSXEditorStyles.cs create mode 100644 Editor/Core/PSXEditorStyles.cs.meta create mode 100644 Editor/Core/SplashBuildPaths.cs create mode 100644 Editor/Core/SplashBuildPaths.cs.meta create mode 100644 Editor/Core/SplashControlPanel.cs create mode 100644 Editor/Core/SplashControlPanel.cs.meta create mode 100644 Editor/Core/SplashSettings.cs create mode 100644 Editor/Core/SplashSettings.cs.meta create mode 100644 Editor/Core/UniromUploader.cs create mode 100644 Editor/Core/UniromUploader.cs.meta delete mode 100644 Editor/DependencyInstallerWindow.cs delete mode 100644 Editor/DependencyInstallerWindow.cs.meta create mode 100644 Editor/Inspectors.meta create mode 100644 Editor/Inspectors/PSXComponentEditors.cs create mode 100644 Editor/Inspectors/PSXComponentEditors.cs.meta create mode 100644 Editor/PSXMenuItems.cs create mode 100644 Editor/PSXMenuItems.cs.meta delete mode 100644 Editor/PSXNavMeshEditor.cs delete mode 100644 Editor/PSXNavMeshEditor.cs.meta create mode 100644 Editor/PSXNavRegionEditor.cs create mode 100644 Editor/PSXNavRegionEditor.cs.meta create mode 100644 Editor/PSXObjectExporterEditor.cs create mode 100644 Editor/PSXObjectExporterEditor.cs.meta create mode 100644 Editor/PSXSceneValidatorWindow.cs create mode 100644 Editor/PSXSceneValidatorWindow.cs.meta delete mode 100644 Editor/SerialConnection.cs delete mode 100644 Editor/SerialConnection.cs.meta delete mode 100644 Editor/UniromConnection.cs delete mode 100644 Editor/UniromConnection.cs.meta delete mode 100644 Editor/UniromConnectionWindow.cs delete mode 100644 Editor/UniromConnectionWindow.cs.meta create mode 100644 Plugins.meta create mode 100644 Plugins/DotRecast.meta create mode 100644 Plugins/DotRecast/DotRecast.Core.dll create mode 100644 Plugins/DotRecast/DotRecast.Core.dll.meta create mode 100644 Plugins/DotRecast/DotRecast.Recast.dll create mode 100644 Plugins/DotRecast/DotRecast.Recast.dll.meta delete mode 100644 Runtime/BSP.cs delete mode 100644 Runtime/BSP.cs.meta create mode 100644 Runtime/BVH.cs create mode 100644 Runtime/BVH.cs.meta create mode 100644 Runtime/Core.meta create mode 100644 Runtime/IPSXBinaryWritable.cs create mode 100644 Runtime/IPSXBinaryWritable.cs.meta create mode 100644 Runtime/IPSXExportable.cs create mode 100644 Runtime/IPSXExportable.cs.meta create mode 100644 Runtime/PSXAudioSource.cs create mode 100644 Runtime/PSXAudioSource.cs.meta create mode 100644 Runtime/PSXCollisionExporter.cs create mode 100644 Runtime/PSXCollisionExporter.cs.meta create mode 100644 Runtime/PSXInteractable.cs create mode 100644 Runtime/PSXInteractable.cs.meta delete mode 100644 Runtime/PSXNavMesh.cs delete mode 100644 Runtime/PSXNavMesh.cs.meta create mode 100644 Runtime/PSXNavRegionBuilder.cs create mode 100644 Runtime/PSXNavRegionBuilder.cs.meta create mode 100644 Runtime/PSXPortalLink.cs create mode 100644 Runtime/PSXPortalLink.cs.meta create mode 100644 Runtime/PSXRoom.cs create mode 100644 Runtime/PSXRoom.cs.meta create mode 100644 Runtime/PSXSceneWriter.cs create mode 100644 Runtime/PSXSceneWriter.cs.meta delete mode 100644 doc/splashbundle.md create mode 100644 tools/LUA_VSCODE_SETUP.md rename doc/splashbundle.md.meta => tools/LUA_VSCODE_SETUP.md.meta (75%) create mode 100644 tools/splash_api.lua create mode 100644 tools/splash_api.lua.meta diff --git a/Debug.unity.meta b/Debug.unity.meta index e43b038..31df3d0 100644 --- a/Debug.unity.meta +++ b/Debug.unity.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 14e1ffa687155c145bfed30e28f26bc1 +guid: ab8d113c2664ebf4d899b0826cd5f10c DefaultImporter: externalObjects: {} userData: diff --git a/Editor.meta b/Editor.meta index 9c8784b..e08a23c 100644 --- a/Editor.meta +++ b/Editor.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: d64fb2a2412d7958ca13d15956c4182b +guid: f7b9a2e33a4c4754997cf0dd0f20acc8 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Editor/Core.meta b/Editor/Core.meta new file mode 100644 index 0000000..0c2167b --- /dev/null +++ b/Editor/Core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8e74ebc4b575d27499f7abd4d82b8849 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Core/PCSXReduxDownloader.cs b/Editor/Core/PCSXReduxDownloader.cs new file mode 100644 index 0000000..adea041 --- /dev/null +++ b/Editor/Core/PCSXReduxDownloader.cs @@ -0,0 +1,263 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using UnityEditor; +using UnityEngine; + +namespace SplashEdit.EditorCode +{ + /// + /// Downloads and installs PCSX-Redux from the official distrib.app CDN. + /// Mirrors the logic from pcsx-redux.js (the official download script). + /// + /// Flow: fetch platform manifest → find latest build ID → fetch build manifest → + /// get download URL → download zip → extract to .tools/pcsx-redux/ + /// + public static class PCSXReduxDownloader + { + private const string MANIFEST_BASE = "https://distrib.app/storage/manifests/pcsx-redux/"; + + private static readonly HttpClient _http; + + static PCSXReduxDownloader() + { + var handler = new HttpClientHandler + { + AutomaticDecompression = System.Net.DecompressionMethods.GZip + | System.Net.DecompressionMethods.Deflate + }; + _http = new HttpClient(handler); + _http.Timeout = TimeSpan.FromSeconds(60); + _http.DefaultRequestHeaders.UserAgent.ParseAdd("SplashEdit/1.0"); + } + + /// + /// Returns the platform variant string for the current platform. + /// + private static string GetPlatformVariant() + { + switch (Application.platform) + { + case RuntimePlatform.WindowsEditor: + return "dev-win-cli-x64"; + case RuntimePlatform.LinuxEditor: + return "dev-linux-x64"; + default: + return "dev-win-cli-x64"; + } + } + + /// + /// Downloads and installs PCSX-Redux to .tools/pcsx-redux/. + /// Shows progress bar during download. + /// + public static async Task DownloadAndInstall(Action log = null) + { + string variant = GetPlatformVariant(); + log?.Invoke($"Platform variant: {variant}"); + + try + { + // Step 1: Fetch the master manifest to get the latest build ID + string manifestUrl = $"{MANIFEST_BASE}{variant}/manifest.json"; + log?.Invoke($"Fetching manifest: {manifestUrl}"); + string manifestJson = await _http.GetStringAsync(manifestUrl); + + // Parse the latest build ID from the manifest. + // The manifest is JSON with a "builds" array. We want the highest ID. + // Simple JSON parsing without dependencies: + int latestBuildId = ParseLatestBuildId(manifestJson); + if (latestBuildId < 0) + { + log?.Invoke("Failed to parse build ID from manifest."); + return false; + } + log?.Invoke($"Latest build ID: {latestBuildId}"); + + // Step 2: Fetch the specific build manifest + string buildManifestUrl = $"{MANIFEST_BASE}{variant}/manifest-{latestBuildId}.json"; + log?.Invoke($"Fetching build manifest..."); + string buildManifestJson = await _http.GetStringAsync(buildManifestUrl); + + // Parse the download path + string downloadPath = ParseDownloadPath(buildManifestJson); + if (string.IsNullOrEmpty(downloadPath)) + { + log?.Invoke("Failed to parse download path from build manifest."); + return false; + } + + string downloadUrl = $"https://distrib.app{downloadPath}"; + log?.Invoke($"Downloading: {downloadUrl}"); + + // Step 3: Download the file + string tempFile = Path.Combine(Path.GetTempPath(), $"pcsx-redux-{latestBuildId}.zip"); + + EditorUtility.DisplayProgressBar("Downloading PCSX-Redux", "Downloading...", 0.1f); + + using (var response = await _http.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead)) + { + response.EnsureSuccessStatusCode(); + long? totalBytes = response.Content.Headers.ContentLength; + long downloadedBytes = 0; + + using (var fileStream = File.Create(tempFile)) + using (var downloadStream = await response.Content.ReadAsStreamAsync()) + { + byte[] buffer = new byte[81920]; + int bytesRead; + while ((bytesRead = await downloadStream.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + await fileStream.WriteAsync(buffer, 0, bytesRead); + downloadedBytes += bytesRead; + + if (totalBytes.HasValue) + { + float progress = (float)downloadedBytes / totalBytes.Value; + string sizeMB = $"{downloadedBytes / (1024 * 1024)}/{totalBytes.Value / (1024 * 1024)} MB"; + EditorUtility.DisplayProgressBar("Downloading PCSX-Redux", + $"Downloading... {sizeMB}", progress); + } + } + } + } + + log?.Invoke($"Downloaded to {tempFile}"); + EditorUtility.DisplayProgressBar("Installing PCSX-Redux", "Extracting...", 0.9f); + + // Step 4: Extract + string installDir = SplashBuildPaths.PCSXReduxDir; + if (Directory.Exists(installDir)) + Directory.Delete(installDir, true); + Directory.CreateDirectory(installDir); + + if (Application.platform == RuntimePlatform.LinuxEditor && tempFile.EndsWith(".tar.gz")) + { + var psi = new ProcessStartInfo + { + FileName = "tar", + Arguments = $"xzf \"{tempFile}\" -C \"{installDir}\" --strip-components=1", + UseShellExecute = false, + CreateNoWindow = true + }; + var proc = Process.Start(psi); + proc?.WaitForExit(); + } + else + { + System.IO.Compression.ZipFile.ExtractToDirectory(tempFile, installDir); + log?.Invoke($"Extracted to {installDir}"); + } + + // Clean up temp file + try { File.Delete(tempFile); } catch { } + + // Step 5: Verify + if (SplashBuildPaths.IsPCSXReduxInstalled()) + { + log?.Invoke("PCSX-Redux installed successfully!"); + EditorUtility.ClearProgressBar(); + return true; + } + else + { + // The zip might have a nested directory — try to find the exe + FixNestedDirectory(installDir); + if (SplashBuildPaths.IsPCSXReduxInstalled()) + { + log?.Invoke("PCSX-Redux installed successfully!"); + EditorUtility.ClearProgressBar(); + return true; + } + + log?.Invoke("Installation completed but PCSX-Redux binary not found at expected path."); + log?.Invoke($"Expected: {SplashBuildPaths.PCSXReduxBinary}"); + log?.Invoke($"Check: {installDir}"); + EditorUtility.ClearProgressBar(); + return false; + } + } + catch (Exception ex) + { + log?.Invoke($"Download failed: {ex.Message}"); + EditorUtility.ClearProgressBar(); + return false; + } + } + + /// + /// If the zip extracts into a nested directory, move files up. + /// + private static void FixNestedDirectory(string installDir) + { + var subdirs = Directory.GetDirectories(installDir); + if (subdirs.Length == 1) + { + string nested = subdirs[0]; + foreach (string file in Directory.GetFiles(nested)) + { + string dest = Path.Combine(installDir, Path.GetFileName(file)); + File.Move(file, dest); + } + foreach (string dir in Directory.GetDirectories(nested)) + { + string dest = Path.Combine(installDir, Path.GetFileName(dir)); + Directory.Move(dir, dest); + } + try { Directory.Delete(nested); } catch { } + } + } + + /// + /// Parse the latest build ID from the master manifest JSON. + /// Expected format: {"builds":[{"id":1234,...},...],...} + /// distrib.app returns builds sorted newest-first, so we take the first. + /// Falls back to scanning all IDs if the "builds" section isn't found. + /// + private static int ParseLatestBuildId(string json) + { + // Fast path: find the first "id" inside "builds" array + int buildsIdx = json.IndexOf("\"builds\"", StringComparison.Ordinal); + int startPos = buildsIdx >= 0 ? buildsIdx : 0; + + string searchToken = "\"id\":"; + int idx = json.IndexOf(searchToken, startPos, StringComparison.Ordinal); + if (idx < 0) return -1; + + int pos = idx + searchToken.Length; + while (pos < json.Length && char.IsWhiteSpace(json[pos])) pos++; + + int numStart = pos; + while (pos < json.Length && char.IsDigit(json[pos])) pos++; + + if (pos > numStart && int.TryParse(json.Substring(numStart, pos - numStart), out int id)) + return id; + + return -1; + } + + /// + /// Parse the download path from a build-specific manifest. + /// Expected format: {...,"path":"/storage/builds/..."} + /// + private static string ParseDownloadPath(string json) + { + string searchToken = "\"path\":"; + int idx = json.IndexOf(searchToken, StringComparison.Ordinal); + if (idx < 0) return null; + + int pos = idx + searchToken.Length; + while (pos < json.Length && char.IsWhiteSpace(json[pos])) pos++; + + if (pos >= json.Length || json[pos] != '"') return null; + pos++; // skip opening quote + + int pathStart = pos; + while (pos < json.Length && json[pos] != '"') pos++; + + return json.Substring(pathStart, pos - pathStart); + } + } +} diff --git a/Editor/Core/PCSXReduxDownloader.cs.meta b/Editor/Core/PCSXReduxDownloader.cs.meta new file mode 100644 index 0000000..86c6a4f --- /dev/null +++ b/Editor/Core/PCSXReduxDownloader.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b3eaffbb1caed9648b5b57d211ead4d6 \ No newline at end of file diff --git a/Editor/Core/PCdrvSerialHost.cs b/Editor/Core/PCdrvSerialHost.cs new file mode 100644 index 0000000..6263331 --- /dev/null +++ b/Editor/Core/PCdrvSerialHost.cs @@ -0,0 +1,769 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Ports; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; + +namespace SplashEdit.EditorCode +{ + /// + /// PCdrv Host — serves files to a PS1 over serial (Unirom/NOTPSXSerial protocol). + /// + /// PCdrv uses MIPS `break` instructions to request file I/O from the host. + /// For real hardware running Unirom, we must: + /// 1. Enter debug mode (DEBG/OKAY) — installs Unirom's kernel-resident SIO handler + /// 2. Continue execution (CONT/OKAY) + /// 3. Monitor serial for escape sequences: 0x00 followed by 'p' = PCDrv command + /// 4. Handle file operations (init, open, read, close, seek, etc.) + /// + /// Without entering debug mode first, `break` instructions cause an unhandled + /// "BP break (0x9)" crash because no handler is registered. + /// + /// Protocol based on NOTPSXSerial: https://github.com/JonathanDotCel/NOTPSXSerial + /// + public class PCdrvSerialHost : IDisposable + { + private SerialPort _port; + private CancellationTokenSource _cts; + private Task _listenTask; + private readonly string _portName; + private readonly int _baudRate; + private readonly string _baseDir; + private readonly Action _log; + private readonly Action _psxLog; + + // File handle table (1-indexed, handles are not recycled) + private readonly List _files = new List(); + + private class PCFile + { + public string Name; + public FileStream Stream; + public int Handle; + public bool Closed; + public FileAccess Mode; + } + + // Protocol escape char — PCDrv commands are prefixed with 0x00 + 'p' + private const byte ESCAPE_CHAR = 0x00; + + // PCDrv function codes (from Unirom kernel) + private const int FUNC_INIT = 0x101; + private const int FUNC_CREAT = 0x102; + private const int FUNC_OPEN = 0x103; + private const int FUNC_CLOSE = 0x104; + private const int FUNC_READ = 0x105; + private const int FUNC_WRITE = 0x106; + private const int FUNC_SEEK = 0x107; + + public bool IsRunning => _listenTask != null && !_listenTask.IsCompleted; + + public PCdrvSerialHost(string portName, int baudRate, string baseDir, Action log, Action psxLog = null) + { + _portName = portName; + _baudRate = baudRate; + _baseDir = baseDir; + _log = log; + _psxLog = psxLog; + } + + /// + /// Opens a new serial port and begins the monitor/PCdrv loop. + /// Note: DEBG must have been sent BEFORE the exe was uploaded (via UniromUploader.UploadExeForPCdrv). + /// Use the Start(SerialPort) overload to pass an already-open port from the uploader. + /// + public void Start() + { + if (IsRunning) return; + + _port = new SerialPort(_portName, _baudRate) + { + ReadTimeout = 5000, + WriteTimeout = 5000, + StopBits = StopBits.Two, + Parity = Parity.None, + DataBits = 8, + Handshake = Handshake.None, + DtrEnable = true, + RtsEnable = true + }; + + _port.Open(); + _log?.Invoke($"PCdrv host: opened {_portName} @ {_baudRate}"); + _log?.Invoke($"PCdrv host: serving files from {_baseDir}"); + + StartMonitorLoop(); + } + + /// + /// Starts the PCDrv monitor loop on an already-open serial port. + /// Use this after UniromUploader.UploadExeForPCdrv() which sends DEBG → SEXE + /// and returns the open port. The debug hooks are already installed and the + /// exe is already running — we just need to listen for escape sequences. + /// + public void Start(SerialPort openPort) + { + if (IsRunning) return; + + _port = openPort; + _log?.Invoke($"PCdrv host: serving files from {_baseDir}"); + _log?.Invoke("PCdrv host: monitoring for PCDrv requests..."); + + StartMonitorLoop(); + } + + private void StartMonitorLoop() + { + _cts = new CancellationTokenSource(); + _listenTask = Task.Run(() => MonitorLoop(_cts.Token)); + } + + public void Stop() + { + _cts?.Cancel(); + try { _listenTask?.Wait(2000); } catch { } + + foreach (var f in _files) + { + if (!f.Closed && f.Stream != null) + { + try { f.Stream.Close(); f.Stream.Dispose(); } catch { } + } + } + _files.Clear(); + + if (_port != null && _port.IsOpen) + { + try { _port.Close(); } catch { } + } + + _port?.Dispose(); + _port = null; + _log?.Invoke("PCdrv host stopped."); + } + + public void Dispose() => Stop(); + + // ═══════════════════════════════════════════════════════════════ + // Monitor loop — reads serial byte-by-byte looking for escape sequences + // Matches NOTPSXSerial's Bridge.MonitorSerial() + // ═══════════════════════════════════════════════════════════════ + + private void MonitorLoop(CancellationToken ct) + { + bool lastByteWasEscape = false; + var textBuffer = new StringBuilder(); + int totalBytesReceived = 0; + DateTime lastLogTime = DateTime.Now; + + _log?.Invoke("PCdrv monitor: waiting for data from PS1..."); + + while (!ct.IsCancellationRequested) + { + try + { + if (_port.BytesToRead == 0) + { + // Flush any accumulated text output periodically + if (textBuffer.Length > 0 && (DateTime.Now - lastLogTime).TotalMilliseconds > 100) + { + EmitPsxLine(textBuffer.ToString()); + textBuffer.Clear(); + lastLogTime = DateTime.Now; + } + Thread.Sleep(1); + continue; + } + + int b = _port.ReadByte(); + totalBytesReceived++; + + // Log first bytes received to help diagnose protocol issues + if (totalBytesReceived <= 32) + { + _log?.Invoke($"PCdrv monitor: byte #{totalBytesReceived} = 0x{b:X2} ('{(b >= 0x20 && b < 0x7F ? (char)b : '.')}')"); + } + else if (totalBytesReceived == 33) + { + _log?.Invoke("PCdrv monitor: (suppressing per-byte logging, check PS1> lines for output)"); + } + + if (lastByteWasEscape) + { + lastByteWasEscape = false; + + // Flush any text before handling escape + if (textBuffer.Length > 0) + { + EmitPsxLine(textBuffer.ToString()); + textBuffer.Clear(); + } + + if (b == ESCAPE_CHAR) + { + // Double escape = literal 0x00 in output, ignore + continue; + } + + if (b == 'p') + { + // PCDrv command incoming + _log?.Invoke("PCdrv monitor: got escape+p → PCDrv command!"); + HandlePCDrvCommand(ct); + } + else + { + // Unknown escape sequence — log it + _log?.Invoke($"PCdrv monitor: unknown escape seq: 0x00 + 0x{b:X2} ('{(b >= 0x20 && b < 0x7F ? (char)b : '.')}')"); + } + + continue; + } + + if (b == ESCAPE_CHAR) + { + lastByteWasEscape = true; + continue; + } + + // Regular byte — this is printf output from the PS1 + if (b == '\n' || b == '\r') + { + if (textBuffer.Length > 0) + { + EmitPsxLine(textBuffer.ToString()); + textBuffer.Clear(); + lastLogTime = DateTime.Now; + } + } + else if (b >= 0x20 && b < 0x7F) + { + textBuffer.Append((char)b); + // Flush long lines immediately + if (textBuffer.Length >= 200) + { + EmitPsxLine(textBuffer.ToString()); + textBuffer.Clear(); + lastLogTime = DateTime.Now; + } + } + // else: non-printable byte that's not escape, skip + } + catch (TimeoutException) { } + catch (OperationCanceledException) { break; } + catch (Exception ex) + { + if (!ct.IsCancellationRequested) + _log?.Invoke($"PCdrv monitor error: {ex.Message}"); + } + } + } + + // ═══════════════════════════════════════════════════════════════ + // PCDrv command dispatcher + // Matches NOTPSXSerial's PCDrv.ReadCommand() + // ═══════════════════════════════════════════════════════════════ + + private void HandlePCDrvCommand(CancellationToken ct) + { + int funcCode = ReadInt32(ct); + + switch (funcCode) + { + case FUNC_INIT: HandleInit(); break; + case FUNC_CREAT: HandleCreate(ct); break; + case FUNC_OPEN: HandleOpen(ct); break; + case FUNC_CLOSE: HandleClose(ct); break; + case FUNC_READ: HandleRead(ct); break; + case FUNC_WRITE: HandleWrite(ct); break; + case FUNC_SEEK: HandleSeek(ct); break; + default: + _log?.Invoke($"PCdrv: unknown function 0x{funcCode:X}"); + break; + } + } + + // ═══════════════════════════════════════════════════════════════ + // Individual PCDrv handlers — match NOTPSXSerial's PCDrv.cs + // ═══════════════════════════════════════════════════════════════ + + private void HandleInit() + { + _log?.Invoke("PCdrv: INIT"); + SendString("OKAY"); + _port.Write(new byte[] { 0 }, 0, 1); // null terminator expected by Unirom + } + + private void HandleOpen(CancellationToken ct) + { + // Unirom sends: we respond OKAY first, then read filename + mode + SendString("OKAY"); + + string filename = ReadNullTermString(ct); + int modeParam = ReadInt32(ct); + + // Log raw bytes for debugging garbled filenames + _log?.Invoke($"PCdrv: OPEN \"{filename}\" mode={modeParam} (len={filename.Length}, hex={BitConverter.ToString(System.Text.Encoding.ASCII.GetBytes(filename))})"); + + // Check if already open + var existing = FindOpenFile(filename); + if (existing != null) + { + _log?.Invoke($"PCdrv: already open, handle={existing.Handle}"); + SendString("OKAY"); + WriteInt32(existing.Handle); + return; + } + + string fullPath; + try + { + fullPath = ResolvePath(filename); + } + catch (Exception ex) + { + _log?.Invoke($"PCdrv: invalid filename \"{filename}\": {ex.Message}"); + SendString("NOPE"); + return; + } + + if (!File.Exists(fullPath)) + { + _log?.Invoke($"PCdrv: file not found: {fullPath}"); + SendString("NOPE"); + return; + } + + try + { + var fs = new FileStream(fullPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + int handle = NextHandle(); + _files.Add(new PCFile { Name = filename, Stream = fs, Handle = handle, Closed = false, Mode = FileAccess.ReadWrite }); + + SendString("OKAY"); + WriteInt32(handle); + _log?.Invoke($"PCdrv: opened handle={handle}"); + } + catch (Exception ex) + { + _log?.Invoke($"PCdrv: open failed: {ex.Message}"); + SendString("NOPE"); + } + } + + private void HandleCreate(CancellationToken ct) + { + SendString("OKAY"); + + string filename = ReadNullTermString(ct); + int parameters = ReadInt32(ct); + + _log?.Invoke($"PCdrv: CREAT \"{filename}\" params={parameters}"); + + var existing = FindOpenFile(filename); + if (existing != null) + { + SendString("OKAY"); + WriteInt32(existing.Handle); + return; + } + + string fullPath; + try { fullPath = ResolvePath(filename); } + catch (Exception ex) + { + _log?.Invoke($"PCdrv: invalid filename \"{filename}\": {ex.Message}"); + SendString("NOPE"); + return; + } + + try + { + // Create or truncate the file + if (!File.Exists(fullPath)) + { + var temp = File.Create(fullPath); + temp.Flush(); temp.Close(); temp.Dispose(); + } + + var fs = new FileStream(fullPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + int handle = NextHandle(); + _files.Add(new PCFile { Name = filename, Stream = fs, Handle = handle, Closed = false, Mode = FileAccess.ReadWrite }); + + SendString("OKAY"); + WriteInt32(handle); + _log?.Invoke($"PCdrv: created handle={handle}"); + } + catch (Exception ex) + { + _log?.Invoke($"PCdrv: create failed: {ex.Message}"); + SendString("NOPE"); + } + } + + private void HandleClose(CancellationToken ct) + { + // Unirom sends: we respond OKAY first, then read handle + 2 unused params + SendString("OKAY"); + + int handle = ReadInt32(ct); + int _unused1 = ReadInt32(ct); + int _unused2 = ReadInt32(ct); + + _log?.Invoke($"PCdrv: CLOSE handle={handle}"); + + var f = FindOpenFile(handle); + if (f == null) + { + // No such file — "great success" per NOTPSXSerial + SendString("OKAY"); + WriteInt32(0); + return; + } + + try + { + f.Stream.Close(); + f.Stream.Dispose(); + f.Closed = true; + SendString("OKAY"); + WriteInt32(handle); + _log?.Invoke($"PCdrv: closed handle={handle}"); + } + catch (Exception ex) + { + _log?.Invoke($"PCdrv: close error: {ex.Message}"); + SendString("NOPE"); + } + } + + private void HandleRead(CancellationToken ct) + { + // Unirom sends: we respond OKAY first, then read handle + len + memaddr + SendString("OKAY"); + + int handle = ReadInt32(ct); + int length = ReadInt32(ct); + int memAddr = ReadInt32(ct); // for debugging only + + _log?.Invoke($"PCdrv: READ handle={handle} len=0x{length:X} memAddr=0x{memAddr:X}"); + + var f = FindOpenFile(handle); + if (f == null) + { + _log?.Invoke($"PCdrv: no file with handle {handle}"); + SendString("NOPE"); + return; + } + + try + { + byte[] data = new byte[length]; + int bytesRead = f.Stream.Read(data, 0, length); + + SendString("OKAY"); + WriteInt32(data.Length); + + // Checksum (simple byte sum, forced V3 = true per NOTPSXSerial) + uint checksum = CalculateChecksum(data); + WriteUInt32(checksum); + + // Send data using chunked writer (with per-chunk ack for V2+) + WriteDataChunked(data); + + _log?.Invoke($"PCdrv: sent {bytesRead} bytes for handle={handle}"); + } + catch (Exception ex) + { + _log?.Invoke($"PCdrv: read error: {ex.Message}"); + SendString("NOPE"); + } + } + + private void HandleWrite(CancellationToken ct) + { + SendString("OKAY"); + + int handle = ReadInt32(ct); + int length = ReadInt32(ct); + int memAddr = ReadInt32(ct); + + _log?.Invoke($"PCdrv: WRITE handle={handle} len={length}"); + + var f = FindOpenFile(handle); + if (f == null) + { + SendString("NOPE"); + return; + } + + SendString("OKAY"); + + // Read data from PSX + byte[] data = ReadBytes(length, ct); + + f.Stream.Write(data, 0, length); + f.Stream.Flush(); + + SendString("OKAY"); + WriteInt32(length); + + _log?.Invoke($"PCdrv: wrote {length} bytes to handle={handle}"); + } + + private void HandleSeek(CancellationToken ct) + { + SendString("OKAY"); + + int handle = ReadInt32(ct); + int offset = ReadInt32(ct); + int whence = ReadInt32(ct); + + _log?.Invoke($"PCdrv: SEEK handle={handle} offset={offset} whence={whence}"); + + var f = FindOpenFile(handle); + if (f == null) + { + SendString("NOPE"); + return; + } + + SeekOrigin origin = whence switch + { + 0 => SeekOrigin.Begin, + 1 => SeekOrigin.Current, + 2 => SeekOrigin.End, + _ => SeekOrigin.Begin + }; + + try + { + long newPos = f.Stream.Seek(offset, origin); + SendString("OKAY"); + WriteInt32((int)newPos); + } + catch (Exception ex) + { + _log?.Invoke($"PCdrv: seek error: {ex.Message}"); + SendString("NOPE"); + } + } + + // ═══════════════════════════════════════════════════════════════ + // PS1 output routing + // ═══════════════════════════════════════════════════════════════ + + /// + /// Routes PS1 printf output to PSXConsoleWindow (via _psxLog) if available, + /// otherwise falls back to the control panel log. + /// + private void EmitPsxLine(string text) + { + if (_psxLog != null) + _psxLog.Invoke(text); + else + _log?.Invoke($"PS1> {text}"); + } + + // ═══════════════════════════════════════════════════════════════ + // Chunked data write — matches NOTPSXSerial's WriteBytes() + // Sends data in 2048-byte chunks; for protocol V2+ Unirom + // responds with CHEK/MORE/ERR! per chunk. + // ═══════════════════════════════════════════════════════════════ + + private void WriteDataChunked(byte[] data) + { + int chunkSize = 2048; + for (int i = 0; i < data.Length; i += chunkSize) + { + int thisChunk = Math.Min(chunkSize, data.Length - i); + _port.Write(data, i, thisChunk); + + // Wait for bytes to drain + while (_port.BytesToWrite > 0) + Thread.Sleep(0); + + // V2 protocol: wait for CHEK, send chunk checksum, wait for MORE + // For now, handle this if present + if (_port.BytesToRead >= 4) + { + string resp = ReadFixedString(4); + if (resp == "CHEK") + { + ulong chunkSum = 0; + for (int j = 0; j < thisChunk; j++) + chunkSum += data[i + j]; + _port.Write(BitConverter.GetBytes((uint)chunkSum), 0, 4); + Thread.Sleep(1); + + // Wait for MORE or ERR! + string ack = WaitFor4CharResponse(5000); + if (ack == "ERR!") + { + _log?.Invoke("PCdrv: chunk checksum error, retrying..."); + i -= chunkSize; // retry + } + } + } + } + } + + // ═══════════════════════════════════════════════════════════════ + // File handle helpers + // ═══════════════════════════════════════════════════════════════ + + private int NextHandle() => _files.Count + 1; + + private PCFile FindOpenFile(string name) + { + for (int i = 0; i < _files.Count; i++) + { + if (!_files[i].Closed && _files[i].Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + return _files[i]; + } + return null; + } + + private PCFile FindOpenFile(int handle) + { + for (int i = 0; i < _files.Count; i++) + { + if (!_files[i].Closed && _files[i].Handle == handle) + return _files[i]; + } + return null; + } + + private string ResolvePath(string filename) + { + // Strip leading slashes and backslashes + filename = filename.TrimStart('/', '\\'); + return Path.Combine(_baseDir, filename); + } + + // ═══════════════════════════════════════════════════════════════ + // Low-level serial I/O + // ═══════════════════════════════════════════════════════════════ + + private int ReadInt32(CancellationToken ct) + { + byte[] buf = new byte[4]; + for (int i = 0; i < 4; i++) + buf[i] = (byte)ReadByteBlocking(ct); + return BitConverter.ToInt32(buf, 0); + } + + private uint ReadUInt32(CancellationToken ct) + { + byte[] buf = new byte[4]; + for (int i = 0; i < 4; i++) + buf[i] = (byte)ReadByteBlocking(ct); + return BitConverter.ToUInt32(buf, 0); + } + + private byte[] ReadBytes(int count, CancellationToken ct) + { + byte[] data = new byte[count]; + int pos = 0; + while (pos < count) + { + ct.ThrowIfCancellationRequested(); + if (_port.BytesToRead > 0) + { + int read = _port.Read(data, pos, Math.Min(count - pos, _port.BytesToRead)); + pos += read; + } + else + { + Thread.Sleep(1); + } + } + return data; + } + + private int ReadByteBlocking(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + if (_port.BytesToRead > 0) + return _port.ReadByte(); + Thread.Sleep(1); + } + throw new OperationCanceledException(); + } + + private string ReadNullTermString(CancellationToken ct) + { + var sb = new StringBuilder(); + while (true) + { + int b = ReadByteBlocking(ct); + if (b == 0) break; + sb.Append((char)b); + if (sb.Length > 255) break; + } + return sb.ToString(); + } + + private void SendString(string s) + { + byte[] data = Encoding.ASCII.GetBytes(s); + _port.Write(data, 0, data.Length); + } + + private void WriteInt32(int value) + { + _port.Write(BitConverter.GetBytes(value), 0, 4); + } + + private void WriteUInt32(uint value) + { + _port.Write(BitConverter.GetBytes(value), 0, 4); + } + + private string ReadFixedString(int count) + { + var sb = new StringBuilder(); + for (int i = 0; i < count; i++) + { + if (_port.BytesToRead > 0) + sb.Append((char)_port.ReadByte()); + } + return sb.ToString(); + } + + private string WaitFor4CharResponse(int timeoutMs) + { + string buffer = ""; + DateTime deadline = DateTime.Now.AddMilliseconds(timeoutMs); + while (DateTime.Now < deadline) + { + if (_port.BytesToRead > 0) + { + buffer += (char)_port.ReadByte(); + if (buffer.Length > 4) + buffer = buffer.Substring(buffer.Length - 4); + if (buffer == "MORE" || buffer == "ERR!") + return buffer; + } + else + { + Thread.Sleep(1); + } + } + return buffer; + } + + private static uint CalculateChecksum(byte[] data) + { + // Force V3-style for PCDrv reads (per NOTPSXSerial: forceProtocolV3=true) + uint sum = 0; + for (int i = 0; i < data.Length; i++) + sum += data[i]; + return sum; + } + } +} diff --git a/Editor/Core/PCdrvSerialHost.cs.meta b/Editor/Core/PCdrvSerialHost.cs.meta new file mode 100644 index 0000000..ccb611f --- /dev/null +++ b/Editor/Core/PCdrvSerialHost.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d27c6a94b1c1f07418799b65d13f7097 \ No newline at end of file diff --git a/Editor/Core/PSXAudioConverter.cs b/Editor/Core/PSXAudioConverter.cs new file mode 100644 index 0000000..bd7a340 --- /dev/null +++ b/Editor/Core/PSXAudioConverter.cs @@ -0,0 +1,318 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using SplashEdit.RuntimeCode; +using UnityEditor; +using UnityEngine; +using Debug = UnityEngine.Debug; + +namespace SplashEdit.EditorCode +{ + /// + /// Downloads psxavenc and converts WAV audio to PS1 SPU ADPCM format. + /// psxavenc is the standard tool for PS1 audio encoding from the + /// WonderfulToolchain project. + /// + [InitializeOnLoad] + public static class PSXAudioConverter + { + static PSXAudioConverter() + { + // Register the converter delegate so Runtime code can call it + // without directly referencing this Editor assembly. + PSXSceneExporter.AudioConvertDelegate = ConvertToADPCM; + } + + private const string PSXAVENC_VERSION = "v0.3.1"; + private const string PSXAVENC_RELEASE_BASE = + "https://github.com/WonderfulToolchain/psxavenc/releases/download/"; + + private static readonly HttpClient _http = new HttpClient(); + + /// + /// Path to the psxavenc binary inside .tools/ + /// + public static string PsxavencBinary + { + get + { + string dir = Path.Combine(SplashBuildPaths.ToolsDir, "psxavenc"); + if (Application.platform == RuntimePlatform.WindowsEditor) + return Path.Combine(dir, "psxavenc.exe"); + return Path.Combine(dir, "psxavenc"); + } + } + + public static bool IsInstalled() => File.Exists(PsxavencBinary); + + /// + /// Downloads and installs psxavenc from the official GitHub releases. + /// + 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"; + break; + case RuntimePlatform.LinuxEditor: + platformSuffix = "x86_64-unknown-linux-gnu"; + archiveName = $"psxavenc-{PSXAVENC_VERSION}-{platformSuffix}.tar.gz"; + break; + default: + log?.Invoke("Only Windows and Linux are supported."); + return false; + } + + string downloadUrl = $"{PSXAVENC_RELEASE_BASE}{PSXAVENC_VERSION}/{archiveName}"; + log?.Invoke($"Downloading psxavenc: {downloadUrl}"); + + try + { + string tempFile = Path.Combine(Path.GetTempPath(), archiveName); + EditorUtility.DisplayProgressBar("Downloading psxavenc", "Downloading...", 0.1f); + + using (var response = await _http.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead)) + { + response.EnsureSuccessStatusCode(); + long? totalBytes = response.Content.Headers.ContentLength; + long downloaded = 0; + + using (var fs = File.Create(tempFile)) + using (var stream = await response.Content.ReadAsStreamAsync()) + { + byte[] buffer = new byte[81920]; + int bytesRead; + while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + await fs.WriteAsync(buffer, 0, bytesRead); + downloaded += bytesRead; + if (totalBytes.HasValue) + { + float progress = (float)downloaded / totalBytes.Value; + EditorUtility.DisplayProgressBar("Downloading psxavenc", + $"{downloaded / 1024}/{totalBytes.Value / 1024} KB", progress); + } + } + } + } + + log?.Invoke("Extracting..."); + EditorUtility.DisplayProgressBar("Installing psxavenc", "Extracting...", 0.9f); + + string installDir = Path.Combine(SplashBuildPaths.ToolsDir, "psxavenc"); + if (Directory.Exists(installDir)) + Directory.Delete(installDir, true); + Directory.CreateDirectory(installDir); + + if (tempFile.EndsWith(".zip")) + { + System.IO.Compression.ZipFile.ExtractToDirectory(tempFile, installDir); + } + else + { + // tar.gz extraction — use system tar + var psi = new ProcessStartInfo + { + FileName = "tar", + Arguments = $"xzf \"{tempFile}\" -C \"{installDir}\" --strip-components=1", + UseShellExecute = false, + CreateNoWindow = true + }; + var proc = Process.Start(psi); + proc.WaitForExit(); + } + + // Fix nested directory (sometimes archives have one extra level) + FixNestedDirectory(installDir); + + try { File.Delete(tempFile); } catch { } + + EditorUtility.ClearProgressBar(); + + if (IsInstalled()) + { + // Make executable on Linux + if (Application.platform == RuntimePlatform.LinuxEditor) + { + var chmod = Process.Start("chmod", $"+x \"{PsxavencBinary}\""); + chmod?.WaitForExit(); + } + log?.Invoke("psxavenc installed successfully!"); + return true; + } + + log?.Invoke($"psxavenc binary not found at: {PsxavencBinary}"); + return false; + } + catch (Exception ex) + { + log?.Invoke($"psxavenc download failed: {ex.Message}"); + EditorUtility.ClearProgressBar(); + return false; + } + } + + private static void FixNestedDirectory(string dir) + { + // If extraction created exactly one subdirectory, flatten it + var subdirs = Directory.GetDirectories(dir); + if (subdirs.Length == 1) + { + string nested = subdirs[0]; + foreach (string file in Directory.GetFiles(nested)) + { + string dest = Path.Combine(dir, Path.GetFileName(file)); + if (!File.Exists(dest)) File.Move(file, dest); + } + foreach (string sub in Directory.GetDirectories(nested)) + { + string dest = Path.Combine(dir, Path.GetFileName(sub)); + if (!Directory.Exists(dest)) Directory.Move(sub, dest); + } + try { Directory.Delete(nested, true); } catch { } + } + } + + /// + /// Converts a Unity AudioClip to PS1 SPU ADPCM format using psxavenc. + /// Returns the ADPCM byte array, or null on failure. + /// + public static byte[] ConvertToADPCM(AudioClip clip, int targetSampleRate, bool loop) + { + if (!IsInstalled()) + { + Debug.LogError("[SplashEdit] psxavenc not installed. Install it from the Setup tab."); + return null; + } + + if (clip == null) + { + Debug.LogError("[SplashEdit] AudioClip is null."); + return null; + } + + // Export Unity AudioClip to a temporary WAV file + string tempWav = Path.Combine(Path.GetTempPath(), $"psx_audio_{clip.name}.wav"); + string tempVag = Path.Combine(Path.GetTempPath(), $"psx_audio_{clip.name}.vag"); + + try + { + ExportWav(clip, tempWav); + + // Run psxavenc: convert WAV to SPU ADPCM + // -t spu: raw SPU ADPCM output (no header, ready for DMA upload) + // -f : target sample rate + // -L: enable looping flag in the last ADPCM block + string loopFlag = loop ? "-L" : ""; + string args = $"-t spu -f {targetSampleRate} {loopFlag} \"{tempWav}\" \"{tempVag}\""; + + var psi = new ProcessStartInfo + { + FileName = PsxavencBinary, + Arguments = args, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + var process = Process.Start(psi); + string stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + Debug.LogError($"[SplashEdit] psxavenc failed: {stderr}"); + return null; + } + + if (!File.Exists(tempVag)) + { + Debug.LogError("[SplashEdit] psxavenc produced no output file."); + return null; + } + + // -t spu outputs raw SPU ADPCM blocks (no header) — use directly. + byte[] adpcm = File.ReadAllBytes(tempVag); + if (adpcm.Length == 0) + { + Debug.LogError("[SplashEdit] psxavenc produced empty output."); + return null; + } + return adpcm; + } + finally + { + try { if (File.Exists(tempWav)) File.Delete(tempWav); } catch { } + try { if (File.Exists(tempVag)) File.Delete(tempVag); } catch { } + } + } + + /// + /// Exports a Unity AudioClip to a 16-bit mono WAV file. + /// + private static void ExportWav(AudioClip clip, string path) + { + float[] samples = new float[clip.samples * clip.channels]; + clip.GetData(samples, 0); + + // Downmix to mono if stereo + float[] mono; + if (clip.channels > 1) + { + mono = new float[clip.samples]; + for (int i = 0; i < clip.samples; i++) + { + float sum = 0; + for (int ch = 0; ch < clip.channels; ch++) + sum += samples[i * clip.channels + ch]; + mono[i] = sum / clip.channels; + } + } + else + { + mono = samples; + } + + // Write WAV + using (var fs = new FileStream(path, FileMode.Create)) + using (var writer = new BinaryWriter(fs)) + { + int sampleCount = mono.Length; + int dataSize = sampleCount * 2; // 16-bit + int fileSize = 44 + dataSize; + + // RIFF header + writer.Write(new char[] { 'R', 'I', 'F', 'F' }); + writer.Write(fileSize - 8); + writer.Write(new char[] { 'W', 'A', 'V', 'E' }); + + // fmt chunk + writer.Write(new char[] { 'f', 'm', 't', ' ' }); + writer.Write(16); // chunk size + writer.Write((short)1); // PCM + writer.Write((short)1); // mono + writer.Write(clip.frequency); + writer.Write(clip.frequency * 2); // byte rate + writer.Write((short)2); // block align + writer.Write((short)16); // bits per sample + + // data chunk + writer.Write(new char[] { 'd', 'a', 't', 'a' }); + writer.Write(dataSize); + + for (int i = 0; i < sampleCount; i++) + { + short sample = (short)(Mathf.Clamp(mono[i], -1f, 1f) * 32767f); + writer.Write(sample); + } + } + } + } +} diff --git a/Editor/Core/PSXAudioConverter.cs.meta b/Editor/Core/PSXAudioConverter.cs.meta new file mode 100644 index 0000000..4e6785f --- /dev/null +++ b/Editor/Core/PSXAudioConverter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 372b2ef07e125584ba43312b0662d7ac \ No newline at end of file diff --git a/Editor/Core/PSXConsoleWindow.cs b/Editor/Core/PSXConsoleWindow.cs new file mode 100644 index 0000000..549a97e --- /dev/null +++ b/Editor/Core/PSXConsoleWindow.cs @@ -0,0 +1,418 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Threading; +using UnityEditor; +using UnityEngine; + +namespace SplashEdit.EditorCode +{ + /// + /// A live console window that displays stdout/stderr from PCSX-Redux and PSX build output. + /// Opens automatically when a build starts or the emulator launches. + /// + public class PSXConsoleWindow : EditorWindow + { + private const string WINDOW_TITLE = "PSX Console"; + private const string MENU_PATH = "PlayStation 1/PSX Console"; + private const int MAX_LINES = 2000; + private const int TRIM_AMOUNT = 500; + + // ── Shared state (set by SplashControlPanel) ── + private static Process _process; + private static readonly List _lines = new List(); + private static readonly object _lock = new object(); + private static volatile bool _autoScroll = true; + private static volatile bool _reading; + + // ── Instance state ── + private Vector2 _scrollPos; + private string _filterText = ""; + private bool _showStdout = true; + private bool _showStderr = true; + private bool _wrapLines = true; + private GUIStyle _monoStyle; + private GUIStyle _monoStyleErr; + private GUIStyle _monoStyleSelected; + private int _lastLineCount; + + // ── Selection state (for shift-click range and right-click copy) ── + private int _selectionAnchor = -1; // first clicked line index (into _lines) + private int _selectionEnd = -1; // last shift-clicked line index (into _lines) + + private struct LogLine + { + public string text; + public bool isError; + public string timestamp; + } + + // ═══════════════════════════════════════════════════════════════ + // Menu + // ═══════════════════════════════════════════════════════════════ + + [MenuItem(MENU_PATH, false, 10)] + public static void ShowWindow() + { + var window = GetWindow(); + window.titleContent = new GUIContent(WINDOW_TITLE, EditorGUIUtility.IconContent("d_UnityEditor.ConsoleWindow").image); + window.minSize = new Vector2(400, 200); + window.Show(); + } + + // ═══════════════════════════════════════════════════════════════ + // Public API — called by SplashControlPanel + // ═══════════════════════════════════════════════════════════════ + + /// + /// Adds a line to the console from any source (serial host, emulator fallback, etc.). + /// Thread-safe. Works whether the window is open or not. + /// + public static void AddLine(string text, bool isError = false) + { + if (string.IsNullOrEmpty(text)) return; + + lock (_lock) + { + _lines.Add(new LogLine + { + text = text, + isError = isError, + timestamp = DateTime.Now.ToString("HH:mm:ss.fff") + }); + + if (_lines.Count > MAX_LINES) + { + _lines.RemoveRange(0, TRIM_AMOUNT); + } + } + + // Repaint is handled by OnEditorUpdate polling _lines.Count changes. + // Do NOT call EditorApplication.delayCall here - AddLine is called + // from background threads (serial host, process readers) and + // delayCall is not thread-safe. It kills the calling thread. + } + + /// + /// Opens the console window and begins capturing output from the given process. + /// The process must have RedirectStandardOutput and RedirectStandardError enabled. + /// + public static PSXConsoleWindow Attach(Process process) + { + // Stop reading from any previous process (but keep existing lines) + _reading = false; + + _process = process; + + var window = GetWindow(); + window.titleContent = new GUIContent(WINDOW_TITLE, EditorGUIUtility.IconContent("d_UnityEditor.ConsoleWindow").image); + window.minSize = new Vector2(400, 200); + window.Show(); + + // Start async readers + _reading = true; + StartReader(process.StandardOutput, false); + StartReader(process.StandardError, true); + + return window; + } + + /// + /// Stops reading and detaches from the current process. + /// + public static void Detach() + { + _reading = false; + _process = null; + } + + // ═══════════════════════════════════════════════════════════════ + // Async readers + // ═══════════════════════════════════════════════════════════════ + + private static void StartReader(System.IO.StreamReader reader, bool isError) + { + var thread = new Thread(() => + { + try + { + while (_reading && !reader.EndOfStream) + { + string line = reader.ReadLine(); + if (line == null) break; + + lock (_lock) + { + _lines.Add(new LogLine + { + text = line, + isError = isError, + timestamp = DateTime.Now.ToString("HH:mm:ss.fff") + }); + + // Trim if too many lines + if (_lines.Count > MAX_LINES) + { + _lines.RemoveRange(0, TRIM_AMOUNT); + } + } + } + } + catch (Exception) + { + // Stream closed — normal when process exits + } + }) + { + IsBackground = true, + Name = isError ? "PSXConsole-stderr" : "PSXConsole-stdout" + }; + thread.Start(); + } + + // ═══════════════════════════════════════════════════════════════ + // Window lifecycle + // ═══════════════════════════════════════════════════════════════ + + private void OnEnable() + { + EditorApplication.update += OnEditorUpdate; + } + + private void OnDisable() + { + EditorApplication.update -= OnEditorUpdate; + } + + private void OnEditorUpdate() + { + // Repaint when new lines arrive + int count; + lock (_lock) { count = _lines.Count; } + if (count != _lastLineCount) + { + _lastLineCount = count; + Repaint(); + } + } + + // ═══════════════════════════════════════════════════════════════ + // GUI + // ═══════════════════════════════════════════════════════════════ + + private void EnsureStyles() + { + if (_monoStyle == null) + { + _monoStyle = new GUIStyle(EditorStyles.label) + { + font = Font.CreateDynamicFontFromOSFont("Consolas", 12), + fontSize = 11, + richText = false, + wordWrap = _wrapLines, + normal = { textColor = new Color(0.85f, 0.85f, 0.85f) }, + padding = new RectOffset(4, 4, 1, 1), + margin = new RectOffset(0, 0, 0, 0) + }; + } + if (_monoStyleErr == null) + { + _monoStyleErr = new GUIStyle(_monoStyle) + { + normal = { textColor = new Color(1f, 0.45f, 0.4f) } + }; + } + if (_monoStyleSelected == null) + { + _monoStyleSelected = new GUIStyle(_monoStyle) + { + normal = + { + textColor = new Color(0.95f, 0.95f, 0.95f), + background = MakeSolidTexture(new Color(0.25f, 0.40f, 0.65f, 0.6f)) + } + }; + } + _monoStyle.wordWrap = _wrapLines; + _monoStyleErr.wordWrap = _wrapLines; + _monoStyleSelected.wordWrap = _wrapLines; + } + + private static Texture2D MakeSolidTexture(Color color) + { + var tex = new Texture2D(1, 1); + tex.SetPixel(0, 0, color); + tex.Apply(); + return tex; + } + + private void OnGUI() + { + EnsureStyles(); + DrawToolbar(); + DrawConsoleOutput(); + } + + private void DrawToolbar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + // Process status + bool alive = _process != null && !_process.HasExited; + var statusColor = GUI.contentColor; + GUI.contentColor = alive ? Color.green : Color.gray; + GUILayout.Label(alive ? "● Live" : "● Stopped", EditorStyles.toolbarButton, GUILayout.Width(60)); + GUI.contentColor = statusColor; + + // Filter + GUILayout.Label("Filter:", GUILayout.Width(40)); + _filterText = EditorGUILayout.TextField(_filterText, EditorStyles.toolbarSearchField, GUILayout.Width(150)); + + GUILayout.FlexibleSpace(); + + // Toggles + _showStdout = GUILayout.Toggle(_showStdout, "stdout", EditorStyles.toolbarButton, GUILayout.Width(50)); + _showStderr = GUILayout.Toggle(_showStderr, "stderr", EditorStyles.toolbarButton, GUILayout.Width(50)); + _wrapLines = GUILayout.Toggle(_wrapLines, "Wrap", EditorStyles.toolbarButton, GUILayout.Width(40)); + + // Auto-scroll + _autoScroll = GUILayout.Toggle(_autoScroll, "Auto↓", EditorStyles.toolbarButton, GUILayout.Width(50)); + + // Clear + if (GUILayout.Button("Clear", EditorStyles.toolbarButton, GUILayout.Width(45))) + { + lock (_lock) { _lines.Clear(); } + } + + // Copy all + if (GUILayout.Button("Copy", EditorStyles.toolbarButton, GUILayout.Width(40))) + { + CopyToClipboard(); + } + + EditorGUILayout.EndHorizontal(); + } + + private void DrawConsoleOutput() + { + // Simple scroll view - no BeginArea/EndArea mixing that causes layout errors. + _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.ExpandHeight(true)); + + // Dark background behind the scroll content + Rect scrollBg = EditorGUILayout.BeginVertical(); + EditorGUI.DrawRect(scrollBg, new Color(0.13f, 0.13f, 0.15f)); + + bool hasFilter = !string.IsNullOrEmpty(_filterText); + string filterLower = hasFilter ? _filterText.ToLowerInvariant() : null; + + int selMin = Mathf.Min(_selectionAnchor, _selectionEnd); + int selMax = Mathf.Max(_selectionAnchor, _selectionEnd); + bool hasSelection = _selectionAnchor >= 0 && _selectionEnd >= 0; + + lock (_lock) + { + if (_lines.Count == 0) + { + GUILayout.Label("Waiting for output...", EditorStyles.centeredGreyMiniLabel); + } + + for (int i = 0; i < _lines.Count; i++) + { + var line = _lines[i]; + + if (line.isError && !_showStderr) continue; + if (!line.isError && !_showStdout) continue; + if (hasFilter && line.text.ToLowerInvariant().IndexOf(filterLower, StringComparison.Ordinal) < 0) + continue; + + bool selected = hasSelection && i >= selMin && i <= selMax; + GUIStyle style = selected ? _monoStyleSelected : (line.isError ? _monoStyleErr : _monoStyle); + + string label = $"[{line.timestamp}] {line.text}"; + GUILayout.Label(label, style); + + // Handle click/right-click on last drawn rect + Rect lineRect = GUILayoutUtility.GetLastRect(); + Event evt = Event.current; + if (evt.type == EventType.MouseDown && lineRect.Contains(evt.mousePosition)) + { + if (evt.button == 0) + { + if (evt.shift && _selectionAnchor >= 0) + _selectionEnd = i; + else + { + _selectionAnchor = i; + _selectionEnd = i; + } + evt.Use(); + Repaint(); + } + else if (evt.button == 1) + { + int clickedLine = i; + bool lineInSelection = hasSelection && clickedLine >= selMin && clickedLine <= selMax; + var menu = new GenericMenu(); + if (lineInSelection && selMin != selMax) + { + menu.AddItem(new GUIContent("Copy selected lines"), false, () => CopyRange(selMin, selMax)); + menu.AddSeparator(""); + } + menu.AddItem(new GUIContent("Copy this line"), false, () => + { + string text; + lock (_lock) + { + text = clickedLine < _lines.Count + ? $"[{_lines[clickedLine].timestamp}] {_lines[clickedLine].text}" + : ""; + } + EditorGUIUtility.systemCopyBuffer = text; + }); + menu.ShowAsContext(); + evt.Use(); + } + } + } + } + + EditorGUILayout.EndVertical(); + + if (_autoScroll) + _scrollPos.y = float.MaxValue; + + EditorGUILayout.EndScrollView(); + } + + private void CopyRange(int fromIndex, int toIndex) + { + var sb = new StringBuilder(); + lock (_lock) + { + int lo = Mathf.Min(fromIndex, toIndex); + int hi = Mathf.Max(fromIndex, toIndex); + for (int i = lo; i <= hi && i < _lines.Count; i++) + { + string prefix = _lines[i].isError ? "[ERR]" : "[OUT]"; + sb.AppendLine($"[{_lines[i].timestamp}] {prefix} {_lines[i].text}"); + } + } + EditorGUIUtility.systemCopyBuffer = sb.ToString(); + } + + private void CopyToClipboard() + { + var sb = new StringBuilder(); + lock (_lock) + { + foreach (var line in _lines) + { + string prefix = line.isError ? "[ERR]" : "[OUT]"; + sb.AppendLine($"[{line.timestamp}] {prefix} {line.text}"); + } + } + EditorGUIUtility.systemCopyBuffer = sb.ToString(); + } + } +} diff --git a/Editor/Core/PSXConsoleWindow.cs.meta b/Editor/Core/PSXConsoleWindow.cs.meta new file mode 100644 index 0000000..1ecdd57 --- /dev/null +++ b/Editor/Core/PSXConsoleWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c4e13fc5b859ac14099eb9f259ba11f0 \ No newline at end of file diff --git a/Editor/Core/PSXEditorStyles.cs b/Editor/Core/PSXEditorStyles.cs new file mode 100644 index 0000000..9cfb1b0 --- /dev/null +++ b/Editor/Core/PSXEditorStyles.cs @@ -0,0 +1,777 @@ +using UnityEngine; +using UnityEditor; +using System.Collections.Generic; + +namespace SplashEdit.EditorCode +{ + /// + /// Unified styling system for PSX Splash editor windows. + /// Provides consistent colors, fonts, icons, and GUIStyles across the entire plugin. + /// + [InitializeOnLoad] + public static class PSXEditorStyles + { + static PSXEditorStyles() + { + AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; + } + + private static void OnBeforeAssemblyReload() + { + foreach (var tex in _textureCache.Values) + { + if (tex != null) + Object.DestroyImmediate(tex); + } + _textureCache.Clear(); + _styleCache.Clear(); + } + + #region Colors - PS1 Inspired Palette + + // Primary colors + public static readonly Color PrimaryBlue = new Color(0.15f, 0.35f, 0.65f); + public static readonly Color PrimaryDark = new Color(0.12f, 0.12f, 0.14f); + public static readonly Color PrimaryLight = new Color(0.22f, 0.22f, 0.25f); + + // Accent colors + public static readonly Color AccentGold = new Color(0.95f, 0.75f, 0.2f); + public static readonly Color AccentCyan = new Color(0.3f, 0.85f, 0.95f); + public static readonly Color AccentMagenta = new Color(0.85f, 0.3f, 0.65f); + public static readonly Color AccentGreen = new Color(0.35f, 0.85f, 0.45f); + + // Semantic colors + public static readonly Color Success = new Color(0.35f, 0.8f, 0.4f); + public static readonly Color Warning = new Color(0.95f, 0.75f, 0.2f); + public static readonly Color Error = new Color(0.9f, 0.3f, 0.35f); + public static readonly Color Info = new Color(0.4f, 0.7f, 0.95f); + + // Background colors + public static readonly Color BackgroundDark = new Color(0.15f, 0.15f, 0.17f); + public static readonly Color BackgroundMedium = new Color(0.2f, 0.2f, 0.22f); + public static readonly Color BackgroundLight = new Color(0.28f, 0.28f, 0.3f); + public static readonly Color BackgroundHighlight = new Color(0.25f, 0.35f, 0.5f); + + // Text colors + public static readonly Color TextPrimary = new Color(0.9f, 0.9f, 0.92f); + public static readonly Color TextSecondary = new Color(0.65f, 0.65f, 0.7f); + public static readonly Color TextMuted = new Color(0.45f, 0.45f, 0.5f); + + // VRAM specific colors + public static readonly Color VRAMFrameBuffer1 = new Color(1f, 0.3f, 0.3f, 0.4f); + public static readonly Color VRAMFrameBuffer2 = new Color(0.3f, 1f, 0.3f, 0.4f); + public static readonly Color VRAMProhibited = new Color(1f, 0f, 0f, 0.25f); + public static readonly Color VRAMTexture = new Color(0.3f, 0.6f, 1f, 0.5f); + public static readonly Color VRAMCLUT = new Color(1f, 0.6f, 0.3f, 0.5f); + + #endregion + + #region Cached Styles + + private static Dictionary _styleCache = new Dictionary(); + private static Dictionary _textureCache = new Dictionary(); + + #endregion + + #region Textures + + public static Texture2D GetSolidTexture(Color color) + { + string key = $"solid_{color.r}_{color.g}_{color.b}_{color.a}"; + if (!_textureCache.TryGetValue(key, out var tex) || tex == null) + { + tex = new Texture2D(1, 1); + tex.SetPixel(0, 0, color); + tex.Apply(); + tex.hideFlags = HideFlags.HideAndDontSave; + _textureCache[key] = tex; + } + return tex; + } + + public static Texture2D CreateGradientTexture(int width, int height, Color top, Color bottom) + { + Texture2D tex = new Texture2D(width, height); + for (int y = 0; y < height; y++) + { + Color c = Color.Lerp(bottom, top, (float)y / height); + for (int x = 0; x < width; x++) + { + tex.SetPixel(x, y, c); + } + } + tex.Apply(); + tex.hideFlags = HideFlags.HideAndDontSave; + return tex; + } + + public static Texture2D CreateRoundedRect(int width, int height, int radius, Color fillColor, Color borderColor, int borderWidth = 1) + { + Texture2D tex = new Texture2D(width, height); + Color transparent = new Color(0, 0, 0, 0); + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + // Check if pixel is within rounded corners + bool inCorner = false; + float dist = 0; + + // Top-left + if (x < radius && y > height - radius - 1) + { + dist = Vector2.Distance(new Vector2(x, y), new Vector2(radius, height - radius - 1)); + inCorner = true; + } + // Top-right + else if (x > width - radius - 1 && y > height - radius - 1) + { + dist = Vector2.Distance(new Vector2(x, y), new Vector2(width - radius - 1, height - radius - 1)); + inCorner = true; + } + // Bottom-left + else if (x < radius && y < radius) + { + dist = Vector2.Distance(new Vector2(x, y), new Vector2(radius, radius)); + inCorner = true; + } + // Bottom-right + else if (x > width - radius - 1 && y < radius) + { + dist = Vector2.Distance(new Vector2(x, y), new Vector2(width - radius - 1, radius)); + inCorner = true; + } + + if (inCorner) + { + if (dist > radius) + tex.SetPixel(x, y, transparent); + else if (dist > radius - borderWidth) + tex.SetPixel(x, y, borderColor); + else + tex.SetPixel(x, y, fillColor); + } + else + { + // Check border + if (x < borderWidth || x >= width - borderWidth || y < borderWidth || y >= height - borderWidth) + tex.SetPixel(x, y, borderColor); + else + tex.SetPixel(x, y, fillColor); + } + } + } + + tex.Apply(); + tex.hideFlags = HideFlags.HideAndDontSave; + return tex; + } + + #endregion + + #region GUIStyles + + private static GUIStyle _windowHeader; + public static GUIStyle WindowHeader + { + get + { + if (_windowHeader == null) + { + _windowHeader = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = 18, + alignment = TextAnchor.MiddleLeft, + padding = new RectOffset(10, 10, 8, 8), + margin = new RectOffset(0, 0, 0, 5) + }; + _windowHeader.normal.textColor = TextPrimary; + } + return _windowHeader; + } + } + + private static GUIStyle _sectionHeader; + public static GUIStyle SectionHeader + { + get + { + if (_sectionHeader == null) + { + _sectionHeader = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = 14, + alignment = TextAnchor.MiddleLeft, + padding = new RectOffset(5, 5, 8, 8), + margin = new RectOffset(0, 0, 10, 5) + }; + _sectionHeader.normal.textColor = TextPrimary; + } + return _sectionHeader; + } + } + + private static GUIStyle _cardStyle; + public static GUIStyle CardStyle + { + get + { + if (_cardStyle == null) + { + _cardStyle = new GUIStyle() + { + padding = new RectOffset(12, 12, 10, 10), + margin = new RectOffset(5, 5, 5, 5), + normal = { background = GetSolidTexture(BackgroundMedium) } + }; + } + return _cardStyle; + } + } + + private static GUIStyle _cardHeaderStyle; + public static GUIStyle CardHeaderStyle + { + get + { + if (_cardHeaderStyle == null) + { + _cardHeaderStyle = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = 13, + padding = new RectOffset(0, 0, 0, 5), + margin = new RectOffset(0, 0, 0, 5) + }; + _cardHeaderStyle.normal.textColor = TextPrimary; + } + return _cardHeaderStyle; + } + } + + private static GUIStyle _primaryButton; + public static GUIStyle PrimaryButton + { + get + { + if (_primaryButton == null) + { + _primaryButton = new GUIStyle(GUI.skin.button) + { + fontSize = 12, + fontStyle = FontStyle.Bold, + padding = new RectOffset(15, 15, 8, 8), + margin = new RectOffset(5, 5, 5, 5), + alignment = TextAnchor.MiddleCenter + }; + _primaryButton.normal.textColor = Color.white; + _primaryButton.normal.background = GetSolidTexture(PrimaryBlue); + _primaryButton.hover.background = GetSolidTexture(PrimaryBlue * 1.2f); + _primaryButton.active.background = GetSolidTexture(PrimaryBlue * 0.8f); + } + return _primaryButton; + } + } + + private static GUIStyle _secondaryButton; + public static GUIStyle SecondaryButton + { + get + { + if (_secondaryButton == null) + { + _secondaryButton = new GUIStyle(GUI.skin.button) + { + fontSize = 11, + padding = new RectOffset(12, 12, 6, 6), + margin = new RectOffset(3, 3, 3, 3), + alignment = TextAnchor.MiddleCenter + }; + _secondaryButton.normal.textColor = TextPrimary; + _secondaryButton.normal.background = GetSolidTexture(BackgroundLight); + _secondaryButton.hover.background = GetSolidTexture(BackgroundLight * 1.3f); + _secondaryButton.active.background = GetSolidTexture(BackgroundLight * 0.7f); + } + return _secondaryButton; + } + } + + private static GUIStyle _successButton; + public static GUIStyle SuccessButton + { + get + { + if (_successButton == null) + { + _successButton = new GUIStyle(PrimaryButton); + _successButton.normal.background = GetSolidTexture(Success * 0.8f); + _successButton.hover.background = GetSolidTexture(Success); + _successButton.active.background = GetSolidTexture(Success * 0.6f); + } + return _successButton; + } + } + + private static GUIStyle _dangerButton; + public static GUIStyle DangerButton + { + get + { + if (_dangerButton == null) + { + _dangerButton = new GUIStyle(PrimaryButton); + _dangerButton.normal.background = GetSolidTexture(Error * 0.8f); + _dangerButton.hover.background = GetSolidTexture(Error); + _dangerButton.active.background = GetSolidTexture(Error * 0.6f); + } + return _dangerButton; + } + } + + private static GUIStyle _statusBadge; + public static GUIStyle StatusBadge + { + get + { + if (_statusBadge == null) + { + _statusBadge = new GUIStyle(EditorStyles.label) + { + fontSize = 10, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleCenter, + padding = new RectOffset(8, 8, 3, 3), + margin = new RectOffset(3, 3, 3, 3) + }; + } + return _statusBadge; + } + } + + private static GUIStyle _toolbarStyle; + public static GUIStyle ToolbarStyle + { + get + { + if (_toolbarStyle == null) + { + _toolbarStyle = new GUIStyle() + { + padding = new RectOffset(8, 8, 6, 6), + margin = new RectOffset(0, 0, 0, 0), + normal = { background = GetSolidTexture(BackgroundDark) } + }; + } + return _toolbarStyle; + } + } + + private static GUIStyle _infoBox; + public static GUIStyle InfoBox + { + get + { + if (_infoBox == null) + { + _infoBox = new GUIStyle(EditorStyles.helpBox) + { + fontSize = 11, + padding = new RectOffset(10, 10, 8, 8), + margin = new RectOffset(5, 5, 5, 5), + richText = true + }; + } + return _infoBox; + } + } + + private static GUIStyle _centeredLabel; + public static GUIStyle CenteredLabel + { + get + { + if (_centeredLabel == null) + { + _centeredLabel = new GUIStyle(EditorStyles.label) + { + alignment = TextAnchor.MiddleCenter, + wordWrap = true + }; + } + return _centeredLabel; + } + } + + private static GUIStyle _richLabel; + public static GUIStyle RichLabel + { + get + { + if (_richLabel == null) + { + _richLabel = new GUIStyle(EditorStyles.label) + { + richText = true, + wordWrap = true + }; + } + return _richLabel; + } + } + + private static GUIStyle _foldoutHeader; + public static GUIStyle FoldoutHeader + { + get + { + if (_foldoutHeader == null) + { + _foldoutHeader = new GUIStyle(EditorStyles.foldout) + { + fontSize = 12, + fontStyle = FontStyle.Bold, + padding = new RectOffset(15, 0, 3, 3) + }; + _foldoutHeader.normal.textColor = TextPrimary; + } + return _foldoutHeader; + } + } + + #endregion + + #region Drawing Helpers + + /// + /// Draw a horizontal separator line + /// + public static void DrawSeparator(float topMargin = 5, float bottomMargin = 5) + { + GUILayout.Space(topMargin); + var rect = GUILayoutUtility.GetRect(1, 1, GUILayout.ExpandWidth(true)); + EditorGUI.DrawRect(rect, TextMuted * 0.5f); + GUILayout.Space(bottomMargin); + } + + /// + /// Draw a status badge with color + /// + public static void DrawStatusBadge(string text, Color color, float width = 80) + { + var style = new GUIStyle(StatusBadge); + style.normal.background = GetSolidTexture(color); + style.normal.textColor = GetContrastColor(color); + GUILayout.Label(text, style, GUILayout.Width(width)); + } + + /// + /// Draw a progress bar + /// + public static void DrawProgressBar(float progress, string label, Color fillColor, float height = 20) + { + var rect = GUILayoutUtility.GetRect(100, height, GUILayout.ExpandWidth(true)); + + // Background + EditorGUI.DrawRect(rect, BackgroundDark); + + // Fill + var fillRect = new Rect(rect.x, rect.y, rect.width * Mathf.Clamp01(progress), rect.height); + EditorGUI.DrawRect(fillRect, fillColor); + + // Border + DrawBorder(rect, TextMuted * 0.5f, 1); + + // Label + var labelStyle = new GUIStyle(EditorStyles.label) + { + alignment = TextAnchor.MiddleCenter, + normal = { textColor = TextPrimary } + }; + GUI.Label(rect, $"{label} ({progress * 100:F0}%)", labelStyle); + } + + /// + /// Draw a border around a rect + /// + public static void DrawBorder(Rect rect, Color color, int thickness = 1) + { + // Top + EditorGUI.DrawRect(new Rect(rect.x, rect.y, rect.width, thickness), color); + // Bottom + EditorGUI.DrawRect(new Rect(rect.x, rect.yMax - thickness, rect.width, thickness), color); + // Left + EditorGUI.DrawRect(new Rect(rect.x, rect.y, thickness, rect.height), color); + // Right + EditorGUI.DrawRect(new Rect(rect.xMax - thickness, rect.y, thickness, rect.height), color); + } + + /// + /// Get a contrasting text color for a background + /// + public static Color GetContrastColor(Color background) + { + float luminance = 0.299f * background.r + 0.587f * background.g + 0.114f * background.b; + return luminance > 0.5f ? Color.black : Color.white; + } + + /// + /// Begin a styled card section + /// + public static void BeginCard() + { + EditorGUILayout.BeginVertical(CardStyle); + } + + /// + /// End a styled card section + /// + public static void EndCard() + { + EditorGUILayout.EndVertical(); + } + + /// + /// Draw a card with header and content + /// + public static bool DrawFoldoutCard(string title, bool isExpanded, System.Action drawContent) + { + EditorGUILayout.BeginVertical(CardStyle); + + EditorGUILayout.BeginHorizontal(); + isExpanded = EditorGUILayout.Foldout(isExpanded, title, true, FoldoutHeader); + EditorGUILayout.EndHorizontal(); + + if (isExpanded) + { + EditorGUILayout.Space(5); + drawContent?.Invoke(); + } + + EditorGUILayout.EndVertical(); + + return isExpanded; + } + + /// + /// Draw a large icon button (for dashboard) + /// + public static bool DrawIconButton(string label, string icon, string description, float width = 150, float height = 100) + { + var rect = GUILayoutUtility.GetRect(width, height); + + bool isHover = rect.Contains(Event.current.mousePosition); + var bgColor = isHover ? BackgroundHighlight : BackgroundMedium; + + EditorGUI.DrawRect(rect, bgColor); + DrawBorder(rect, isHover ? AccentCyan : TextMuted * 0.3f, 1); + + // Icon (using Unity's built-in icons or a placeholder) + var iconRect = new Rect(rect.x + rect.width / 2 - 16, rect.y + 15, 32, 32); + var iconContent = EditorGUIUtility.IconContent(icon); + if (iconContent != null && iconContent.image != null) + { + GUI.DrawTexture(iconRect, iconContent.image); + } + + // Label + var labelRect = new Rect(rect.x, rect.y + 52, rect.width, 20); + var labelStyle = new GUIStyle(EditorStyles.boldLabel) + { + alignment = TextAnchor.MiddleCenter, + normal = { textColor = TextPrimary } + }; + GUI.Label(labelRect, label, labelStyle); + + // Description + var descRect = new Rect(rect.x + 5, rect.y + 70, rect.width - 10, 25); + var descStyle = new GUIStyle(EditorStyles.miniLabel) + { + alignment = TextAnchor.UpperCenter, + wordWrap = true, + normal = { textColor = TextSecondary } + }; + GUI.Label(descRect, description, descStyle); + + return GUI.Button(rect, GUIContent.none, GUIStyle.none); + } + + /// + /// Draw a horizontal button group + /// + public static int DrawButtonGroup(string[] labels, int selected, float height = 25) + { + EditorGUILayout.BeginHorizontal(); + + for (int i = 0; i < labels.Length; i++) + { + bool isSelected = i == selected; + var style = new GUIStyle(GUI.skin.button) + { + fontStyle = isSelected ? FontStyle.Bold : FontStyle.Normal + }; + + if (isSelected) + { + style.normal.background = GetSolidTexture(PrimaryBlue); + style.normal.textColor = Color.white; + } + else + { + style.normal.background = GetSolidTexture(BackgroundLight); + style.normal.textColor = TextSecondary; + } + + if (GUILayout.Button(labels[i], style, GUILayout.Height(height))) + { + selected = i; + } + } + + EditorGUILayout.EndHorizontal(); + + return selected; + } + + #endregion + + #region Layout Helpers + + /// + /// Begin a toolbar row + /// + public static void BeginToolbar() + { + EditorGUILayout.BeginHorizontal(ToolbarStyle); + } + + /// + /// End a toolbar row + /// + public static void EndToolbar() + { + EditorGUILayout.EndHorizontal(); + } + + /// + /// Add flexible space + /// + public static void FlexibleSpace() + { + GUILayout.FlexibleSpace(); + } + + /// + /// Begin a centered layout + /// + public static void BeginCentered() + { + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + EditorGUILayout.BeginVertical(); + } + + /// + /// End a centered layout + /// + public static void EndCentered() + { + EditorGUILayout.EndVertical(); + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + } + + #endregion + + #region Cleanup + + /// + /// Clear cached styles and textures. Call when recompiling. + /// + public static void ClearCache() + { + foreach (var tex in _textureCache.Values) + { + if (tex != null) + Object.DestroyImmediate(tex); + } + _textureCache.Clear(); + + _windowHeader = null; + _sectionHeader = null; + _cardStyle = null; + _cardHeaderStyle = null; + _primaryButton = null; + _secondaryButton = null; + _successButton = null; + _dangerButton = null; + _statusBadge = null; + _toolbarStyle = null; + _infoBox = null; + _centeredLabel = null; + _richLabel = null; + _foldoutHeader = null; + } + + #endregion + } + + /// + /// Icons used throughout the PSX Splash editor + /// + public static class PSXIcons + { + // Unity built-in icons that work well for our purposes + public const string Scene = "d_SceneAsset Icon"; + public const string Build = "d_BuildSettings.SelectedIcon"; + public const string Settings = "d_Settings"; + public const string Play = "d_PlayButton"; + public const string Refresh = "d_Refresh"; + public const string Warning = "d_console.warnicon"; + public const string Error = "d_console.erroricon"; + public const string Info = "d_console.infoicon"; + public const string Success = "d_Progress"; + public const string Texture = "d_Texture Icon"; + public const string Mesh = "d_Mesh Icon"; + public const string Script = "d_cs Script Icon"; + public const string Folder = "d_Folder Icon"; + public const string Download = "d_Download-Available"; + public const string Upload = "d_UpArrow"; + public const string Link = "d_Linked"; + public const string Unlink = "d_Unlinked"; + public const string Eye = "d_scenevis_visible_hover"; + public const string EyeOff = "d_scenevis_hidden_hover"; + public const string Add = "d_Toolbar Plus"; + public const string Remove = "d_Toolbar Minus"; + public const string Edit = "d_editicon.sml"; + public const string Search = "d_Search Icon"; + public const string Console = "d_UnityEditor.ConsoleWindow"; + public const string Help = "d__Help"; + public const string GameObject = "d_GameObject Icon"; + public const string Camera = "d_Camera Icon"; + public const string Light = "d_Light Icon"; + public const string Prefab = "d_Prefab Icon"; + + /// + /// Get a GUIContent with icon and tooltip + /// + public static GUIContent GetContent(string icon, string tooltip = "") + { + var content = EditorGUIUtility.IconContent(icon); + if (content == null) content = new GUIContent(); + content.tooltip = tooltip; + return content; + } + + /// + /// Get a GUIContent with icon, text and tooltip + /// + public static GUIContent GetContent(string icon, string text, string tooltip) + { + var content = EditorGUIUtility.IconContent(icon); + if (content == null) content = new GUIContent(); + content.text = text; + content.tooltip = tooltip; + return content; + } + } +} diff --git a/Editor/Core/PSXEditorStyles.cs.meta b/Editor/Core/PSXEditorStyles.cs.meta new file mode 100644 index 0000000..92272b2 --- /dev/null +++ b/Editor/Core/PSXEditorStyles.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8aefa79a412d32c4f8bc8249bb4cd118 \ No newline at end of file diff --git a/Editor/Core/SplashBuildPaths.cs b/Editor/Core/SplashBuildPaths.cs new file mode 100644 index 0000000..714a901 --- /dev/null +++ b/Editor/Core/SplashBuildPaths.cs @@ -0,0 +1,178 @@ +using System.IO; +using UnityEngine; + +namespace SplashEdit.EditorCode +{ + /// + /// Manages all build-related paths for the SplashEdit pipeline. + /// All output goes outside Assets/ to avoid Unity import overhead. + /// + public static class SplashBuildPaths + { + /// + /// The build output directory at the Unity project root. + /// Contains exported splashpacks, manifest, compiled .ps-exe, ISO, build log. + /// + public static string BuildOutputDir => + Path.Combine(ProjectRoot, "PSXBuild"); + + /// + /// The tools directory at the Unity project root. + /// Contains auto-downloaded tools like PCSX-Redux. + /// + public static string ToolsDir => + Path.Combine(ProjectRoot, ".tools"); + + /// + /// PCSX-Redux install directory inside .tools/. + /// + public static string PCSXReduxDir => + Path.Combine(ToolsDir, "pcsx-redux"); + + /// + /// Platform-specific PCSX-Redux binary path. + /// + public static string PCSXReduxBinary + { + get + { + switch (Application.platform) + { + case RuntimePlatform.WindowsEditor: + return Path.Combine(PCSXReduxDir, "pcsx-redux.exe"); + case RuntimePlatform.LinuxEditor: + return Path.Combine(ToolsDir, "PCSX-Redux-HEAD-x86_64.AppImage"); + default: + return Path.Combine(PCSXReduxDir, "pcsx-redux"); + } + } + } + + /// + /// The Unity project root (parent of Assets/). + /// + public static string ProjectRoot => + Directory.GetParent(Application.dataPath).FullName; + + /// + /// Path to the native psxsplash source. + /// First checks SplashSettings override, then looks for common locations. + /// + public static string NativeSourceDir + { + get + { + // 1. Check the user-configured path from SplashSettings + string custom = SplashSettings.NativeProjectPath; + if (!string.IsNullOrEmpty(custom) && Directory.Exists(custom)) + return custom; + + // 2. Look inside the Unity project's Assets/ folder (git clone location) + string assetsClone = Path.Combine(UnityEngine.Application.dataPath, "psxsplash"); + if (Directory.Exists(assetsClone) && File.Exists(Path.Combine(assetsClone, "Makefile"))) + return assetsClone; + + // 3. Look for Native/ inside the package + string packageNative = Path.GetFullPath( + Path.Combine("Packages", "net.psxsplash.splashedit", "Native")); + if (Directory.Exists(packageNative)) + return packageNative; + + return ""; + } + } + + /// + /// The compiled .ps-exe output from the native build. + /// + public static string CompiledExePath => + Path.Combine(BuildOutputDir, "psxsplash.ps-exe"); + + /// + /// The scene manifest file path. + /// + public static string ManifestPath => + Path.Combine(BuildOutputDir, "manifest.bin"); + + /// + /// Build log file path. + /// + public static string BuildLogPath => + Path.Combine(BuildOutputDir, "build.log"); + + /// + /// Gets the splashpack output path for a scene by index. + /// Uses a deterministic naming scheme: scene_0.splashpack, scene_1.splashpack, etc. + /// + public static string GetSceneSplashpackPath(int sceneIndex, string sceneName) + { + return Path.Combine(BuildOutputDir, $"scene_{sceneIndex}.splashpack"); + } + + /// + /// ISO output path for release builds. + /// + public static string ISOOutputPath => + Path.Combine(BuildOutputDir, "psxsplash.bin"); + + /// + /// CUE sheet path for release builds. + /// + public static string CUEOutputPath => + Path.Combine(BuildOutputDir, "psxsplash.cue"); + + /// + /// Ensures the build output and tools directories exist. + /// Also appends entries to the project .gitignore if not present. + /// + public static void EnsureDirectories() + { + Directory.CreateDirectory(BuildOutputDir); + Directory.CreateDirectory(ToolsDir); + EnsureGitIgnore(); + } + + /// + /// Checks if PCSX-Redux is installed in the tools directory. + /// + public static bool IsPCSXReduxInstalled() + { + return File.Exists(PCSXReduxBinary); + } + + private static void EnsureGitIgnore() + { + string gitignorePath = Path.Combine(ProjectRoot, ".gitignore"); + + string[] entriesToAdd = new[] { "/PSXBuild/", "/.tools/" }; + + string existingContent = ""; + if (File.Exists(gitignorePath)) + { + existingContent = File.ReadAllText(gitignorePath); + } + + bool modified = false; + string toAppend = ""; + + foreach (string entry in entriesToAdd) + { + // Check if entry already exists (exact line match) + if (!existingContent.Contains(entry)) + { + if (!modified) + { + toAppend += "\n# SplashEdit build output\n"; + modified = true; + } + toAppend += entry + "\n"; + } + } + + if (modified) + { + File.AppendAllText(gitignorePath, toAppend); + } + } + } +} diff --git a/Editor/Core/SplashBuildPaths.cs.meta b/Editor/Core/SplashBuildPaths.cs.meta new file mode 100644 index 0000000..bd2b6f6 --- /dev/null +++ b/Editor/Core/SplashBuildPaths.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3988772ca929eb14ea3bee6b643de4d0 \ No newline at end of file diff --git a/Editor/Core/SplashControlPanel.cs b/Editor/Core/SplashControlPanel.cs new file mode 100644 index 0000000..b4e5bce --- /dev/null +++ b/Editor/Core/SplashControlPanel.cs @@ -0,0 +1,1564 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Ports; +using System.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; +using SplashEdit.RuntimeCode; +using Debug = UnityEngine.Debug; + +namespace SplashEdit.EditorCode +{ + /// + /// SplashEdit Control Panel — the single unified window for the entire pipeline. + /// One window. One button. Everything works. + /// + public class SplashControlPanel : EditorWindow + { + // ───── Constants ───── + private const string WINDOW_TITLE = "SplashEdit Control Panel"; + private const string MENU_PATH = "PlayStation 1/SplashEdit Control Panel %#p"; + + // ───── UI State ───── + private Vector2 _scrollPos; + private bool _showQuickStart = true; + private bool _showNativeProject = true; + private bool _showToolchainSection = true; + private bool _showScenesSection = true; + private bool _showVRAMSection = true; + private bool _showBuildSection = true; + + // ───── Build State ───── + private static bool _isBuilding; + private static bool _isRunning; + private static Process _emulatorProcess; + + // ───── Scene List ───── + private List _sceneList = new List(); + + // ───── Toolchain Cache ───── + private bool _hasMIPS; + private bool _hasMake; + private bool _hasRedux; + private bool _hasNativeProject; + private bool _hasPsxavenc; + private string _reduxVersion = ""; + + // ───── Native project installer ───── + private bool _isInstallingNative; + private string _nativeInstallStatus = ""; + private string _manualNativePath = ""; + + // PCdrv serial host instance (for real hardware file serving) + private static PCdrvSerialHost _pcdrvHost; + + private struct SceneEntry + { + public SceneAsset asset; + public string path; + public string name; + } + + // ═══════════════════════════════════════════════════════════════ + // Menu & Window Lifecycle + // ═══════════════════════════════════════════════════════════════ + + [MenuItem(MENU_PATH, false, 0)] + public static void ShowWindow() + { + var window = GetWindow(); + window.titleContent = new GUIContent(WINDOW_TITLE, EditorGUIUtility.IconContent("d_BuildSettings.PSP2.Small").image); + window.minSize = new Vector2(420, 600); + window.Show(); + } + + private void OnEnable() + { + SplashBuildPaths.EnsureDirectories(); + RefreshToolchainStatus(); + LoadSceneList(); + _manualNativePath = SplashSettings.NativeProjectPath; + EditorApplication.playModeStateChanged += OnPlayModeChanged; + } + + private void OnDisable() + { + EditorApplication.playModeStateChanged -= OnPlayModeChanged; + } + + private void OnFocus() + { + RefreshToolchainStatus(); + } + + // ═══════════════════════════════════════════════════════════════ + // Play Mode Intercept + // ═══════════════════════════════════════════════════════════════ + + private void OnPlayModeChanged(PlayModeStateChange state) + { + if (state == PlayModeStateChange.ExitingEditMode && SplashSettings.InterceptPlayMode) + { + EditorApplication.isPlaying = false; + Log("Play Mode intercepted — starting Build & Run instead.", LogType.Log); + BuildAndRun(); + } + } + + // ═══════════════════════════════════════════════════════════════ + // Main GUI + // ═══════════════════════════════════════════════════════════════ + + private void OnGUI() + { + _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos); + + DrawHeader(); + EditorGUILayout.Space(4); + + // Show Quick Start prominently if toolchain is not ready + if (!_hasMIPS || !_hasNativeProject) + { + DrawQuickStartSection(); + EditorGUILayout.Space(2); + } + else + { + // Collapsed quick start for experienced users + _showQuickStart = DrawSectionFoldout("Quick Start Guide", _showQuickStart); + if (_showQuickStart) + { + DrawQuickStartContent(); + } + EditorGUILayout.Space(2); + } + + DrawNativeProjectSection(); + EditorGUILayout.Space(2); + + DrawToolchainSection(); + EditorGUILayout.Space(2); + + DrawScenesSection(); + EditorGUILayout.Space(2); + + DrawVRAMSection(); + EditorGUILayout.Space(2); + + DrawBuildSection(); + + EditorGUILayout.EndScrollView(); + } + + // ═══════════════════════════════════════════════════════════════ + // Header + // ═══════════════════════════════════════════════════════════════ + + private void DrawHeader() + { + EditorGUILayout.BeginHorizontal(PSXEditorStyles.ToolbarStyle); + + GUILayout.Label("SplashEdit", PSXEditorStyles.WindowHeader); + GUILayout.FlexibleSpace(); + + // Play mode intercept toggle + bool intercept = SplashSettings.InterceptPlayMode; + var toggleContent = new GUIContent( + intercept ? "▶ Intercept ON" : "▶ Intercept OFF", + "When enabled, pressing Play in Unity triggers Build & Run instead."); + bool newIntercept = GUILayout.Toggle(intercept, toggleContent, EditorStyles.toolbarButton, GUILayout.Width(120)); + if (newIntercept != intercept) + SplashSettings.InterceptPlayMode = newIntercept; + + EditorGUILayout.EndHorizontal(); + + // Status bar + EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); + { + string statusText; + Color statusColor; + + if (!_hasMIPS) + { + statusText = "Setup required — install the MIPS toolchain to get started"; + statusColor = PSXEditorStyles.Warning; + } + else if (!_hasNativeProject) + { + statusText = "Native project not found — clone or set path below"; + statusColor = PSXEditorStyles.Warning; + } + else if (_isBuilding) + { + statusText = "Building..."; + statusColor = PSXEditorStyles.Info; + } + else if (_isRunning) + { + statusText = "Running on " + (SplashSettings.Target == BuildTarget.Emulator ? "emulator" : "hardware"); + statusColor = PSXEditorStyles.AccentGreen; + } + else + { + statusText = "Ready"; + statusColor = PSXEditorStyles.Success; + } + + var prevColor = GUI.contentColor; + GUI.contentColor = statusColor; + GUILayout.Label(statusText, EditorStyles.miniLabel); + GUI.contentColor = prevColor; + } + EditorGUILayout.EndHorizontal(); + } + + // ═══════════════════════════════════════════════════════════════ + // Quick Start Guide + // ═══════════════════════════════════════════════════════════════ + + private void DrawQuickStartSection() + { + EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle); + + var prevColor = GUI.contentColor; + GUI.contentColor = PSXEditorStyles.AccentGold; + GUILayout.Label("Getting Started with SplashEdit", PSXEditorStyles.CardHeaderStyle); + GUI.contentColor = prevColor; + + EditorGUILayout.Space(4); + DrawQuickStartContent(); + + EditorGUILayout.EndVertical(); + } + + private void DrawQuickStartContent() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + DrawQuickStartStep(1, "Install Toolchain", + "Install the MIPS cross-compiler and GNU Make below.", + _hasMIPS && _hasMake); + + DrawQuickStartStep(2, "Get Native Project", + "Clone the psxsplash runtime or set a path to your local copy.", + _hasNativeProject); + + DrawQuickStartStep(3, "Add Scenes", + "Add Unity scenes containing a PSXSceneExporter to the scene list.", + _sceneList.Count > 0); + + DrawQuickStartStep(4, "Configure VRAM", + "Set the framebuffer resolution and texture packing settings.", + true); // Always "done" since defaults are fine + + DrawQuickStartStep(5, "Build & Run", + "Click BUILD & RUN to export, compile, and launch on the emulator or real hardware.", + false); + + EditorGUILayout.Space(4); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("Open Documentation", EditorStyles.miniButton, GUILayout.Width(140))) + { + Application.OpenURL("https://github.com/psxsplash/splashedit"); + } + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.EndVertical(); + } + + private void DrawQuickStartStep(int step, string title, string description, bool done) + { + EditorGUILayout.BeginHorizontal(); + + // Checkbox/step indicator + string prefix = done ? "✓" : $"{step}."; + var style = done ? EditorStyles.miniLabel : EditorStyles.boldLabel; + + var prevColor = GUI.contentColor; + GUI.contentColor = done ? PSXEditorStyles.Success : PSXEditorStyles.TextPrimary; + GUILayout.Label(prefix, style, GUILayout.Width(20)); + GUI.contentColor = prevColor; + + EditorGUILayout.BeginVertical(); + GUILayout.Label(title, EditorStyles.boldLabel); + GUILayout.Label(description, EditorStyles.wordWrappedMiniLabel); + EditorGUILayout.EndVertical(); + + EditorGUILayout.EndHorizontal(); + EditorGUILayout.Space(2); + } + + // ═══════════════════════════════════════════════════════════════ + // Native Project Section + // ═══════════════════════════════════════════════════════════════ + + private void DrawNativeProjectSection() + { + _showNativeProject = DrawSectionFoldout("Native Project (psxsplash)", _showNativeProject); + if (!_showNativeProject) return; + + EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle); + + string currentPath = SplashBuildPaths.NativeSourceDir; + bool hasProject = !string.IsNullOrEmpty(currentPath) && Directory.Exists(currentPath); + + // Status + EditorGUILayout.BeginHorizontal(); + DrawStatusIcon(hasProject); + if (hasProject) + { + GUILayout.Label("Found at:", GUILayout.Width(60)); + GUILayout.Label(TruncatePath(currentPath, 50), EditorStyles.miniLabel); + } + else + { + GUILayout.Label("Not found — clone from GitHub or set path manually", EditorStyles.miniLabel); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(6); + + // ── Option 1: Auto-clone from GitHub ── + EditorGUILayout.LabelField("Clone from GitHub", EditorStyles.boldLabel); + EditorGUILayout.BeginHorizontal(); + GUILayout.Label(PSXSplashInstaller.RepoUrl, EditorStyles.miniLabel); + GUILayout.FlexibleSpace(); + EditorGUI.BeginDisabledGroup(_isInstallingNative || PSXSplashInstaller.IsInstalled()); + if (GUILayout.Button(PSXSplashInstaller.IsInstalled() ? "Installed" : "Clone", GUILayout.Width(80))) + { + CloneNativeProject(); + } + EditorGUI.EndDisabledGroup(); + EditorGUILayout.EndHorizontal(); + + if (_isInstallingNative) + { + EditorGUILayout.HelpBox(_nativeInstallStatus, MessageType.Info); + } + + // If already cloned, show version management + if (PSXSplashInstaller.IsInstalled()) + { + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("Fetch Latest", EditorStyles.miniButton, GUILayout.Width(90))) + { + FetchNativeLatest(); + } + if (GUILayout.Button("Open Folder", EditorStyles.miniButton, GUILayout.Width(90))) + { + EditorUtility.RevealInFinder(PSXSplashInstaller.FullInstallPath); + } + EditorGUILayout.EndHorizontal(); + } + + EditorGUILayout.Space(6); + + // ── Option 2: Manual path ── + EditorGUILayout.LabelField("Or set path manually", EditorStyles.boldLabel); + EditorGUILayout.BeginHorizontal(); + + string newPath = EditorGUILayout.TextField(_manualNativePath); + if (newPath != _manualNativePath) + { + _manualNativePath = newPath; + } + + if (GUILayout.Button("...", GUILayout.Width(30))) + { + string selected = EditorUtility.OpenFolderPanel("Select psxsplash Source Directory", _manualNativePath, ""); + if (!string.IsNullOrEmpty(selected)) + { + _manualNativePath = selected; + } + } + EditorGUILayout.EndHorizontal(); + + // Validate & apply the path + bool manualPathValid = !string.IsNullOrEmpty(_manualNativePath) && + Directory.Exists(_manualNativePath) && + File.Exists(Path.Combine(_manualNativePath, "Makefile")); + + EditorGUILayout.BeginHorizontal(); + if (!string.IsNullOrEmpty(_manualNativePath) && !manualPathValid) + { + EditorGUILayout.HelpBox("Invalid path. The directory must contain a Makefile.", MessageType.Warning); + } + else if (manualPathValid && _manualNativePath != SplashSettings.NativeProjectPath) + { + if (GUILayout.Button("Apply", GUILayout.Width(60))) + { + SplashSettings.NativeProjectPath = _manualNativePath; + RefreshToolchainStatus(); + Log($"Native project path set: {_manualNativePath}", LogType.Log); + } + } + EditorGUILayout.EndHorizontal(); + + if (manualPathValid && _manualNativePath == SplashSettings.NativeProjectPath) + { + var prevColor = GUI.contentColor; + GUI.contentColor = PSXEditorStyles.Success; + GUILayout.Label("✓ Path is set and valid", EditorStyles.miniLabel); + GUI.contentColor = prevColor; + } + + EditorGUILayout.EndVertical(); + } + + // ═══════════════════════════════════════════════════════════════ + // Toolchain Section + // ═══════════════════════════════════════════════════════════════ + + private void DrawToolchainSection() + { + _showToolchainSection = DrawSectionFoldout("Toolchain", _showToolchainSection); + if (!_showToolchainSection) return; + + EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle); + + // MIPS Compiler + EditorGUILayout.BeginHorizontal(); + DrawStatusIcon(_hasMIPS); + GUILayout.Label("MIPS Cross-Compiler", GUILayout.Width(160)); + GUILayout.FlexibleSpace(); + if (!_hasMIPS) + { + if (GUILayout.Button("Install", GUILayout.Width(60))) + InstallMIPS(); + } + else + { + GUILayout.Label("Ready", EditorStyles.miniLabel); + } + EditorGUILayout.EndHorizontal(); + + // GNU Make + EditorGUILayout.BeginHorizontal(); + DrawStatusIcon(_hasMake); + GUILayout.Label("GNU Make", GUILayout.Width(160)); + GUILayout.FlexibleSpace(); + if (!_hasMake) + { + if (GUILayout.Button("Install", GUILayout.Width(60))) + InstallMake(); + } + else + { + GUILayout.Label("Ready", EditorStyles.miniLabel); + } + EditorGUILayout.EndHorizontal(); + + // PCSX-Redux + EditorGUILayout.BeginHorizontal(); + DrawStatusIcon(_hasRedux); + GUILayout.Label("PCSX-Redux", GUILayout.Width(160)); + GUILayout.FlexibleSpace(); + if (!_hasRedux) + { + if (GUILayout.Button("Download", GUILayout.Width(80))) + DownloadRedux(); + } + else + { + GUILayout.Label(_reduxVersion, EditorStyles.miniLabel); + } + EditorGUILayout.EndHorizontal(); + + // psxavenc (audio encoder) + EditorGUILayout.BeginHorizontal(); + DrawStatusIcon(_hasPsxavenc); + GUILayout.Label("psxavenc (Audio)", GUILayout.Width(160)); + GUILayout.FlexibleSpace(); + if (!_hasPsxavenc) + { + if (GUILayout.Button("Download", GUILayout.Width(80))) + DownloadPsxavenc(); + } + else + { + GUILayout.Label("Installed", EditorStyles.miniLabel); + } + EditorGUILayout.EndHorizontal(); + + // Refresh button + EditorGUILayout.Space(2); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("Refresh", EditorStyles.miniButton, GUILayout.Width(60))) + RefreshToolchainStatus(); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.EndVertical(); + } + + // ═══════════════════════════════════════════════════════════════ + // Scenes Section + // ═══════════════════════════════════════════════════════════════ + + private void DrawScenesSection() + { + _showScenesSection = DrawSectionFoldout("Scenes", _showScenesSection); + if (!_showScenesSection) return; + + EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle); + + if (_sceneList.Count == 0) + { + EditorGUILayout.HelpBox( + "No scenes added yet.\n" + + "Each scene needs a GameObject with a PSXSceneExporter component.\n" + + "Drag scene assets here, or use the buttons below to add them.", + MessageType.Info); + } + + // Draw scene list + int removeIndex = -1; + int moveUp = -1; + int moveDown = -1; + + for (int i = 0; i < _sceneList.Count; i++) + { + EditorGUILayout.BeginHorizontal(); + + // Index badge + GUILayout.Label($"[{i}]", EditorStyles.miniLabel, GUILayout.Width(24)); + + // Scene asset field + var newAsset = (SceneAsset)EditorGUILayout.ObjectField( + _sceneList[i].asset, typeof(SceneAsset), false); + if (newAsset != _sceneList[i].asset) + { + var entry = _sceneList[i]; + entry.asset = newAsset; + if (newAsset != null) + { + entry.path = AssetDatabase.GetAssetPath(newAsset); + entry.name = newAsset.name; + } + _sceneList[i] = entry; + SaveSceneList(); + } + + // Move buttons + EditorGUI.BeginDisabledGroup(i == 0); + if (GUILayout.Button("▲", EditorStyles.miniButtonLeft, GUILayout.Width(22))) + moveUp = i; + EditorGUI.EndDisabledGroup(); + + EditorGUI.BeginDisabledGroup(i == _sceneList.Count - 1); + if (GUILayout.Button("▼", EditorStyles.miniButtonRight, GUILayout.Width(22))) + moveDown = i; + EditorGUI.EndDisabledGroup(); + + // Remove + if (GUILayout.Button("×", EditorStyles.miniButton, GUILayout.Width(20))) + removeIndex = i; + + EditorGUILayout.EndHorizontal(); + } + + // Apply deferred operations + if (removeIndex >= 0) + { + _sceneList.RemoveAt(removeIndex); + SaveSceneList(); + } + if (moveUp >= 1) + { + var temp = _sceneList[moveUp]; + _sceneList[moveUp] = _sceneList[moveUp - 1]; + _sceneList[moveUp - 1] = temp; + SaveSceneList(); + } + if (moveDown >= 0 && moveDown < _sceneList.Count - 1) + { + var temp = _sceneList[moveDown]; + _sceneList[moveDown] = _sceneList[moveDown + 1]; + _sceneList[moveDown + 1] = temp; + SaveSceneList(); + } + + // Add scene buttons + EditorGUILayout.Space(4); + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("+ Add Current Scene", EditorStyles.miniButton)) + { + AddCurrentScene(); + } + if (GUILayout.Button("+ Add Scene...", EditorStyles.miniButton)) + { + string path = EditorUtility.OpenFilePanel("Select Scene", "Assets", "unity"); + if (!string.IsNullOrEmpty(path)) + { + // Convert to project-relative path + string projectPath = Application.dataPath; + if (path.StartsWith(projectPath)) + path = "Assets" + path.Substring(projectPath.Length); + AddSceneByPath(path); + } + } + EditorGUILayout.EndHorizontal(); + + // Handle drag & drop + HandleSceneDragDrop(); + + EditorGUILayout.EndVertical(); + } + + // ═══════════════════════════════════════════════════════════════ + // VRAM & Textures Section + // ═══════════════════════════════════════════════════════════════ + + private void DrawVRAMSection() + { + _showVRAMSection = DrawSectionFoldout("VRAM & Textures", _showVRAMSection); + if (!_showVRAMSection) return; + + EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle); + + // Resolution settings + EditorGUILayout.LabelField("Framebuffer Settings", EditorStyles.boldLabel); + + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("Resolution:", GUILayout.Width(75)); + SplashSettings.ResolutionWidth = EditorGUILayout.IntField(SplashSettings.ResolutionWidth, GUILayout.Width(50)); + GUILayout.Label("×", GUILayout.Width(14)); + SplashSettings.ResolutionHeight = EditorGUILayout.IntField(SplashSettings.ResolutionHeight, GUILayout.Width(50)); + + // Common resolutions dropdown + if (GUILayout.Button("Presets", EditorStyles.miniButton, GUILayout.Width(55))) + { + var menu = new GenericMenu(); + menu.AddItem(new GUIContent("256×240"), false, () => { SplashSettings.ResolutionWidth = 256; SplashSettings.ResolutionHeight = 240; Repaint(); }); + menu.AddItem(new GUIContent("320×240"), false, () => { SplashSettings.ResolutionWidth = 320; SplashSettings.ResolutionHeight = 240; Repaint(); }); + menu.AddItem(new GUIContent("368×240"), false, () => { SplashSettings.ResolutionWidth = 368; SplashSettings.ResolutionHeight = 240; Repaint(); }); + menu.AddItem(new GUIContent("512×240"), false, () => { SplashSettings.ResolutionWidth = 512; SplashSettings.ResolutionHeight = 240; Repaint(); }); + menu.AddItem(new GUIContent("640×240"), false, () => { SplashSettings.ResolutionWidth = 640; SplashSettings.ResolutionHeight = 240; Repaint(); }); + menu.AddItem(new GUIContent("256×480"), false, () => { SplashSettings.ResolutionWidth = 256; SplashSettings.ResolutionHeight = 480; Repaint(); }); + menu.AddItem(new GUIContent("320×480"), false, () => { SplashSettings.ResolutionWidth = 320; SplashSettings.ResolutionHeight = 480; Repaint(); }); + menu.AddItem(new GUIContent("512×480"), false, () => { SplashSettings.ResolutionWidth = 512; SplashSettings.ResolutionHeight = 480; Repaint(); }); + menu.ShowAsContext(); + } + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + + SplashSettings.DualBuffering = EditorGUILayout.Toggle("Dual Buffering", SplashSettings.DualBuffering); + SplashSettings.VerticalLayout = EditorGUILayout.Toggle("Vertical Layout", SplashSettings.VerticalLayout); + + 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))) + { + VRAMEditorWindow.ShowWindow(); + } + if (GUILayout.Button("Quantized Preview", GUILayout.Height(24))) + { + QuantizedPreviewWindow.ShowWindow(); + } + EditorGUILayout.EndHorizontal(); + + if (GUILayout.Button("Open Scene Validator", EditorStyles.miniButton)) + { + PSXSceneValidatorWindow.ShowWindow(); + } + + EditorGUILayout.EndVertical(); + } + + // ═══════════════════════════════════════════════════════════════ + // Build & Run Section + // ═══════════════════════════════════════════════════════════════ + + private void DrawBuildSection() + { + _showBuildSection = DrawSectionFoldout("Build && Run", _showBuildSection); + if (!_showBuildSection) return; + + EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle); + + // Target & Mode + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("Target:", GUILayout.Width(50)); + SplashSettings.Target = (BuildTarget)EditorGUILayout.EnumPopup(SplashSettings.Target); + GUILayout.Label("Mode:", GUILayout.Width(40)); + SplashSettings.Mode = (BuildMode)EditorGUILayout.EnumPopup(SplashSettings.Mode); + EditorGUILayout.EndHorizontal(); + + // Serial port (only for Real Hardware) + if (SplashSettings.Target == BuildTarget.RealHardware) + { + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("Serial Port:", GUILayout.Width(80)); + SplashSettings.SerialPort = EditorGUILayout.TextField(SplashSettings.SerialPort); + if (GUILayout.Button("Scan", EditorStyles.miniButton, GUILayout.Width(40))) + ScanSerialPorts(); + EditorGUILayout.EndHorizontal(); + } + + EditorGUILayout.Space(8); + + // Big Build & Run button + EditorGUI.BeginDisabledGroup(_isBuilding); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + + var buildColor = GUI.backgroundColor; + GUI.backgroundColor = _isBuilding ? Color.gray : new Color(0.3f, 0.8f, 0.4f); + + string buttonLabel = _isBuilding ? "Building..." : "BUILD & RUN"; + if (GUILayout.Button(buttonLabel, GUILayout.Width(200), GUILayout.Height(36))) + { + BuildAndRun(); + } + + GUI.backgroundColor = buildColor; + + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + EditorGUI.EndDisabledGroup(); + + // Stop button (if running — emulator or hardware PCdrv host) + if (_isRunning) + { + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + GUI.backgroundColor = new Color(0.9f, 0.3f, 0.3f); + string stopLabel = _emulatorProcess != null ? "■ STOP EMULATOR" : "■ STOP PCdrv HOST"; + if (GUILayout.Button(stopLabel, GUILayout.Width(200), GUILayout.Height(24))) + { + StopAll(); + } + GUI.backgroundColor = buildColor; + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + } + + // Export-only / Compile-only + EditorGUILayout.Space(4); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("Export Only", EditorStyles.miniButton, GUILayout.Width(100))) + { + ExportAllScenes(); + } + if (GUILayout.Button("Compile Only", EditorStyles.miniButton, GUILayout.Width(100))) + { + CompileNative(); + } + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.EndVertical(); + } + + // ═══════════════════════════════════════════════════════════════ + // Pipeline Actions + // ═══════════════════════════════════════════════════════════════ + + /// + /// The main pipeline: Validate → Export all scenes → Compile → Launch. + /// + public 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); + console.Show(); + + try + { + // Step 1: Validate + Log("Validating toolchain...", LogType.Log); + if (!ValidateToolchain()) + { + Log("Toolchain validation failed. Fix issues above.", LogType.Error); + return; + } + Log("Toolchain OK.", LogType.Log); + + // Step 2: Export all scenes + Log("Exporting scenes...", LogType.Log); + if (!ExportAllScenes()) + { + Log("Export failed.", LogType.Error); + return; + } + Log($"Exported {_sceneList.Count} scene(s).", LogType.Log); + + // Step 3: Compile native + Log("Compiling native code...", LogType.Log); + if (!CompileNative()) + { + Log("Compilation failed. Check build log.", LogType.Error); + return; + } + Log("Compile succeeded.", LogType.Log); + + // Step 4: Launch + Log("Launching...", LogType.Log); + Launch(); + } + catch (Exception ex) + { + Log($"Pipeline error: {ex.Message}", LogType.Error); + Debug.LogException(ex); + } + finally + { + _isBuilding = false; + EditorUtility.ClearProgressBar(); + Repaint(); + } + } + + // ───── Step 1: Validate ───── + + private bool ValidateToolchain() + { + RefreshToolchainStatus(); + + if (!_hasMIPS) + { + Log("MIPS cross-compiler not found. Click Install in the Toolchain section.", LogType.Error); + return false; + } + if (!_hasMake) + { + Log("GNU Make not found. Click Install in the Toolchain section.", LogType.Error); + return false; + } + if (SplashSettings.Target == BuildTarget.Emulator && !_hasRedux) + { + Log("PCSX-Redux not found. Click Download in the Toolchain section.", LogType.Error); + return false; + } + + string nativeDir = SplashBuildPaths.NativeSourceDir; + if (string.IsNullOrEmpty(nativeDir) || !Directory.Exists(nativeDir)) + { + Log("Native project directory not found. Set it in the Toolchain section.", LogType.Error); + return false; + } + + if (_sceneList.Count == 0) + { + Log("No scenes in the scene list. Add at least one scene.", LogType.Error); + return false; + } + + return true; + } + + // ───── Step 2: Export ───── + + /// + /// Exports all scenes in the scene list to splashpack files in PSXBuild/. + /// + public bool ExportAllScenes() + { + SplashBuildPaths.EnsureDirectories(); + + // Save current scene + string currentScenePath = SceneManager.GetActiveScene().path; + + bool success = true; + for (int i = 0; i < _sceneList.Count; i++) + { + var scene = _sceneList[i]; + if (scene.asset == null) + { + Log($"Scene [{i}] is null, skipping.", LogType.Warning); + continue; + } + + EditorUtility.DisplayProgressBar("SplashEdit Export", + $"Exporting scene {i + 1}/{_sceneList.Count}: {scene.name}", + (float)i / _sceneList.Count); + + try + { + // Open the scene + EditorSceneManager.OpenScene(scene.path, OpenSceneMode.Single); + + // Find the exporter + var exporter = UnityEngine.Object.FindObjectOfType(); + if (exporter == null) + { + Log($"Scene '{scene.name}' has no PSXSceneExporter. Skipping.", LogType.Warning); + continue; + } + + // Export to the build directory + string outputPath = SplashBuildPaths.GetSceneSplashpackPath(i, scene.name); + exporter.ExportToPath(outputPath); + Log($"Exported '{scene.name}' → {Path.GetFileName(outputPath)}", LogType.Log); + } + catch (Exception ex) + { + Log($"Error exporting '{scene.name}': {ex.Message}", LogType.Error); + success = false; + } + } + + // Write manifest (simple binary: scene count + list of filenames) + WriteManifest(); + + EditorUtility.ClearProgressBar(); + + // Reopen orignal scene + if (!string.IsNullOrEmpty(currentScenePath)) + { + EditorSceneManager.OpenScene(currentScenePath, OpenSceneMode.Single); + } + + return success; + } + + private void WriteManifest() + { + string manifestPath = SplashBuildPaths.ManifestPath; + using (var writer = new BinaryWriter(File.Open(manifestPath, FileMode.Create))) + { + // Magic "SM" for Scene Manifest + writer.Write((byte)'S'); + writer.Write((byte)'M'); + // Version + writer.Write((ushort)1); + // Scene count + writer.Write((uint)_sceneList.Count); + + for (int i = 0; i < _sceneList.Count; i++) + { + string filename = Path.GetFileName( + SplashBuildPaths.GetSceneSplashpackPath(i, _sceneList[i].name)); + byte[] nameBytes = System.Text.Encoding.UTF8.GetBytes(filename); + // Length-prefixed string + writer.Write((byte)nameBytes.Length); + writer.Write(nameBytes); + } + } + Log("Wrote scene manifest.", LogType.Log); + } + + // ───── Step 3: Compile ───── + + /// + /// Runs make in the native project directory. + /// + public bool CompileNative() + { + string nativeDir = SplashBuildPaths.NativeSourceDir; + if (string.IsNullOrEmpty(nativeDir)) + { + Log("Native project directory not set.", LogType.Error); + return false; + } + + string buildArg = SplashSettings.Mode == BuildMode.Debug ? "BUILD=Debug" : ""; + // 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(); + + Log($"Running: {makeCmd}", LogType.Log); + + var psi = new ProcessStartInfo + { + FileName = Application.platform == RuntimePlatform.WindowsEditor ? "cmd.exe" : "/bin/bash", + Arguments = Application.platform == RuntimePlatform.WindowsEditor + ? $"/c {makeCmd}" + : $"-c \"{makeCmd}\"", + WorkingDirectory = nativeDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + try + { + var process = Process.Start(psi); + string stdout = process.StandardOutput.ReadToEnd(); + string stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + // Log output to panel only (no Unity console spam) + if (!string.IsNullOrEmpty(stdout)) + { + foreach (string line in stdout.Split('\n')) + { + if (!string.IsNullOrWhiteSpace(line)) + LogToPanel(line.Trim(), LogType.Log); + } + } + + if (process.ExitCode != 0) + { + if (!string.IsNullOrEmpty(stderr)) + { + foreach (string line in stderr.Split('\n')) + { + if (!string.IsNullOrWhiteSpace(line)) + LogToPanel(line.Trim(), LogType.Error); + } + } + Log($"Make exited with code {process.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); + } + else + { + Log("Warning: Could not find compiled .ps-exe", LogType.Warning); + } + + return true; + } + catch (Exception ex) + { + Log($"Compile error: {ex.Message}", LogType.Error); + return false; + } + } + + private string FindCompiledExe(string nativeDir) + { + // Look for .ps-exe files in the native dir + var files = Directory.GetFiles(nativeDir, "*.ps-exe", SearchOption.TopDirectoryOnly); + if (files.Length > 0) + return files[0]; + + // Also check common build output locations + foreach (string subdir in new[] { "build", "bin", "." }) + { + string dir = Path.Combine(nativeDir, subdir); + if (Directory.Exists(dir)) + { + files = Directory.GetFiles(dir, "*.ps-exe", SearchOption.TopDirectoryOnly); + if (files.Length > 0) + return files[0]; + } + } + return null; + } + + // ───── Step 4: Launch ───── + + private void Launch() + { + switch (SplashSettings.Target) + { + case BuildTarget.Emulator: + LaunchEmulator(); + break; + case BuildTarget.RealHardware: + LaunchToHardware(); + break; + case BuildTarget.ISO: + Log("ISO build not yet implemented.", LogType.Warning); + break; + } + } + + private void LaunchEmulator() + { + string reduxPath = SplashSettings.PCSXReduxPath; + if (string.IsNullOrEmpty(reduxPath) || !File.Exists(reduxPath)) + { + Log("PCSX-Redux binary not found.", LogType.Error); + return; + } + + string exePath = SplashBuildPaths.CompiledExePath; + if (!File.Exists(exePath)) + { + Log("Compiled .ps-exe not found in PSXBuild/.", LogType.Error); + return; + } + + // Kill previous instance without clearing the console + StopAllQuiet(); + + string pcdrvBase = SplashBuildPaths.BuildOutputDir; + string args = $"-exe \"{exePath}\" -run -fastboot -pcdrv -pcdrvbase \"{pcdrvBase}\" -stdout"; + + Log($"Launching: {Path.GetFileName(reduxPath)} {args}", LogType.Log); + + var psi = new ProcessStartInfo + { + FileName = reduxPath, + Arguments = args, + UseShellExecute = false, + // CreateNoWindow = true prevents pcsx-redux's -stdout AllocConsole() from + // stealing stdout away from our pipe. pcsx-redux is a GUI app and doesn't + // need a console window - it creates its own OpenGL/SDL window. + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + try + { + _emulatorProcess = Process.Start(psi); + _isRunning = true; + Log("PCSX-Redux launched.", LogType.Log); + + // Open the PSX Console window and attach to the process output + PSXConsoleWindow.Attach(_emulatorProcess); + } + catch (Exception ex) + { + Log($"Failed to launch emulator: {ex.Message}", LogType.Error); + } + } + + private void LaunchToHardware() + { + string exePath = SplashBuildPaths.CompiledExePath; + if (!File.Exists(exePath)) + { + Log("Compiled .ps-exe not found in PSXBuild/.", LogType.Error); + return; + } + + string port = SplashSettings.SerialPort; + int baud = SplashSettings.SerialBaudRate; + + // Stop any previous run (emulator or PCdrv) without clearing the console + StopAllQuiet(); + + // Upload the exe with debug hooks (DEBG → SEXE on the same port). + // DEBG installs kernel-resident break handlers BEFORE the exe auto-starts. + // The returned port stays open so PCDrv monitoring can begin immediately. + Log($"Uploading to {port}...", LogType.Log); + SerialPort serialPort = UniromUploader.UploadExeForPCdrv(port, baud, exePath, + msg => Log(msg, LogType.Log)); + if (serialPort == null) + { + Log("Upload failed.", LogType.Error); + return; + } + + // Start PCdrv host on the same open port — no re-open, no DEBG/CONT needed + try + { + _pcdrvHost = new PCdrvSerialHost(port, baud, SplashBuildPaths.BuildOutputDir, + msg => LogToPanel(msg, LogType.Log), + msg => PSXConsoleWindow.AddLine(msg)); + _pcdrvHost.Start(serialPort); + _isRunning = true; + Log("PCdrv serial host started. Serving files to PS1.", LogType.Log); + } + catch (Exception ex) + { + Log($"PCdrv host error: {ex.Message}", LogType.Error); + try { serialPort.Close(); } catch { } + serialPort.Dispose(); + } + } + + private void StopPCdrvHost() + { + if (_pcdrvHost != null) + { + _pcdrvHost.Dispose(); + _pcdrvHost = null; + } + } + + /// + /// Stops everything (emulator, PCdrv host, console reader) — used by the STOP button. + /// + private void StopAll() + { + PSXConsoleWindow.Detach(); + StopEmulatorProcess(); + StopPCdrvHost(); + _isRunning = false; + Log("Stopped.", LogType.Log); + } + + /// + /// Stops emulator and PCdrv host without touching the console window. + /// Used before re-launching so the console keeps its history. + /// + private void StopAllQuiet() + { + StopEmulatorProcess(); + StopPCdrvHost(); + _isRunning = false; + } + + private void StopEmulatorProcess() + { + if (_emulatorProcess != null && !_emulatorProcess.HasExited) + { + try + { + _emulatorProcess.Kill(); + _emulatorProcess.Dispose(); + } + catch { } + } + _emulatorProcess = null; + } + + // ═══════════════════════════════════════════════════════════════ + // Toolchain Detection & Install + // ═══════════════════════════════════════════════════════════════ + + private void RefreshToolchainStatus() + { + _hasMIPS = ToolchainChecker.IsToolAvailable( + Application.platform == RuntimePlatform.WindowsEditor + ? "mipsel-none-elf-gcc" + : "mipsel-linux-gnu-gcc"); + + _hasMake = ToolchainChecker.IsToolAvailable("make"); + + string reduxBin = SplashSettings.PCSXReduxPath; + _hasRedux = !string.IsNullOrEmpty(reduxBin) && File.Exists(reduxBin); + _reduxVersion = _hasRedux ? "Installed" : ""; + + _hasPsxavenc = PSXAudioConverter.IsInstalled(); + + string nativeDir = SplashBuildPaths.NativeSourceDir; + _hasNativeProject = !string.IsNullOrEmpty(nativeDir) && Directory.Exists(nativeDir); + } + + private async void InstallMIPS() + { + Log("Installing MIPS toolchain...", LogType.Log); + try + { + await ToolchainInstaller.InstallToolchain(); + Log("MIPS toolchain installation started. You may need to restart.", LogType.Log); + } + catch (Exception ex) + { + Log($"MIPS install error: {ex.Message}", LogType.Error); + } + RefreshToolchainStatus(); + Repaint(); + } + + private async void InstallMake() + { + Log("Installing GNU Make...", LogType.Log); + try + { + await ToolchainInstaller.InstallMake(); + Log("GNU Make installation complete.", LogType.Log); + } + catch (Exception ex) + { + Log($"Make install error: {ex.Message}", LogType.Error); + } + RefreshToolchainStatus(); + Repaint(); + } + + private async void DownloadRedux() + { + Log("Downloading PCSX-Redux...", LogType.Log); + bool success = await PCSXReduxDownloader.DownloadAndInstall(msg => Log(msg, LogType.Log)); + if (success) + { + // Clear any custom path so it uses the auto-downloaded one + SplashSettings.PCSXReduxPath = ""; + RefreshToolchainStatus(); + Log("PCSX-Redux ready!", LogType.Log); + } + else + { + // Fall back to manual selection + Log("Auto-download failed. Select binary manually.", LogType.Warning); + string path = EditorUtility.OpenFilePanel("Select PCSX-Redux Binary", "", + Application.platform == RuntimePlatform.WindowsEditor ? "exe" : ""); + if (!string.IsNullOrEmpty(path)) + { + SplashSettings.PCSXReduxPath = path; + RefreshToolchainStatus(); + Log($"PCSX-Redux set: {path}", LogType.Log); + } + } + Repaint(); + } + + private async void DownloadPsxavenc() + { + Log("Downloading psxavenc audio encoder...", LogType.Log); + bool success = await PSXAudioConverter.DownloadAndInstall(msg => Log(msg, LogType.Log)); + if (success) + { + RefreshToolchainStatus(); + Log("psxavenc ready!", LogType.Log); + } + else + { + Log("psxavenc download failed. Audio export will not work.", LogType.Error); + } + Repaint(); + } + + private void ScanSerialPorts() + { + try + { + string[] ports = System.IO.Ports.SerialPort.GetPortNames(); + if (ports.Length == 0) + { + Log("No serial ports found.", LogType.Warning); + } + else + { + Log($"Available ports: {string.Join(", ", ports)}", LogType.Log); + // Auto-select first port if current is empty + if (string.IsNullOrEmpty(SplashSettings.SerialPort)) + SplashSettings.SerialPort = ports[0]; + } + } + catch (Exception ex) + { + Log($"Error scanning ports: {ex.Message}", LogType.Error); + } + } + + // ───── Native Project Clone/Fetch ───── + + private async void CloneNativeProject() + { + _isInstallingNative = true; + _nativeInstallStatus = "Cloning psxsplash repository (this may take a minute)..."; + Repaint(); + + Log("Cloning psxsplash native project from GitHub...", LogType.Log); + + try + { + bool success = await PSXSplashInstaller.Install(); + if (success) + { + Log("psxsplash cloned successfully!", LogType.Log); + _nativeInstallStatus = ""; + RefreshToolchainStatus(); + } + else + { + Log("Clone failed. Check console for errors.", LogType.Error); + _nativeInstallStatus = "Clone failed — check console for details."; + } + } + catch (Exception ex) + { + Log($"Clone error: {ex.Message}", LogType.Error); + _nativeInstallStatus = $"Error: {ex.Message}"; + } + finally + { + _isInstallingNative = false; + Repaint(); + } + } + + private async void FetchNativeLatest() + { + Log("Fetching latest changes...", LogType.Log); + try + { + bool success = await PSXSplashInstaller.FetchLatestAsync(); + if (success) + Log("Fetch complete. Use 'git pull' to apply updates.", LogType.Log); + else + Log("Fetch failed.", LogType.Warning); + } + catch (Exception ex) + { + Log($"Fetch error: {ex.Message}", LogType.Error); + } + Repaint(); + } + + // ═══════════════════════════════════════════════════════════════ + // Scene List Persistence (EditorPrefs) + // ═══════════════════════════════════════════════════════════════ + + private void LoadSceneList() + { + _sceneList.Clear(); + string prefix = "SplashEdit_" + Application.dataPath.GetHashCode().ToString("X8") + "_"; + int count = EditorPrefs.GetInt(prefix + "SceneCount", 0); + + for (int i = 0; i < count; i++) + { + string path = EditorPrefs.GetString(prefix + $"Scene_{i}", ""); + if (string.IsNullOrEmpty(path)) continue; + + var asset = AssetDatabase.LoadAssetAtPath(path); + _sceneList.Add(new SceneEntry + { + asset = asset, + path = path, + name = asset != null ? asset.name : Path.GetFileNameWithoutExtension(path) + }); + } + } + + private void SaveSceneList() + { + string prefix = "SplashEdit_" + Application.dataPath.GetHashCode().ToString("X8") + "_"; + EditorPrefs.SetInt(prefix + "SceneCount", _sceneList.Count); + + for (int i = 0; i < _sceneList.Count; i++) + { + EditorPrefs.SetString(prefix + $"Scene_{i}", _sceneList[i].path); + } + } + + private void AddCurrentScene() + { + string scenePath = SceneManager.GetActiveScene().path; + if (string.IsNullOrEmpty(scenePath)) + { + Log("Current scene is not saved. Save it first.", LogType.Warning); + return; + } + AddSceneByPath(scenePath); + } + + private void AddSceneByPath(string path) + { + // Check for duplicates + if (_sceneList.Any(s => s.path == path)) + { + Log($"Scene already in list: {path}", LogType.Warning); + return; + } + + var asset = AssetDatabase.LoadAssetAtPath(path); + _sceneList.Add(new SceneEntry + { + asset = asset, + path = path, + name = asset != null ? asset.name : Path.GetFileNameWithoutExtension(path) + }); + SaveSceneList(); + Log($"Added scene: {Path.GetFileNameWithoutExtension(path)}", LogType.Log); + } + + private void HandleSceneDragDrop() + { + Event evt = Event.current; + Rect dropArea = GUILayoutUtility.GetLastRect(); + + if (evt.type == EventType.DragUpdated || evt.type == EventType.DragPerform) + { + if (!dropArea.Contains(evt.mousePosition)) return; + + bool hasScenes = DragAndDrop.objectReferences.Any(o => o is SceneAsset); + if (hasScenes) + { + DragAndDrop.visualMode = DragAndDropVisualMode.Copy; + + if (evt.type == EventType.DragPerform) + { + DragAndDrop.AcceptDrag(); + foreach (var obj in DragAndDrop.objectReferences) + { + if (obj is SceneAsset) + { + string path = AssetDatabase.GetAssetPath(obj); + AddSceneByPath(path); + } + } + } + + evt.Use(); + } + } + } + + // ═══════════════════════════════════════════════════════════════ + // Utilities + // ═══════════════════════════════════════════════════════════════ + + private static void Log(string message, LogType type) + { + bool isError = type == LogType.Error; + PSXConsoleWindow.AddLine(message, isError); + + // Always log to Unity console as a fallback. + switch (type) + { + case LogType.Error: + Debug.LogError($"[SplashEdit] {message}"); + break; + case LogType.Warning: + Debug.LogWarning($"[SplashEdit] {message}"); + break; + default: + Debug.Log($"[SplashEdit] {message}"); + break; + } + } + + /// + /// Writes make stdout/stderr to PSX Console and Unity console. + /// + private static void LogToPanel(string message, LogType type) + { + PSXConsoleWindow.AddLine(message, type == LogType.Error); + Debug.Log($"[SplashEdit Build] {message}"); + } + + private bool DrawSectionFoldout(string title, bool isOpen) + { + EditorGUILayout.BeginHorizontal(); + isOpen = EditorGUILayout.Foldout(isOpen, title, true, PSXEditorStyles.SectionHeader); + EditorGUILayout.EndHorizontal(); + return isOpen; + } + + private void DrawStatusIcon(bool ok) + { + var content = ok + ? EditorGUIUtility.IconContent("d_greenLight") + : EditorGUIUtility.IconContent("d_redLight"); + GUILayout.Label(content, GUILayout.Width(20), GUILayout.Height(20)); + } + + private string TruncatePath(string path, int maxLen) + { + if (path.Length <= maxLen) return path; + return "..." + path.Substring(path.Length - maxLen + 3); + } + } +} diff --git a/Editor/Core/SplashControlPanel.cs.meta b/Editor/Core/SplashControlPanel.cs.meta new file mode 100644 index 0000000..80b4c48 --- /dev/null +++ b/Editor/Core/SplashControlPanel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5540e6cbefeb70d48a0c1e3843719784 \ No newline at end of file diff --git a/Editor/Core/SplashSettings.cs b/Editor/Core/SplashSettings.cs new file mode 100644 index 0000000..374eaf2 --- /dev/null +++ b/Editor/Core/SplashSettings.cs @@ -0,0 +1,159 @@ +using UnityEditor; +using UnityEngine; + +namespace SplashEdit.EditorCode +{ + /// + /// Enumerates the pipeline target for builds. + /// + public enum BuildTarget + { + Emulator, // PCSX-Redux with PCdrv + RealHardware, // Send .ps-exe over serial via Unirom + ISO // Build a CD image + } + + /// + /// Enumerates the build configuration. + /// + public enum BuildMode + { + Debug, + Release + } + + /// + /// Centralized EditorPrefs-backed settings for the SplashEdit pipeline. + /// All settings are project-scoped using a prefix derived from the project path. + /// + public static class SplashSettings + { + // Prefix all keys with project path hash to support multiple projects + internal static string Prefix => "SplashEdit_" + Application.dataPath.GetHashCode().ToString("X8") + "_"; + + // --- Build settings --- + public static BuildTarget Target + { + get => (BuildTarget)EditorPrefs.GetInt(Prefix + "Target", (int)BuildTarget.Emulator); + set => EditorPrefs.SetInt(Prefix + "Target", (int)value); + } + + public static BuildMode Mode + { + get => (BuildMode)EditorPrefs.GetInt(Prefix + "Mode", (int)BuildMode.Release); + set => EditorPrefs.SetInt(Prefix + "Mode", (int)value); + } + + // --- Toolchain paths --- + public static string NativeProjectPath + { + get => EditorPrefs.GetString(Prefix + "NativeProjectPath", ""); + set => EditorPrefs.SetString(Prefix + "NativeProjectPath", value); + } + + public static string MIPSToolchainPath + { + get => EditorPrefs.GetString(Prefix + "MIPSToolchainPath", ""); + set => EditorPrefs.SetString(Prefix + "MIPSToolchainPath", value); + } + + // --- PCSX-Redux --- + public static string PCSXReduxPath + { + get + { + string custom = EditorPrefs.GetString(Prefix + "PCSXReduxPath", ""); + if (!string.IsNullOrEmpty(custom)) + return custom; + // Fall back to auto-downloaded location + if (SplashBuildPaths.IsPCSXReduxInstalled()) + return SplashBuildPaths.PCSXReduxBinary; + return ""; + } + set => EditorPrefs.SetString(Prefix + "PCSXReduxPath", value); + } + + public static string PCSXReduxPCdrvBase + { + get => EditorPrefs.GetString(Prefix + "PCSXReduxPCdrvBase", SplashBuildPaths.BuildOutputDir); + set => EditorPrefs.SetString(Prefix + "PCSXReduxPCdrvBase", value); + } + + // --- Serial / Real Hardware --- + public static string SerialPort + { + get => EditorPrefs.GetString(Prefix + "SerialPort", "COM3"); + set => EditorPrefs.SetString(Prefix + "SerialPort", value); + } + + public static int SerialBaudRate + { + get => EditorPrefs.GetInt(Prefix + "SerialBaudRate", 115200); + set => EditorPrefs.SetInt(Prefix + "SerialBaudRate", value); + } + + // --- VRAM Layout --- + public static int ResolutionWidth + { + get => EditorPrefs.GetInt(Prefix + "ResWidth", 320); + set => EditorPrefs.SetInt(Prefix + "ResWidth", value); + } + + public static int ResolutionHeight + { + get => EditorPrefs.GetInt(Prefix + "ResHeight", 240); + set => EditorPrefs.SetInt(Prefix + "ResHeight", value); + } + + public static bool DualBuffering + { + get => EditorPrefs.GetBool(Prefix + "DualBuffering", true); + set => EditorPrefs.SetBool(Prefix + "DualBuffering", value); + } + + public static bool VerticalLayout + { + get => EditorPrefs.GetBool(Prefix + "VerticalLayout", true); + set => EditorPrefs.SetBool(Prefix + "VerticalLayout", value); + } + + // --- Export settings --- + public static float DefaultGTEScaling + { + get => EditorPrefs.GetFloat(Prefix + "GTEScaling", 100f); + set => EditorPrefs.SetFloat(Prefix + "GTEScaling", value); + } + + public static bool AutoValidateOnExport + { + get => EditorPrefs.GetBool(Prefix + "AutoValidate", true); + set => EditorPrefs.SetBool(Prefix + "AutoValidate", value); + } + + // --- Play Mode Intercept --- + public static bool InterceptPlayMode + { + get => EditorPrefs.GetBool(Prefix + "InterceptPlayMode", false); + set => EditorPrefs.SetBool(Prefix + "InterceptPlayMode", value); + } + + /// + /// Resets all settings to defaults by deleting all prefixed keys. + /// + public static void ResetAll() + { + string[] keys = new[] + { + "Target", "Mode", "NativeProjectPath", "MIPSToolchainPath", + "PCSXReduxPath", "PCSXReduxPCdrvBase", "SerialPort", "SerialBaudRate", + "ResWidth", "ResHeight", "DualBuffering", "VerticalLayout", + "GTEScaling", "AutoValidate", "InterceptPlayMode" + }; + + foreach (string key in keys) + { + EditorPrefs.DeleteKey(Prefix + key); + } + } + } +} diff --git a/Editor/Core/SplashSettings.cs.meta b/Editor/Core/SplashSettings.cs.meta new file mode 100644 index 0000000..5a38f40 --- /dev/null +++ b/Editor/Core/SplashSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4765dbe728569d84699a22347e7c14ff \ No newline at end of file diff --git a/Editor/Core/UniromUploader.cs b/Editor/Core/UniromUploader.cs new file mode 100644 index 0000000..8aa293c --- /dev/null +++ b/Editor/Core/UniromUploader.cs @@ -0,0 +1,549 @@ +using System; +using System.IO; +using System.IO.Ports; +using System.Text; +using System.Threading; +using UnityEngine; + +namespace SplashEdit.EditorCode +{ + /// + /// Uploads a .ps-exe to a PS1 running Unirom 8 via serial. + /// Implements the NOTPSXSerial / Unirom protocol: + /// Challenge/Response handshake → header → metadata → chunked data with checksums. + /// Reference: https://github.com/JonathanDotCel/NOTPSXSerial + /// + public static class UniromUploader + { + // Protocol constants + private const string CHALLENGE_SEND_EXE = "SEXE"; + private const string RESPONSE_OK = "OKAY"; + private const int CHUNK_SIZE = 2048; + private const int HEADER_SIZE = 0x800; // 2048 + private const int SERIAL_TIMEOUT_MS = 5000; + + // Protocol version — negotiated during handshake + private static int _protocolVersion = 1; + + /// + /// Uploads a .ps-exe file to the PS1 via serial. + /// The PS1 must be at the Unirom shell prompt. + /// + public static bool UploadExe(string portName, int baudRate, string exePath, Action log) + { + var port = DoUpload(portName, baudRate, exePath, log, installDebugHooks: false); + if (port == null) return false; + try { port.Close(); } catch { } + port.Dispose(); + return true; + } + + /// + /// Uploads a .ps-exe with Unirom debug hooks installed, using SBIN+JUMP + /// instead of SEXE to avoid BIOS Exec() clobbering the debug handler. + /// + /// Flow: DEBG (install kernel-resident debug hooks) → SBIN (raw binary to address) + /// → JUMP (start execution at entry point). This bypasses BIOS Exec() entirely, + /// so the exception vector table patched by DEBG survives into the running program. + /// + /// Returns the open SerialPort for the caller to use for PCDrv monitoring. + /// The caller takes ownership of the returned port. + /// + public static SerialPort UploadExeForPCdrv(string portName, int baudRate, string exePath, Action log) + { + return DoUploadSBIN(portName, baudRate, exePath, log); + } + + /// + /// Core SEXE upload implementation. Opens port, optionally sends DEBG, does SEXE upload. + /// Used by UploadExe() for simple uploads without PCDrv. + /// Returns the open SerialPort (caller must close/dispose when done). + /// Returns null on failure. + /// + private static SerialPort DoUpload(string portName, int baudRate, string exePath, Action log, bool installDebugHooks) + { + if (!File.Exists(exePath)) + { + log?.Invoke($"File not found: {exePath}"); + return null; + } + + byte[] exeData = File.ReadAllBytes(exePath); + log?.Invoke($"Uploading {Path.GetFileName(exePath)} ({exeData.Length} bytes)"); + + // Pad to 2048-byte sector boundary (required by Unirom) + int mod = exeData.Length % CHUNK_SIZE; + if (mod != 0) + { + int paddingRequired = CHUNK_SIZE - mod; + byte[] padded = new byte[exeData.Length + paddingRequired]; + Buffer.BlockCopy(exeData, 0, padded, 0, exeData.Length); + exeData = padded; + log?.Invoke($"Padded to {exeData.Length} bytes (2048-byte boundary)"); + } + + _protocolVersion = 1; + SerialPort port = null; + + try + { + port = new SerialPort(portName, baudRate) + { + ReadTimeout = SERIAL_TIMEOUT_MS, + WriteTimeout = SERIAL_TIMEOUT_MS, + StopBits = StopBits.Two, + Parity = Parity.None, + DataBits = 8, + Handshake = Handshake.None, + DtrEnable = true, + RtsEnable = true + }; + port.Open(); + + // Drain any leftover bytes in the buffer + while (port.BytesToRead > 0) + port.ReadByte(); + + // ── Step 0 (PCDrv only): Install debug hooks while Unirom is still in command mode ── + if (installDebugHooks) + { + log?.Invoke("Installing debug hooks (DEBG)..."); + if (!ChallengeResponse(port, "DEBG", "OKAY", log)) + { + log?.Invoke("WARNING: DEBG failed. Is Unirom at the shell? PCDrv may not work."); + } + else + { + log?.Invoke("Debug hooks installed."); + } + + Thread.Sleep(100); + while (port.BytesToRead > 0) + port.ReadByte(); + } + + // ── Step 1: Challenge/Response handshake ── + log?.Invoke("Sending SEXE challenge..."); + if (!ChallengeResponse(port, CHALLENGE_SEND_EXE, RESPONSE_OK, log)) + { + log?.Invoke("No response from Unirom. Is the PS1 at the Unirom shell?"); + port.Close(); port.Dispose(); + return null; + } + log?.Invoke($"Unirom responded (protocol V{_protocolVersion}). Starting transfer..."); + + // ── Step 2: Calculate checksum (skip first 0x800 header sector) ── + uint checksum = CalculateChecksum(exeData, skipFirstSector: true); + + // ── Step 3: Send the 2048-byte header sector ── + port.Write(exeData, 0, HEADER_SIZE); + + // ── Step 4: Send metadata ── + port.Write(exeData, 0x10, 4); // Jump/PC address + port.Write(exeData, 0x18, 4); // Base/write address + port.Write(BitConverter.GetBytes(exeData.Length - HEADER_SIZE), 0, 4); // Data length + port.Write(BitConverter.GetBytes(checksum), 0, 4); // Checksum + + // ── Step 5: Send data chunks (skip first sector) ── + if (!WriteChunked(port, exeData, skipFirstSector: true, log)) + { + log?.Invoke("Data transfer failed."); + port.Close(); port.Dispose(); + return null; + } + + log?.Invoke("Upload complete. Exe executing on PS1."); + return port; + } + catch (Exception ex) + { + log?.Invoke($"Upload failed: {ex.Message}"); + if (port != null && port.IsOpen) + { + try { port.Close(); } catch { } + } + port?.Dispose(); + return null; + } + } + + /// + /// Uploads a .ps-exe using DEBG + SBIN + JUMP to preserve debug hooks. + /// + /// Unlike SEXE which calls BIOS Exec() (reinitializing the exception vector table + /// and destroying DEBG's kernel-resident debug handler), SBIN writes raw bytes + /// directly to the target address and JUMP starts execution without touching + /// the BIOS. This preserves the break-instruction handler that PCDrv depends on. + /// + /// Protocol: + /// 1. DEBG → OKAY: Install kernel-resident SIO debug stub + /// 2. SBIN → OKAY: addr(4 LE) + len(4 LE) + checksum(4 LE) + raw program data + /// 3. JUMP → OKAY: addr(4 LE) — jump to entry point + /// + private static SerialPort DoUploadSBIN(string portName, int baudRate, string exePath, Action log) + { + if (!File.Exists(exePath)) + { + log?.Invoke($"File not found: {exePath}"); + return null; + } + + byte[] exeData = File.ReadAllBytes(exePath); + log?.Invoke($"Uploading {Path.GetFileName(exePath)} ({exeData.Length} bytes) via SBIN+JUMP"); + + // Validate this is a PS-X EXE + if (exeData.Length < HEADER_SIZE + 4) + { + log?.Invoke("File too small to be a valid PS-X EXE."); + return null; + } + string magic = Encoding.ASCII.GetString(exeData, 0, 8); + if (!magic.StartsWith("PS-X EXE")) + { + log?.Invoke($"Not a PS-X EXE (magic: '{magic}')"); + return null; + } + + // Parse header + uint entryPoint = BitConverter.ToUInt32(exeData, 0x10); // PC / jump address + uint destAddr = BitConverter.ToUInt32(exeData, 0x18); // Copy destination + uint textSize = BitConverter.ToUInt32(exeData, 0x1C); // Text section size + + log?.Invoke($"PS-X EXE: entry=0x{entryPoint:X8}, dest=0x{destAddr:X8}, textSz=0x{textSize:X}"); + + // Extract program data (everything after the 2048-byte header) + int progDataLen = exeData.Length - HEADER_SIZE; + byte[] progData = new byte[progDataLen]; + Buffer.BlockCopy(exeData, HEADER_SIZE, progData, 0, progDataLen); + + // Pad program data to 2048-byte boundary (required by Unirom chunked transfer) + int mod = progData.Length % CHUNK_SIZE; + if (mod != 0) + { + int paddingRequired = CHUNK_SIZE - mod; + byte[] padded = new byte[progData.Length + paddingRequired]; + Buffer.BlockCopy(progData, 0, padded, 0, progData.Length); + progData = padded; + log?.Invoke($"Program data padded to {progData.Length} bytes"); + } + + _protocolVersion = 1; + SerialPort port = null; + + try + { + port = new SerialPort(portName, baudRate) + { + ReadTimeout = SERIAL_TIMEOUT_MS, + WriteTimeout = SERIAL_TIMEOUT_MS, + StopBits = StopBits.Two, + Parity = Parity.None, + DataBits = 8, + Handshake = Handshake.None, + DtrEnable = true, + RtsEnable = true + }; + port.Open(); + + // Drain any leftover bytes + while (port.BytesToRead > 0) + port.ReadByte(); + + // ── Step 1: DEBG — Install kernel-resident debug hooks ── + log?.Invoke("Installing debug hooks (DEBG)..."); + if (!ChallengeResponse(port, "DEBG", "OKAY", log)) + { + log?.Invoke("DEBG failed. Is Unirom at the shell?"); + port.Close(); port.Dispose(); + return null; + } + log?.Invoke("Debug hooks installed."); + + // Drain + settle — Unirom may send extra bytes after DEBG + Thread.Sleep(100); + while (port.BytesToRead > 0) + port.ReadByte(); + + // ── Step 2: SBIN — Upload raw program data to target address ── + log?.Invoke($"Sending SBIN to 0x{destAddr:X8} ({progData.Length} bytes)..."); + if (!ChallengeResponse(port, "SBIN", "OKAY", log)) + { + log?.Invoke("SBIN failed. Unirom may not support this command."); + port.Close(); port.Dispose(); + return null; + } + + // SBIN metadata: address(4) + length(4) + checksum(4) + uint checksum = CalculateChecksum(progData, skipFirstSector: false); + port.Write(BitConverter.GetBytes(destAddr), 0, 4); + port.Write(BitConverter.GetBytes(progData.Length), 0, 4); + port.Write(BitConverter.GetBytes(checksum), 0, 4); + + log?.Invoke($"SBIN metadata sent (checksum=0x{checksum:X8}). Sending data..."); + + // Send program data chunks + if (!WriteChunked(port, progData, skipFirstSector: false, log)) + { + log?.Invoke("SBIN data transfer failed."); + port.Close(); port.Dispose(); + return null; + } + log?.Invoke("SBIN upload complete."); + + // Drain any residual + Thread.Sleep(100); + while (port.BytesToRead > 0) + port.ReadByte(); + + // ── Step 3: JUMP — Start execution at entry point ── + log?.Invoke($"Sending JUMP to 0x{entryPoint:X8}..."); + if (!ChallengeResponse(port, "JUMP", "OKAY", log)) + { + log?.Invoke("JUMP failed."); + port.Close(); port.Dispose(); + return null; + } + // JUMP payload: just the address (4 bytes LE) + port.Write(BitConverter.GetBytes(entryPoint), 0, 4); + + log?.Invoke("JUMP sent. Exe now running (debug hooks preserved)."); + return port; + } + catch (Exception ex) + { + log?.Invoke($"Upload failed: {ex.Message}"); + if (port != null && port.IsOpen) + { + try { port.Close(); } catch { } + } + port?.Dispose(); + return null; + } + } + + // ═══════════════════════════════════════════════════════════════ + // Challenge / Response with protocol negotiation + // ═══════════════════════════════════════════════════════════════ + + private static bool ChallengeResponse(SerialPort port, string challenge, string expectedResponse, Action log) + { + // Send the challenge + byte[] challengeBytes = Encoding.ASCII.GetBytes(challenge); + port.Write(challengeBytes, 0, challengeBytes.Length); + Thread.Sleep(50); + + // Wait for the response with protocol negotiation + return WaitResponse(port, expectedResponse, log); + } + + private static bool WaitResponse(SerialPort port, string expected, Action log, int timeoutMs = 10000) + { + string buffer = ""; + DateTime deadline = DateTime.Now.AddMilliseconds(timeoutMs); + + while (DateTime.Now < deadline) + { + if (port.BytesToRead > 0) + { + buffer += (char)port.ReadByte(); + + // Keep buffer at 4 chars max (rolling window) + if (buffer.Length > 4) + buffer = buffer.Substring(buffer.Length - 4); + + // Protocol V3 upgrade (DJB2 checksums) + // Always respond — Unirom re-offers V2/V3 for each command, + // and our protocolVersion may already be >1 from a prior DEBG exchange. + if (buffer == "OKV3") + { + log?.Invoke("Upgraded to protocol V3"); + byte[] upv3 = Encoding.ASCII.GetBytes("UPV3"); + port.Write(upv3, 0, upv3.Length); + _protocolVersion = 3; + buffer = ""; + continue; + } + + // Protocol V2 upgrade (per-chunk checksums) + if (buffer == "OKV2") + { + log?.Invoke("Upgraded to protocol V2"); + byte[] upv2 = Encoding.ASCII.GetBytes("UPV2"); + port.Write(upv2, 0, upv2.Length); + if (_protocolVersion < 2) _protocolVersion = 2; + buffer = ""; + continue; + } + + // Unsupported in debug mode + if (buffer == "UNSP") + { + log?.Invoke("Command not supported while Unirom is in debug mode."); + return false; + } + + // Got the expected response + if (buffer == expected) + return true; + } + else + { + Thread.Sleep(1); + } + } + + return false; + } + + // ═══════════════════════════════════════════════════════════════ + // Chunked data transfer with per-chunk checksum verification + // ═══════════════════════════════════════════════════════════════ + + private static bool WriteChunked(SerialPort port, byte[] data, bool skipFirstSector, Action log) + { + int start = skipFirstSector ? CHUNK_SIZE : 0; + int totalDataBytes = data.Length - start; + int numChunks = (totalDataBytes + CHUNK_SIZE - 1) / CHUNK_SIZE; + int chunkIndex = 0; + + for (int offset = start; offset < data.Length; ) + { + // Determine chunk size (last chunk may be smaller) + int thisChunk = Math.Min(CHUNK_SIZE, data.Length - offset); + + // Calculate per-chunk checksum (simple byte sum for V2, also works for V1) + ulong chunkChecksum = 0; + for (int j = 0; j < thisChunk; j++) + chunkChecksum += data[offset + j]; + + // Send the chunk + port.Write(data, offset, thisChunk); + + // Wait for bytes to drain + while (port.BytesToWrite > 0) + Thread.Sleep(0); + + chunkIndex++; + + // Progress report every 10 chunks or on last chunk + if (chunkIndex % 10 == 0 || offset + thisChunk >= data.Length) + { + int sent = offset + thisChunk - start; + int pct = totalDataBytes > 0 ? sent * 100 / totalDataBytes : 100; + log?.Invoke($"Upload: {pct}% ({sent}/{totalDataBytes})"); + } + + // Protocol V2/V3: per-chunk checksum verification + if (_protocolVersion >= 2) + { + if (!HandleChunkAck(port, chunkChecksum, data, offset, thisChunk, log, out bool retry)) + { + return false; + } + if (retry) + continue; // Don't advance offset — resend this chunk + } + + offset += thisChunk; + } + + return true; + } + + /// + /// Handles the per-chunk CHEK/MORE/ERR! exchange for protocol V2+. + /// + private static bool HandleChunkAck(SerialPort port, ulong chunkChecksum, byte[] data, int offset, int chunkSize, Action log, out bool retry) + { + retry = false; + + // Wait for "CHEK" request from Unirom + string cmdBuffer = ""; + DateTime deadline = DateTime.Now.AddMilliseconds(SERIAL_TIMEOUT_MS); + + while (DateTime.Now < deadline) + { + if (port.BytesToRead > 0) + { + cmdBuffer += (char)port.ReadByte(); + if (cmdBuffer.Length > 4) + cmdBuffer = cmdBuffer.Substring(cmdBuffer.Length - 4); + + if (cmdBuffer == "CHEK") + break; + } + else + { + Thread.Sleep(1); + } + } + + if (cmdBuffer != "CHEK") + { + log?.Invoke("Timeout waiting for CHEK from Unirom"); + return false; + } + + // Send the chunk checksum (4 bytes, little-endian) + port.Write(BitConverter.GetBytes((uint)chunkChecksum), 0, 4); + Thread.Sleep(1); + + // Wait for MORE (ok) or ERR! (resend) + cmdBuffer = ""; + deadline = DateTime.Now.AddMilliseconds(SERIAL_TIMEOUT_MS); + + while (DateTime.Now < deadline) + { + if (port.BytesToRead > 0) + { + cmdBuffer += (char)port.ReadByte(); + if (cmdBuffer.Length > 4) + cmdBuffer = cmdBuffer.Substring(cmdBuffer.Length - 4); + + if (cmdBuffer == "MORE") + return true; + + if (cmdBuffer == "ERR!") + { + log?.Invoke("Checksum error — retrying chunk..."); + retry = true; + return true; + } + } + else + { + Thread.Sleep(1); + } + } + + log?.Invoke("Timeout waiting for MORE/ERR! from Unirom"); + return false; + } + + // ═══════════════════════════════════════════════════════════════ + // Checksum calculation + // ═══════════════════════════════════════════════════════════════ + + private static uint CalculateChecksum(byte[] data, bool skipFirstSector) + { + int start = skipFirstSector ? HEADER_SIZE : 0; + + if (_protocolVersion == 3) + { + // DJB2 hash + uint hash = 5381; + for (int i = start; i < data.Length; i++) + hash = ((hash << 5) + hash) ^ data[i]; + return hash; + } + else + { + // Simple byte sum + uint sum = 0; + for (int i = start; i < data.Length; i++) + sum += data[i]; + return sum; + } + } + } +} diff --git a/Editor/Core/UniromUploader.cs.meta b/Editor/Core/UniromUploader.cs.meta new file mode 100644 index 0000000..06d2c0c --- /dev/null +++ b/Editor/Core/UniromUploader.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e39963a5097ad6a48952a0a9d04d1563 \ No newline at end of file diff --git a/Editor/DependencyCheckInitializer.cs b/Editor/DependencyCheckInitializer.cs index 75b13fc..846aa35 100644 --- a/Editor/DependencyCheckInitializer.cs +++ b/Editor/DependencyCheckInitializer.cs @@ -1,21 +1,39 @@ using UnityEditor; -using UnityEditor.Callbacks; -[InitializeOnLoad] -public static class DependencyCheckInitializer +namespace SplashEdit.EditorCode { - static DependencyCheckInitializer() + /// + /// Automatically opens the SplashEdit Control Panel on the first editor + /// session if the MIPS toolchain has not been installed yet. + /// + [InitializeOnLoad] + public static class DependencyCheckInitializer { - EditorApplication.update += OpenInstallerOnStart; - } + private const string SessionKey = "SplashEditOpenedThisSession"; - private static void OpenInstallerOnStart() - { - EditorApplication.update -= OpenInstallerOnStart; - if (!SessionState.GetBool("InstallerWindowOpened", false)) + static DependencyCheckInitializer() { - InstallerWindow.ShowWindow(); - SessionState.SetBool("InstallerWindowOpened", true); // only once per session + EditorApplication.update += OpenControlPanelOnStart; + } + + private static void OpenControlPanelOnStart() + { + EditorApplication.update -= OpenControlPanelOnStart; + + if (SessionState.GetBool(SessionKey, false)) + return; + + SessionState.SetBool(SessionKey, true); + + // Only auto-open the Control Panel when the toolchain is missing + bool toolchainReady = ToolchainChecker.IsToolAvailable("mips") || + ToolchainChecker.IsToolAvailable("mipsel-none-elf-gcc") || + ToolchainChecker.IsToolAvailable("mipsel-linux-gnu-gcc"); + + if (!toolchainReady) + { + SplashControlPanel.ShowWindow(); + } } } -} \ No newline at end of file +} diff --git a/Editor/DependencyCheckInitializer.cs.meta b/Editor/DependencyCheckInitializer.cs.meta index bc4a4e9..a45e2c7 100644 --- a/Editor/DependencyCheckInitializer.cs.meta +++ b/Editor/DependencyCheckInitializer.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: 044a70d388c22fd2aa69bb757eb4c071 \ No newline at end of file +guid: c7043b9e1acbfbe40b9bd9be80e764e5 \ No newline at end of file diff --git a/Editor/DependencyInstallerWindow.cs b/Editor/DependencyInstallerWindow.cs deleted file mode 100644 index aa0a9e7..0000000 --- a/Editor/DependencyInstallerWindow.cs +++ /dev/null @@ -1,522 +0,0 @@ -using UnityEngine; -using UnityEditor; -using System.Collections.Generic; -using SplashEdit.RuntimeCode; -using System.IO; -using System.Linq; -using System.Threading.Tasks; - - -namespace SplashEdit.EditorCode -{ - public class InstallerWindow : EditorWindow - { - // Cached status for MIPS toolchain binaries. - private Dictionary mipsToolStatus = new Dictionary(); - - // Cached status for optional tools. - private bool makeInstalled; - private bool gdbInstalled; - private string pcsxReduxPath; - - // PSXSplash related variables - private bool psxsplashInstalled = false; - private bool psxsplashInstalling = false; - private bool psxsplashFetching = false; - private string selectedVersion = "main"; - private Dictionary availableBranches = new Dictionary(); - private List availableReleases = new List(); - private bool showBranches = true; - private bool showReleases = false; - private Vector2 scrollPosition; - private Vector2 versionScrollPosition; - - private bool isInstalling = false; - - [MenuItem("PSX/Toolchain & Build Tools Installer")] - public static void ShowWindow() - { - InstallerWindow window = GetWindow("Toolchain Installer"); - window.RefreshToolStatus(); - window.pcsxReduxPath = DataStorage.LoadData().PCSXReduxPath; - window.CheckPSXSplashInstallation(); - } - - /// - /// Refresh the cached statuses for all tools. - /// - private void RefreshToolStatus() - { - mipsToolStatus.Clear(); - foreach (var tool in ToolchainChecker.GetRequiredTools()) - { - mipsToolStatus[tool] = ToolchainChecker.IsToolAvailable(tool); - } - - makeInstalled = ToolchainChecker.IsToolAvailable("make"); - gdbInstalled = ToolchainChecker.IsToolAvailable("gdb-multiarch"); - } - - private void CheckPSXSplashInstallation() - { - psxsplashInstalled = PSXSplashInstaller.IsInstalled(); - - if (psxsplashInstalled) - { - FetchPSXSplashVersions(); - } - else - { - availableBranches = new Dictionary(); - availableReleases = new List(); - } - } - - private async void FetchPSXSplashVersions() - { - if (psxsplashFetching) return; - - psxsplashFetching = true; - try - { - // Fetch latest from remote - await PSXSplashInstaller.FetchLatestAsync(); - - // Get all available versions - var branchesTask = PSXSplashInstaller.GetBranchesWithLatestCommitsAsync(); - var releasesTask = PSXSplashInstaller.GetReleasesAsync(); - - await Task.WhenAll(branchesTask, releasesTask); - - availableBranches = branchesTask.Result; - availableReleases = releasesTask.Result; - - // If no branches were found, add main as default - if (!availableBranches.Any()) - { - availableBranches["main"] = "latest"; - } - - // Select the first branch by default - if (availableBranches.Any() && string.IsNullOrEmpty(selectedVersion)) - { - selectedVersion = availableBranches.Keys.First(); - } - - Repaint(); - } - catch (System.Exception e) - { - UnityEngine.Debug.LogError($"Failed to fetch PSXSplash versions: {e.Message}"); - } - finally - { - psxsplashFetching = false; - } - } - - private void OnGUI() - { - scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); - - GUILayout.Label("Toolchain & Build Tools Installer", EditorStyles.boldLabel); - GUILayout.Space(5); - - if (GUILayout.Button("Refresh Status")) - { - RefreshToolStatus(); - CheckPSXSplashInstallation(); - } - GUILayout.Space(10); - - EditorGUILayout.BeginHorizontal(); - DrawToolchainColumn(); - DrawAdditionalToolsColumn(); - DrawPSXSplashColumn(); - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.EndScrollView(); - } - - private void DrawToolchainColumn() - { - EditorGUILayout.BeginVertical("box", GUILayout.MaxWidth(position.width / 3 - 10)); - GUILayout.Label("MIPS Toolchain", EditorStyles.boldLabel); - GUILayout.Space(5); - - // Display cached status for each required MIPS tool. - foreach (var kvp in mipsToolStatus) - { - GUI.color = kvp.Value ? Color.green : Color.red; - GUILayout.Label($"{kvp.Key}: {(kvp.Value ? "Found" : "Missing")}"); - } - GUI.color = Color.white; - GUILayout.Space(5); - - if (GUILayout.Button("Install MIPS Toolchain")) - { - if (!isInstalling) - InstallMipsToolchainAsync(); - } - EditorGUILayout.EndVertical(); - } - - private void DrawAdditionalToolsColumn() - { - EditorGUILayout.BeginVertical("box", GUILayout.MaxWidth(position.width / 3 - 10)); - GUILayout.Label("Optional Tools", EditorStyles.boldLabel); - GUILayout.Space(5); - - // GNU Make status (required). - GUI.color = makeInstalled ? Color.green : Color.red; - GUILayout.Label($"GNU Make: {(makeInstalled ? "Found" : "Missing")} (Required)"); - GUI.color = Color.white; - GUILayout.Space(5); - if (GUILayout.Button("Install GNU Make")) - { - if (!isInstalling) - InstallMakeAsync(); - } - - GUILayout.Space(10); - - // GDB status (optional). - GUI.color = gdbInstalled ? Color.green : Color.red; - GUILayout.Label($"GDB: {(gdbInstalled ? "Found" : "Missing")} (Optional)"); - GUI.color = Color.white; - GUILayout.Space(5); - if (GUILayout.Button("Install GDB")) - { - if (!isInstalling) - InstallGDBAsync(); - } - - GUILayout.Space(10); - - // PCSX-Redux (manual install) - GUI.color = string.IsNullOrEmpty(pcsxReduxPath) ? Color.red : Color.green; - GUILayout.Label($"PCSX-Redux: {(string.IsNullOrEmpty(pcsxReduxPath) ? "Not Configured" : "Configured")} (Optional)"); - GUI.color = Color.white; - - GUILayout.BeginHorizontal(); - if (GUILayout.Button("Browse for PCSX-Redux")) - { - string selectedPath = EditorUtility.OpenFilePanel("Select PCSX-Redux Executable", "", ""); - if (!string.IsNullOrEmpty(selectedPath)) - { - pcsxReduxPath = selectedPath; - PSXData data = DataStorage.LoadData(); - data.PCSXReduxPath = pcsxReduxPath; - DataStorage.StoreData(data); - } - } - if (!string.IsNullOrEmpty(pcsxReduxPath)) - { - if (GUILayout.Button("Clear", GUILayout.Width(60))) - { - pcsxReduxPath = ""; - PSXData data = DataStorage.LoadData(); - data.PCSXReduxPath = pcsxReduxPath; - DataStorage.StoreData(data); - } - } - GUILayout.EndHorizontal(); - EditorGUILayout.EndVertical(); - } - - private void DrawPSXSplashColumn() - { - EditorGUILayout.BeginVertical("box", GUILayout.MaxWidth(position.width / 3 - 10)); - GUILayout.Label("PSXSplash", EditorStyles.boldLabel); - GUILayout.Space(5); - - // PSXSplash status - GUI.color = psxsplashInstalled ? Color.green : Color.red; - GUILayout.Label($"PSXSplash: {(psxsplashInstalled ? "Installed" : "Not Installed")}"); - GUI.color = Color.white; - - if (psxsplashFetching) - { - GUILayout.Label("Fetching versions..."); - } - else if (!psxsplashInstalled) - { - GUILayout.Space(5); - EditorGUILayout.HelpBox("Git is required to install PSXSplash. Make sure it's installed and available in your PATH.", MessageType.Info); - - // Show version selection even before installation - DrawVersionSelection(); - - if (GUILayout.Button("Install PSXSplash") && !psxsplashInstalling) - { - InstallPSXSplashAsync(); - } - } - else - { - GUILayout.Space(10); - - // Current version - EditorGUILayout.LabelField($"Current Version: {selectedVersion}", EditorStyles.boldLabel); - - // Version selection - DrawVersionSelection(); - - GUILayout.Space(10); - - // Refresh and update buttons - EditorGUILayout.BeginHorizontal(); - if (GUILayout.Button("Refresh Versions")) - { - FetchPSXSplashVersions(); - } - - if (GUILayout.Button("Update PSXSplash")) - { - UpdatePSXSplashAsync(); - } - EditorGUILayout.EndHorizontal(); - } - - EditorGUILayout.EndVertical(); - } - - private void DrawVersionSelection() - { - EditorGUILayout.LabelField("Available Versions:", EditorStyles.boldLabel); - - versionScrollPosition = EditorGUILayout.BeginScrollView(versionScrollPosition, GUILayout.Height(200)); - - // Branches (with latest commits) - showBranches = EditorGUILayout.Foldout(showBranches, $"Branches ({availableBranches.Count})"); - if (showBranches && availableBranches.Any()) - { - foreach (var branch in availableBranches) - { - EditorGUILayout.BeginHorizontal(); - bool isSelected = selectedVersion == branch.Key; - if (GUILayout.Toggle(isSelected, "", GUILayout.Width(20)) && !isSelected) - { - selectedVersion = branch.Key; - if (psxsplashInstalled) - { - CheckoutPSXSplashVersionAsync(branch.Key); - } - } - GUILayout.Label($"{branch.Key} (Latest: {branch.Value})", EditorStyles.label); - EditorGUILayout.EndHorizontal(); - } - } - else if (showBranches) - { - GUILayout.Label("No branches available"); - } - - // Releases - showReleases = EditorGUILayout.Foldout(showReleases, $"Releases ({availableReleases.Count})"); - if (showReleases && availableReleases.Any()) - { - foreach (var release in availableReleases) - { - EditorGUILayout.BeginHorizontal(); - bool isSelected = selectedVersion == release; - if (GUILayout.Toggle(isSelected, "", GUILayout.Width(20)) && !isSelected) - { - selectedVersion = release; - if (psxsplashInstalled) - { - CheckoutPSXSplashVersionAsync(release); - } - } - GUILayout.Label(release, EditorStyles.label); - EditorGUILayout.EndHorizontal(); - } - } - else if (showReleases) - { - GUILayout.Label("No releases available"); - } - - EditorGUILayout.EndScrollView(); - } - - private async void InstallPSXSplashAsync() - { - try - { - psxsplashInstalling = true; - EditorUtility.DisplayProgressBar("Installing PSXSplash", "Cloning repository...", 0.3f); - - bool success = await PSXSplashInstaller.Install(); - - EditorUtility.ClearProgressBar(); - - if (success) - { - EditorUtility.DisplayDialog("Installation Complete", "PSXSplash installed successfully.", "OK"); - CheckPSXSplashInstallation(); - - // Checkout the selected version after installation - if (!string.IsNullOrEmpty(selectedVersion)) - { - await CheckoutPSXSplashVersionAsync(selectedVersion); - } - } - else - { - EditorUtility.DisplayDialog("Installation Failed", - "Failed to install PSXSplash. Make sure Git is installed and available in your PATH.", "OK"); - } - } - catch (System.Exception ex) - { - EditorUtility.ClearProgressBar(); - EditorUtility.DisplayDialog("Installation Failed", $"Error: {ex.Message}", "OK"); - } - finally - { - psxsplashInstalling = false; - } - } - - private async Task CheckoutPSXSplashVersionAsync(string version) - { - try - { - psxsplashInstalling = true; - EditorUtility.DisplayProgressBar("Checking Out Version", $"Switching to {version}...", 0.3f); - - bool success = await PSXSplashInstaller.CheckoutVersionAsync(version); - - EditorUtility.ClearProgressBar(); - - if (success) - { - EditorUtility.DisplayDialog("Checkout Complete", $"Switched to {version} successfully.", "OK"); - return true; - } - else - { - EditorUtility.DisplayDialog("Checkout Failed", - $"Failed to switch to {version}.", "OK"); - return false; - } - } - catch (System.Exception ex) - { - EditorUtility.ClearProgressBar(); - EditorUtility.DisplayDialog("Checkout Failed", $"Error: {ex.Message}", "OK"); - return false; - } - finally - { - psxsplashInstalling = false; - } - } - - private async void UpdatePSXSplashAsync() - { - try - { - psxsplashInstalling = true; - EditorUtility.DisplayProgressBar("Updating PSXSplash", "Pulling latest changes...", 0.3f); - - // Pull the latest changes - bool success = await PSXSplashInstaller.CheckoutVersionAsync(selectedVersion); - - EditorUtility.ClearProgressBar(); - - if (success) - { - EditorUtility.DisplayDialog("Update Complete", "PSXSplash updated successfully.", "OK"); - } - else - { - EditorUtility.DisplayDialog("Update Failed", - "Failed to update PSXSplash.", "OK"); - } - } - catch (System.Exception ex) - { - EditorUtility.ClearProgressBar(); - EditorUtility.DisplayDialog("Update Failed", $"Error: {ex.Message}", "OK"); - } - finally - { - psxsplashInstalling = false; - } - } - - private async void InstallMipsToolchainAsync() - { - try - { - isInstalling = true; - EditorUtility.DisplayProgressBar("Installing MIPS Toolchain", - "Please wait while the MIPS toolchain is being installed...", 0f); - bool success = await ToolchainInstaller.InstallToolchain(); - EditorUtility.ClearProgressBar(); - if (success) - { - EditorUtility.DisplayDialog("Installation Complete", "MIPS toolchain installed successfully.", "OK"); - } - RefreshToolStatus(); // Update cached statuses after installation - } - catch (System.Exception ex) - { - EditorUtility.ClearProgressBar(); - EditorUtility.DisplayDialog("Installation Failed", $"Error: {ex.Message}", "OK"); - } - finally - { - isInstalling = false; - } - } - - private async void InstallMakeAsync() - { - try - { - isInstalling = true; - EditorUtility.DisplayProgressBar("Installing GNU Make", - "Please wait while GNU Make is being installed...", 0f); - await ToolchainInstaller.InstallMake(); - EditorUtility.ClearProgressBar(); - EditorUtility.DisplayDialog("Installation Complete", "GNU Make installed successfully.", "OK"); - RefreshToolStatus(); - } - catch (System.Exception ex) - { - EditorUtility.ClearProgressBar(); - EditorUtility.DisplayDialog("Installation Failed", $"Error: {ex.Message}", "OK"); - } - finally - { - isInstalling = false; - } - } - - private async void InstallGDBAsync() - { - try - { - isInstalling = true; - EditorUtility.DisplayProgressBar("Installing GDB", - "Please wait while GDB is being installed...", 0f); - await ToolchainInstaller.InstallGDB(); - EditorUtility.ClearProgressBar(); - EditorUtility.DisplayDialog("Installation Complete", "GDB installed successfully.", "OK"); - RefreshToolStatus(); - } - catch (System.Exception ex) - { - EditorUtility.ClearProgressBar(); - EditorUtility.DisplayDialog("Installation Failed", $"Error: {ex.Message}", "OK"); - } - finally - { - isInstalling = false; - } - } - } -} \ No newline at end of file diff --git a/Editor/DependencyInstallerWindow.cs.meta b/Editor/DependencyInstallerWindow.cs.meta deleted file mode 100644 index a32883e..0000000 --- a/Editor/DependencyInstallerWindow.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 72dec7ea237a8497abc6150ea907b3e2 \ No newline at end of file diff --git a/Editor/Inspectors.meta b/Editor/Inspectors.meta new file mode 100644 index 0000000..5e05c3b --- /dev/null +++ b/Editor/Inspectors.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0279126b700b37d4485c1f4f1ae44e54 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Inspectors/PSXComponentEditors.cs b/Editor/Inspectors/PSXComponentEditors.cs new file mode 100644 index 0000000..d790e63 --- /dev/null +++ b/Editor/Inspectors/PSXComponentEditors.cs @@ -0,0 +1,136 @@ +using UnityEngine; +using UnityEditor; +using SplashEdit.RuntimeCode; + +namespace SplashEdit.EditorCode +{ + /// + /// Custom inspector for PSXInteractable component. + /// + [CustomEditor(typeof(PSXInteractable))] + public class PSXInteractableEditor : UnityEditor.Editor + { + private bool _interactionFoldout = true; + private bool _advancedFoldout = false; + + private SerializedProperty _interactionRadius; + private SerializedProperty _interactButton; + private SerializedProperty _isRepeatable; + private SerializedProperty _cooldownFrames; + private SerializedProperty _showPrompt; + private SerializedProperty _requireLineOfSight; + private SerializedProperty _interactionOffset; + + private static readonly string[] ButtonNames = + { + "Select", "L3", "R3", "Start", "Up", "Right", "Down", "Left", + "L2", "R2", "L1", "R1", "Triangle", "Circle", "Cross", "Square" + }; + + private void OnEnable() + { + _interactionRadius = serializedObject.FindProperty("interactionRadius"); + _interactButton = serializedObject.FindProperty("interactButton"); + _isRepeatable = serializedObject.FindProperty("isRepeatable"); + _cooldownFrames = serializedObject.FindProperty("cooldownFrames"); + _showPrompt = serializedObject.FindProperty("showPrompt"); + _requireLineOfSight = serializedObject.FindProperty("requireLineOfSight"); + _interactionOffset = serializedObject.FindProperty("interactionOffset"); + } + + public override void OnInspectorGUI() + { + serializedObject.Update(); + + DrawHeader(); + + EditorGUILayout.Space(5); + + _interactionFoldout = DrawFoldoutSection("Interaction Settings", _interactionFoldout, () => + { + EditorGUILayout.PropertyField(_interactionRadius); + + // Button selector with visual + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.PrefixLabel("Interact Button"); + _interactButton.intValue = EditorGUILayout.Popup(_interactButton.intValue, ButtonNames); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.PropertyField(_isRepeatable); + + if (_isRepeatable.boolValue) + { + EditorGUI.indentLevel++; + EditorGUILayout.PropertyField(_cooldownFrames, new GUIContent("Cooldown (frames)")); + + // Show cooldown in seconds + float seconds = _cooldownFrames.intValue / 60f; + EditorGUILayout.LabelField($"≈ {seconds:F2} seconds at 60fps", EditorStyles.miniLabel); + EditorGUI.indentLevel--; + } + + EditorGUILayout.PropertyField(_showPrompt); + }); + + _advancedFoldout = DrawFoldoutSection("Advanced", _advancedFoldout, () => + { + EditorGUILayout.PropertyField(_requireLineOfSight); + EditorGUILayout.PropertyField(_interactionOffset); + }); + + DrawLuaEventsInfo(new[] { "onInteract" }); + + serializedObject.ApplyModifiedProperties(); + } + + private void DrawHeader() + { + EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); + + GUILayout.Label(EditorGUIUtility.IconContent("d_Selectable Icon"), GUILayout.Width(30), GUILayout.Height(30)); + + EditorGUILayout.BeginVertical(); + GUILayout.Label("PSX Interactable", EditorStyles.boldLabel); + GUILayout.Label("Player interaction trigger for PS1", EditorStyles.miniLabel); + EditorGUILayout.EndVertical(); + + EditorGUILayout.EndHorizontal(); + } + + private bool DrawFoldoutSection(string title, bool isExpanded, System.Action drawContent) + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + isExpanded = EditorGUILayout.Foldout(isExpanded, title, true, EditorStyles.foldoutHeader); + + if (isExpanded) + { + EditorGUI.indentLevel++; + drawContent?.Invoke(); + EditorGUI.indentLevel--; + } + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(3); + + return isExpanded; + } + + private void DrawLuaEventsInfo(string[] events) + { + EditorGUILayout.Space(5); + + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + GUILayout.Label("Lua Events", EditorStyles.boldLabel); + + EditorGUILayout.BeginHorizontal(); + foreach (var evt in events) + { + GUILayout.Label($"• {evt}", EditorStyles.miniLabel); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.EndVertical(); + } + } +} diff --git a/Editor/Inspectors/PSXComponentEditors.cs.meta b/Editor/Inspectors/PSXComponentEditors.cs.meta new file mode 100644 index 0000000..8cf8dc1 --- /dev/null +++ b/Editor/Inspectors/PSXComponentEditors.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7bd9caaf5a0cb90409cf0acdf17d8d89 \ No newline at end of file diff --git a/Editor/LuaFileAssetEditor.cs b/Editor/LuaFileAssetEditor.cs index 2f549d9..f52b64d 100644 --- a/Editor/LuaFileAssetEditor.cs +++ b/Editor/LuaFileAssetEditor.cs @@ -1,15 +1,43 @@ -using Splashedit.RuntimeCode; +using SplashEdit.RuntimeCode; using UnityEditor; using UnityEngine; -[CustomEditor(typeof(LuaFile))] -public class LuaScriptAssetEditor : Editor +namespace SplashEdit.EditorCode { - private TextAsset asset; - - public override void OnInspectorGUI() + /// + /// Custom inspector for assets that displays the + /// embedded Lua source code in a read-only text area with an option to + /// open the source file in an external editor. + /// + [CustomEditor(typeof(LuaFile))] + public class LuaScriptAssetEditor : Editor { - LuaFile luaScriptAsset = (LuaFile)target; - EditorGUILayout.TextArea(luaScriptAsset.LuaScript); + private Vector2 _scrollPosition; + + public override void OnInspectorGUI() + { + LuaFile luaScriptAsset = (LuaFile)target; + + // Open in external editor button + string assetPath = AssetDatabase.GetAssetPath(target); + if (!string.IsNullOrEmpty(assetPath)) + { + if (GUILayout.Button("Open in External Editor")) + { + // Opens the .lua source file in the OS-configured editor + UnityEditorInternal.InternalEditorUtility.OpenFileAtLineExternal(assetPath, 1); + } + EditorGUILayout.Space(4); + } + + // Read-only source view + EditorGUILayout.LabelField("Lua Source", EditorStyles.boldLabel); + _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, + GUILayout.MaxHeight(400)); + EditorGUI.BeginDisabledGroup(true); + EditorGUILayout.TextArea(luaScriptAsset.LuaScript, GUILayout.ExpandHeight(true)); + EditorGUI.EndDisabledGroup(); + EditorGUILayout.EndScrollView(); + } } } diff --git a/Editor/LuaFileAssetEditor.cs.meta b/Editor/LuaFileAssetEditor.cs.meta index 670ebc0..190916e 100644 --- a/Editor/LuaFileAssetEditor.cs.meta +++ b/Editor/LuaFileAssetEditor.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: 32c0501d523345500be12e6e4214ec9d \ No newline at end of file +guid: 66e212c64ebd0a34f9c23febe3e8545d \ No newline at end of file diff --git a/Editor/LuaImporter.cs b/Editor/LuaImporter.cs index 2b931da..c5c1e5c 100644 --- a/Editor/LuaImporter.cs +++ b/Editor/LuaImporter.cs @@ -2,11 +2,11 @@ using UnityEngine; using System.IO; using UnityEditor; using UnityEditor.AssetImporters; -using Splashedit.RuntimeCode; +using SplashEdit.RuntimeCode; namespace SplashEdit.EditorCode { - [ScriptedImporter(1, "lua")] + [ScriptedImporter(2, "lua")] class LuaImporter : ScriptedImporter { public override void OnImportAsset(AssetImportContext ctx) @@ -19,7 +19,7 @@ namespace SplashEdit.EditorCode ctx.AddObjectToAsset("Text", text); ctx.AddObjectToAsset("Script", asset); - ctx.SetMainObject(text); + ctx.SetMainObject(asset); // LuaFile is the main object, not TextAsset } } } \ No newline at end of file diff --git a/Editor/LuaImporter.cs.meta b/Editor/LuaImporter.cs.meta index dcc9288..230ff6a 100644 --- a/Editor/LuaImporter.cs.meta +++ b/Editor/LuaImporter.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: d364a1392e3bccd77aca824ac471f89c \ No newline at end of file +guid: 74e983e6cf3376944af7b469023d6e4d \ No newline at end of file diff --git a/Editor/PSXMenuItems.cs b/Editor/PSXMenuItems.cs new file mode 100644 index 0000000..70c6122 --- /dev/null +++ b/Editor/PSXMenuItems.cs @@ -0,0 +1,80 @@ +using UnityEditor; +using UnityEngine; +using SplashEdit.RuntimeCode; +using System.Linq; + +namespace SplashEdit.EditorCode +{ + /// + /// Minimal menu items — everything goes through the unified Control Panel. + /// Only keeps: Control Panel shortcut + GameObject creation helpers. + /// + public static class PSXMenuItems + { + private const string MENU_ROOT = "PlayStation 1/"; + + // ───── Main Entry Point ───── + + [MenuItem(MENU_ROOT + "SplashEdit Control Panel %#p", false, 0)] + public static void OpenControlPanel() + { + SplashControlPanel.ShowWindow(); + } + + // ───── GameObject Menu ───── + + [MenuItem("GameObject/PlayStation 1/Scene Exporter", false, 10)] + public static void CreateSceneExporter(MenuCommand menuCommand) + { + var existing = Object.FindObjectOfType(); + if (existing != null) + { + EditorUtility.DisplayDialog( + "Scene Exporter Exists", + "A PSXSceneExporter already exists in this scene.\n\n" + + "Only one exporter is needed per scene.", + "OK"); + Selection.activeGameObject = existing.gameObject; + return; + } + + var go = new GameObject("PSXSceneExporter"); + go.AddComponent(); + GameObjectUtility.SetParentAndAlign(go, menuCommand.context as GameObject); + Undo.RegisterCreatedObjectUndo(go, "Create PSX Scene Exporter"); + Selection.activeGameObject = go; + } + + [MenuItem("GameObject/PlayStation 1/Exportable Object", false, 12)] + public static void CreateExportableObject(MenuCommand menuCommand) + { + var go = new GameObject("PSXObject"); + go.AddComponent(); + GameObjectUtility.SetParentAndAlign(go, menuCommand.context as GameObject); + Undo.RegisterCreatedObjectUndo(go, "Create PSX Object"); + Selection.activeGameObject = go; + } + + // ───── Context Menu ───── + + [MenuItem("CONTEXT/MeshFilter/Add PSX Object Exporter")] + public static void AddPSXObjectExporterFromMesh(MenuCommand command) + { + var meshFilter = command.context as MeshFilter; + if (meshFilter != null && meshFilter.GetComponent() == null) + { + Undo.AddComponent(meshFilter.gameObject); + } + } + + [MenuItem("CONTEXT/MeshRenderer/Add PSX Object Exporter")] + public static void AddPSXObjectExporterFromRenderer(MenuCommand command) + { + var renderer = command.context as MeshRenderer; + if (renderer != null && renderer.GetComponent() == null) + { + Undo.AddComponent(renderer.gameObject); + } + } + } +} diff --git a/Editor/PSXMenuItems.cs.meta b/Editor/PSXMenuItems.cs.meta new file mode 100644 index 0000000..6808272 --- /dev/null +++ b/Editor/PSXMenuItems.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 174ee99c9e9aafd4ea9002fc3548f53d \ No newline at end of file diff --git a/Editor/PSXNavMeshEditor.cs b/Editor/PSXNavMeshEditor.cs deleted file mode 100644 index 6caebda..0000000 --- a/Editor/PSXNavMeshEditor.cs +++ /dev/null @@ -1,31 +0,0 @@ -using UnityEngine; -using UnityEditor; -using SplashEdit.RuntimeCode; -using System.Linq; - -namespace SplashEdit.EditorCode -{ - [CustomEditor(typeof(PSXNavMesh))] - public class PSXNavMeshEditor : Editor - { - public override void OnInspectorGUI() - { - DrawDefaultInspector(); - - PSXNavMesh comp = (PSXNavMesh)target; - if (GUILayout.Button("Create preview")) - { - PSXSceneExporter exporter = FindObjectsByType(FindObjectsSortMode.None).FirstOrDefault(); - if(exporter != null) - { - comp.CreateNavmesh(exporter.GTEScaling); - } - else - { - Debug.LogError("No PSXSceneExporter found in the scene. We can't pull the GTE scaling from the exporter."); - } - } - - } - } -} \ No newline at end of file diff --git a/Editor/PSXNavMeshEditor.cs.meta b/Editor/PSXNavMeshEditor.cs.meta deleted file mode 100644 index 5a8298d..0000000 --- a/Editor/PSXNavMeshEditor.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 9d3bd83aac4c3ce9ab1698a6a2bc735d \ No newline at end of file diff --git a/Editor/PSXNavRegionEditor.cs b/Editor/PSXNavRegionEditor.cs new file mode 100644 index 0000000..446caf0 --- /dev/null +++ b/Editor/PSXNavRegionEditor.cs @@ -0,0 +1,401 @@ +using UnityEngine; +using UnityEditor; +using SplashEdit.RuntimeCode; +using System.Collections.Generic; + +namespace SplashEdit.EditorCode +{ + /// + /// Editor window for PS1 navigation mesh generation. + /// Uses DotRecast (C# Recast) to voxelize scene geometry and build + /// convex navigation regions for the PS1 runtime. + /// All nav settings live on the PSXPlayer component so the editor + /// preview and the scene export always use the same values. + /// + public class PSXNavRegionEditor : EditorWindow + { + private PSXNavRegionBuilder _builder; + private bool _previewRegions = true; + private bool _previewPortals = true; + private bool _previewLabels = true; + private int _selectedRegion = -1; + private bool _showAdvanced = false; + + [MenuItem("PSX/Nav Region Builder")] + public static void ShowWindow() + { + GetWindow("Nav Region Builder"); + } + + private void OnEnable() + { + SceneView.duringSceneGui += OnSceneGUI; + } + + private void OnDisable() + { + SceneView.duringSceneGui -= OnSceneGUI; + } + + private void OnGUI() + { + EditorGUILayout.Space(5); + GUILayout.Label("PSX Nav Region Builder", EditorStyles.boldLabel); + EditorGUILayout.Space(5); + + var players = FindObjectsByType(FindObjectsSortMode.None); + + if (players.Length == 0) + { + EditorGUILayout.HelpBox( + "No PSXPlayer in scene. Add a PSXPlayer component to configure navigation settings.", + MessageType.Warning); + return; + } + + var player = players[0]; + var so = new SerializedObject(player); + so.Update(); + + // Info + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + EditorGUILayout.HelpBox( + "Uses DotRecast (Recast voxelization) to build PS1 nav regions.\n" + + "Settings are on the PSXPlayer component so editor preview\n" + + "and scene export always match.\n" + + "1. Configure settings below (saved on PSXPlayer)\n" + + "2. Click 'Build Nav Regions' to preview\n" + + "3. Results export automatically with the scene", + MessageType.Info); + } + + EditorGUILayout.Space(5); + + // Agent settings (from PSXPlayer serialized fields) + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + GUILayout.Label("Agent Settings (PSXPlayer)", EditorStyles.boldLabel); + EditorGUILayout.PropertyField(so.FindProperty("playerHeight"), + new GUIContent("Agent Height", "Camera eye height above feet")); + EditorGUILayout.PropertyField(so.FindProperty("playerRadius"), + new GUIContent("Agent Radius", "Collision radius for wall sliding")); + EditorGUILayout.PropertyField(so.FindProperty("maxStepHeight"), + new GUIContent("Max Step Height", "Maximum height the agent can step up")); + EditorGUILayout.PropertyField(so.FindProperty("walkableSlopeAngle"), + new GUIContent("Max Slope", "Maximum walkable slope angle in degrees")); + } + + EditorGUILayout.Space(5); + + // Advanced settings + _showAdvanced = EditorGUILayout.Foldout(_showAdvanced, "Advanced Settings"); + if (_showAdvanced) + { + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + EditorGUILayout.PropertyField(so.FindProperty("navCellSize"), + new GUIContent("Cell Size", "Voxel size in XZ plane. Smaller = more accurate but slower.")); + EditorGUILayout.PropertyField(so.FindProperty("navCellHeight"), + new GUIContent("Cell Height", "Voxel height. Smaller = more accurate vertical resolution.")); + + EditorGUILayout.Space(3); + float cs = player.NavCellSize; + float ch = player.NavCellHeight; + int walkH = (int)System.Math.Ceiling(player.PlayerHeight / ch); + int walkR = (int)System.Math.Ceiling(player.PlayerRadius / cs); + int walkC = (int)System.Math.Floor(player.MaxStepHeight / ch); + EditorGUILayout.LabelField("Voxel walkable height", $"{walkH} cells"); + EditorGUILayout.LabelField("Voxel walkable radius", $"{walkR} cells"); + EditorGUILayout.LabelField("Voxel walkable climb", $"{walkC} cells ({walkC * ch:F3} units)"); + } + } + + so.ApplyModifiedProperties(); + + EditorGUILayout.Space(5); + + // Build button + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + GUILayout.Label("Generation", EditorStyles.boldLabel); + + GUI.backgroundColor = new Color(0.4f, 0.8f, 0.4f); + if (GUILayout.Button("Build Nav Regions", GUILayout.Height(35))) + { + BuildNavRegions(player); + } + GUI.backgroundColor = Color.white; + + if (_builder != null && _builder.RegionCount > 0) + { + EditorGUILayout.Space(3); + if (GUILayout.Button("Clear Regions")) + { + _builder = null; + _selectedRegion = -1; + SceneView.RepaintAll(); + } + } + } + + EditorGUILayout.Space(5); + + // Visualization + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + GUILayout.Label("Visualization", EditorStyles.boldLabel); + _previewRegions = EditorGUILayout.Toggle("Show Regions", _previewRegions); + _previewPortals = EditorGUILayout.Toggle("Show Portals", _previewPortals); + _previewLabels = EditorGUILayout.Toggle("Show Labels", _previewLabels); + } + + EditorGUILayout.Space(5); + + // Statistics + if (_builder != null && _builder.RegionCount > 0) + { + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + GUILayout.Label("Statistics", EditorStyles.boldLabel); + EditorGUILayout.LabelField("Regions", _builder.RegionCount.ToString()); + EditorGUILayout.LabelField("Portals", _builder.PortalCount.ToString()); + + var rooms = new HashSet(); + for (int i = 0; i < _builder.RegionCount; i++) + rooms.Add(_builder.Regions[i].roomIndex); + EditorGUILayout.LabelField("Rooms", rooms.Count.ToString()); + + int exportSize = _builder.GetBinarySize(); + EditorGUILayout.LabelField("Export Size", + $"{exportSize:N0} bytes ({exportSize / 1024f:F1} KB)"); + + int flat = 0, ramp = 0, stairs = 0; + for (int i = 0; i < _builder.RegionCount; i++) + { + switch (_builder.Regions[i].surfaceType) + { + case NavSurfaceType.Flat: flat++; break; + case NavSurfaceType.Ramp: ramp++; break; + case NavSurfaceType.Stairs: stairs++; break; + } + } + EditorGUILayout.LabelField("Types", + $"{flat} flat, {ramp} ramp, {stairs} stairs"); + + if (_selectedRegion >= 0 && _selectedRegion < _builder.RegionCount) + { + EditorGUILayout.Space(3); + GUILayout.Label($"Selected Region #{_selectedRegion}", + EditorStyles.miniLabel); + var region = _builder.Regions[_selectedRegion]; + EditorGUILayout.LabelField(" Vertices", + region.vertsXZ.Count.ToString()); + EditorGUILayout.LabelField(" Portals", + region.portalCount.ToString()); + EditorGUILayout.LabelField(" Surface", + region.surfaceType.ToString()); + EditorGUILayout.LabelField(" Room", + region.roomIndex.ToString()); + EditorGUILayout.LabelField(" Floor Y", + $"{region.planeD:F2} (A={region.planeA:F3}, B={region.planeB:F3})"); + } + } + + ValidateRegions(); + } + else + { + EditorGUILayout.HelpBox( + "No nav regions built. Click 'Build Nav Regions' to generate.", + MessageType.Warning); + } + } + + // ==================================================================== + // Build + // ==================================================================== + + private void BuildNavRegions(PSXPlayer player) + { + EditorUtility.DisplayProgressBar("Nav Region Builder", "Building nav regions with DotRecast...", 0.3f); + + _builder = new PSXNavRegionBuilder(); + _builder.AgentHeight = player.PlayerHeight; + _builder.AgentRadius = player.PlayerRadius; + _builder.MaxStepHeight = player.MaxStepHeight; + _builder.WalkableSlopeAngle = player.WalkableSlopeAngle; + _builder.CellSize = player.NavCellSize; + _builder.CellHeight = player.NavCellHeight; + + Vector3 playerSpawn = player.transform.position; + player.FindNavmesh(); + playerSpawn = player.CamPoint; + + PSXObjectExporter[] exporters = + FindObjectsByType(FindObjectsSortMode.None); + + _builder.Build(exporters, playerSpawn); + + _selectedRegion = -1; + EditorUtility.ClearProgressBar(); + SceneView.RepaintAll(); + } + + // ==================================================================== + // Validation + // ==================================================================== + + private void ValidateRegions() + { + if (_builder == null) return; + + List warnings = new List(); + + for (int i = 0; i < _builder.RegionCount; i++) + { + var region = _builder.Regions[i]; + if (region.vertsXZ.Count < 3) + warnings.Add($"Region {i}: degenerate ({region.vertsXZ.Count} verts)"); + if (region.portalCount == 0 && _builder.RegionCount > 1) + warnings.Add($"Region {i}: isolated (no portals)"); + if (region.vertsXZ.Count > 8) + warnings.Add($"Region {i}: too many verts ({region.vertsXZ.Count} > 8)"); + } + + int exportSize = _builder.GetBinarySize(); + if (exportSize > 8192) + warnings.Add($"Export size {exportSize} bytes is large for PS1 (> 8KB)"); + + if (warnings.Count > 0) + { + EditorGUILayout.Space(5); + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + GUILayout.Label("Warnings", EditorStyles.boldLabel); + foreach (string w in warnings) + EditorGUILayout.LabelField(w, EditorStyles.miniLabel); + } + } + } + + // ==================================================================== + // Scene view drawing + // ==================================================================== + + private static readonly Color[] RoomColors = new Color[] + { + new Color(0.2f, 0.8f, 0.2f), + new Color(0.2f, 0.6f, 0.9f), + new Color(0.9f, 0.7f, 0.1f), + new Color(0.8f, 0.2f, 0.8f), + new Color(0.1f, 0.9f, 0.9f), + new Color(0.9f, 0.5f, 0.2f), + new Color(0.5f, 0.9f, 0.3f), + new Color(0.9f, 0.3f, 0.5f), + new Color(0.4f, 0.4f, 0.9f), + new Color(0.7f, 0.9f, 0.7f), + new Color(0.9f, 0.9f, 0.4f), + new Color(0.6f, 0.3f, 0.6f), + new Color(0.3f, 0.7f, 0.7f), + new Color(0.8f, 0.6f, 0.4f), + new Color(0.4f, 0.8f, 0.6f), + new Color(0.7f, 0.4f, 0.4f), + }; + + private void OnSceneGUI(SceneView sceneView) + { + if (_builder == null || _builder.RegionCount == 0) return; + + var regions = _builder.Regions; + + if (_previewRegions) + { + for (int i = 0; i < regions.Count; i++) + { + var region = regions[i]; + bool selected = (i == _selectedRegion); + + Color baseColor = RoomColors[region.roomIndex % RoomColors.Length]; + float fillAlpha = selected ? 0.4f : 0.15f; + + if (region.vertsXZ.Count >= 3) + { + Vector3[] worldVerts = new Vector3[region.vertsXZ.Count]; + for (int v = 0; v < region.vertsXZ.Count; v++) + { + float y = region.planeA * region.vertsXZ[v].x + + region.planeB * region.vertsXZ[v].y + + region.planeD; + worldVerts[v] = new Vector3( + region.vertsXZ[v].x, y + 0.05f, region.vertsXZ[v].y); + } + + Handles.color = selected + ? Color.white + : new Color(baseColor.r, baseColor.g, baseColor.b, 0.8f); + for (int v = 0; v < worldVerts.Length; v++) + Handles.DrawLine(worldVerts[v], + worldVerts[(v + 1) % worldVerts.Length]); + + Handles.color = new Color(baseColor.r, baseColor.g, baseColor.b, + fillAlpha); + for (int v = 1; v < worldVerts.Length - 1; v++) + Handles.DrawAAConvexPolygon( + worldVerts[0], worldVerts[v], worldVerts[v + 1]); + + if (_previewLabels) + { + Vector3 center = Vector3.zero; + foreach (var wv in worldVerts) center += wv; + center /= worldVerts.Length; + + string label = $"R{i}"; + if (region.roomIndex != 0xFF) + label += $"\nRm{region.roomIndex}"; + Handles.Label(center, label, EditorStyles.whiteBoldLabel); + + if (Handles.Button(center, Quaternion.identity, + 0.2f, 0.3f, Handles.SphereHandleCap)) + { + _selectedRegion = i; + Repaint(); + } + } + } + } + } + + if (_previewPortals && _builder.Portals != null) + { + for (int i = 0; i < regions.Count; i++) + { + var region = regions[i]; + int pStart = region.portalStart; + int pCount = region.portalCount; + + for (int p = pStart; + p < pStart + pCount && p < _builder.Portals.Count; p++) + { + var portal = _builder.Portals[p]; + + float yA = region.planeA * portal.a.x + + region.planeB * portal.a.y + region.planeD; + float yB = region.planeA * portal.b.x + + region.planeB * portal.b.y + region.planeD; + + Vector3 worldA = new Vector3(portal.a.x, yA + 0.08f, portal.a.y); + Vector3 worldB = new Vector3(portal.b.x, yB + 0.08f, portal.b.y); + + if (Mathf.Abs(portal.heightDelta) <= 0.35f) + Handles.color = new Color(1f, 1f, 1f, 0.9f); + else + Handles.color = new Color(1f, 0.9f, 0.2f, 0.9f); + + Handles.DrawLine(worldA, worldB, 3f); + } + } + } + } + } +} diff --git a/Editor/PSXNavRegionEditor.cs.meta b/Editor/PSXNavRegionEditor.cs.meta new file mode 100644 index 0000000..7fe67b3 --- /dev/null +++ b/Editor/PSXNavRegionEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e6ea40b4c8e02314c9388c86b2920403 \ No newline at end of file diff --git a/Editor/PSXObjectExporterEditor.cs b/Editor/PSXObjectExporterEditor.cs new file mode 100644 index 0000000..0dafbda --- /dev/null +++ b/Editor/PSXObjectExporterEditor.cs @@ -0,0 +1,514 @@ +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 exportCollisionMeshProp; + private SerializedProperty customCollisionMeshProp; + private SerializedProperty collisionLayerProp; + private SerializedProperty previewNormalsProp; + private SerializedProperty normalPreviewLengthProp; + private SerializedProperty showCollisionBoundsProp; + private SerializedProperty textureProp; + + // UI State + private bool showMeshInfo = true; + private bool showTextureInfo = true; + private bool showExportSettings = true; + private bool showCollisionSettings = true; + private bool showGizmoSettings = false; + private bool showValidation = true; + + // Cached data + private MeshFilter meshFilter; + private MeshRenderer meshRenderer; + private int triangleCount; + private int vertexCount; + private Bounds meshBounds; + private List validationErrors = new List(); + private List validationWarnings = new List(); + + // Styles + private GUIStyle headerStyle; + private GUIStyle errorStyle; + private GUIStyle warningStyle; + + // Validation + private bool _validationDirty = true; + + private void OnEnable() + { + // Get serialized properties + isActiveProp = serializedObject.FindProperty("isActive"); + bitDepthProp = serializedObject.FindProperty("bitDepth"); + luaFileProp = serializedObject.FindProperty("luaFile"); + objectFlagsProp = serializedObject.FindProperty("objectFlags"); + collisionTypeProp = serializedObject.FindProperty("collisionType"); + exportCollisionMeshProp = serializedObject.FindProperty("exportCollisionMesh"); + customCollisionMeshProp = serializedObject.FindProperty("customCollisionMesh"); + collisionLayerProp = serializedObject.FindProperty("collisionLayer"); + previewNormalsProp = serializedObject.FindProperty("previewNormals"); + normalPreviewLengthProp = serializedObject.FindProperty("normalPreviewLength"); + showCollisionBoundsProp = serializedObject.FindProperty("showCollisionBounds"); + textureProp = serializedObject.FindProperty("texture"); + + // Cache mesh info + CacheMeshInfo(); + + // Defer validation to first inspector draw + _validationDirty = true; + } + + private void CacheMeshInfo() + { + var exporter = target as PSXObjectExporter; + if (exporter == null) return; + + meshFilter = exporter.GetComponent(); + meshRenderer = exporter.GetComponent(); + + if (meshFilter != null && meshFilter.sharedMesh != null) + { + var mesh = meshFilter.sharedMesh; + triangleCount = mesh.triangles.Length / 3; + vertexCount = mesh.vertexCount; + meshBounds = mesh.bounds; + } + } + + private void RunValidation() + { + validationErrors.Clear(); + validationWarnings.Clear(); + + var exporter = target as PSXObjectExporter; + if (exporter == null) return; + + // Check mesh + if (meshFilter == null || meshFilter.sharedMesh == null) + { + validationErrors.Add("No mesh assigned to MeshFilter"); + } + else + { + if (triangleCount > 100) + { + validationWarnings.Add($"High triangle count ({triangleCount}). PS1 recommended: <100 per object"); + } + + // Check vertex bounds + var mesh = meshFilter.sharedMesh; + var verts = mesh.vertices; + bool hasOutOfBounds = false; + + foreach (var v in verts) + { + var world = exporter.transform.TransformPoint(v); + float scaled = Mathf.Max(Mathf.Abs(world.x), Mathf.Abs(world.y), Mathf.Abs(world.z)) * 4096f; + if (scaled > 32767f) + { + hasOutOfBounds = true; + break; + } + } + + if (hasOutOfBounds) + { + validationErrors.Add("Vertices exceed PS1 coordinate limits (±8 units from origin)"); + } + } + + // Check renderer + if (meshRenderer == null) + { + validationWarnings.Add("No MeshRenderer - object will not be visible"); + } + else if (meshRenderer.sharedMaterial == null) + { + validationWarnings.Add("No material assigned - will use default colors"); + } + } + + public override void OnInspectorGUI() + { + serializedObject.Update(); + + // Run deferred validation + if (_validationDirty) + { + RunValidation(); + _validationDirty = false; + } + + InitStyles(); + + // Active toggle at top + EditorGUILayout.PropertyField(isActiveProp, new GUIContent("Export This Object")); + + if (!isActiveProp.boolValue) + { + EditorGUILayout.HelpBox("This object will be skipped during export.", MessageType.Info); + serializedObject.ApplyModifiedProperties(); + return; + } + + EditorGUILayout.Space(5); + + // Mesh Info Section + DrawMeshInfoSection(); + + // Texture Section + DrawTextureSection(); + + // Export Settings Section + DrawExportSettingsSection(); + + // Collision Settings Section + DrawCollisionSettingsSection(); + + // Gizmo Settings Section + DrawGizmoSettingsSection(); + + // Validation Section + DrawValidationSection(); + + // Action Buttons + DrawActionButtons(); + + if (serializedObject.ApplyModifiedProperties()) + { + _validationDirty = true; + } + } + + private void InitStyles() + { + if (headerStyle == null) + { + headerStyle = new GUIStyle(EditorStyles.foldoutHeader); + } + + if (errorStyle == null) + { + errorStyle = new GUIStyle(EditorStyles.label); + errorStyle.normal.textColor = Color.red; + } + + if (warningStyle == null) + { + warningStyle = new GUIStyle(EditorStyles.label); + warningStyle.normal.textColor = new Color(1f, 0.7f, 0f); + } + } + + private void DrawMeshInfoSection() + { + showMeshInfo = EditorGUILayout.BeginFoldoutHeaderGroup(showMeshInfo, "Mesh Information"); + if (showMeshInfo) + { + EditorGUI.indentLevel++; + + if (meshFilter != null && meshFilter.sharedMesh != null) + { + EditorGUILayout.LabelField("Mesh", meshFilter.sharedMesh.name); + EditorGUILayout.LabelField("Triangles", triangleCount.ToString()); + EditorGUILayout.LabelField("Vertices", vertexCount.ToString()); + EditorGUILayout.LabelField("Bounds Size", meshBounds.size.ToString("F2")); + + // Triangle budget bar + float budgetPercent = triangleCount / 100f; + Rect rect = EditorGUILayout.GetControlRect(false, 20); + EditorGUI.ProgressBar(rect, Mathf.Clamp01(budgetPercent), $"Triangle Budget: {triangleCount}/100"); + } + else + { + EditorGUILayout.HelpBox("No mesh assigned", MessageType.Warning); + } + + EditorGUI.indentLevel--; + } + EditorGUILayout.EndFoldoutHeaderGroup(); + } + + private void DrawTextureSection() + { + showTextureInfo = EditorGUILayout.BeginFoldoutHeaderGroup(showTextureInfo, "Texture Settings"); + if (showTextureInfo) + { + EditorGUI.indentLevel++; + + EditorGUILayout.PropertyField(textureProp, new GUIContent("Override Texture")); + EditorGUILayout.PropertyField(bitDepthProp, new GUIContent("Bit Depth")); + + // Show texture preview if assigned + var tex = textureProp.objectReferenceValue as Texture2D; + if (tex != null) + { + EditorGUILayout.Space(5); + + using (new EditorGUILayout.HorizontalScope()) + { + GUILayout.FlexibleSpace(); + Rect previewRect = GUILayoutUtility.GetRect(64, 64, GUILayout.Width(64)); + EditorGUI.DrawPreviewTexture(previewRect, tex); + GUILayout.FlexibleSpace(); + } + + EditorGUILayout.LabelField($"Size: {tex.width}x{tex.height}"); + + // VRAM estimate + int bpp = bitDepthProp.enumValueIndex == 0 ? 4 : (bitDepthProp.enumValueIndex == 1 ? 8 : 16); + int vramBytes = (tex.width * tex.height * bpp) / 8; + EditorGUILayout.LabelField($"Est. VRAM: {vramBytes} bytes ({bpp}bpp)"); + } + else if (meshRenderer != null && meshRenderer.sharedMaterial != null) + { + var matTex = meshRenderer.sharedMaterial.mainTexture; + if (matTex != null) + { + EditorGUILayout.HelpBox($"Using material texture: {matTex.name}", MessageType.Info); + } + } + + EditorGUI.indentLevel--; + } + EditorGUILayout.EndFoldoutHeaderGroup(); + } + + private void DrawExportSettingsSection() + { + showExportSettings = EditorGUILayout.BeginFoldoutHeaderGroup(showExportSettings, "Export Settings"); + if (showExportSettings) + { + EditorGUI.indentLevel++; + + EditorGUILayout.PropertyField(objectFlagsProp, new GUIContent("Object Flags")); + EditorGUILayout.PropertyField(luaFileProp, new GUIContent("Lua Script")); + + // Quick Lua file buttons + if (luaFileProp.objectReferenceValue != null) + { + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Edit Lua", GUILayout.Width(80))) + { + AssetDatabase.OpenAsset(luaFileProp.objectReferenceValue); + } + if (GUILayout.Button("Clear", GUILayout.Width(60))) + { + luaFileProp.objectReferenceValue = null; + } + } + } + else + { + if (GUILayout.Button("Create New Lua Script")) + { + CreateNewLuaScript(); + } + } + + EditorGUI.indentLevel--; + } + EditorGUILayout.EndFoldoutHeaderGroup(); + } + + private void DrawCollisionSettingsSection() + { + showCollisionSettings = EditorGUILayout.BeginFoldoutHeaderGroup(showCollisionSettings, "Collision Settings"); + if (showCollisionSettings) + { + EditorGUI.indentLevel++; + + EditorGUILayout.PropertyField(collisionTypeProp, new GUIContent("Collision Type")); + + var collType = (PSXCollisionType)collisionTypeProp.enumValueIndex; + if (collType != PSXCollisionType.None) + { + EditorGUILayout.PropertyField(exportCollisionMeshProp, new GUIContent("Export Collision Mesh")); + EditorGUILayout.PropertyField(customCollisionMeshProp, new GUIContent("Custom Collision Mesh")); + EditorGUILayout.PropertyField(collisionLayerProp, new GUIContent("Collision Layer")); + + // Collision info + EditorGUILayout.Space(5); + string collisionInfo = collType switch + { + PSXCollisionType.Solid => "Solid: Blocks movement, fires onCollision", + PSXCollisionType.Trigger => "Trigger: Fires onTriggerEnter/Exit, doesn't block", + PSXCollisionType.Platform => "Platform: Solid from above only", + _ => "" + }; + EditorGUILayout.HelpBox(collisionInfo, MessageType.Info); + } + + EditorGUI.indentLevel--; + } + EditorGUILayout.EndFoldoutHeaderGroup(); + } + + private void DrawGizmoSettingsSection() + { + showGizmoSettings = EditorGUILayout.BeginFoldoutHeaderGroup(showGizmoSettings, "Gizmo Settings"); + if (showGizmoSettings) + { + EditorGUI.indentLevel++; + + EditorGUILayout.PropertyField(previewNormalsProp, new GUIContent("Preview Normals")); + if (previewNormalsProp.boolValue) + { + EditorGUILayout.PropertyField(normalPreviewLengthProp, new GUIContent("Normal Length")); + } + + EditorGUILayout.PropertyField(showCollisionBoundsProp, new GUIContent("Show Collision Bounds")); + + EditorGUI.indentLevel--; + } + EditorGUILayout.EndFoldoutHeaderGroup(); + } + + private void DrawValidationSection() + { + if (validationErrors.Count == 0 && validationWarnings.Count == 0) + return; + + showValidation = EditorGUILayout.BeginFoldoutHeaderGroup(showValidation, "Validation"); + if (showValidation) + { + foreach (var error in validationErrors) + { + EditorGUILayout.HelpBox(error, MessageType.Error); + } + + foreach (var warning in validationWarnings) + { + EditorGUILayout.HelpBox(warning, MessageType.Warning); + } + + if (GUILayout.Button("Refresh Validation")) + { + CacheMeshInfo(); + RunValidation(); + } + } + EditorGUILayout.EndFoldoutHeaderGroup(); + } + + private void DrawActionButtons() + { + EditorGUILayout.Space(10); + + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Select Scene Exporter")) + { + var exporter = FindObjectOfType(); + if (exporter != null) + { + Selection.activeGameObject = exporter.gameObject; + } + else + { + EditorUtility.DisplayDialog("Not Found", "No PSXSceneExporter in scene.", "OK"); + } + } + + if (GUILayout.Button("Open Scene Validator")) + { + PSXSceneValidatorWindow.ShowWindow(); + } + } + } + + private void CreateNewLuaScript() + { + var exporter = target as PSXObjectExporter; + string defaultName = exporter.gameObject.name.ToLower().Replace(" ", "_"); + string path = EditorUtility.SaveFilePanelInProject( + "Create Lua Script", + defaultName + ".lua", + "lua", + "Create a new Lua script for this object"); + + if (!string.IsNullOrEmpty(path)) + { + string template = $@"-- Lua script for {exporter.gameObject.name} +-- +-- Available globals: Entity, Vec3, Input, Timer, Camera, Audio, +-- Debug, Math, Scene, Persist +-- +-- Available events: +-- onCreate(self) — called once when the object is registered +-- onUpdate(self, dt) — called every frame (dt = delta frames, usually 1) +-- onEnable(self) — called when the object becomes active +-- onDisable(self) — called when the object becomes inactive +-- onCollision(self, other) — called on collision with another object +-- onTriggerEnter(self, other) +-- onTriggerStay(self, other) +-- onTriggerExit(self, other) +-- onInteract(self) — called when the player interacts +-- onButtonPress(self, btn) — called on button press (btn = Input.CROSS etc.) +-- onButtonRelease(self, btn) +-- onDestroy(self) — called before the object is destroyed +-- +-- Properties: self.position (Vec3), self.rotationY (pi-units), self.active (bool) + +function onCreate(self) + -- Called once when this object is registered in the scene +end + +function onUpdate(self, dt) + -- Called every frame. dt = number of elapsed frames (usually 1). +end + +function onInteract(self) + -- Called when the player interacts with this object +end +"; + System.IO.File.WriteAllText(path, template); + AssetDatabase.Refresh(); + + var luaFile = AssetDatabase.LoadAssetAtPath(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); + } + } + } + } +} diff --git a/Editor/PSXObjectExporterEditor.cs.meta b/Editor/PSXObjectExporterEditor.cs.meta new file mode 100644 index 0000000..4fdb97c --- /dev/null +++ b/Editor/PSXObjectExporterEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d45032f12fc4b614783ad30927846e6c \ No newline at end of file diff --git a/Editor/PSXSceneExporterEditor.cs b/Editor/PSXSceneExporterEditor.cs index 85cfdde..b408b41 100644 --- a/Editor/PSXSceneExporterEditor.cs +++ b/Editor/PSXSceneExporterEditor.cs @@ -4,19 +4,140 @@ using SplashEdit.RuntimeCode; 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 : Editor + public class PSXSceneExporterEditor : UnityEditor.Editor { - public override void OnInspectorGUI() - { - DrawDefaultInspector(); + // Saved RenderSettings state so we can restore it on deselect. + private bool _savedFog; + private Color _savedFogColor; + private FogMode _savedFogMode; + private float _savedFogStart; + private float _savedFogEnd; - PSXSceneExporter comp = (PSXSceneExporter)target; - if (GUILayout.Button("Export")) + private bool _previewActive = false; + + private void OnEnable() + { + SaveAndApplyFogPreview(); + // Re-apply whenever the scene is repainted (handles inspector value changes). + EditorApplication.update += OnEditorUpdate; + } + + private void OnDisable() + { + EditorApplication.update -= OnEditorUpdate; + RestoreFog(); + } + + private void OnEditorUpdate() + { + // Keep the preview in sync when the user tweaks values in the inspector. + if (_previewActive) + ApplyFogPreview(); + } + + private void SaveAndApplyFogPreview() + { + _savedFog = RenderSettings.fog; + _savedFogColor = RenderSettings.fogColor; + _savedFogMode = RenderSettings.fogMode; + _savedFogStart = RenderSettings.fogStartDistance; + _savedFogEnd = RenderSettings.fogEndDistance; + + _previewActive = true; + ApplyFogPreview(); + } + + private void ApplyFogPreview() + { + var exporter = (PSXSceneExporter)target; + if (exporter == null) return; + + if (!exporter.FogEnabled) { - comp.Export(); + // Fog disabled on the component - turn off the preview. + RenderSettings.fog = false; + return; } + float gteScale = exporter.GTEScaling; + int density = Mathf.Clamp(exporter.FogDensity, 1, 10); + + // fogFarSZ in GTE SZ units (20.12 fp); convert to Unity world-space. + // SZ = (unityDist / GTEScaling) * 4096, so unityDist = SZ * GTEScaling / 4096 + float fogFarSZ = 8000f / density; + float fogNearSZ = fogFarSZ / 3f; + + float fogFarUnity = fogFarSZ * gteScale / 4096f; + float fogNearUnity = fogNearSZ * gteScale / 4096f; + + RenderSettings.fog = true; + RenderSettings.fogColor = exporter.FogColor; + RenderSettings.fogMode = FogMode.Linear; + RenderSettings.fogStartDistance = fogNearUnity; + RenderSettings.fogEndDistance = fogFarUnity; + } + + private void RestoreFog() + { + if (!_previewActive) return; + _previewActive = false; + + RenderSettings.fog = _savedFog; + RenderSettings.fogColor = _savedFogColor; + RenderSettings.fogMode = _savedFogMode; + RenderSettings.fogStartDistance = _savedFogStart; + RenderSettings.fogEndDistance = _savedFogEnd; + } + + public override void OnInspectorGUI() + { + serializedObject.Update(); + + DrawDefaultInspector(); + + // Show computed fog distances when fog is enabled, so the user + // can see exactly what range the preview represents. + var exporter = (PSXSceneExporter)target; + if (exporter.FogEnabled) + { + EditorGUILayout.Space(4); + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + GUILayout.Label("Fog Preview (active in Scene view)", EditorStyles.boldLabel); + + float gteScale = exporter.GTEScaling; + int density = Mathf.Clamp(exporter.FogDensity, 1, 10); + float fogFarUnity = (8000f / density) * gteScale / 4096f; + float fogNearUnity = fogFarUnity / 3f; + + EditorGUILayout.LabelField("Near distance", $"{fogNearUnity:F1} Unity units"); + EditorGUILayout.LabelField("Far distance", $"{fogFarUnity:F1} Unity units"); + EditorGUILayout.LabelField("(PS1 SZ range)", $"{8000f / (density * 3f):F0} - {8000f / density:F0} GTE units"); + EditorGUILayout.EndVertical(); + + // Keep preview applied as values may have changed. + ApplyFogPreview(); + } + else + { + // Make sure preview is off when fog is disabled. + RenderSettings.fog = false; + } + + serializedObject.ApplyModifiedProperties(); } } -} \ No newline at end of file +} diff --git a/Editor/PSXSceneExporterEditor.cs.meta b/Editor/PSXSceneExporterEditor.cs.meta index 81f6257..32a4787 100644 --- a/Editor/PSXSceneExporterEditor.cs.meta +++ b/Editor/PSXSceneExporterEditor.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: becf2eb607e7a60baaf3bebe4683d66f \ No newline at end of file +guid: 738efb5c0ed755b45991d2067957b997 \ No newline at end of file diff --git a/Editor/PSXSceneValidatorWindow.cs b/Editor/PSXSceneValidatorWindow.cs new file mode 100644 index 0000000..f90411d --- /dev/null +++ b/Editor/PSXSceneValidatorWindow.cs @@ -0,0 +1,496 @@ +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 new file mode 100644 index 0000000..58a8245 --- /dev/null +++ b/Editor/PSXSceneValidatorWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0a26bf89301a2554ca287b9e28e44906 \ No newline at end of file diff --git a/Editor/QuantizedPreviewWindow.cs b/Editor/QuantizedPreviewWindow.cs index ca98f9d..c93e5c5 100644 --- a/Editor/QuantizedPreviewWindow.cs +++ b/Editor/QuantizedPreviewWindow.cs @@ -17,7 +17,7 @@ namespace SplashEdit.EditorCode private PSXBPP bpp = PSXBPP.TEX_4BIT; private readonly int previewSize = 256; - [MenuItem("PSX/Quantized Preview")] + [MenuItem("PlayStation 1/Quantized Preview")] public static void ShowWindow() { // Creates and displays the window diff --git a/Editor/SerialConnection.cs b/Editor/SerialConnection.cs deleted file mode 100644 index 86d4d61..0000000 --- a/Editor/SerialConnection.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.IO.Ports; - - -namespace SplashEdit.EditorCode -{ - public class SerialConnection - { - private static SerialPort serialPort; - - public SerialConnection(string portName, int baudRate) - { - serialPort = new SerialPort(portName, baudRate); - serialPort.ReadTimeout = 50; - serialPort.WriteTimeout = 50; - } - - public void Open() - { serialPort.Open(); } - - public void Close() - { serialPort.Close(); } - - public int ReadByte() - { return serialPort.ReadByte(); } - - public int ReadChar() - { return serialPort.ReadChar(); } - - public void Write(string text) - { serialPort.Write(text); } - - public void Write(char[] buffer, int offset, int count) - { serialPort.Write(buffer, offset, count); } - - public void Write(byte[] buffer, int offset, int count) - { serialPort.Write(buffer, offset, count); } - - } -} \ No newline at end of file diff --git a/Editor/SerialConnection.cs.meta b/Editor/SerialConnection.cs.meta deleted file mode 100644 index 43d5dbe..0000000 --- a/Editor/SerialConnection.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 714bd2374b7a9a14686078e5eb431795 \ No newline at end of file diff --git a/Editor/ToolchainChecker.cs b/Editor/ToolchainChecker.cs index 081827e..82778f1 100644 --- a/Editor/ToolchainChecker.cs +++ b/Editor/ToolchainChecker.cs @@ -4,9 +4,16 @@ using System.Linq; using System.IO; using System; +/// +/// Utility that detects whether required build tools (MIPS cross-compiler, +/// GNU Make, GDB, etc.) are available on the host system by probing the +/// PATH via where (Windows) or which (Unix). +/// +namespace SplashEdit.EditorCode +{ public static class ToolchainChecker { - public static readonly string[] mipsToolSuffixes = new[] + private static readonly string[] mipsToolSuffixes = new[] { "addr2line", "ar", "as", "cpp", "elfedit", "g++", "gcc", "gcc-ar", "gcc-nm", "gcc-ranlib", "gcov", "ld", "nm", "objcopy", "objdump", "ranlib", "readelf", @@ -74,3 +81,4 @@ public static class ToolchainChecker } } } +} diff --git a/Editor/ToolchainChecker.cs.meta b/Editor/ToolchainChecker.cs.meta index 3a276af..9bca30e 100644 --- a/Editor/ToolchainChecker.cs.meta +++ b/Editor/ToolchainChecker.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: 326c6443947d4e5e783e90882b641ce8 \ No newline at end of file +guid: 142296fdef504c64bb08110e6f28e581 \ No newline at end of file diff --git a/Editor/ToolchainInstaller.cs b/Editor/ToolchainInstaller.cs index 48e1c89..f506498 100644 --- a/Editor/ToolchainInstaller.cs +++ b/Editor/ToolchainInstaller.cs @@ -5,360 +5,133 @@ using UnityEngine; using UnityEditor; using System.IO; -public static class ToolchainInstaller +namespace SplashEdit.EditorCode { - // Flags to prevent duplicate installations. - private static bool mipsInstalling = false; - private static bool win32MipsToolsInstalling = false; - - // The version string used by the installer command. - public static string mipsVersion = "14.2.0"; - /// - /// Executes an external process asynchronously. - /// Throws an exception if the process returns a nonzero exit code. + /// Installs the MIPS cross-compiler toolchain and GNU Make. + /// Supports Windows and Linux only. /// - public static async Task RunCommandAsync(string fileName, string arguments, string workingDirectory = "") + public static class ToolchainInstaller { - var tcs = new TaskCompletionSource(); + private static bool _installing; - if (fileName.Equals("mips", StringComparison.OrdinalIgnoreCase)) + public static string MipsVersion = "14.2.0"; + + /// + /// Runs an external process and waits for it to exit. + /// + public static async Task RunCommandAsync(string fileName, string arguments, string workingDirectory = "") { - fileName = "powershell"; + if (fileName.Equals("mips", StringComparison.OrdinalIgnoreCase)) + { + fileName = "powershell"; + string roamingPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + string scriptPath = Path.Combine(roamingPath, "mips", "mips.ps1"); + arguments = $"-ExecutionPolicy Bypass -File \"{scriptPath}\" {arguments}"; + } - // Get the AppData\Roaming path for the user - string roamingPath = System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData); - string scriptPath = Path.Combine(roamingPath, "mips\\mips.ps1"); + var tcs = new TaskCompletionSource(); - // Pass the arguments to the PowerShell script - arguments = $"-ExecutionPolicy Bypass -File \"{scriptPath}\" {arguments}"; - } + Process process = new Process(); + process.StartInfo.FileName = fileName; + process.StartInfo.Arguments = arguments; + process.StartInfo.CreateNoWindow = false; + process.StartInfo.UseShellExecute = true; + if (!string.IsNullOrEmpty(workingDirectory)) + process.StartInfo.WorkingDirectory = workingDirectory; - Process process = new Process(); - process.StartInfo.FileName = fileName; - process.StartInfo.Arguments = arguments; - process.StartInfo.CreateNoWindow = false; - process.StartInfo.UseShellExecute = true; + process.EnableRaisingEvents = true; + process.Exited += (sender, args) => + { + tcs.SetResult(process.ExitCode); + process.Dispose(); + }; - if (!string.IsNullOrEmpty(workingDirectory)) - process.StartInfo.WorkingDirectory = workingDirectory; - - process.EnableRaisingEvents = true; - process.Exited += (sender, args) => - { - tcs.SetResult(process.ExitCode); - process.Dispose(); - }; - - try - { process.Start(); - } - catch (Exception ex) - { - throw new Exception($"Failed to start process {fileName}: {ex.Message}"); + + int exitCode = await tcs.Task; + if (exitCode != 0) + throw new Exception($"Process '{fileName}' exited with code {exitCode}"); } - int exitCode = await tcs.Task; - if (exitCode != 0) - throw new Exception($"Process '{fileName} {arguments}' exited with code {exitCode}"); - } - - - #region MIPS Toolchain Installation - - /// - /// Installs the MIPS toolchain on Windows using a PowerShell script. - /// (On Windows this installer bundles GNU Make as part of the toolchain.) - /// - public static async Task InstallMips() - { - if (mipsInstalling) return; - mipsInstalling = true; - try + /// + /// Installs the MIPS GCC cross-compiler for the current platform. + /// + public static async Task InstallToolchain() { - // Download and run the installer script via PowerShell. - await RunCommandAsync("powershell", - "-c \"& { iwr -UseBasicParsing https://raw.githubusercontent.com/grumpycoders/pcsx-redux/main/mips.ps1 | iex }\""); - EditorUtility.DisplayDialog("Reboot Required", - "Installing the MIPS toolchain requires a reboot. Please reboot your computer and click the button again.", - "OK"); - } - catch (Exception ex) - { - EditorUtility.DisplayDialog("Error", - "An error occurred while installing the MIPS toolchain. Please install it manually.", "OK"); - throw ex; - } - } + if (_installing) return false; + _installing = true; - /// - /// Installs the MIPS toolchain based on the current platform. - /// Uses pkexec on Linux to request graphical elevation. - /// - public static async Task InstallToolchain() - { - switch (Application.platform) - { - case RuntimePlatform.WindowsEditor: - try + try + { + if (Application.platform == RuntimePlatform.WindowsEditor) { if (!ToolchainChecker.IsToolAvailable("mips")) { - await InstallMips(); + await RunCommandAsync("powershell", + "-c \"& { iwr -UseBasicParsing https://raw.githubusercontent.com/grumpycoders/pcsx-redux/main/mips.ps1 | iex }\""); + EditorUtility.DisplayDialog("Reboot Required", + "Installing the MIPS toolchain requires a reboot. Please reboot and try again.", + "OK"); return false; } else { - if (win32MipsToolsInstalling) return false; - win32MipsToolsInstalling = true; - await RunCommandAsync("mips", $"install {mipsVersion}"); + await RunCommandAsync("mips", $"install {MipsVersion}"); } } - catch (Exception ex) - { - EditorUtility.DisplayDialog("Error", - "An error occurred while installing the MIPS toolchain. Please install it manually.", "OK"); - throw ex; - } - break; - - case RuntimePlatform.LinuxEditor: - try + else if (Application.platform == RuntimePlatform.LinuxEditor) { if (ToolchainChecker.IsToolAvailable("apt")) - { await RunCommandAsync("pkexec", "apt install g++-mipsel-linux-gnu -y"); - } else if (ToolchainChecker.IsToolAvailable("trizen")) - { await RunCommandAsync("trizen", "-S cross-mipsel-linux-gnu-binutils cross-mipsel-linux-gnu-gcc"); - } - else if (ToolchainChecker.IsToolAvailable("brew")) - { - string binutilsScriptPath = Application.dataPath + "/Scripts/mipsel-none-elf-binutils.rb"; - string gccScriptPath = Application.dataPath + "/Scripts/mipsel-none-elf-gcc.rb"; - await RunCommandAsync("brew", $"install --formula \"{binutilsScriptPath}\" \"{gccScriptPath}\""); - } else - { - EditorUtility.DisplayDialog("Error", - "Your Linux distribution is not supported. Please install the MIPS toolchain manually.", "OK"); - throw new Exception("Unsupported Linux distribution"); - } + throw new Exception("Unsupported Linux distribution. Install mipsel-linux-gnu-gcc manually."); } - catch (Exception ex) + else { - EditorUtility.DisplayDialog("Error", - "An error occurred while installing the MIPS toolchain. Please install it manually.", "OK"); - throw ex; + throw new Exception("Only Windows and Linux are supported."); } - break; - - case RuntimePlatform.OSXEditor: - try - { - if (ToolchainChecker.IsToolAvailable("brew")) - { - string binutilsScriptPath = Application.dataPath + "/Scripts/mipsel-none-elf-binutils.rb"; - string gccScriptPath = Application.dataPath + "/Scripts/mipsel-none-elf-gcc.rb"; - await RunCommandAsync("brew", $"install --formula \"{binutilsScriptPath}\" \"{gccScriptPath}\""); - } - else - { - await RunCommandAsync("/bin/bash", "-c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""); - EditorUtility.DisplayDialog("Reboot Required", - "Installing Homebrew requires a reboot. Please reboot your computer before proceeding further.", "OK"); - } - } - catch (Exception ex) - { - EditorUtility.DisplayDialog("Error", - "An error occurred while installing the MIPS toolchain. Please install it manually.", "OK"); - throw ex; - } - break; - - default: - EditorUtility.DisplayDialog("Error", - "Your platform is not supported by this extension. Please install the MIPS toolchain manually.", "OK"); - throw new Exception("Unsupported platform"); + return true; + } + catch (Exception ex) + { + EditorUtility.DisplayDialog("Error", + $"Toolchain installation failed: {ex.Message}", "OK"); + return false; + } + finally + { + _installing = false; + } } - return true; - } - #endregion - - #region GNU Make Installation - - /// - /// Installs GNU Make. - /// On Linux/macOS it installs GNU Make normally. - /// On Windows, GNU Make is bundled with the MIPS toolchain—so the user is warned before proceeding. - /// - public static async Task InstallMake() - { - switch (Application.platform) + /// + /// Installs GNU Make. On Windows it is bundled with the MIPS toolchain. + /// + public static async Task InstallMake() { - case RuntimePlatform.WindowsEditor: - // Inform the user that GNU Make is bundled with the MIPS toolchain. + if (Application.platform == RuntimePlatform.WindowsEditor) + { bool proceed = EditorUtility.DisplayDialog( "Install GNU Make", - "On Windows, GNU Make is installed as part of the MIPS toolchain installer. Would you like to install the full toolchain?", - "Yes", - "No" - ); - if (proceed) - { - await InstallToolchain(); - } - break; - - case RuntimePlatform.LinuxEditor: - try - { - if (ToolchainChecker.IsToolAvailable("apt")) - { - await RunCommandAsync("pkexec", "apt install build-essential -y"); - } - else - { - EditorUtility.DisplayDialog("Error", - "Your Linux distribution is not supported. Please install GNU Make manually.", "OK"); - throw new Exception("Unsupported Linux distribution"); - } - } - catch (Exception ex) - { - EditorUtility.DisplayDialog("Error", - "An error occurred while installing GNU Make. Please install it manually.", "OK"); - throw ex; - } - break; - - case RuntimePlatform.OSXEditor: - try - { - if (ToolchainChecker.IsToolAvailable("brew")) - { - await RunCommandAsync("brew", "install make"); - } - else - { - EditorUtility.DisplayDialog("Error", - "Homebrew is not installed. Please install GNU Make manually.", "OK"); - throw new Exception("Brew not installed"); - } - } - catch (Exception ex) - { - EditorUtility.DisplayDialog("Error", - "An error occurred while installing GNU Make. Please install it manually.", "OK"); - throw ex; - } - break; - - default: - EditorUtility.DisplayDialog("Error", - "Your platform is not supported. Please install GNU Make manually.", "OK"); - throw new Exception("Unsupported platform"); + "On Windows, GNU Make is included with the MIPS toolchain installer. Install the full toolchain?", + "Yes", "No"); + if (proceed) await InstallToolchain(); + } + else if (Application.platform == RuntimePlatform.LinuxEditor) + { + if (ToolchainChecker.IsToolAvailable("apt")) + await RunCommandAsync("pkexec", "apt install build-essential -y"); + else + throw new Exception("Unsupported Linux distribution. Install 'make' manually."); + } + else + { + throw new Exception("Only Windows and Linux are supported."); + } } } - - #endregion - - #region GDB Installation (Optional) - - /// - /// Installs GDB Multiarch (or GDB on macOS) - /// - public static async Task InstallGDB() - { - switch (Application.platform) - { - case RuntimePlatform.WindowsEditor: - try - { - if (!ToolchainChecker.IsToolAvailable("mips")) - { - await InstallMips(); - } - else - { - if (win32MipsToolsInstalling) return; - win32MipsToolsInstalling = true; - await RunCommandAsync("mips", $"install {mipsVersion}"); - } - } - catch (Exception ex) - { - EditorUtility.DisplayDialog("Error", - "An error occurred while installing GDB Multiarch. Please install it manually.", "OK"); - throw ex; - } - break; - - case RuntimePlatform.LinuxEditor: - try - { - if (ToolchainChecker.IsToolAvailable("apt")) - { - await RunCommandAsync("pkexec", "apt install gdb-multiarch -y"); - } - else if (ToolchainChecker.IsToolAvailable("trizen")) - { - await RunCommandAsync("trizen", "-S gdb-multiarch"); - } - else if (ToolchainChecker.IsToolAvailable("brew")) - { - await RunCommandAsync("brew", "install gdb-multiarch"); - } - else - { - EditorUtility.DisplayDialog("Error", - "Your Linux distribution is not supported. Please install GDB Multiarch manually.", "OK"); - throw new Exception("Unsupported Linux distribution"); - } - } - catch (Exception ex) - { - EditorUtility.DisplayDialog("Error", - "An error occurred while installing GDB Multiarch. Please install it manually.", "OK"); - throw ex; - } - break; - - case RuntimePlatform.OSXEditor: - try - { - if (ToolchainChecker.IsToolAvailable("brew")) - { - await RunCommandAsync("brew", "install gdb"); - } - else - { - await RunCommandAsync("/bin/bash", "-c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""); - EditorUtility.DisplayDialog("Reboot Required", - "Installing Homebrew requires a reboot. Please reboot your computer before proceeding further.", "OK"); - } - } - catch (Exception ex) - { - EditorUtility.DisplayDialog("Error", - "An error occurred while installing GDB Multiarch. Please install it manually.", "OK"); - throw ex; - } - break; - - default: - EditorUtility.DisplayDialog("Error", - "Your platform is not supported. Please install GDB Multiarch manually.", "OK"); - throw new Exception("Unsupported platform"); - } - } - - #endregion - - } diff --git a/Editor/ToolchainInstaller.cs.meta b/Editor/ToolchainInstaller.cs.meta index f931e6a..27e051b 100644 --- a/Editor/ToolchainInstaller.cs.meta +++ b/Editor/ToolchainInstaller.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: 93df68ab9518c5bf6a962c185fe742fe \ No newline at end of file +guid: c5aa88b01a3eef145806c8e9e59f4e9d \ No newline at end of file diff --git a/Editor/UniromConnection.cs b/Editor/UniromConnection.cs deleted file mode 100644 index bd753a5..0000000 --- a/Editor/UniromConnection.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace SplashEdit.EditorCode -{ - public class UniromConnection - { - - private SerialConnection serialConnection; - - public UniromConnection(int baudRate, string portName) - { - serialConnection = new SerialConnection(portName, baudRate); - } - - public void Reset() - { - serialConnection.Open(); - serialConnection.Write("REST"); - serialConnection.Close(); - } - - - } -} \ No newline at end of file diff --git a/Editor/UniromConnection.cs.meta b/Editor/UniromConnection.cs.meta deleted file mode 100644 index 0520c58..0000000 --- a/Editor/UniromConnection.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: d8fbc734f42ab9d42a843b6718127da7 \ No newline at end of file diff --git a/Editor/UniromConnectionWindow.cs b/Editor/UniromConnectionWindow.cs deleted file mode 100644 index 3ec1041..0000000 --- a/Editor/UniromConnectionWindow.cs +++ /dev/null @@ -1,145 +0,0 @@ -using UnityEngine; -using UnityEditor; -using System.IO.Ports; -using System.Collections; -using SplashEdit.RuntimeCode; -using System.Runtime.InteropServices; - -namespace SplashEdit.EditorCode -{ - public class PSXConnectionConfigWindow : EditorWindow - { - - public PSXConnectionType connectionType = PSXConnectionType.REAL_HARDWARE; - - // REAL HARDWARE (Unirom) SETTINGS - private string[] portNames; - private int selectedPortIndex = 1; - private int[] baudRates = { 9600, 115200 }; - private int selectedBaudIndex = 0; - - - - - private string statusMessage = ""; - private MessageType statusType; - private Vector2 scrollPosition; - - [MenuItem("PSX/Console or Emulator Connection")] - public static void ShowWindow() - { - GetWindow("Serial Config"); - } - - private void OnEnable() - { - RefreshPorts(); - LoadSettings(); - } - - private void RefreshPorts() - { - portNames = SerialPort.GetPortNames(); - if (portNames.Length == 0) - { - portNames = new[] { "No ports available" }; - } - } - - private void OnGUI() - { - using (var scrollView = new EditorGUILayout.ScrollViewScope(scrollPosition)) - { - scrollPosition = scrollView.scrollPosition; - - EditorGUILayout.LabelField("Pick connection type", EditorStyles.boldLabel); - connectionType = (PSXConnectionType)EditorGUILayout.EnumPopup("Connection Type", connectionType); - - if (connectionType == PSXConnectionType.REAL_HARDWARE) - { - // Port selection - EditorGUILayout.LabelField("Select COM Port", EditorStyles.boldLabel); - selectedPortIndex = EditorGUILayout.Popup("Available Ports", selectedPortIndex, portNames); - - // Baud rate selection - EditorGUILayout.Space(); - EditorGUILayout.LabelField("Select Baud Rate", EditorStyles.boldLabel); - selectedBaudIndex = EditorGUILayout.Popup("Baud Rate", selectedBaudIndex, new[] { "9600", "115200" }); - - // Buttons - EditorGUILayout.Space(); - using (new EditorGUILayout.HorizontalScope()) - { - if (GUILayout.Button("Refresh Ports")) - { - RefreshPorts(); - } - - if (GUILayout.Button("Test Connection")) - { - TestConnection(); - } - } - } - - if (GUILayout.Button("Save settings")) - { - SaveSettings(); - - } - - // Status message - EditorGUILayout.Space(); - if (!string.IsNullOrEmpty(statusMessage)) - { - EditorGUILayout.HelpBox(statusMessage, statusType); - } - - } - } - - private void LoadSettings() - { - PSXData _psxData = DataStorage.LoadData(); - if (_psxData != null) - { - connectionType = _psxData.ConnectionType; - selectedBaudIndex = System.Array.IndexOf(baudRates, _psxData.BaudRate); - if (selectedBaudIndex == -1) selectedBaudIndex = 0; - - RefreshPorts(); - selectedPortIndex = System.Array.IndexOf(portNames, _psxData.PortName); - if (selectedPortIndex == -1) selectedPortIndex = 0; - } - } - - private void TestConnection() - { - if (portNames.Length == 0 || portNames[0] == "No ports available") - { - statusMessage = "No serial ports available"; - statusType = MessageType.Error; - return; - } - - UniromConnection connection = new UniromConnection(baudRates[selectedBaudIndex], portNames[selectedPortIndex]); - connection.Reset(); - - statusMessage = "Connection tested. If your PlayStation reset, it worked!"; - statusType = MessageType.Info; - Repaint(); - } - - private void SaveSettings() - { - PSXData _psxData = DataStorage.LoadData(); - _psxData.ConnectionType = connectionType; - _psxData.BaudRate = baudRates[selectedBaudIndex]; - _psxData.PortName = portNames[selectedPortIndex]; - DataStorage.StoreData(_psxData); - statusMessage = "Settings saved"; - statusType = MessageType.Info; - Repaint(); - } - } -} \ No newline at end of file diff --git a/Editor/UniromConnectionWindow.cs.meta b/Editor/UniromConnectionWindow.cs.meta deleted file mode 100644 index 9c02991..0000000 --- a/Editor/UniromConnectionWindow.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: ade0bf0fd69f449458c5b43e0f48ddff \ No newline at end of file diff --git a/Editor/VramEditorWindow.cs b/Editor/VramEditorWindow.cs index 20e68cb..813495f 100644 --- a/Editor/VramEditorWindow.cs +++ b/Editor/VramEditorWindow.cs @@ -37,7 +37,7 @@ namespace SplashEdit.EditorCode }; private static string[] resolutionsStrings => resolutions.Select(c => $"{c.x}x{c.y}").ToArray(); - [MenuItem("PSX/VRAM Editor")] + [MenuItem("PlayStation 1/VRAM Editor")] public static void ShowWindow() { VRAMEditorWindow window = GetWindow("VRAM Editor"); @@ -61,7 +61,7 @@ namespace SplashEdit.EditorCode } /// - /// Pastes an overlay texture onto a base texture at the specified position. + /// Pastes an overlay texture onto a base texture at the specified position. /// public static void PasteTexture(Texture2D baseTexture, Texture2D overlayTexture, int posX, int posY) { @@ -299,11 +299,5 @@ namespace SplashEdit.EditorCode GUILayout.EndHorizontal(); } - - /// - /// Stores current configuration to the PSX data asset. - /// This is now triggered manually via the "Save Settings" button. - /// - } } \ No newline at end of file diff --git a/Editor/net.psxsplash.splashedit.Editor.asmdef b/Editor/net.psxsplash.splashedit.Editor.asmdef index f9fc32e..c3b7299 100644 --- a/Editor/net.psxsplash.splashedit.Editor.asmdef +++ b/Editor/net.psxsplash.splashedit.Editor.asmdef @@ -1,10 +1,13 @@ { - "name": "net.psxplash.splashedit.Editor", + "name": "net.psxsplash.splashedit.Editor", "rootNamespace": "", "references": [ - "net.psxsplash.splashedit.Runtime" + "net.psxsplash.splashedit.Runtime", + "Unity.AI.Navigation" + ], + "includePlatforms": [ + "Editor" ], - "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": false, @@ -13,4 +16,4 @@ "defineConstraints": [], "versionDefines": [], "noEngineReferences": false -} \ No newline at end of file +} diff --git a/Editor/net.psxsplash.splashedit.Editor.asmdef.meta b/Editor/net.psxsplash.splashedit.Editor.asmdef.meta index 88082a4..608c666 100644 --- a/Editor/net.psxsplash.splashedit.Editor.asmdef.meta +++ b/Editor/net.psxsplash.splashedit.Editor.asmdef.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 7e3500b5974da9723bdd0d457348ea2d +guid: 8bf64a45e6e447140a68258cd60d0ec1 AssemblyDefinitionImporter: externalObjects: {} userData: diff --git a/Icons.meta b/Icons.meta index 3ac132a..d9c8620 100644 --- a/Icons.meta +++ b/Icons.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: ab7e1dbd79d3e1101b7d44cdf06a2991 +guid: f1210e43ecf5c354486bc01af97ba9eb folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Icons/PSXNavmesh.png b/Icons/PSXNavmesh.png index f02757ca400643f83068f3b02ff126f41f63385f..0b07dfb9e1cb5920546191868c61f40680c013dd 100644 GIT binary patch literal 6837 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 literal 129 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 literal 129 zcmWN?%Mk)03;@tORnUNo;S*uIfg}hsDm#LEuzG!$cglPA@zQOrWA4V>`?@^p-2S&u z*=Rh?JcP^%YIN4KWgBoW6oM@z=yFE31#Yn-LkQ$5g7*lHSx`K98?Ry$W#Zyu6mW@M LQL}$VCqPg?(#9u= diff --git a/Icons/PSXPlayer.png.meta b/Icons/PSXPlayer.png.meta index 261c306..fad596c 100644 --- a/Icons/PSXPlayer.png.meta +++ b/Icons/PSXPlayer.png.meta @@ -1,12 +1,12 @@ fileFormatVersion: 2 -guid: 4d7bd095e76e6f3df976224b15405059 +guid: 67d31a8682e9646419734c5412403992 TextureImporter: internalIDToNameTable: [] externalObjects: {} serializedVersion: 13 mipmaps: mipMapMode: 0 - enableMipMap: 0 + enableMipMap: 1 sRGBTexture: 1 linearTexture: 0 fadeOut: 0 @@ -37,10 +37,10 @@ TextureImporter: filterMode: 1 aniso: 1 mipBias: 0 - wrapU: 1 - wrapV: 1 + wrapU: 0 + wrapV: 0 wrapW: 0 - nPOTScale: 0 + nPOTScale: 1 lightmap: 0 compressionQuality: 50 spriteMode: 0 @@ -52,9 +52,9 @@ TextureImporter: spriteBorder: {x: 0, y: 0, z: 0, w: 0} spriteGenerateFallbackPhysicsShape: 1 alphaUsage: 1 - alphaIsTransparency: 1 + alphaIsTransparency: 0 spriteTessellationDetail: -1 - textureType: 2 + textureType: 0 textureShape: 1 singleChannelComponent: 0 flipbookRows: 1 @@ -94,7 +94,20 @@ TextureImporter: androidETC2FallbackOverride: 0 forceMaximumCompressionQuality_BC6H_BC7: 0 - serializedVersion: 4 - buildTarget: WebGL + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: iOS maxTextureSize: 2048 resizeAlgorithm: 0 textureFormat: -1 diff --git a/Icons/PSXSObjectExporter.png b/Icons/PSXSObjectExporter.png index b7e5049da81d6a507cc13d67dfebec7377fc45e5..7dbfda722071afe9486d6fe587e92f6d3d936c06 100644 GIT binary patch literal 6839 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 literal 129 zcmWN?K@x)?3;@78uiyifkO+#u5eS8uwm1!X=Md)jJ37B54kacaLRV&IdG;6!1xu8H7E8q?xLdMg$|3q4kr)_Owj4Zz LlJ;Z0q6PH_upB18 diff --git a/Icons/PSXSceneExporter.png.meta b/Icons/PSXSceneExporter.png.meta index 9580ffa..51b0029 100644 --- a/Icons/PSXSceneExporter.png.meta +++ b/Icons/PSXSceneExporter.png.meta @@ -1,12 +1,12 @@ fileFormatVersion: 2 -guid: 0be7a2d4700082dbc83b9274837c70bc +guid: 44fe191eb81c12e4698ab9e87406b878 TextureImporter: internalIDToNameTable: [] externalObjects: {} serializedVersion: 13 mipmaps: mipMapMode: 0 - enableMipMap: 0 + enableMipMap: 1 sRGBTexture: 1 linearTexture: 0 fadeOut: 0 @@ -37,10 +37,10 @@ TextureImporter: filterMode: 1 aniso: 1 mipBias: 0 - wrapU: 1 - wrapV: 1 + wrapU: 0 + wrapV: 0 wrapW: 0 - nPOTScale: 0 + nPOTScale: 1 lightmap: 0 compressionQuality: 50 spriteMode: 0 @@ -52,9 +52,9 @@ TextureImporter: spriteBorder: {x: 0, y: 0, z: 0, w: 0} spriteGenerateFallbackPhysicsShape: 1 alphaUsage: 1 - alphaIsTransparency: 1 + alphaIsTransparency: 0 spriteTessellationDetail: -1 - textureType: 2 + textureType: 0 textureShape: 1 singleChannelComponent: 0 flipbookRows: 1 @@ -94,7 +94,20 @@ TextureImporter: androidETC2FallbackOverride: 0 forceMaximumCompressionQuality_BC6H_BC7: 0 - serializedVersion: 4 - buildTarget: WebGL + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: iOS maxTextureSize: 2048 resizeAlgorithm: 0 textureFormat: -1 diff --git a/LICENSE.meta b/LICENSE.meta index af41383..23c0b6a 100644 --- a/LICENSE.meta +++ b/LICENSE.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: c1679c9d58898f14494d614dfe5f76a6 +guid: 07933442bdb4ee14f83fd0b0d0144b8a DefaultImporter: externalObjects: {} userData: diff --git a/Plugins.meta b/Plugins.meta new file mode 100644 index 0000000..4fd65df --- /dev/null +++ b/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Plugins/DotRecast.meta b/Plugins/DotRecast.meta new file mode 100644 index 0000000..68ac981 --- /dev/null +++ b/Plugins/DotRecast.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Plugins/DotRecast/DotRecast.Core.dll b/Plugins/DotRecast/DotRecast.Core.dll new file mode 100644 index 0000000..8a83d7f --- /dev/null +++ b/Plugins/DotRecast/DotRecast.Core.dll @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23681119c55c6a967cdeeb136405a25928e6d33ef78e529777e1b001e2d0d811 +size 76288 diff --git a/Plugins/DotRecast/DotRecast.Core.dll.meta b/Plugins/DotRecast/DotRecast.Core.dll.meta new file mode 100644 index 0000000..5ee2066 --- /dev/null +++ b/Plugins/DotRecast/DotRecast.Core.dll.meta @@ -0,0 +1,27 @@ +fileFormatVersion: 2 +guid: c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Plugins/DotRecast/DotRecast.Recast.dll b/Plugins/DotRecast/DotRecast.Recast.dll new file mode 100644 index 0000000..6963a91 --- /dev/null +++ b/Plugins/DotRecast/DotRecast.Recast.dll @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45ae6b90a67af55ec76409d4dec49f3bddb5677cb14f6301a26f05acbf27eb65 +size 108032 diff --git a/Plugins/DotRecast/DotRecast.Recast.dll.meta b/Plugins/DotRecast/DotRecast.Recast.dll.meta new file mode 100644 index 0000000..d13408e --- /dev/null +++ b/Plugins/DotRecast/DotRecast.Recast.dll.meta @@ -0,0 +1,27 @@ +fileFormatVersion: 2 +guid: d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/README.md b/README.md index 1ea4b7c..0a2ac86 100644 --- a/README.md +++ b/README.md @@ -52,13 +52,11 @@ There are currently four custom Unity components for exporting your scenes to th **PSX Player**: Attach this to any GameObject that you want to act as the player. For now, it acts as a FPS / Free Camera template. The analogue does work for it if you have the option specified in your choice emulator. -**PSX Nav Mesh**: Attach this to any GameObject that you want to apply collisions to. These collisions are rudimentary and currently only work in a top-down view. There must be at least one existing GameObject that uses the **PSX Player** component for this to work properly. - ## Before Exporting: Check the following: -**Player and Nav Meshes**: In case it wasn't clear already, a **PSX Player** component and **PSX Nav Mesh** component must coexist in the same scene for the player to walk on the collisions properly - as both rely on eachother to work. +**Player and Navigation**: A **PSX Player** component must exist in the scene. Navigation is handled by the Nav Region system, which is built automatically from objects with ``Generate Navigation`` enabled in their **PSX Object Exporter** settings. **Scaling**: Ensure that your Geometry Transformation Engine (or GTE) scaling is set to a reasonable value, which can be found in your GameObject holding the component. Ideally, each object in your scene should be within the GTE bounds (or red wireframe bounding box). Bandwidth does mention that there is a scaling / overflow bug with Nav Meshes being worked on and the way to circumvent this is by scaling up the GTE value to be higher. @@ -76,11 +74,11 @@ Convert and preview your textures in a PSX-compatible format. **Light Baking**:\ Prebakes any lights in the scene into the vertex colors of the nearest triangles in the exported mesh data. -**Nav Mesh Generation**:\ -Generates a new Nav Mesh for FPS movement in your scene. +**Nav Region Generation**:\ +Automatically builds convex navigation regions from walkable surfaces for efficient player movement and floor resolution. **FPS / Free Camera Template**:\ -Allows you to walk around in your scene or view it from different perspectives. It's really just the way **PSX Player** and **PSX Nav Mesh** components are setup right now. +Allows you to walk around in your scene or view it from different perspectives using the **PSX Player** component. ## Additional Features: **VRAM Editor**: diff --git a/README.md.meta b/README.md.meta index 36e6b35..073f27e 100644 --- a/README.md.meta +++ b/README.md.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 4df40ce535b32f3a4b30ce0803fa699a +guid: 589dcafb532c388449644bfeb4cf5178 TextScriptImporter: externalObjects: {} userData: diff --git a/Runtime.meta b/Runtime.meta index 57f552a..740af00 100644 --- a/Runtime.meta +++ b/Runtime.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 49f302f0a6255c06c8e340024aa43f45 +guid: 383aa9702d932194ea91e4f3488b41e2 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Runtime/BSP.cs b/Runtime/BSP.cs deleted file mode 100644 index 3ba84ed..0000000 --- a/Runtime/BSP.cs +++ /dev/null @@ -1,845 +0,0 @@ -using System.Collections.Generic; -using UnityEngine; -using System.Diagnostics; -using SplashEdit.RuntimeCode; - -public class BSP -{ - private List _objects; - private Node root; - private const float EPSILON = 1e-6f; - private const int MAX_TRIANGLES_PER_LEAF = 256; - private const int MAX_TREE_DEPTH = 50; - private const int CANDIDATE_PLANE_COUNT = 15; - - // Statistics - private int totalTrianglesProcessed; - private int totalSplits; - private int treeDepth; - private Stopwatch buildTimer; - - public bool verboseLogging = false; - - // Store the triangle that was used for the split plane for debugging - private Dictionary splitPlaneTriangles = new Dictionary(); - - private struct Triangle - { - public Vector3 v0; - public Vector3 v1; - public Vector3 v2; - public Vector3 n0; - public Vector3 n1; - public Vector3 n2; - public Vector2 uv0; - public Vector2 uv1; - public Vector2 uv2; - public Plane plane; - public Bounds bounds; - public PSXObjectExporter sourceExporter; - public int materialIndex; // Store material index instead of submesh index - - public Triangle(Vector3 a, Vector3 b, Vector3 c, Vector3 na, Vector3 nb, Vector3 nc, - Vector2 uva, Vector2 uvb, Vector2 uvc, PSXObjectExporter exporter, int matIndex) - { - v0 = a; - v1 = b; - v2 = c; - n0 = na; - n1 = nb; - n2 = nc; - uv0 = uva; - uv1 = uvb; - uv2 = uvc; - sourceExporter = exporter; - materialIndex = matIndex; - - // Calculate plane - Vector3 edge1 = v1 - v0; - Vector3 edge2 = v2 - v0; - Vector3 normal = Vector3.Cross(edge1, edge2); - - if (normal.sqrMagnitude < 1e-4f) - { - plane = new Plane(Vector3.up, 0); - } - else - { - normal.Normalize(); - plane = new Plane(normal, v0); - } - - // Calculate bounds - bounds = new Bounds(v0, Vector3.zero); - bounds.Encapsulate(v1); - bounds.Encapsulate(v2); - } - - public void Transform(Matrix4x4 matrix) - { - v0 = matrix.MultiplyPoint3x4(v0); - v1 = matrix.MultiplyPoint3x4(v1); - v2 = matrix.MultiplyPoint3x4(v2); - - // Transform normals (using inverse transpose for correct scaling) - Matrix4x4 invTranspose = matrix.inverse.transpose; - n0 = invTranspose.MultiplyVector(n0).normalized; - n1 = invTranspose.MultiplyVector(n1).normalized; - n2 = invTranspose.MultiplyVector(n2).normalized; - - // Recalculate plane and bounds after transformation - Vector3 edge1 = v1 - v0; - Vector3 edge2 = v2 - v0; - Vector3 normal = Vector3.Cross(edge1, edge2); - - if (normal.sqrMagnitude < 1e-4f) - { - plane = new Plane(Vector3.up, 0); - } - else - { - normal.Normalize(); - plane = new Plane(normal, v0); - } - - bounds = new Bounds(v0, Vector3.zero); - bounds.Encapsulate(v1); - bounds.Encapsulate(v2); - } - } - - private class Node - { - public Plane plane; - public Node front; - public Node back; - public List triangles; - public bool isLeaf = false; - public Bounds bounds; - public int depth; - public int triangleSourceIndex = -1; - } - - public BSP(List objects) - { - _objects = objects; - buildTimer = new Stopwatch(); - } - - public void Build() - { - buildTimer.Start(); - - List triangles = ExtractTrianglesFromMeshes(); - totalTrianglesProcessed = triangles.Count; - - if (verboseLogging) - UnityEngine.Debug.Log($"Starting BSP build with {totalTrianglesProcessed} triangles"); - - if (triangles.Count == 0) - { - root = null; - return; - } - - // Calculate overall bounds - Bounds overallBounds = CalculateBounds(triangles); - - // Build tree recursively with depth tracking - root = BuildNode(triangles, overallBounds, 0); - - // Create modified meshes for all exporters - CreateModifiedMeshes(); - - buildTimer.Stop(); - - if (verboseLogging) - { - UnityEngine.Debug.Log($"BSP build completed in {buildTimer.Elapsed.TotalMilliseconds}ms"); - UnityEngine.Debug.Log($"Total splits: {totalSplits}, Max depth: {treeDepth}"); - } - } - - private List ExtractTrianglesFromMeshes() - { - List triangles = new List(); - - foreach (var meshObj in _objects) - { - if (!meshObj.IsActive) continue; - - MeshFilter mf = meshObj.GetComponent(); - Renderer renderer = meshObj.GetComponent(); - if (mf == null || mf.sharedMesh == null || renderer == null) continue; - - Mesh mesh = mf.sharedMesh; - Vector3[] vertices = mesh.vertices; - Vector3[] normals = mesh.normals.Length > 0 ? mesh.normals : new Vector3[vertices.Length]; - Vector2[] uvs = mesh.uv.Length > 0 ? mesh.uv : new Vector2[vertices.Length]; - Matrix4x4 matrix = meshObj.transform.localToWorldMatrix; - - // Handle case where normals are missing - if (mesh.normals.Length == 0) - { - for (int i = 0; i < normals.Length; i++) - { - normals[i] = Vector3.up; - } - } - - // Handle case where UVs are missing - if (mesh.uv.Length == 0) - { - for (int i = 0; i < uvs.Length; i++) - { - uvs[i] = Vector2.zero; - } - } - - // Process each submesh and track material index - for (int submesh = 0; submesh < mesh.subMeshCount; submesh++) - { - int materialIndex = Mathf.Min(submesh, renderer.sharedMaterials.Length - 1); - int[] indices = mesh.GetTriangles(submesh); - - for (int i = 0; i < indices.Length; i += 3) - { - int idx0 = indices[i]; - int idx1 = indices[i + 1]; - int idx2 = indices[i + 2]; - - Vector3 v0 = vertices[idx0]; - Vector3 v1 = vertices[idx1]; - Vector3 v2 = vertices[idx2]; - - // Skip degenerate triangles - if (Vector3.Cross(v1 - v0, v2 - v0).sqrMagnitude < 1e-4f) - continue; - - Vector3 n0 = normals[idx0]; - Vector3 n1 = normals[idx1]; - Vector3 n2 = normals[idx2]; - - Vector2 uv0 = uvs[idx0]; - Vector2 uv1 = uvs[idx1]; - Vector2 uv2 = uvs[idx2]; - - Triangle tri = new Triangle(v0, v1, v2, n0, n1, n2, uv0, uv1, uv2, meshObj, materialIndex); - tri.Transform(matrix); - triangles.Add(tri); - } - } - } - - return triangles; - } - - private Node BuildNode(List triangles, Bounds bounds, int depth) - { - if (triangles == null || triangles.Count == 0) - return null; - - Node node = new Node - { - triangles = new List(), - bounds = bounds, - depth = depth - }; - - treeDepth = Mathf.Max(treeDepth, depth); - - // Create leaf node if conditions are met - if (triangles.Count <= MAX_TRIANGLES_PER_LEAF || depth >= MAX_TREE_DEPTH) - { - node.isLeaf = true; - node.triangles = triangles; - - if (verboseLogging && depth >= MAX_TREE_DEPTH) - UnityEngine.Debug.LogWarning($"Max tree depth reached at depth {depth} with {triangles.Count} triangles"); - - return node; - } - - // Select the best splitting plane using multiple strategies - Triangle? splitTriangle = null; - if (!SelectBestSplittingPlane(triangles, bounds, out node.plane, out splitTriangle)) - { - // Fallback: create leaf if no good split found - node.isLeaf = true; - node.triangles = triangles; - - if (verboseLogging) - UnityEngine.Debug.Log($"Created leaf node with {triangles.Count} triangles (no good split found)"); - - return node; - } - - // Store the triangle that provided the split plane for debugging - if (splitTriangle.HasValue) - { - splitPlaneTriangles[node] = splitTriangle.Value; - } - - List frontList = new List(); - List backList = new List(); - List coplanarList = new List(); - - // Classify all triangles - foreach (var tri in triangles) - { - ClassifyTriangle(tri, node.plane, coplanarList, frontList, backList); - } - - // Handle cases where splitting doesn't provide benefit - if (frontList.Count == 0 || backList.Count == 0) - { - // If split doesn't separate geometry, create a leaf - node.isLeaf = true; - node.triangles = triangles; - - if (verboseLogging) - UnityEngine.Debug.Log($"Created leaf node with {triangles.Count} triangles (ineffective split)"); - - return node; - } - - // Distribute coplanar triangles to the side with fewer triangles - if (coplanarList.Count > 0) - { - if (frontList.Count <= backList.Count) - { - frontList.AddRange(coplanarList); - } - else - { - backList.AddRange(coplanarList); - } - } - - if (verboseLogging) - UnityEngine.Debug.Log($"Node at depth {depth}: {triangles.Count} triangles -> {frontList.Count} front, {backList.Count} back"); - - // Calculate bounds for children - Bounds frontBounds = CalculateBounds(frontList); - Bounds backBounds = CalculateBounds(backList); - - // Recursively build child nodes - node.front = BuildNode(frontList, frontBounds, depth + 1); - node.back = BuildNode(backList, backBounds, depth + 1); - - return node; - } - - private bool SelectBestSplittingPlane(List triangles, Bounds bounds, out Plane bestPlane, out Triangle? splitTriangle) - { - bestPlane = new Plane(); - splitTriangle = null; - int bestScore = int.MaxValue; - bool foundValidPlane = false; - - // Strategy 1: Try planes from triangle centroids - int candidatesToTry = Mathf.Min(CANDIDATE_PLANE_COUNT, triangles.Count); - for (int i = 0; i < candidatesToTry; i++) - { - Triangle tri = triangles[i]; - Plane candidate = tri.plane; - - int score = EvaluateSplitPlane(triangles, candidate); - if (score < bestScore && score >= 0) - { - bestScore = score; - bestPlane = candidate; - splitTriangle = tri; - foundValidPlane = true; - } - } - - // Strategy 2: Try axis-aligned planes through bounds center - if (!foundValidPlane || bestScore > triangles.Count * 3) - { - Vector3[] axes = { Vector3.right, Vector3.up, Vector3.forward }; - for (int i = 0; i < 3; i++) - { - Plane candidate = new Plane(axes[i], bounds.center); - int score = EvaluateSplitPlane(triangles, candidate); - if (score < bestScore && score >= 0) - { - bestScore = score; - bestPlane = candidate; - splitTriangle = null; - foundValidPlane = true; - } - } - } - - // Strategy 3: Try planes based on bounds extents - if (!foundValidPlane) - { - Vector3 extents = bounds.extents; - if (extents.x >= extents.y && extents.x >= extents.z) - bestPlane = new Plane(Vector3.right, bounds.center); - else if (extents.y >= extents.x && extents.y >= extents.z) - bestPlane = new Plane(Vector3.up, bounds.center); - else - bestPlane = new Plane(Vector3.forward, bounds.center); - - splitTriangle = null; - foundValidPlane = true; - } - - return foundValidPlane; - } - - private int EvaluateSplitPlane(List triangles, Plane plane) - { - int frontCount = 0; - int backCount = 0; - int splitCount = 0; - int coplanarCount = 0; - - foreach (var tri in triangles) - { - float d0 = plane.GetDistanceToPoint(tri.v0); - float d1 = plane.GetDistanceToPoint(tri.v1); - float d2 = plane.GetDistanceToPoint(tri.v2); - - // Check for NaN/infinity - if (float.IsNaN(d0) || float.IsNaN(d1) || float.IsNaN(d2) || - float.IsInfinity(d0) || float.IsInfinity(d1) || float.IsInfinity(d2)) - { - return int.MaxValue; - } - - bool front = d0 > EPSILON || d1 > EPSILON || d2 > EPSILON; - bool back = d0 < -EPSILON || d1 < -EPSILON || d2 < -EPSILON; - - if (front && back) - splitCount++; - else if (front) - frontCount++; - else if (back) - backCount++; - else - coplanarCount++; - } - - // Reject planes that would cause too many splits or imbalanced trees - if (splitCount > triangles.Count / 2) - return int.MaxValue; - - // Score based on balance and split count - return Mathf.Abs(frontCount - backCount) + splitCount * 2; - } - - private void ClassifyTriangle(Triangle tri, Plane plane, List coplanar, List front, List back) - { - float d0 = plane.GetDistanceToPoint(tri.v0); - float d1 = plane.GetDistanceToPoint(tri.v1); - float d2 = plane.GetDistanceToPoint(tri.v2); - - // Check for numerical issues - if (float.IsNaN(d0) || float.IsNaN(d1) || float.IsNaN(d2) || - float.IsInfinity(d0) || float.IsInfinity(d1) || float.IsInfinity(d2)) - { - coplanar.Add(tri); - return; - } - - bool front0 = d0 > EPSILON; - bool front1 = d1 > EPSILON; - bool front2 = d2 > EPSILON; - - bool back0 = d0 < -EPSILON; - bool back1 = d1 < -EPSILON; - bool back2 = d2 < -EPSILON; - - int fCount = (front0 ? 1 : 0) + (front1 ? 1 : 0) + (front2 ? 1 : 0); - int bCount = (back0 ? 1 : 0) + (back1 ? 1 : 0) + (back2 ? 1 : 0); - - if (fCount == 3) - { - front.Add(tri); - } - else if (bCount == 3) - { - back.Add(tri); - } - else if (fCount == 0 && bCount == 0) - { - coplanar.Add(tri); - } - else - { - totalSplits++; - SplitTriangle(tri, plane, front, back); - } - } - - private void SplitTriangle(Triangle tri, Plane plane, List front, List back) - { - // Get distances - float d0 = plane.GetDistanceToPoint(tri.v0); - float d1 = plane.GetDistanceToPoint(tri.v1); - float d2 = plane.GetDistanceToPoint(tri.v2); - - // Classify points - bool[] frontSide = { d0 > EPSILON, d1 > EPSILON, d2 > EPSILON }; - bool[] backSide = { d0 < -EPSILON, d1 < -EPSILON, d2 < -EPSILON }; - - // Count how many points are on each side - int frontCount = (frontSide[0] ? 1 : 0) + (frontSide[1] ? 1 : 0) + (frontSide[2] ? 1 : 0); - int backCount = (backSide[0] ? 1 : 0) + (backSide[1] ? 1 : 0) + (backSide[2] ? 1 : 0); - - // 2 points on one side, 1 on the other - if (frontCount == 2 && backCount == 1) - { - int loneIndex = backSide[0] ? 0 : (backSide[1] ? 1 : 2); - SplitTriangle2To1(tri, plane, loneIndex, true, front, back); - } - else if (backCount == 2 && frontCount == 1) - { - int loneIndex = frontSide[0] ? 0 : (frontSide[1] ? 1 : 2); - SplitTriangle2To1(tri, plane, loneIndex, false, front, back); - } - else - { - // Complex case - add to both sides (should be rare) - front.Add(tri); - back.Add(tri); - } - } - - private void SplitTriangle2To1(Triangle tri, Plane plane, int loneIndex, bool loneIsBack, - List front, List back) - { - Vector3[] v = { tri.v0, tri.v1, tri.v2 }; - Vector3[] n = { tri.n0, tri.n1, tri.n2 }; - Vector2[] uv = { tri.uv0, tri.uv1, tri.uv2 }; - - Vector3 loneVertex = v[loneIndex]; - Vector3 loneNormal = n[loneIndex]; - Vector2 loneUV = uv[loneIndex]; - - Vector3 v1 = v[(loneIndex + 1) % 3]; - Vector3 v2 = v[(loneIndex + 2) % 3]; - Vector3 n1 = n[(loneIndex + 1) % 3]; - Vector3 n2 = n[(loneIndex + 2) % 3]; - Vector2 uv1 = uv[(loneIndex + 1) % 3]; - Vector2 uv2 = uv[(loneIndex + 2) % 3]; - - Vector3 i1 = PlaneIntersection(plane, loneVertex, v1); - float t1 = CalculateInterpolationFactor(plane, loneVertex, v1); - Vector3 n_i1 = Vector3.Lerp(loneNormal, n1, t1).normalized; - Vector2 uv_i1 = Vector2.Lerp(loneUV, uv1, t1); - - Vector3 i2 = PlaneIntersection(plane, loneVertex, v2); - float t2 = CalculateInterpolationFactor(plane, loneVertex, v2); - Vector3 n_i2 = Vector3.Lerp(loneNormal, n2, t2).normalized; - Vector2 uv_i2 = Vector2.Lerp(loneUV, uv2, t2); - - // Desired normal: prefer triangle's plane normal, fallback to geometric normal - Vector3 desired = tri.plane.normal; - if (desired.sqrMagnitude < 1e-4f) - desired = Vector3.Cross(tri.v1 - tri.v0, tri.v2 - tri.v0).normalized; - if (desired.sqrMagnitude < 1e-4f) - desired = Vector3.up; - - // Helper: decide and swap b/c if necessary, then add triangle - void AddTriClockwise(List list, - Vector3 a, Vector3 b, Vector3 c, - Vector3 na, Vector3 nb, Vector3 nc, - Vector2 ua, Vector2 ub, Vector2 uc) - { - Vector3 cross = Vector3.Cross(b - a, c - a); - if (cross.z > 0f) // <-- assumes you're working in PS1 screen space (z forward) - { - // swap b <-> c - var tmpV = b; b = c; c = tmpV; - var tmpN = nb; nb = nc; nc = tmpN; - var tmpUv = ub; ub = uc; uc = tmpUv; - } - - list.Add(new Triangle(a, b, c, na, nb, nc, ua, ub, uc, tri.sourceExporter, tri.materialIndex)); - } - - if (loneIsBack) - { - // back: (lone, i1, i2) - AddTriClockwise(back, loneVertex, i1, i2, loneNormal, n_i1, n_i2, loneUV, uv_i1, uv_i2); - - // front: (v1, i1, i2) and (v1, i2, v2) - AddTriClockwise(front, v1, i1, i2, n1, n_i1, n_i2, uv1, uv_i1, uv_i2); - AddTriClockwise(front, v1, i2, v2, n1, n_i2, n2, uv1, uv_i2, uv2); - } - else - { - // front: (lone, i1, i2) - AddTriClockwise(front, loneVertex, i1, i2, loneNormal, n_i1, n_i2, loneUV, uv_i1, uv_i2); - - // back: (v1, i1, i2) and (v1, i2, v2) - AddTriClockwise(back, v1, i1, i2, n1, n_i1, n_i2, uv1, uv_i1, uv_i2); - AddTriClockwise(back, v1, i2, v2, n1, n_i2, n2, uv1, uv_i2, uv2); - } - } - - - private Vector3 PlaneIntersection(Plane plane, Vector3 a, Vector3 b) - { - Vector3 ba = b - a; - float denominator = Vector3.Dot(plane.normal, ba); - - // Check for parallel line (shouldn't happen in our case) - if (Mathf.Abs(denominator) < 1e-4f) - return a; - - float t = (-plane.distance - Vector3.Dot(plane.normal, a)) / denominator; - return a + ba * Mathf.Clamp01(t); - } - - private float CalculateInterpolationFactor(Plane plane, Vector3 a, Vector3 b) - { - Vector3 ba = b - a; - float denominator = Vector3.Dot(plane.normal, ba); - - if (Mathf.Abs(denominator) < 1e-4f) - return 0.5f; - - float t = (-plane.distance - Vector3.Dot(plane.normal, a)) / denominator; - return Mathf.Clamp01(t); - } - - private Bounds CalculateBounds(List triangles) - { - if (triangles == null || triangles.Count == 0) - return new Bounds(); - - Bounds bounds = triangles[0].bounds; - for (int i = 1; i < triangles.Count; i++) - { - bounds.Encapsulate(triangles[i].bounds); - } - - return bounds; - } - - // Add a method to create modified meshes after BSP construction - // Add a method to create modified meshes after BSP construction - private void CreateModifiedMeshes() - { - if (root == null) return; - - // Collect all triangles from the BSP tree - List allTriangles = new List(); - CollectTrianglesFromNode(root, allTriangles); - - // Group triangles by their source exporter and material index - Dictionary>> exporterTriangles = - new Dictionary>>(); - - foreach (var tri in allTriangles) - { - if (!exporterTriangles.ContainsKey(tri.sourceExporter)) - { - exporterTriangles[tri.sourceExporter] = new Dictionary>(); - } - - var materialDict = exporterTriangles[tri.sourceExporter]; - if (!materialDict.ContainsKey(tri.materialIndex)) - { - materialDict[tri.materialIndex] = new List(); - } - - materialDict[tri.materialIndex].Add(tri); - } - - // Create modified meshes for each exporter - foreach (var kvp in exporterTriangles) - { - PSXObjectExporter exporter = kvp.Key; - Dictionary> materialTriangles = kvp.Value; - - Mesh originalMesh = exporter.GetComponent().sharedMesh; - Renderer renderer = exporter.GetComponent(); - - Mesh modifiedMesh = new Mesh(); - modifiedMesh.name = originalMesh.name + "_BSP"; - - List vertices = new List(); - List normals = new List(); - List uvs = new List(); - List tangents = new List(); - List colors = new List(); - - // Create a list for each material's triangles - List> materialIndices = new List>(); - for (int i = 0; i < renderer.sharedMaterials.Length; i++) - { - materialIndices.Add(new List()); - } - - // Get the inverse transform to convert from world space back to object space - Matrix4x4 worldToLocal = exporter.transform.worldToLocalMatrix; - - // Process each material - foreach (var materialKvp in materialTriangles) - { - int materialIndex = materialKvp.Key; - List triangles = materialKvp.Value; - - // Add vertices, normals, and uvs for this material - for (int i = 0; i < triangles.Count; i++) - { - Triangle tri = triangles[i]; - - // Transform vertices from world space back to object space - Vector3 v0 = worldToLocal.MultiplyPoint3x4(tri.v0); - Vector3 v1 = worldToLocal.MultiplyPoint3x4(tri.v1); - Vector3 v2 = worldToLocal.MultiplyPoint3x4(tri.v2); - - int vertexIndex = vertices.Count; - vertices.Add(v0); - vertices.Add(v1); - vertices.Add(v2); - - // Transform normals from world space back to object space - Vector3 n0 = worldToLocal.MultiplyVector(tri.n0).normalized; - Vector3 n1 = worldToLocal.MultiplyVector(tri.n1).normalized; - Vector3 n2 = worldToLocal.MultiplyVector(tri.n2).normalized; - - normals.Add(n0); - normals.Add(n1); - normals.Add(n2); - - uvs.Add(tri.uv0); - uvs.Add(tri.uv1); - uvs.Add(tri.uv2); - - // Add default tangents and colors (will be recalculated later) - tangents.Add(new Vector4(1, 0, 0, 1)); - tangents.Add(new Vector4(1, 0, 0, 1)); - tangents.Add(new Vector4(1, 0, 0, 1)); - - colors.Add(Color.white); - colors.Add(Color.white); - colors.Add(Color.white); - - // Add indices for this material - materialIndices[materialIndex].Add(vertexIndex); - materialIndices[materialIndex].Add(vertexIndex + 1); - materialIndices[materialIndex].Add(vertexIndex + 2); - } - } - - // Assign data to the mesh - modifiedMesh.vertices = vertices.ToArray(); - modifiedMesh.normals = normals.ToArray(); - modifiedMesh.uv = uvs.ToArray(); - modifiedMesh.tangents = tangents.ToArray(); - modifiedMesh.colors = colors.ToArray(); - - // Set up submeshes based on materials - modifiedMesh.subMeshCount = materialIndices.Count; - for (int i = 0; i < materialIndices.Count; i++) - { - modifiedMesh.SetTriangles(materialIndices[i].ToArray(), i); - } - - // Recalculate important mesh properties - modifiedMesh.RecalculateBounds(); - modifiedMesh.RecalculateTangents(); - - // Assign the modified mesh to the exporter - exporter.ModifiedMesh = modifiedMesh; - } - } - // Helper method to collect all triangles from the BSP tree - private void CollectTrianglesFromNode(Node node, List triangles) - { - if (node == null) return; - - if (node.isLeaf) - { - triangles.AddRange(node.triangles); - } - else - { - CollectTrianglesFromNode(node.front, triangles); - CollectTrianglesFromNode(node.back, triangles); - } - } - - public void DrawGizmos(int maxDepth) - { - if (root == null) return; - - DrawNodeGizmos(root, 0, maxDepth); - } - - private void DrawNodeGizmos(Node node, int depth, int maxDepth) - { - if (node == null) return; - if (depth > maxDepth) return; - - Color nodeColor = Color.HSVToRGB((depth * 0.1f) % 1f, 0.8f, 0.8f); - Gizmos.color = nodeColor; - - if (node.isLeaf) - { - foreach (var tri in node.triangles) - { - DrawTriangleGizmo(tri); - } - } - else - { - DrawPlaneGizmo(node.plane, node.bounds); - - // Draw the triangle that was used for the split plane if available - if (splitPlaneTriangles.ContainsKey(node)) - { - Gizmos.color = Color.magenta; - DrawTriangleGizmo(splitPlaneTriangles[node]); - Gizmos.color = nodeColor; - } - - DrawNodeGizmos(node.front, depth + 1, maxDepth); - DrawNodeGizmos(node.back, depth + 1, maxDepth); - } - } - - private void DrawTriangleGizmo(Triangle tri) - { - Gizmos.DrawLine(tri.v0, tri.v1); - Gizmos.DrawLine(tri.v1, tri.v2); - Gizmos.DrawLine(tri.v2, tri.v0); - } - - private void DrawPlaneGizmo(Plane plane, Bounds bounds) - { - Vector3 center = bounds.center; - Vector3 normal = plane.normal; - - Vector3 tangent = Vector3.Cross(normal, Vector3.up); - if (tangent.magnitude < 0.1f) tangent = Vector3.Cross(normal, Vector3.right); - tangent = tangent.normalized; - - Vector3 bitangent = Vector3.Cross(normal, tangent).normalized; - - float size = Mathf.Max(bounds.size.x, bounds.size.y, bounds.size.z) * 0.5f; - tangent *= size; - bitangent *= size; - - Vector3 p0 = center - tangent - bitangent; - Vector3 p1 = center + tangent - bitangent; - Vector3 p2 = center + tangent + bitangent; - Vector3 p3 = center - tangent + bitangent; - - Gizmos.DrawLine(p0, p1); - Gizmos.DrawLine(p1, p2); - Gizmos.DrawLine(p2, p3); - Gizmos.DrawLine(p3, p0); - - Gizmos.color = Color.red; - Gizmos.DrawLine(center, center + normal * size * 0.5f); - } -} \ No newline at end of file diff --git a/Runtime/BSP.cs.meta b/Runtime/BSP.cs.meta deleted file mode 100644 index 3a55181..0000000 --- a/Runtime/BSP.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 15144e67b42b92447a546346e594155b \ No newline at end of file diff --git a/Runtime/BVH.cs b/Runtime/BVH.cs new file mode 100644 index 0000000..8e5a006 --- /dev/null +++ b/Runtime/BVH.cs @@ -0,0 +1,393 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEngine; +using SplashEdit.RuntimeCode; + +namespace SplashEdit.RuntimeCode +{ + /// + /// Bounding Volume Hierarchy for PS1 frustum culling. + /// Unlike BSP, BVH doesn't split triangles - it groups them by spatial locality. + /// This is better for PS1 because: + /// 1. No additional triangles created (memory constrained) + /// 2. Simple AABB tests are fast on 33MHz CPU + /// 3. Natural hierarchical culling + /// + public class BVH : IPSXBinaryWritable + { + // Configuration + private const int MAX_TRIANGLES_PER_LEAF = 64; // PS1 can handle batches of this size + private const int MAX_DEPTH = 16; // Prevent pathological cases + private const int MIN_TRIANGLES_TO_SPLIT = 8; // Don't split tiny groups + + private List _objects; + private BVHNode _root; + private List _allNodes; // Flat list for export + private List _allTriangleRefs; // Triangle references for export + + public int NodeCount => _allNodes?.Count ?? 0; + public int TriangleRefCount => _allTriangleRefs?.Count ?? 0; + + /// + /// Reference to a triangle - doesn't copy data, just points to it + /// + public struct TriangleRef + { + public ushort objectIndex; // Which GameObject + public ushort triangleIndex; // Which triangle in that object's mesh + + public TriangleRef(int objIdx, int triIdx) + { + objectIndex = (ushort)objIdx; + triangleIndex = (ushort)triIdx; + } + } + + /// + /// BVH Node - 32 bytes when exported + /// + public class BVHNode + { + public Bounds bounds; + public BVHNode left; + public BVHNode right; + public List triangles; // Only for leaf nodes + public int depth; + + // Export indices (filled during serialization) + public int nodeIndex = -1; + public int leftIndex = -1; // -1 = no child (leaf check) + public int rightIndex = -1; + public int firstTriangleIndex = -1; + public int triangleCount = 0; + + public bool IsLeaf => left == null && right == null; + } + + /// + /// Triangle with bounds for building + /// + private struct TriangleWithBounds + { + public TriangleRef reference; + public Bounds bounds; + public Vector3 centroid; + } + + public BVH(List objects) + { + _objects = objects; + _allNodes = new List(); + _allTriangleRefs = new List(); + } + + public void Build() + { + _allNodes.Clear(); + _allTriangleRefs.Clear(); + + // Extract all triangles with their bounds + List triangles = ExtractTriangles(); + + if (triangles.Count == 0) + { + Debug.LogWarning("BVH: No triangles to process"); + return; + } + + // Build the tree + _root = BuildNode(triangles, 0); + + // Flatten for export + FlattenTree(); + } + + private List ExtractTriangles() + { + var result = new List(); + + for (int objIdx = 0; objIdx < _objects.Count; objIdx++) + { + var exporter = _objects[objIdx]; + if (!exporter.IsActive) continue; + + MeshFilter mf = exporter.GetComponent(); + if (mf == null || mf.sharedMesh == null) continue; + + Mesh mesh = mf.sharedMesh; + Vector3[] vertices = mesh.vertices; + int[] indices = mesh.triangles; + Matrix4x4 worldMatrix = exporter.transform.localToWorldMatrix; + + for (int i = 0; i < indices.Length; i += 3) + { + Vector3 v0 = worldMatrix.MultiplyPoint3x4(vertices[indices[i]]); + Vector3 v1 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 1]]); + Vector3 v2 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 2]]); + + // Calculate bounds + Bounds triBounds = new Bounds(v0, Vector3.zero); + triBounds.Encapsulate(v1); + triBounds.Encapsulate(v2); + + result.Add(new TriangleWithBounds + { + reference = new TriangleRef(objIdx, i / 3), + bounds = triBounds, + centroid = (v0 + v1 + v2) / 3f + }); + } + } + + return result; + } + + private BVHNode BuildNode(List triangles, int depth) + { + if (triangles.Count == 0) + return null; + + var node = new BVHNode { depth = depth }; + + // Calculate bounds encompassing all triangles + node.bounds = triangles[0].bounds; + foreach (var tri in triangles) + { + node.bounds.Encapsulate(tri.bounds); + } + + // Create leaf if conditions met + if (triangles.Count <= MAX_TRIANGLES_PER_LEAF || + depth >= MAX_DEPTH || + triangles.Count < MIN_TRIANGLES_TO_SPLIT) + { + node.triangles = triangles.Select(t => t.reference).ToList(); + return node; + } + + // Find best split axis (longest extent) + Vector3 extent = node.bounds.size; + int axis = 0; + if (extent.y > extent.x && extent.y > extent.z) axis = 1; + else if (extent.z > extent.x && extent.z > extent.y) axis = 2; + + // Sort by centroid along chosen axis + triangles.Sort((a, b) => + { + float va = axis == 0 ? a.centroid.x : (axis == 1 ? a.centroid.y : a.centroid.z); + float vb = axis == 0 ? b.centroid.x : (axis == 1 ? b.centroid.y : b.centroid.z); + return va.CompareTo(vb); + }); + + // Find split plane position at median centroid + int mid = triangles.Count / 2; + if (mid == 0) mid = 1; + if (mid >= triangles.Count) mid = triangles.Count - 1; + + float splitPos = axis == 0 ? triangles[mid].centroid.x : + (axis == 1 ? triangles[mid].centroid.y : triangles[mid].centroid.z); + + // Partition triangles - allow overlap for triangles spanning the split plane + var leftTris = new List(); + var rightTris = new List(); + + foreach (var tri in triangles) + { + float triMin = axis == 0 ? tri.bounds.min.x : (axis == 1 ? tri.bounds.min.y : tri.bounds.min.z); + float triMax = axis == 0 ? tri.bounds.max.x : (axis == 1 ? tri.bounds.max.y : tri.bounds.max.z); + + // Triangle spans split plane - add to BOTH children (spatial split) + // This fixes large triangles at screen edges being culled incorrectly + if (triMin < splitPos && triMax > splitPos) + { + leftTris.Add(tri); + rightTris.Add(tri); + } + // Triangle entirely on left side + else if (triMax <= splitPos) + { + leftTris.Add(tri); + } + // Triangle entirely on right side + else + { + rightTris.Add(tri); + } + } + + // Check if split is beneficial (prevents infinite recursion on coincident triangles) + if (leftTris.Count == 0 || rightTris.Count == 0 || + (leftTris.Count == triangles.Count && rightTris.Count == triangles.Count)) + { + node.triangles = triangles.Select(t => t.reference).ToList(); + return node; + } + + node.left = BuildNode(leftTris, depth + 1); + node.right = BuildNode(rightTris, depth + 1); + + return node; + } + + /// + /// Flatten tree to arrays for export + /// + private void FlattenTree() + { + _allNodes.Clear(); + _allTriangleRefs.Clear(); + + if (_root == null) return; + + // BFS to assign indices + var queue = new Queue(); + queue.Enqueue(_root); + + while (queue.Count > 0) + { + var node = queue.Dequeue(); + node.nodeIndex = _allNodes.Count; + _allNodes.Add(node); + + if (node.left != null) queue.Enqueue(node.left); + if (node.right != null) queue.Enqueue(node.right); + } + + // Second pass: fill in child indices and triangle data + foreach (var node in _allNodes) + { + if (node.left != null) + node.leftIndex = node.left.nodeIndex; + if (node.right != null) + node.rightIndex = node.right.nodeIndex; + + if (node.IsLeaf && node.triangles != null && node.triangles.Count > 0) + { + // Sort tri-refs by objectIndex within each leaf so the C++ renderer + // can batch consecutive refs and avoid redundant GTE matrix reloads. + node.triangles.Sort((a, b) => a.objectIndex.CompareTo(b.objectIndex)); + node.firstTriangleIndex = _allTriangleRefs.Count; + node.triangleCount = node.triangles.Count; + _allTriangleRefs.AddRange(node.triangles); + } + } + } + + /// + /// Export BVH to binary writer + /// Format: + /// - uint16 nodeCount + /// - uint16 triangleRefCount + /// - BVHNode[nodeCount] (32 bytes each) + /// - TriangleRef[triangleRefCount] (4 bytes each) + /// + public void WriteToBinary(BinaryWriter writer, float gteScaling) + { + // Note: counts are already in the file header (bvhNodeCount, bvhTriangleRefCount) + // Don't write them again here - C++ reads BVH data directly after colliders + + // Write nodes (32 bytes each) + foreach (var node in _allNodes) + { + // AABB bounds (24 bytes) + Vector3 min = node.bounds.min; + Vector3 max = node.bounds.max; + + writer.Write(PSXTrig.ConvertWorldToFixed12(min.x / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-max.y / gteScaling)); // Y flipped + writer.Write(PSXTrig.ConvertWorldToFixed12(min.z / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(max.x / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-min.y / gteScaling)); // Y flipped + writer.Write(PSXTrig.ConvertWorldToFixed12(max.z / gteScaling)); + + // Child indices (4 bytes) - 0xFFFF means no child + writer.Write((ushort)(node.leftIndex >= 0 ? node.leftIndex : 0xFFFF)); + writer.Write((ushort)(node.rightIndex >= 0 ? node.rightIndex : 0xFFFF)); + + // Triangle data (4 bytes) + writer.Write((ushort)(node.firstTriangleIndex >= 0 ? node.firstTriangleIndex : 0)); + writer.Write((ushort)node.triangleCount); + } + + // Write triangle references (4 bytes each) + foreach (var triRef in _allTriangleRefs) + { + writer.Write(triRef.objectIndex); + writer.Write(triRef.triangleIndex); + } + } + + /// + /// Get total bytes that will be written + /// + public int GetBinarySize() + { + // Just nodes + triangle refs, counts are in file header + return (_allNodes.Count * 32) + (_allTriangleRefs.Count * 4); + } + + /// + /// Draw gizmos for debugging + /// + public void DrawGizmos(int maxDepth = 999) + { + if (_root == null) return; + DrawNodeGizmos(_root, maxDepth); + } + + private void DrawNodeGizmos(BVHNode node, int maxDepth) + { + if (node == null || node.depth > maxDepth) return; + + // Color by depth + Color c = Color.HSVToRGB((node.depth * 0.12f) % 1f, 0.7f, 0.9f); + c.a = node.IsLeaf ? 0.3f : 0.1f; + Gizmos.color = c; + + // Draw bounds + Gizmos.DrawWireCube(node.bounds.center, node.bounds.size); + + if (node.IsLeaf) + { + // Draw leaf as semi-transparent + Gizmos.color = new Color(c.r, c.g, c.b, 0.1f); + Gizmos.DrawCube(node.bounds.center, node.bounds.size); + } + + // Recurse + DrawNodeGizmos(node.left, maxDepth); + DrawNodeGizmos(node.right, maxDepth); + } + + /// + /// Get statistics for debugging + /// + public string GetStatistics() + { + if (_root == null) return "BVH not built"; + + int leafCount = 0; + int maxDepth = 0; + int totalTris = 0; + + void CountNodes(BVHNode node) + { + if (node == null) return; + if (node.depth > maxDepth) maxDepth = node.depth; + if (node.IsLeaf) + { + leafCount++; + totalTris += node.triangleCount; + } + CountNodes(node.left); + CountNodes(node.right); + } + + CountNodes(_root); + + return $"Nodes: {_allNodes.Count}, Leaves: {leafCount}, Max Depth: {maxDepth}, Triangle Refs: {totalTris}"; + } + } +} diff --git a/Runtime/BVH.cs.meta b/Runtime/BVH.cs.meta new file mode 100644 index 0000000..7431357 --- /dev/null +++ b/Runtime/BVH.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 735c7edec8b9f5d4facdf22f48d99ee0 \ No newline at end of file diff --git a/Runtime/Core.meta b/Runtime/Core.meta new file mode 100644 index 0000000..3b5e699 --- /dev/null +++ b/Runtime/Core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 90864d7c8ee7ae6409c8a0c0a2ea9075 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/IPSXBinaryWritable.cs b/Runtime/IPSXBinaryWritable.cs new file mode 100644 index 0000000..a418c95 --- /dev/null +++ b/Runtime/IPSXBinaryWritable.cs @@ -0,0 +1,18 @@ +using System.IO; + +namespace SplashEdit.RuntimeCode +{ + /// + /// Implemented by scene-level data builders that serialize their output + /// into the splashpack binary stream. + /// + public interface IPSXBinaryWritable + { + /// + /// Write binary data to the splashpack stream. + /// + /// The binary writer positioned at the correct offset. + /// GTE coordinate scaling factor. + void WriteToBinary(BinaryWriter writer, float gteScaling); + } +} diff --git a/Runtime/IPSXBinaryWritable.cs.meta b/Runtime/IPSXBinaryWritable.cs.meta new file mode 100644 index 0000000..a900b22 --- /dev/null +++ b/Runtime/IPSXBinaryWritable.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: aa53647f0fc3ed24292dd3fd9a0b294e \ No newline at end of file diff --git a/Runtime/IPSXExportable.cs b/Runtime/IPSXExportable.cs new file mode 100644 index 0000000..7bb2f9d --- /dev/null +++ b/Runtime/IPSXExportable.cs @@ -0,0 +1,20 @@ +namespace SplashEdit.RuntimeCode +{ + /// + /// Implemented by MonoBehaviours that participate in the PSX scene export pipeline. + /// Each exportable object converts its Unity representation into PSX-ready data. + /// + public interface IPSXExportable + { + /// + /// Convert Unity textures into PSX texture data (palette-quantized, packed). + /// + void CreatePSXTextures2D(); + + /// + /// Convert the Unity mesh into a PSX-ready triangle list. + /// + /// GTE coordinate scaling factor. + void CreatePSXMesh(float gteScaling); + } +} diff --git a/Runtime/IPSXExportable.cs.meta b/Runtime/IPSXExportable.cs.meta new file mode 100644 index 0000000..f8f8011 --- /dev/null +++ b/Runtime/IPSXExportable.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0598c601ee3672b40828f0d31bbec29b \ No newline at end of file diff --git a/Runtime/ImageProcessing.cs b/Runtime/ImageProcessing.cs index 8a46be2..7c9f746 100644 --- a/Runtime/ImageProcessing.cs +++ b/Runtime/ImageProcessing.cs @@ -70,7 +70,7 @@ namespace SplashEdit.RuntimeCode List centroids = Enumerable.Range(0, k).Select(i => colors[i * colors.Count / k]).ToList(); List> clusters; - for (int i = 0; i < 10; i++) // Fixed iterations for performance.... i hate this... + for (int i = 0; i < 10; i++) // Fixed iteration count { clusters = Enumerable.Range(0, k).Select(_ => new List()).ToList(); foreach (Vector3 color in colors) diff --git a/Runtime/ImageProcessing.cs.meta b/Runtime/ImageProcessing.cs.meta index df80c60..9d3ca2d 100644 --- a/Runtime/ImageProcessing.cs.meta +++ b/Runtime/ImageProcessing.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: 1291c85b333132b8392486949420d31a \ No newline at end of file +guid: c760e5745d5c72746aec8ac9583c456f \ No newline at end of file diff --git a/Runtime/LuaFile.cs b/Runtime/LuaFile.cs index 82a2df6..2c4cee1 100644 --- a/Runtime/LuaFile.cs +++ b/Runtime/LuaFile.cs @@ -1,6 +1,6 @@ using UnityEngine; -namespace Splashedit.RuntimeCode +namespace SplashEdit.RuntimeCode { public class LuaFile : ScriptableObject { @@ -13,3 +13,4 @@ namespace Splashedit.RuntimeCode } } } + \ No newline at end of file diff --git a/Runtime/LuaFile.cs.meta b/Runtime/LuaFile.cs.meta index 25e5cc5..b683cc8 100644 --- a/Runtime/LuaFile.cs.meta +++ b/Runtime/LuaFile.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: e3b07239f3beb7a87ad987c3fedae9c1 \ No newline at end of file +guid: 00e64fcbfc4e23e4dbe284131fa4d89b \ No newline at end of file diff --git a/Runtime/PSXAudioSource.cs b/Runtime/PSXAudioSource.cs new file mode 100644 index 0000000..ef813a9 --- /dev/null +++ b/Runtime/PSXAudioSource.cs @@ -0,0 +1,43 @@ +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// Pre-converted audio clip data ready for splashpack serialization. + /// Populated by the Editor (PSXSceneExporter) so Runtime code never + /// touches PSXAudioConverter. + /// + public struct AudioClipExport + { + public byte[] adpcmData; + public int sampleRate; + public bool loop; + public string clipName; + } + + /// + /// Attach to a GameObject to include an audio clip in the PS1 build. + /// At export time, the AudioClip is converted to SPU ADPCM and packed + /// into the splashpack binary. Use Audio.Play(clipIndex) from Lua. + /// + [AddComponentMenu("PSX/Audio Source")] + public class PSXAudioSource : MonoBehaviour + { + [Tooltip("Name used to identify this clip in Lua (Audio.Play(\"name\"))." )] + public string ClipName = ""; + + [Tooltip("Unity AudioClip to convert to PS1 SPU ADPCM format.")] + public AudioClip Clip; + + [Tooltip("Target sample rate for the PS1 (lower = smaller, max 44100).")] + [Range(8000, 44100)] + public int SampleRate = 22050; + + [Tooltip("Whether this clip should loop when played.")] + public bool Loop = false; + + [Tooltip("Default playback volume (0-127).")] + [Range(0, 127)] + public int DefaultVolume = 100; + } +} diff --git a/Runtime/PSXAudioSource.cs.meta b/Runtime/PSXAudioSource.cs.meta new file mode 100644 index 0000000..fda9cdb --- /dev/null +++ b/Runtime/PSXAudioSource.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3c4c3feb30e8c264baddc3a5e774473b \ No newline at end of file diff --git a/Runtime/PSXCollisionExporter.cs b/Runtime/PSXCollisionExporter.cs new file mode 100644 index 0000000..db9ef71 --- /dev/null +++ b/Runtime/PSXCollisionExporter.cs @@ -0,0 +1,357 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// Surface flags for collision triangles — must match C++ SurfaceFlag enum + /// + [Flags] + public enum PSXSurfaceFlag : byte + { + Solid = 0x01, + Slope = 0x02, + Stairs = 0x04, + Trigger = 0x08, + NoWalk = 0x10, + } + + /// + /// Exports scene collision geometry as a flat world-space triangle soup + /// with per-triangle surface flags and world-space AABBs. + /// + /// Binary layout (matches C++ structs): + /// CollisionDataHeader (20 bytes) + /// CollisionMeshHeader[meshCount] (32 bytes each) + /// CollisionTri[triangleCount] (52 bytes each) + /// CollisionChunk[chunkW*chunkH] (4 bytes each, exterior only) + /// + public class PSXCollisionExporter : IPSXBinaryWritable + { + // Configurable + public float WalkableSlopeAngle = 46.0f; // Degrees; steeper = wall + + // Build results + private List _meshes = new List(); + private List _allTriangles = new List(); + private CollisionChunkExport[,] _chunks; + private Vector3 _chunkOrigin; + private float _chunkSize; + private int _chunkGridW, _chunkGridH; + + public int MeshCount => _meshes.Count; + public int TriangleCount => _allTriangles.Count; + + // Internal types + private class CollisionMesh + { + public Bounds worldAABB; + public int firstTriangle; + public int triangleCount; + public byte roomIndex; + } + + private struct CollisionTriExport + { + public Vector3 v0, e1, e2, normal; + public byte flags; + public byte roomIndex; + } + + private struct CollisionChunkExport + { + public int firstMeshIndex; + public int meshCount; + } + + /// + /// Build collision data from scene exporters. + /// When autoIncludeSolid is true, objects with CollisionType=None are + /// automatically treated as Solid. This ensures all scene geometry + /// blocks the player without requiring manual flagging. + /// + public void Build(PSXObjectExporter[] exporters, float gteScaling, + bool autoIncludeSolid = true) + { + _meshes.Clear(); + _allTriangles.Clear(); + + float cosWalkable = Mathf.Cos(WalkableSlopeAngle * Mathf.Deg2Rad); + int autoIncluded = 0; + + foreach (var exporter in exporters) + { + PSXCollisionType effectiveType = exporter.CollisionType; + + if (effectiveType == PSXCollisionType.None) + { + if (autoIncludeSolid) + { + // Auto-include as Solid so all geometry blocks the player + effectiveType = PSXCollisionType.Solid; + autoIncluded++; + } + else + { + continue; + } + } + + // Get the collision mesh (custom or render mesh) + MeshFilter mf = exporter.GetComponent(); + Mesh collisionMesh = exporter.CustomCollisionMesh != null + ? exporter.CustomCollisionMesh + : mf?.sharedMesh; + + if (collisionMesh == null) + continue; + + Matrix4x4 worldMatrix = exporter.transform.localToWorldMatrix; + Vector3[] vertices = collisionMesh.vertices; + int[] indices = collisionMesh.triangles; + + int firstTri = _allTriangles.Count; + Bounds meshBoundsWorld = new Bounds(); + bool boundsInit = false; + + for (int i = 0; i < indices.Length; i += 3) + { + Vector3 v0 = worldMatrix.MultiplyPoint3x4(vertices[indices[i]]); + Vector3 v1 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 1]]); + Vector3 v2 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 2]]); + + Vector3 edge1 = v1 - v0; + Vector3 edge2 = v2 - v0; + Vector3 normal = Vector3.Cross(edge1, edge2).normalized; + + // Determine surface flags + byte flags = 0; + + if (effectiveType == PSXCollisionType.Trigger) + { + flags = (byte)PSXSurfaceFlag.Trigger; + } + else + { + // Floor-like: normal.y > cosWalkable + // Note: Unity Y is up; PS1 Y is down. We export in Unity space + // and convert to PS1 space during WriteToBinary. + float dotUp = normal.y; + + if (dotUp > cosWalkable) + { + flags = (byte)PSXSurfaceFlag.Solid; + + // Check if stairs (tagged on exporter or steep-ish) + if (exporter.ObjectFlags.HasFlag(PSXObjectFlags.Static) && + dotUp < 0.95f && dotUp > cosWalkable) + { + flags |= (byte)PSXSurfaceFlag.Stairs; + } + } + else if (dotUp > 0.0f) + { + // Slope too steep to walk on + flags = (byte)(PSXSurfaceFlag.Solid | PSXSurfaceFlag.Slope); + } + else + { + // Wall or ceiling + flags = (byte)PSXSurfaceFlag.Solid; + } + } + + _allTriangles.Add(new CollisionTriExport + { + v0 = v0, + e1 = edge1, + e2 = edge2, + normal = normal, + flags = flags, + roomIndex = 0xFF, + }); + + // Update world bounds + if (!boundsInit) + { + meshBoundsWorld = new Bounds(v0, Vector3.zero); + boundsInit = true; + } + meshBoundsWorld.Encapsulate(v0); + meshBoundsWorld.Encapsulate(v1); + meshBoundsWorld.Encapsulate(v2); + } + + int triCount = _allTriangles.Count - firstTri; + if (triCount > 0) + { + _meshes.Add(new CollisionMesh + { + worldAABB = meshBoundsWorld, + firstTriangle = firstTri, + triangleCount = triCount, + roomIndex = 0xFF, + }); + } + } + + // Build spatial grid + if (_meshes.Count > 0) + { + BuildSpatialGrid(gteScaling); + } + else + { + _chunkGridW = 0; + _chunkGridH = 0; + } + } + + private void BuildSpatialGrid(float gteScaling) + { + // Compute world bounds of all collision + Bounds allBounds = _meshes[0].worldAABB; + foreach (var mesh in _meshes) + allBounds.Encapsulate(mesh.worldAABB); + + // Grid cell size: ~4 GTE units in world space + _chunkSize = 4.0f * gteScaling; + _chunkOrigin = new Vector3(allBounds.min.x, 0, allBounds.min.z); + + _chunkGridW = Mathf.CeilToInt((allBounds.max.x - allBounds.min.x) / _chunkSize); + _chunkGridH = Mathf.CeilToInt((allBounds.max.z - allBounds.min.z) / _chunkSize); + + // Clamp to reasonable limits + _chunkGridW = Mathf.Clamp(_chunkGridW, 1, 64); + _chunkGridH = Mathf.Clamp(_chunkGridH, 1, 64); + + // For each chunk, find which meshes overlap it + // We store mesh indices sorted per chunk + var chunkMeshLists = new List[_chunkGridW, _chunkGridH]; + for (int z = 0; z < _chunkGridH; z++) + for (int x = 0; x < _chunkGridW; x++) + chunkMeshLists[x, z] = new List(); + + for (int mi = 0; mi < _meshes.Count; mi++) + { + var mesh = _meshes[mi]; + int minCX = Mathf.FloorToInt((mesh.worldAABB.min.x - _chunkOrigin.x) / _chunkSize); + int maxCX = Mathf.FloorToInt((mesh.worldAABB.max.x - _chunkOrigin.x) / _chunkSize); + int minCZ = Mathf.FloorToInt((mesh.worldAABB.min.z - _chunkOrigin.z) / _chunkSize); + int maxCZ = Mathf.FloorToInt((mesh.worldAABB.max.z - _chunkOrigin.z) / _chunkSize); + + minCX = Mathf.Clamp(minCX, 0, _chunkGridW - 1); + maxCX = Mathf.Clamp(maxCX, 0, _chunkGridW - 1); + minCZ = Mathf.Clamp(minCZ, 0, _chunkGridH - 1); + maxCZ = Mathf.Clamp(maxCZ, 0, _chunkGridH - 1); + + for (int cz = minCZ; cz <= maxCZ; cz++) + for (int cx = minCX; cx <= maxCX; cx++) + chunkMeshLists[cx, cz].Add(mi); + } + + // Flatten into contiguous array (mesh indices already in order) + // We'll write chunks as (firstMeshIndex, meshCount) referencing the mesh header array + _chunks = new CollisionChunkExport[_chunkGridW, _chunkGridH]; + for (int z = 0; z < _chunkGridH; z++) + { + for (int x = 0; x < _chunkGridW; x++) + { + var list = chunkMeshLists[x, z]; + _chunks[x, z] = new CollisionChunkExport + { + firstMeshIndex = list.Count > 0 ? list[0] : 0, + meshCount = list.Count, + }; + } + } + } + + /// + /// Write collision data to binary. + /// All coordinates converted to PS1 20.12 fixed-point with Y flip. + /// + public void WriteToBinary(BinaryWriter writer, float gteScaling) + { + // Header (20 bytes) + writer.Write((ushort)_meshes.Count); + writer.Write((ushort)_allTriangles.Count); + writer.Write((ushort)_chunkGridW); + writer.Write((ushort)_chunkGridH); + writer.Write(PSXTrig.ConvertWorldToFixed12(_chunkOrigin.x / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(_chunkOrigin.z / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(_chunkSize / gteScaling)); + + // Mesh headers (32 bytes each) + foreach (var mesh in _meshes) + { + writer.Write(PSXTrig.ConvertWorldToFixed12(mesh.worldAABB.min.x / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-mesh.worldAABB.max.y / gteScaling)); // Y flip + writer.Write(PSXTrig.ConvertWorldToFixed12(mesh.worldAABB.min.z / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(mesh.worldAABB.max.x / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-mesh.worldAABB.min.y / gteScaling)); // Y flip + writer.Write(PSXTrig.ConvertWorldToFixed12(mesh.worldAABB.max.z / gteScaling)); + writer.Write((ushort)mesh.firstTriangle); + writer.Write((ushort)mesh.triangleCount); + writer.Write(mesh.roomIndex); + writer.Write((byte)0); + writer.Write((byte)0); + writer.Write((byte)0); + } + + // Triangles (52 bytes each) + foreach (var tri in _allTriangles) + { + // v0 + writer.Write(PSXTrig.ConvertWorldToFixed12(tri.v0.x / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-tri.v0.y / gteScaling)); // Y flip + writer.Write(PSXTrig.ConvertWorldToFixed12(tri.v0.z / gteScaling)); + // edge1 + writer.Write(PSXTrig.ConvertWorldToFixed12(tri.e1.x / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-tri.e1.y / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(tri.e1.z / gteScaling)); + // edge2 + writer.Write(PSXTrig.ConvertWorldToFixed12(tri.e2.x / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-tri.e2.y / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(tri.e2.z / gteScaling)); + // normal (in PS1 space: Y negated) + writer.Write(PSXTrig.ConvertWorldToFixed12(tri.normal.x)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-tri.normal.y)); + writer.Write(PSXTrig.ConvertWorldToFixed12(tri.normal.z)); + // flags + writer.Write(tri.flags); + writer.Write(tri.roomIndex); + writer.Write((ushort)0); // pad + } + + // Spatial grid chunks (4 bytes each, exterior only) + if (_chunkGridW > 0 && _chunkGridH > 0) + { + for (int z = 0; z < _chunkGridH; z++) + { + for (int x = 0; x < _chunkGridW; x++) + { + writer.Write((ushort)_chunks[x, z].firstMeshIndex); + writer.Write((ushort)_chunks[x, z].meshCount); + } + } + } + } + + /// + /// Get total bytes that will be written. + /// + public int GetBinarySize() + { + int size = 20; // header + size += _meshes.Count * 32; + size += _allTriangles.Count * 52; + if (_chunkGridW > 0 && _chunkGridH > 0) + size += _chunkGridW * _chunkGridH * 4; + return size; + } + } +} diff --git a/Runtime/PSXCollisionExporter.cs.meta b/Runtime/PSXCollisionExporter.cs.meta new file mode 100644 index 0000000..de10ed0 --- /dev/null +++ b/Runtime/PSXCollisionExporter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 701b39be55b3bbb46b1c2a4ddaa34132 \ No newline at end of file diff --git a/Runtime/PSXData.cs b/Runtime/PSXData.cs index 6828f35..3faa457 100644 --- a/Runtime/PSXData.cs +++ b/Runtime/PSXData.cs @@ -4,13 +4,7 @@ using UnityEngine; namespace SplashEdit.RuntimeCode { - public enum PSXConnectionType - { - REAL_HARDWARE, // Unirom - EMULATOR // PCSX-Redux - } - - [CreateAssetMenu(fileName = "PSXData", menuName = "Scriptable Objects/PSXData")] + [CreateAssetMenu(fileName = "PSXData", menuName = "PSXSplash/PS1 Project Data")] public class PSXData : ScriptableObject { @@ -19,20 +13,5 @@ namespace SplashEdit.RuntimeCode public bool DualBuffering = true; public bool VerticalBuffering = true; public List ProhibitedAreas = new List(); - - - // Connection settings - public PSXConnectionType ConnectionType = PSXConnectionType.REAL_HARDWARE; - - // Real hardware settings - public string PortName = "COM3"; - public int BaudRate = 0; - - // Emulator settings - public string PCSXReduxPath = ""; - - - - } } \ No newline at end of file diff --git a/Runtime/PSXData.cs.meta b/Runtime/PSXData.cs.meta index a1b124a..7785717 100644 --- a/Runtime/PSXData.cs.meta +++ b/Runtime/PSXData.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: cbd8c66199e036896848ce1569567dd6 \ No newline at end of file +guid: b6e1524fb8b4b754e965d03e634658e6 \ No newline at end of file diff --git a/Runtime/PSXInteractable.cs b/Runtime/PSXInteractable.cs new file mode 100644 index 0000000..5095c03 --- /dev/null +++ b/Runtime/PSXInteractable.cs @@ -0,0 +1,61 @@ +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// Makes an object interactable by the player. + /// When the player is within range and presses the interact button, + /// the onInteract Lua event fires. + /// + [RequireComponent(typeof(PSXObjectExporter))] + public class PSXInteractable : MonoBehaviour + { + [Header("Interaction Settings")] + [Tooltip("Distance within which the player can interact with this object")] + [SerializeField] private float interactionRadius = 2.0f; + + [Tooltip("Button that triggers interaction (0-15, matches PS1 button mapping)")] + [SerializeField] private int interactButton = 5; // Default to Cross button + + [Tooltip("Can this object be interacted with multiple times?")] + [SerializeField] private bool isRepeatable = true; + + [Tooltip("Cooldown between interactions (in frames, 60 = 1 second at NTSC)")] + [SerializeField] private ushort cooldownFrames = 30; + + [Tooltip("Show interaction prompt when in range (requires UI system)")] + [SerializeField] private bool showPrompt = true; + + [Header("Advanced")] + [Tooltip("Require line-of-sight to player for interaction")] + [SerializeField] private bool requireLineOfSight = false; + + [Tooltip("Custom interaction point offset from object center")] + [SerializeField] private Vector3 interactionOffset = Vector3.zero; + + // Public accessors for export + public float InteractionRadius => interactionRadius; + public int InteractButton => interactButton; + public bool IsRepeatable => isRepeatable; + public ushort CooldownFrames => cooldownFrames; + public bool ShowPrompt => showPrompt; + public bool RequireLineOfSight => requireLineOfSight; + public Vector3 InteractionOffset => interactionOffset; + + private void OnDrawGizmosSelected() + { + // Draw interaction radius + Gizmos.color = new Color(1f, 1f, 0f, 0.3f); // Yellow, semi-transparent + Vector3 center = transform.position + interactionOffset; + Gizmos.DrawWireSphere(center, interactionRadius); + + // Draw filled sphere with lower alpha + Gizmos.color = new Color(1f, 1f, 0f, 0.1f); + Gizmos.DrawSphere(center, interactionRadius); + + // Draw interaction point + Gizmos.color = Color.yellow; + Gizmos.DrawSphere(center, 0.1f); + } + } +} diff --git a/Runtime/PSXInteractable.cs.meta b/Runtime/PSXInteractable.cs.meta new file mode 100644 index 0000000..9fa3a4b --- /dev/null +++ b/Runtime/PSXInteractable.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9b542f4ca31fa6548b8914e96dd0fae2 \ No newline at end of file diff --git a/Runtime/PSXLightingBaker.cs b/Runtime/PSXLightingBaker.cs index c9a8337..b67f17b 100644 --- a/Runtime/PSXLightingBaker.cs +++ b/Runtime/PSXLightingBaker.cs @@ -1,88 +1,87 @@ using UnityEngine; -public static class PSXLightingBaker +namespace SplashEdit.RuntimeCode { - /// - /// Computes the per-vertex lighting from all scene light sources. - /// Incorporates ambient, diffuse, and spotlight falloff. - /// - /// The world-space position of the vertex. - /// The normalized world-space normal of the vertex. - /// A Color representing the lit vertex. - public static Color ComputeLighting(Vector3 vertex, Vector3 normal) + public static class PSXLightingBaker { - Color finalColor = Color.black; - - Light[] lights = Object.FindObjectsByType(FindObjectsSortMode.None); - - foreach (Light light in lights) + /// + /// Computes the per-vertex lighting from all scene light sources. + /// Incorporates ambient, diffuse, and spotlight falloff. + /// + /// The world-space position of the vertex. + /// The normalized world-space normal of the vertex. + /// Pre-gathered array of enabled scene lights (pass to avoid per-vertex FindObjectsByType). + /// A Color representing the lit vertex. + public static Color ComputeLighting(Vector3 vertex, Vector3 normal, Light[] sceneLights) { - if (!light.enabled) - continue; + Color finalColor = Color.black; - Color lightContribution = Color.black; - - if (light.type == LightType.Directional) + foreach (Light light in sceneLights) { - Vector3 lightDir = -light.transform.forward; - float NdotL = Mathf.Max(0f, Vector3.Dot(normal, lightDir)); - lightContribution = light.color * light.intensity * NdotL; - } - else if (light.type == LightType.Point) - { - Vector3 lightDir = light.transform.position - vertex; - float distance = lightDir.magnitude; - lightDir.Normalize(); + Color lightContribution = Color.black; - float NdotL = Mathf.Max(0f, Vector3.Dot(normal, lightDir)); - float attenuation = 1.0f / Mathf.Max(distance * distance, 0.0001f); - lightContribution = light.color * light.intensity * NdotL * attenuation; - } - else if (light.type == LightType.Spot) - { - Vector3 L = light.transform.position - vertex; - float distance = L.magnitude; - L = L / distance; - - float NdotL = Mathf.Max(0f, Vector3.Dot(normal, L)); - float attenuation = 1.0f / Mathf.Max(distance * distance, 0.0001f); - - float outerAngleRad = (light.spotAngle * 0.5f) * Mathf.Deg2Rad; - float innerAngleRad = outerAngleRad * 0.8f; - - if (light is Light spotLight) + if (light.type == LightType.Directional) { - if (spotLight.innerSpotAngle > 0) + Vector3 lightDir = -light.transform.forward; + float NdotL = Mathf.Max(0f, Vector3.Dot(normal, lightDir)); + lightContribution = light.color * light.intensity * NdotL; + } + else if (light.type == LightType.Point) + { + Vector3 lightDir = light.transform.position - vertex; + float distance = lightDir.magnitude; + lightDir.Normalize(); + + float NdotL = Mathf.Max(0f, Vector3.Dot(normal, lightDir)); + float attenuation = 1.0f / Mathf.Max(distance * distance, 0.0001f); + lightContribution = light.color * light.intensity * NdotL * attenuation; + } + else if (light.type == LightType.Spot) + { + Vector3 L = light.transform.position - vertex; + float distance = L.magnitude; + L = L / distance; + + float NdotL = Mathf.Max(0f, Vector3.Dot(normal, L)); + float attenuation = 1.0f / Mathf.Max(distance * distance, 0.0001f); + + float outerAngleRad = (light.spotAngle * 0.5f) * Mathf.Deg2Rad; + float innerAngleRad = outerAngleRad * 0.8f; + + if (light is Light spotLight) { - innerAngleRad = (spotLight.innerSpotAngle * 0.5f) * Mathf.Deg2Rad; + if (spotLight.innerSpotAngle > 0) + { + innerAngleRad = (spotLight.innerSpotAngle * 0.5f) * Mathf.Deg2Rad; + } + } + + float cosOuter = Mathf.Cos(outerAngleRad); + float cosInner = Mathf.Cos(innerAngleRad); + float cosAngle = Vector3.Dot(L, -light.transform.forward); + + if (cosAngle >= cosOuter) + { + float spotFactor = Mathf.Clamp01((cosAngle - cosOuter) / (cosInner - cosOuter)); + spotFactor = Mathf.Pow(spotFactor, 4.0f); + + lightContribution = light.color * light.intensity * NdotL * attenuation * spotFactor; + } + else + { + lightContribution = Color.black; } } - float cosOuter = Mathf.Cos(outerAngleRad); - float cosInner = Mathf.Cos(innerAngleRad); - float cosAngle = Vector3.Dot(L, -light.transform.forward); - - if (cosAngle >= cosOuter) - { - float spotFactor = Mathf.Clamp01((cosAngle - cosOuter) / (cosInner - cosOuter)); - spotFactor = Mathf.Pow(spotFactor, 4.0f); - - lightContribution = light.color * light.intensity * NdotL * attenuation * spotFactor; - } - else - { - lightContribution = Color.black; - } + finalColor += lightContribution; } - finalColor += lightContribution; + finalColor.r = Mathf.Clamp(finalColor.r, 0.0f, 0.8f); + finalColor.g = Mathf.Clamp(finalColor.g, 0.0f, 0.8f); + finalColor.b = Mathf.Clamp(finalColor.b, 0.0f, 0.8f); + finalColor.a = 1f; + + return finalColor; } - - finalColor.r = Mathf.Clamp(finalColor.r, 0.0f, 0.8f); - finalColor.g = Mathf.Clamp(finalColor.g, 0.0f, 0.8f); - finalColor.b = Mathf.Clamp(finalColor.b, 0.0f, 0.8f); - finalColor.a = 1f; - - return finalColor; } } diff --git a/Runtime/PSXLightingBaker.cs.meta b/Runtime/PSXLightingBaker.cs.meta index 7493f55..d82a122 100644 --- a/Runtime/PSXLightingBaker.cs.meta +++ b/Runtime/PSXLightingBaker.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: b707b7d499862621fb6c82aba4caa183 \ No newline at end of file +guid: 15a0e6c8af6d78e46bb65ef21c3f75fb \ No newline at end of file diff --git a/Runtime/PSXMesh.cs b/Runtime/PSXMesh.cs index 0af0989..f473148 100644 --- a/Runtime/PSXMesh.cs +++ b/Runtime/PSXMesh.cs @@ -28,8 +28,17 @@ namespace SplashEdit.RuntimeCode public PSXVertex v1; public PSXVertex v2; + /// + /// Index into the texture list for this triangle's material. + /// -1 means untextured (vertex-color only, rendered as POLY_G3). + /// public int TextureIndex; - public readonly PSXVertex[] Vertexes => new PSXVertex[] { v0, v1, v2 }; + + /// + /// Whether this triangle is untextured (vertex-color only). + /// Untextured triangles are rendered as GouraudTriangle (POLY_G3) on PS1. + /// + public bool IsUntextured => TextureIndex == -1; } /// @@ -77,121 +86,56 @@ namespace SplashEdit.RuntimeCode /// /// Creates a PSXMesh from a Unity Mesh by converting its vertices, normals, UVs, and applying shading. /// - /// The Unity mesh to convert. - /// Width of the texture (default is 256). - /// Height of the texture (default is 256). - /// Optional transform to convert vertices to world space. - /// A new PSXMesh containing the converted triangles. + /// + /// Creates a PSXMesh from a Unity Renderer by extracting its mesh and materials. + /// public static PSXMesh CreateFromUnityRenderer(Renderer renderer, float GTEScaling, Transform transform, List textures) { - PSXMesh psxMesh = new PSXMesh { Triangles = new List() }; - Material[] materials = renderer.sharedMaterials; Mesh mesh = renderer.GetComponent().sharedMesh; - - for (int submeshIndex = 0; submeshIndex < materials.Length; submeshIndex++) - { - int[] submeshTriangles = mesh.GetTriangles(submeshIndex); - Material material = materials[submeshIndex]; - Texture2D texture = material.mainTexture as Texture2D; - - // Find texture index instead of the texture itself - int textureIndex = -1; - if (texture != null) - { - for (int i = 0; i < textures.Count; i++) - { - if (textures[i].OriginalTexture == texture) - { - textureIndex = i; - break; - } - } - } - - if (textureIndex == -1) - { - continue; - } - - // Get mesh data arrays - mesh.RecalculateNormals(); - Vector3[] vertices = mesh.vertices; - Vector3[] normals = mesh.normals; - Vector3[] smoothNormals = RecalculateSmoothNormals(mesh); - Vector2[] uv = mesh.uv; - - PSXVertex convertData(int index) - { - Vector3 v = Vector3.Scale(vertices[index], transform.lossyScale); - Vector3 wv = transform.TransformPoint(vertices[index]); - Vector3 wn = transform.TransformDirection(smoothNormals[index]).normalized; - Color c = PSXLightingBaker.ComputeLighting(wv, wn); - return ConvertToPSXVertex(v, GTEScaling, normals[index], uv[index], textures[textureIndex]?.Width, textures[textureIndex]?.Height, c); - } - - for (int i = 0; i < submeshTriangles.Length; i += 3) - { - int vid0 = submeshTriangles[i]; - int vid1 = submeshTriangles[i + 1]; - int vid2 = submeshTriangles[i + 2]; - - Vector3 faceNormal = Vector3.Cross(vertices[vid1] - vertices[vid0], vertices[vid2] - vertices[vid0]).normalized; - - if (Vector3.Dot(faceNormal, normals[vid0]) < 0) - { - (vid1, vid2) = (vid2, vid1); - } - - psxMesh.Triangles.Add(new Tri - { - v0 = convertData(vid0), - v1 = convertData(vid1), - v2 = convertData(vid2), - TextureIndex = textureIndex - }); - } - } - - return psxMesh; + return BuildFromMesh(mesh, renderer, GTEScaling, transform, textures); } + /// + /// Creates a PSXMesh from a supplied Unity Mesh with the renderer's materials. + /// public static PSXMesh CreateFromUnityMesh(Mesh mesh, Renderer renderer, float GTEScaling, Transform transform, List textures) + { + return BuildFromMesh(mesh, renderer, GTEScaling, transform, textures); + } + + private static PSXMesh BuildFromMesh(Mesh mesh, Renderer renderer, float GTEScaling, Transform transform, List textures) { PSXMesh psxMesh = new PSXMesh { Triangles = new List() }; Material[] materials = renderer.sharedMaterials; - // Ensure mesh has required data + // Guard: only recalculate normals if missing if (mesh.normals == null || mesh.normals.Length == 0) - { mesh.RecalculateNormals(); - } if (mesh.uv == null || mesh.uv.Length == 0) - { - Vector2[] uvs = new Vector2[mesh.vertices.Length]; - mesh.uv = uvs; - } + mesh.uv = new Vector2[mesh.vertices.Length]; - // Precompute smooth normals for the entire mesh Vector3[] smoothNormals = RecalculateSmoothNormals(mesh); + // Cache lights once for the entire mesh + Light[] sceneLights = Object.FindObjectsByType(FindObjectsSortMode.None) + .Where(l => l.enabled).ToArray(); + // Precompute world positions and normals for all vertices Vector3[] worldVertices = new Vector3[mesh.vertices.Length]; Vector3[] worldNormals = new Vector3[mesh.normals.Length]; - for (int i = 0; i < mesh.vertices.Length; i++) { worldVertices[i] = transform.TransformPoint(mesh.vertices[i]); - worldNormals[i] = transform.TransformDirection(mesh.normals[i]).normalized; + worldNormals[i] = transform.TransformDirection(smoothNormals[i]).normalized; } for (int submeshIndex = 0; submeshIndex < mesh.subMeshCount; submeshIndex++) { int materialIndex = Mathf.Min(submeshIndex, materials.Length - 1); Material material = materials[materialIndex]; - Texture2D texture = material.mainTexture as Texture2D; + Texture2D texture = material != null ? material.mainTexture as Texture2D : null; - // Find texture index int textureIndex = -1; if (texture != null) { @@ -206,8 +150,6 @@ namespace SplashEdit.RuntimeCode } int[] submeshTriangles = mesh.GetTriangles(submeshIndex); - - // Get mesh data arrays Vector3[] vertices = mesh.vertices; Vector3[] normals = mesh.normals; Vector2[] uv = mesh.uv; @@ -215,16 +157,20 @@ namespace SplashEdit.RuntimeCode PSXVertex convertData(int index) { Vector3 v = Vector3.Scale(vertices[index], transform.lossyScale); - - // Use precomputed world position and normal for consistent lighting Vector3 wv = worldVertices[index]; Vector3 wn = worldNormals[index]; + Color c = PSXLightingBaker.ComputeLighting(wv, wn, sceneLights); - // For split triangles, use the original vertex's lighting if possible - Color c = PSXLightingBaker.ComputeLighting(wv, wn); + if (textureIndex == -1) + { + Color matColor = material != null && material.HasProperty("_Color") + ? material.color : Color.white; + c = new Color(c.r * matColor.r, c.g * matColor.g, c.b * matColor.b); + return ConvertToPSXVertex(v, GTEScaling, normals[index], Vector2.zero, null, null, c); + } return ConvertToPSXVertex(v, GTEScaling, normals[index], uv[index], - textures[textureIndex]?.Width, textures[textureIndex]?.Height, c); + textures[textureIndex]?.Width, textures[textureIndex]?.Height, c); } for (int i = 0; i < submeshTriangles.Length; i += 3) diff --git a/Runtime/PSXMesh.cs.meta b/Runtime/PSXMesh.cs.meta index b174097..947421d 100644 --- a/Runtime/PSXMesh.cs.meta +++ b/Runtime/PSXMesh.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: 9025daa0c62549ee29d968f86c69eec9 \ No newline at end of file +guid: 0bde77749a0264146a4ead39946dce2f \ No newline at end of file diff --git a/Runtime/PSXNavMesh.cs b/Runtime/PSXNavMesh.cs deleted file mode 100644 index 332263a..0000000 --- a/Runtime/PSXNavMesh.cs +++ /dev/null @@ -1,95 +0,0 @@ -using UnityEngine; -using Unity.AI.Navigation; -using UnityEngine.AI; -using System.Collections.Generic; - -namespace SplashEdit.RuntimeCode -{ - public struct PSXNavMeshTri - { - public PSXNavmeshVertex v0, v1, v2; - } - - public struct PSXNavmeshVertex - { - public short vx, vy, vz; - } - - [RequireComponent(typeof(NavMeshSurface))] - public class PSXNavMesh : MonoBehaviour - { - - Mesh mesh; - - [HideInInspector] - public List Navmesh { get; set; } - - public void CreateNavmesh(float GTEScaling) - { - mesh = new Mesh(); - Navmesh = new List(); - NavMeshSurface navMeshSurface = GetComponent(); - navMeshSurface.overrideTileSize = true; - navMeshSurface.tileSize = 16; - navMeshSurface.overrideVoxelSize = true; - navMeshSurface.voxelSize = 0.1f; - navMeshSurface.BuildNavMesh(); - NavMeshTriangulation triangulation = NavMesh.CalculateTriangulation(); - navMeshSurface.overrideTileSize = false; - navMeshSurface.overrideVoxelSize = false; - - int[] triangles = triangulation.indices; - Vector3[] vertices = triangulation.vertices; - - mesh.vertices = vertices; - mesh.triangles = triangles; - - mesh.RecalculateNormals(); - - for (int i = 0; i < triangles.Length; i += 3) - { - int vid0 = triangles[i]; - int vid1 = triangles[i + 1]; - int vid2 = triangles[i + 2]; - - PSXNavMeshTri tri = new PSXNavMeshTri(); - - tri.v0.vx = PSXTrig.ConvertCoordinateToPSX(vertices[vid0].x, GTEScaling); - tri.v0.vy = PSXTrig.ConvertCoordinateToPSX(-vertices[vid0].y, GTEScaling); - tri.v0.vz = PSXTrig.ConvertCoordinateToPSX(vertices[vid0].z, GTEScaling); - - tri.v1.vx = PSXTrig.ConvertCoordinateToPSX(vertices[vid1].x, GTEScaling); - tri.v1.vy = PSXTrig.ConvertCoordinateToPSX(-vertices[vid1].y, GTEScaling); - tri.v1.vz = PSXTrig.ConvertCoordinateToPSX(vertices[vid1].z, GTEScaling); - - tri.v2.vx = PSXTrig.ConvertCoordinateToPSX(vertices[vid2].x, GTEScaling); - tri.v2.vy = PSXTrig.ConvertCoordinateToPSX(-vertices[vid2].y, GTEScaling); - tri.v2.vz = PSXTrig.ConvertCoordinateToPSX(vertices[vid2].z, GTEScaling); - - Navmesh.Add(tri); - } - } - - - public void OnDrawGizmos() - { - if (mesh == null) return; - Gizmos.DrawMesh(mesh); - Gizmos.color = Color.green; - - var vertices = mesh.vertices; - var triangles = mesh.triangles; - - for (int i = 0; i < triangles.Length; i += 3) - { - Vector3 v0 = vertices[triangles[i]]; - Vector3 v1 = vertices[triangles[i + 1]]; - Vector3 v2 = vertices[triangles[i + 2]]; - - Gizmos.DrawLine(v0, v1); - Gizmos.DrawLine(v1, v2); - Gizmos.DrawLine(v2, v0); - } - } - } -} \ No newline at end of file diff --git a/Runtime/PSXNavMesh.cs.meta b/Runtime/PSXNavMesh.cs.meta deleted file mode 100644 index 00c207d..0000000 --- a/Runtime/PSXNavMesh.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 6a2f8d45e1591de1e945b3b7bdfb123b -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {fileID: 2800000, guid: d695ef52da250cdcea6c30ab1122c56e, type: 3} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Runtime/PSXNavRegionBuilder.cs b/Runtime/PSXNavRegionBuilder.cs new file mode 100644 index 0000000..084f596 --- /dev/null +++ b/Runtime/PSXNavRegionBuilder.cs @@ -0,0 +1,522 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEngine; +using DotRecast.Core; +using DotRecast.Core.Numerics; +using DotRecast.Recast; + +namespace SplashEdit.RuntimeCode +{ + public enum NavSurfaceType : byte { Flat = 0, Ramp = 1, Stairs = 2 } + + /// + /// PS1 nav mesh builder using DotRecast (C# port of Recast). + /// Runs the full Recast voxelization pipeline on scene geometry, + /// then extracts convex polygons with accurate heights from the detail mesh. + /// + public class PSXNavRegionBuilder + { + public float AgentHeight = 1.8f; + public float AgentRadius = 0.3f; + public float MaxStepHeight = 0.35f; + public float WalkableSlopeAngle = 46.0f; + public float CellSize = 0.05f; + public float CellHeight = 0.025f; + public float MergePlaneError = 0.1f; + public const int MaxVertsPerRegion = 8; + + private List _regions = new(); + private List _portals = new(); + private int _startRegion; + + public int RegionCount => _regions.Count; + public int PortalCount => _portals.Count; + public IReadOnlyList Regions => _regions; + public IReadOnlyList Portals => _portals; + public VoxelCell[] DebugCells => null; + public int DebugGridW => 0; + public int DebugGridH => 0; + public float DebugOriginX => 0; + public float DebugOriginZ => 0; + public float DebugVoxelSize => 0; + + public class NavRegionExport + { + public List vertsXZ = new(); + public float planeA, planeB, planeD; + public int portalStart, portalCount; + public NavSurfaceType surfaceType; + public byte roomIndex; + public Plane floorPlane; + public List worldTris = new(); + public List sourceTriIndices = new(); + } + + public struct NavPortalExport + { + public Vector2 a, b; + public int neighborRegion; + public float heightDelta; + } + + public struct VoxelCell + { + public float worldY, slopeAngle; + public bool occupied, blocked; + public int regionId; + } + + /// PSXRoom volumes for spatial room assignment. Set before Build(). + public PSXRoom[] PSXRooms { get; set; } + + public void Build(PSXObjectExporter[] exporters, Vector3 spawn) + { + _regions.Clear(); _portals.Clear(); _startRegion = 0; + + // 1. Collect world-space geometry from all exporters + var allVerts = new List(); + var allTris = new List(); + CollectGeometry(exporters, allVerts, allTris); + + if (allVerts.Count < 9 || allTris.Count < 3) + { + Debug.LogWarning("[Nav] No geometry to build navmesh from."); + return; + } + + float[] verts = allVerts.ToArray(); + int[] tris = allTris.ToArray(); + int nverts = allVerts.Count / 3; + int ntris = allTris.Count / 3; + + // 2. Recast parameters (convert to voxel units) + float cs = CellSize; + float ch = CellHeight; + int walkableHeight = (int)Math.Ceiling(AgentHeight / ch); + int walkableClimb = (int)Math.Floor(MaxStepHeight / ch); + int walkableRadius = (int)Math.Ceiling(AgentRadius / cs); + int maxEdgeLen = (int)(12.0f / cs); + float maxSimplificationError = 1.3f; + int minRegionArea = 8; + int mergeRegionArea = 20; + int maxVertsPerPoly = 6; + float detailSampleDist = cs * 6; + float detailSampleMaxError = ch * 1; + + // 3. Compute bounds with border padding + float bminX = float.MaxValue, bminY = float.MaxValue, bminZ = float.MaxValue; + float bmaxX = float.MinValue, bmaxY = float.MinValue, bmaxZ = float.MinValue; + for (int i = 0; i < verts.Length; i += 3) + { + bminX = Math.Min(bminX, verts[i]); bmaxX = Math.Max(bmaxX, verts[i]); + bminY = Math.Min(bminY, verts[i+1]); bmaxY = Math.Max(bmaxY, verts[i+1]); + bminZ = Math.Min(bminZ, verts[i+2]); bmaxZ = Math.Max(bmaxZ, verts[i+2]); + } + + float borderPad = walkableRadius * cs; + bminX -= borderPad; bminZ -= borderPad; + bmaxX += borderPad; bmaxZ += borderPad; + + var bmin = new RcVec3f(bminX, bminY, bminZ); + var bmax = new RcVec3f(bmaxX, bmaxY, bmaxZ); + + int gw = (int)((bmaxX - bminX) / cs + 0.5f); + int gh = (int)((bmaxZ - bminZ) / cs + 0.5f); + + // 4. Run Recast pipeline + var ctx = new RcContext(); + + // Create heightfield + var solid = new RcHeightfield(gw, gh, bmin, bmax, cs, ch, 0); + + // Mark walkable triangles + int[] areas = RcRecast.MarkWalkableTriangles(ctx, WalkableSlopeAngle, verts, tris, ntris, + new RcAreaModification(RcRecast.RC_WALKABLE_AREA)); + + // Rasterize + RcRasterizations.RasterizeTriangles(ctx, verts, tris, areas, ntris, solid, walkableClimb); + + // Filter + RcFilters.FilterLowHangingWalkableObstacles(ctx, walkableClimb, solid); + RcFilters.FilterLedgeSpans(ctx, walkableHeight, walkableClimb, solid); + RcFilters.FilterWalkableLowHeightSpans(ctx, walkableHeight, solid); + + // Build compact heightfield + var chf = RcCompacts.BuildCompactHeightfield(ctx, walkableHeight, walkableClimb, solid); + + // Erode walkable area + RcAreas.ErodeWalkableArea(ctx, walkableRadius, chf); + + // Build distance field and regions + RcRegions.BuildDistanceField(ctx, chf); + RcRegions.BuildRegions(ctx, chf, minRegionArea, mergeRegionArea); + + // Build contours + var cset = RcContours.BuildContours(ctx, chf, maxSimplificationError, maxEdgeLen, + (int)RcBuildContoursFlags.RC_CONTOUR_TESS_WALL_EDGES); + + // Build polygon mesh + var pmesh = RcMeshs.BuildPolyMesh(ctx, cset, maxVertsPerPoly); + + // Build detail mesh for accurate heights + var dmesh = RcMeshDetails.BuildPolyMeshDetail(ctx, pmesh, chf, detailSampleDist, detailSampleMaxError); + + // 5. Extract polygons as NavRegions + int nvp = pmesh.nvp; + int RC_MESH_NULL_IDX = 0xffff; + + for (int i = 0; i < pmesh.npolys; i++) + { + // Count valid vertices in this polygon + int nv = 0; + for (int j = 0; j < nvp; j++) + { + if (pmesh.polys[i * 2 * nvp + j] == RC_MESH_NULL_IDX) break; + nv++; + } + if (nv < 3) continue; + + var region = new NavRegionExport(); + var pts3d = new List(); + + for (int j = 0; j < nv; j++) + { + int vi = pmesh.polys[i * 2 * nvp + j]; + + // Get XZ from poly mesh (cell coords -> world) + float wx = pmesh.bmin.X + pmesh.verts[vi * 3 + 0] * pmesh.cs; + float wz = pmesh.bmin.Z + pmesh.verts[vi * 3 + 2] * pmesh.cs; + + // Get accurate Y from detail mesh + float wy; + if (dmesh != null && i < dmesh.nmeshes) + { + int vbase = dmesh.meshes[i * 4 + 0]; + // Detail mesh stores polygon verts first, in order + wy = dmesh.verts[(vbase + j) * 3 + 1]; + } + else + { + // Fallback: coarse Y from poly mesh + wy = pmesh.bmin.Y + pmesh.verts[vi * 3 + 1] * pmesh.ch; + } + + region.vertsXZ.Add(new Vector2(wx, wz)); + pts3d.Add(new Vector3(wx, wy, wz)); + } + + // Ensure CCW winding + float signedArea = 0; + for (int j = 0; j < region.vertsXZ.Count; j++) + { + var a = region.vertsXZ[j]; + var b = region.vertsXZ[(j + 1) % region.vertsXZ.Count]; + signedArea += a.x * b.y - b.x * a.y; + } + if (signedArea < 0) + { + region.vertsXZ.Reverse(); + pts3d.Reverse(); + } + + FitPlane(region, pts3d); + _regions.Add(region); + } + + // 6. Build portals from Recast neighbor connectivity + var perRegion = new Dictionary>(); + for (int i = 0; i < _regions.Count; i++) + perRegion[i] = new List(); + + // Build mapping: pmesh poly index -> region index + // (some polys may be skipped if nv < 3, so we need this mapping) + var polyToRegion = new Dictionary(); + int regionIdx = 0; + for (int i = 0; i < pmesh.npolys; i++) + { + int nv = 0; + for (int j = 0; j < nvp; j++) + { + if (pmesh.polys[i * 2 * nvp + j] == RC_MESH_NULL_IDX) break; + nv++; + } + if (nv < 3) continue; + polyToRegion[i] = regionIdx++; + } + + for (int i = 0; i < pmesh.npolys; i++) + { + if (!polyToRegion.TryGetValue(i, out int srcRegion)) continue; + + int nv = 0; + for (int j = 0; j < nvp; j++) + { + if (pmesh.polys[i * 2 * nvp + j] == RC_MESH_NULL_IDX) break; + nv++; + } + + for (int j = 0; j < nv; j++) + { + int neighbor = pmesh.polys[i * 2 * nvp + nvp + j]; + if (neighbor == RC_MESH_NULL_IDX || (neighbor & 0x8000) != 0) continue; + if (!polyToRegion.TryGetValue(neighbor, out int dstRegion)) continue; + + // Portal edge vertices from pmesh directly (NOT from region, + // which may have been reversed for CCW winding) + int vi0 = pmesh.polys[i * 2 * nvp + j]; + int vi1 = pmesh.polys[i * 2 * nvp + (j + 1) % nv]; + + float ax = pmesh.bmin.X + pmesh.verts[vi0 * 3 + 0] * pmesh.cs; + float az = pmesh.bmin.Z + pmesh.verts[vi0 * 3 + 2] * pmesh.cs; + float bx = pmesh.bmin.X + pmesh.verts[vi1 * 3 + 0] * pmesh.cs; + float bz = pmesh.bmin.Z + pmesh.verts[vi1 * 3 + 2] * pmesh.cs; + + var a2 = new Vector2(ax, az); + var b2 = new Vector2(bx, bz); + + // Height delta at midpoint of portal edge + var mid = new Vector2((ax + bx) / 2, (az + bz) / 2); + float yHere = EvalY(_regions[srcRegion], mid); + float yThere = EvalY(_regions[dstRegion], mid); + + perRegion[srcRegion].Add(new NavPortalExport + { + a = a2, + b = b2, + neighborRegion = dstRegion, + heightDelta = yThere - yHere + }); + } + } + + // Assign portals + foreach (var kvp in perRegion) + { + _regions[kvp.Key].portalStart = _portals.Count; + _regions[kvp.Key].portalCount = kvp.Value.Count; + _portals.AddRange(kvp.Value); + } + + // 7. Assign rooms: spatial containment if PSXRooms provided, BFS fallback + if (PSXRooms != null && PSXRooms.Length > 0) + AssignRoomsFromPSXRooms(PSXRooms); + else + AssignRoomsByBFS(); + + // 8. Find start region closest to spawn + _startRegion = FindClosestRegion(spawn); + } + + void CollectGeometry(PSXObjectExporter[] exporters, List outVerts, List outTris) + { + foreach (var exporter in exporters) + { + + MeshFilter mf = exporter.GetComponent(); + Mesh mesh = mf?.sharedMesh; + if (mesh == null) continue; + + Matrix4x4 worldMatrix = exporter.transform.localToWorldMatrix; + Vector3[] vertices = mesh.vertices; + int[] indices = mesh.triangles; + + int baseVert = outVerts.Count / 3; + foreach (var v in vertices) + { + Vector3 w = worldMatrix.MultiplyPoint3x4(v); + outVerts.Add(w.x); + outVerts.Add(w.y); + outVerts.Add(w.z); + } + + // Filter triangles: reject downward-facing normals + // (ceilings, roofs, undersides) which should never be walkable. + for (int i = 0; i < indices.Length; i += 3) + { + Vector3 v0 = worldMatrix.MultiplyPoint3x4(vertices[indices[i]]); + Vector3 v1 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 1]]); + Vector3 v2 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 2]]); + + Vector3 normal = Vector3.Cross(v1 - v0, v2 - v0); + // Skip triangles whose world-space normal points downward (y < 0) + // This eliminates ceilings/roofs that Recast might incorrectly voxelize + if (normal.y < 0f) continue; + + outTris.Add(indices[i] + baseVert); + outTris.Add(indices[i + 1] + baseVert); + outTris.Add(indices[i + 2] + baseVert); + } + } + } + + int FindClosestRegion(Vector3 spawn) + { + int best = 0; + float bestDist = float.MaxValue; + for (int i = 0; i < _regions.Count; i++) + { + var r = _regions[i]; + // Compute centroid + float cx = 0, cz = 0; + foreach (var v in r.vertsXZ) { cx += v.x; cz += v.y; } + cx /= r.vertsXZ.Count; cz /= r.vertsXZ.Count; + float cy = EvalY(r, new Vector2(cx, cz)); + + float dx = spawn.x - cx, dy = spawn.y - cy, dz = spawn.z - cz; + float dist = dx * dx + dy * dy + dz * dz; + if (dist < bestDist) { bestDist = dist; best = i; } + } + return best; + } + + float EvalY(NavRegionExport r, Vector2 xz) => r.planeA * xz.x + r.planeB * xz.y + r.planeD; + + void FitPlane(NavRegionExport r, List pts) + { + int n = pts.Count; + if (n < 3) { r.planeA = 0; r.planeB = 0; r.planeD = n > 0 ? pts[0].y : 0; r.surfaceType = NavSurfaceType.Flat; return; } + + if (n == 3) + { + // Exact 3-point solve: Y = A*X + B*Z + D + double x0 = pts[0].x, z0 = pts[0].z, y0 = pts[0].y; + double x1 = pts[1].x, z1 = pts[1].z, y1 = pts[1].y; + double x2 = pts[2].x, z2 = pts[2].z, y2 = pts[2].y; + double det = (x0 - x2) * (z1 - z2) - (x1 - x2) * (z0 - z2); + if (Math.Abs(det) < 1e-12) { r.planeA = 0; r.planeB = 0; r.planeD = (float)((y0 + y1 + y2) / 3); } + else + { + double inv = 1.0 / det; + r.planeA = (float)(((y0 - y2) * (z1 - z2) - (y1 - y2) * (z0 - z2)) * inv); + r.planeB = (float)(((x0 - x2) * (y1 - y2) - (x1 - x2) * (y0 - y2)) * inv); + r.planeD = (float)(y0 - r.planeA * x0 - r.planeB * z0); + } + } + else + { + // Least-squares: Y = A*X + B*Z + D + double sX = 0, sZ = 0, sY = 0, sXX = 0, sXZ = 0, sZZ = 0, sXY = 0, sZY = 0; + foreach (var p in pts) { sX += p.x; sZ += p.z; sY += p.y; sXX += p.x * p.x; sXZ += p.x * p.z; sZZ += p.z * p.z; sXY += p.x * p.y; sZY += p.z * p.y; } + double det = sXX * (sZZ * n - sZ * sZ) - sXZ * (sXZ * n - sZ * sX) + sX * (sXZ * sZ - sZZ * sX); + if (Math.Abs(det) < 1e-12) { r.planeA = 0; r.planeB = 0; r.planeD = (float)(sY / n); } + else + { + double inv = 1.0 / det; + r.planeA = (float)((sXY * (sZZ * n - sZ * sZ) - sXZ * (sZY * n - sZ * sY) + sX * (sZY * sZ - sZZ * sY)) * inv); + r.planeB = (float)((sXX * (sZY * n - sZ * sY) - sXY * (sXZ * n - sZ * sX) + sX * (sXZ * sY - sZY * sX)) * inv); + r.planeD = (float)((sXX * (sZZ * sY - sZ * sZY) - sXZ * (sXZ * sY - sZY * sX) + sXY * (sXZ * sZ - sZZ * sX)) * inv); + } + } + + float slope = Mathf.Atan(Mathf.Sqrt(r.planeA * r.planeA + r.planeB * r.planeB)) * Mathf.Rad2Deg; + r.surfaceType = slope < 3f ? NavSurfaceType.Flat : slope < 25f ? NavSurfaceType.Ramp : NavSurfaceType.Stairs; + } + + /// + /// Assign room indices to nav regions using PSXRoom spatial containment. + /// Each region's centroid is tested against all PSXRoom volumes. The smallest + /// containing room wins (most specific). Regions outside all rooms get 0xFF. + /// This ensures nav region room indices match the PSXRoomBuilder room indices + /// used by the rendering portal system. + /// + void AssignRoomsFromPSXRooms(PSXRoom[] psxRooms) + { + const float MARGIN = 0.5f; + Bounds[] roomBounds = new Bounds[psxRooms.Length]; + for (int r = 0; r < psxRooms.Length; r++) + { + roomBounds[r] = psxRooms[r].GetWorldBounds(); + roomBounds[r].Expand(MARGIN * 2f); + } + + for (int i = 0; i < _regions.Count; i++) + { + var reg = _regions[i]; + // Compute region centroid from polygon vertices + float cx = 0, cz = 0; + foreach (var v in reg.vertsXZ) { cx += v.x; cz += v.y; } + cx /= reg.vertsXZ.Count; cz /= reg.vertsXZ.Count; + float cy = EvalY(reg, new Vector2(cx, cz)); + Vector3 centroid = new Vector3(cx, cy, cz); + + byte bestRoom = 0xFF; + float bestVolume = float.MaxValue; + for (int r = 0; r < psxRooms.Length; r++) + { + if (roomBounds[r].Contains(centroid)) + { + float vol = roomBounds[r].size.x * roomBounds[r].size.y * roomBounds[r].size.z; + if (vol < bestVolume) + { + bestVolume = vol; + bestRoom = (byte)r; + } + } + } + reg.roomIndex = bestRoom; + } + } + + /// + /// Fallback room assignment via BFS over nav portal connectivity. + /// Used when no PSXRoom volumes exist (exterior scenes). + /// + void AssignRoomsByBFS() + { + byte room = 0; + var vis = new bool[_regions.Count]; + for (int i = 0; i < _regions.Count; i++) + { + if (vis[i]) continue; + byte rm = room++; + var q = new Queue(); q.Enqueue(i); vis[i] = true; + while (q.Count > 0) + { + int ri = q.Dequeue(); _regions[ri].roomIndex = rm; + for (int p = _regions[ri].portalStart; p < _regions[ri].portalStart + _regions[ri].portalCount; p++) + { + int nb = _portals[p].neighborRegion; + if (nb >= 0 && nb < _regions.Count && !vis[nb]) { vis[nb] = true; q.Enqueue(nb); } + } + } + } + } + + public void WriteToBinary(BinaryWriter writer, float gteScaling) + { + writer.Write((ushort)_regions.Count); + writer.Write((ushort)_portals.Count); + writer.Write((ushort)_startRegion); + writer.Write((ushort)0); + foreach (var r in _regions) + { + for (int v = 0; v < MaxVertsPerRegion; v++) + writer.Write(v < r.vertsXZ.Count ? PSXTrig.ConvertWorldToFixed12(r.vertsXZ[v].x / gteScaling) : 0); + for (int v = 0; v < MaxVertsPerRegion; v++) + writer.Write(v < r.vertsXZ.Count ? PSXTrig.ConvertWorldToFixed12(r.vertsXZ[v].y / gteScaling) : 0); + writer.Write(PSXTrig.ConvertWorldToFixed12(-r.planeA)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-r.planeB)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-r.planeD / gteScaling)); + writer.Write((ushort)r.portalStart); + writer.Write((byte)r.portalCount); + writer.Write((byte)r.vertsXZ.Count); + writer.Write((byte)r.surfaceType); + writer.Write(r.roomIndex); + writer.Write((byte)0); + writer.Write((byte)0); + } + foreach (var p in _portals) + { + writer.Write(PSXTrig.ConvertWorldToFixed12(p.a.x / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(p.a.y / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(p.b.x / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(p.b.y / gteScaling)); + writer.Write((ushort)p.neighborRegion); + writer.Write((short)PSXTrig.ConvertToFixed12(p.heightDelta / gteScaling)); + } + } + + public int GetBinarySize() => 8 + _regions.Count * 84 + _portals.Count * 20; + } +} diff --git a/Runtime/PSXNavRegionBuilder.cs.meta b/Runtime/PSXNavRegionBuilder.cs.meta new file mode 100644 index 0000000..4545123 --- /dev/null +++ b/Runtime/PSXNavRegionBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7446b9ee150d0994fb534c61cd894d6c \ No newline at end of file diff --git a/Runtime/PSXObjectExporter.cs b/Runtime/PSXObjectExporter.cs index ac291fa..47f1f96 100644 --- a/Runtime/PSXObjectExporter.cs +++ b/Runtime/PSXObjectExporter.cs @@ -1,16 +1,45 @@ using System.Collections.Generic; -using Splashedit.RuntimeCode; +using SplashEdit.RuntimeCode; using UnityEngine; 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 + } + [RequireComponent(typeof(Renderer))] - public class PSXObjectExporter : MonoBehaviour + public class PSXObjectExporter : MonoBehaviour, IPSXExportable { public LuaFile LuaFile => luaFile; - public bool IsActive = true; + [FormerlySerializedAs("IsActive")] + [SerializeField] private bool isActive = true; + public bool IsActive => isActive; public List Textures { get; set; } = new List(); public PSXMesh Mesh { get; protected set; } @@ -20,22 +49,41 @@ namespace SplashEdit.RuntimeCode [SerializeField] private PSXBPP bitDepth = PSXBPP.TEX_8BIT; [SerializeField] private LuaFile luaFile; - [Header("BSP Settings")] - [SerializeField] private Mesh _modifiedMesh; // Mesh after BSP processing + [Header("Object Flags")] + [SerializeField] private PSXObjectFlags objectFlags = PSXObjectFlags.Static | PSXObjectFlags.Visible; + + [Header("Collision Settings")] + [SerializeField] private PSXCollisionType collisionType = PSXCollisionType.None; + [SerializeField] private bool exportCollisionMesh = false; + [SerializeField] private Mesh customCollisionMesh; // Optional simplified collision mesh + [Tooltip("Layer mask for collision detection (1-8)")] + [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 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(); - public Mesh ModifiedMesh - { - get => _modifiedMesh; - set => _modifiedMesh = value; - } - private void OnDrawGizmos() { if (previewNormals) @@ -60,6 +108,48 @@ namespace SplashEdit.RuntimeCode } } } + + 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() { @@ -67,6 +157,24 @@ namespace SplashEdit.RuntimeCode Textures.Clear(); if (renderer != null) { + // If an override texture is set, use it for all submeshes + if (texture != null) + { + 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; + } + Material[] materials = renderer.sharedMaterials; foreach (Material mat in materials) @@ -129,34 +237,12 @@ namespace SplashEdit.RuntimeCode return null; } - public void CreatePSXMesh(float GTEScaling, bool useBSP = false) + public void CreatePSXMesh(float GTEScaling) { Renderer renderer = GetComponent(); if (renderer != null) { - if (useBSP && _modifiedMesh != null) - { - // Create a temporary GameObject with the modified mesh but same materials - GameObject tempGO = new GameObject("TempBSPMesh"); - tempGO.transform.position = transform.position; - tempGO.transform.rotation = transform.rotation; - tempGO.transform.localScale = transform.localScale; - - MeshFilter tempMF = tempGO.AddComponent(); - tempMF.sharedMesh = _modifiedMesh; - - MeshRenderer tempMR = tempGO.AddComponent(); - tempMR.sharedMaterials = renderer.sharedMaterials; - - Mesh = PSXMesh.CreateFromUnityRenderer(tempMR, GTEScaling, transform, Textures); - - // Clean up - GameObject.DestroyImmediate(tempGO); - } - else - { - Mesh = PSXMesh.CreateFromUnityRenderer(renderer, GTEScaling, transform, Textures); - } + Mesh = PSXMesh.CreateFromUnityRenderer(renderer, GTEScaling, transform, Textures); } } } diff --git a/Runtime/PSXObjectExporter.cs.meta b/Runtime/PSXObjectExporter.cs.meta index 4ebe016..5156528 100644 --- a/Runtime/PSXObjectExporter.cs.meta +++ b/Runtime/PSXObjectExporter.cs.meta @@ -1,11 +1,2 @@ fileFormatVersion: 2 -guid: bea0f31a495202580ac77bd9fd6e99f2 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {fileID: 2800000, guid: e11677149a517ca5186e32dfda3ec088, type: 3} - userData: - assetBundleName: - assetBundleVariant: +guid: a192e0a30d827ba40be5c99d32a83a12 \ No newline at end of file diff --git a/Runtime/PSXPlayer.cs b/Runtime/PSXPlayer.cs index 8d5d4ad..ffd3b15 100644 --- a/Runtime/PSXPlayer.cs +++ b/Runtime/PSXPlayer.cs @@ -1,5 +1,4 @@ using UnityEngine; -using UnityEngine.AI; using UnityEngine.Serialization; @@ -7,27 +6,81 @@ namespace SplashEdit.RuntimeCode { public class PSXPlayer : MonoBehaviour { - private const float LookOutDistance = 1000f; - + [Header("Player Dimensions")] [FormerlySerializedAs("PlayerHeight")] - [SerializeField] private float playerHeight; + [Tooltip("Camera eye height above the player's feet")] + [SerializeField] private float playerHeight = 1.8f; + [Tooltip("Collision radius for wall sliding")] + [SerializeField] private float playerRadius = 0.5f; + + [Header("Movement")] + [Tooltip("Walk speed in world units per second")] + [SerializeField] private float moveSpeed = 3.0f; + + [Tooltip("Sprint speed in world units per second")] + [SerializeField] private float sprintSpeed = 8.0f; + + [Header("Navigation")] + [Tooltip("Maximum height the agent can step up")] + [SerializeField] private float maxStepHeight = 0.35f; + + [Tooltip("Maximum walkable slope angle in degrees")] + [SerializeField] private float walkableSlopeAngle = 46.0f; + + [Tooltip("Voxel size in XZ plane (smaller = more accurate but slower)")] + [SerializeField] private float navCellSize = 0.05f; + + [Tooltip("Voxel height (smaller = more accurate vertical resolution)")] + [SerializeField] private float navCellHeight = 0.025f; + + [Header("Jump & Gravity")] + [Tooltip("Peak jump height in world units")] + [SerializeField] private float jumpHeight = 2.0f; + + [Tooltip("Downward acceleration in world units per second squared (positive value)")] + [SerializeField] private float gravity = 20.0f; + + // Public accessors public float PlayerHeight => playerHeight; + public float PlayerRadius => playerRadius; + public float MoveSpeed => moveSpeed; + public float SprintSpeed => sprintSpeed; + public float MaxStepHeight => maxStepHeight; + public float WalkableSlopeAngle => walkableSlopeAngle; + public float NavCellSize => navCellSize; + public float NavCellHeight => navCellHeight; + public float JumpHeight => jumpHeight; + public float Gravity => gravity; public Vector3 CamPoint { get; protected set; } public void FindNavmesh() { - if (NavMesh.SamplePosition(transform.position, out NavMeshHit hit, LookOutDistance, NavMesh.AllAreas)) + // Raycast down from the transform to find the ground, + // then place CamPoint at ground + playerHeight + if (Physics.Raycast(transform.position, Vector3.down, out RaycastHit hit, 100f)) { - CamPoint = hit.position + new Vector3(0, PlayerHeight, 0); + CamPoint = hit.point + new Vector3(0, playerHeight, 0); + } + else + { + // Fallback: no ground hit, use transform directly + CamPoint = transform.position + new Vector3(0, playerHeight, 0); } } void OnDrawGizmos() { FindNavmesh(); + + // Red sphere at camera eye point Gizmos.color = Color.red; Gizmos.DrawSphere(CamPoint, 0.2f); + + // Wireframe sphere at feet showing player radius + Gizmos.color = new Color(0f, 1f, 0f, 0.3f); + Vector3 feet = CamPoint - new Vector3(0, playerHeight, 0); + Gizmos.DrawWireSphere(feet, playerRadius); } } } diff --git a/Runtime/PSXPlayer.cs.meta b/Runtime/PSXPlayer.cs.meta index 7245d08..cf76c8c 100644 --- a/Runtime/PSXPlayer.cs.meta +++ b/Runtime/PSXPlayer.cs.meta @@ -1,11 +1,2 @@ fileFormatVersion: 2 -guid: dee32f3a19300d7a3aae7424f01c9332 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {fileID: 2800000, guid: 4d7bd095e76e6f3df976224b15405059, type: 3} - userData: - assetBundleName: - assetBundleVariant: +guid: 3cc71b54d0db2604087cd4ae7781dc98 \ No newline at end of file diff --git a/Runtime/PSXPortalLink.cs b/Runtime/PSXPortalLink.cs new file mode 100644 index 0000000..fec8181 --- /dev/null +++ b/Runtime/PSXPortalLink.cs @@ -0,0 +1,59 @@ +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// Defines a portal connecting two PSXRoom volumes. + /// Place this object between two rooms and drag-and-drop the PSXRoom references + /// into RoomA and RoomB. The transform position becomes the portal center used + /// for the camera-forward visibility test at runtime on PS1. + /// + /// This is independent of the navigation portal system (PSXNavRegion). + /// + [ExecuteInEditMode] + public class PSXPortalLink : MonoBehaviour + { + [Tooltip("First room connected by this portal.")] + public PSXRoom RoomA; + + [Tooltip("Second room connected by this portal.")] + public PSXRoom RoomB; + + [Tooltip("Size of the portal opening (width, height) in world units. " + + "Used for the gizmo visualization and the screen-space margin " + + "when checking portal visibility at runtime.")] + public Vector2 PortalSize = new Vector2(2f, 3f); + + void OnDrawGizmos() + { + Gizmos.color = new Color(1f, 0.5f, 0f, 0.3f); + Gizmos.matrix = transform.localToWorldMatrix; + Gizmos.DrawCube(Vector3.zero, new Vector3(PortalSize.x, PortalSize.y, 0.05f)); + Gizmos.color = new Color(1f, 0.5f, 0f, 0.8f); + Gizmos.DrawWireCube(Vector3.zero, new Vector3(PortalSize.x, PortalSize.y, 0.05f)); + Gizmos.matrix = Matrix4x4.identity; + + // Draw lines to connected rooms. + if (RoomA != null) + { + Gizmos.color = Color.green; + Gizmos.DrawLine(transform.position, RoomA.transform.position); + } + if (RoomB != null) + { + Gizmos.color = Color.cyan; + Gizmos.DrawLine(transform.position, RoomB.transform.position); + } + } + + void OnDrawGizmosSelected() + { + Gizmos.color = new Color(1f, 0.7f, 0.2f, 0.6f); + Gizmos.matrix = transform.localToWorldMatrix; + Gizmos.DrawCube(Vector3.zero, new Vector3(PortalSize.x, PortalSize.y, 0.05f)); + Gizmos.color = Color.yellow; + Gizmos.DrawWireCube(Vector3.zero, new Vector3(PortalSize.x, PortalSize.y, 0.05f)); + Gizmos.matrix = Matrix4x4.identity; + } + } +} diff --git a/Runtime/PSXPortalLink.cs.meta b/Runtime/PSXPortalLink.cs.meta new file mode 100644 index 0000000..7d919cf --- /dev/null +++ b/Runtime/PSXPortalLink.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b4d8e9f3a52c6b05c937fa20e48d1b6f diff --git a/Runtime/PSXRoom.cs b/Runtime/PSXRoom.cs new file mode 100644 index 0000000..a3933f0 --- /dev/null +++ b/Runtime/PSXRoom.cs @@ -0,0 +1,439 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// Defines a convex room volume for the portal/room occlusion system. + /// Place one of these per room in the scene. Geometry is assigned to rooms by + /// centroid containment during export. Portals between adjacent rooms are detected + /// automatically. + /// + /// This is independent of the navregion/portal system used for navigation. + /// + [ExecuteInEditMode] + public class PSXRoom : MonoBehaviour + { + [Tooltip("Optional display name for this room (used in editor gizmos).")] + public string RoomName = ""; + + [Tooltip("Size of the room volume in local space. Defaults to the object's scale.")] + public Vector3 VolumeSize = Vector3.one; + + [Tooltip("Offset of the volume center relative to the transform position.")] + public Vector3 VolumeOffset = Vector3.zero; + + /// World-space AABB of this room. + public Bounds GetWorldBounds() + { + Vector3 center = transform.TransformPoint(VolumeOffset); + // Transform the 8 corners to get a world-space AABB + Vector3 halfSize = VolumeSize * 0.5f; + Vector3 wMin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue); + Vector3 wMax = new Vector3(float.MinValue, float.MinValue, float.MinValue); + for (int i = 0; i < 8; i++) + { + Vector3 corner = VolumeOffset + new Vector3( + (i & 1) != 0 ? halfSize.x : -halfSize.x, + (i & 2) != 0 ? halfSize.y : -halfSize.y, + (i & 4) != 0 ? halfSize.z : -halfSize.z + ); + Vector3 world = transform.TransformPoint(corner); + wMin = Vector3.Min(wMin, world); + wMax = Vector3.Max(wMax, world); + } + Bounds b = new Bounds(); + b.SetMinMax(wMin, wMax); + return b; + } + + /// Check if a world-space point is inside this room volume. + public bool ContainsPoint(Vector3 worldPoint) + { + return GetWorldBounds().Contains(worldPoint); + } + + void OnDrawGizmos() + { + Gizmos.color = new Color(0.2f, 0.8f, 0.4f, 0.15f); + Gizmos.matrix = transform.localToWorldMatrix; + Gizmos.DrawCube(VolumeOffset, VolumeSize); + Gizmos.color = new Color(0.2f, 0.8f, 0.4f, 0.6f); + Gizmos.DrawWireCube(VolumeOffset, VolumeSize); + Gizmos.matrix = Matrix4x4.identity; + +#if UNITY_EDITOR + if (!string.IsNullOrEmpty(RoomName)) + { + UnityEditor.Handles.Label(transform.TransformPoint(VolumeOffset), + RoomName, new GUIStyle { normal = { textColor = Color.green } }); + } +#endif + } + } + + /// + /// Portal between two PSXRoom volumes, stored during export. + /// Built from PSXPortalLink scene components. + /// + public struct PSXPortal + { + public int roomA; + public int roomB; + public Vector3 center; // World-space portal center (from PSXPortalLink transform) + public Vector2 portalSize; // Portal opening size in world units (width, height) + public Vector3 normal; // Portal facing direction (from PSXPortalLink transform.forward) + public Vector3 right; // Portal local right axis (world space) + public Vector3 up; // Portal local up axis (world space) + } + + /// + /// Builds and exports the room/portal system for a scene. + /// Called during PSXSceneExporter.Export(). + /// Portals are user-defined via PSXPortalLink components instead of auto-detected. + /// + public class PSXRoomBuilder + { + private PSXRoom[] _rooms; + private List _portals = new List(); + private List[] _roomTriRefs; + private List _catchAllTriRefs = new List(); + + public int RoomCount => _rooms?.Length ?? 0; + public int PortalCount => _portals?.Count ?? 0; + public int TotalTriRefCount + { + get + { + int count = 0; + if (_roomTriRefs != null) + foreach (var list in _roomTriRefs) count += list.Count; + count += _catchAllTriRefs.Count; + return count; + } + } + + /// + /// Build the room system: assign triangles to rooms and read user-defined portals. + /// + /// All PSXRoom components in the scene. + /// All PSXPortalLink components (user-placed portals). + /// All object exporters (for triangle centroid testing). + /// GTE coordinate scaling factor. + public void Build(PSXRoom[] rooms, PSXPortalLink[] portalLinks, + PSXObjectExporter[] exporters, float gteScaling) + { + _rooms = rooms; + if (rooms == null || rooms.Length == 0) return; + + _roomTriRefs = new List[rooms.Length]; + for (int i = 0; i < rooms.Length; i++) + _roomTriRefs[i] = new List(); + _catchAllTriRefs.Clear(); + _portals.Clear(); + + // Assign each triangle to a room by vertex majority containment. + // For each triangle, test all 3 world-space vertices against each room's AABB + // (expanded by a margin to catch boundary geometry). The room containing the + // most vertices wins. Ties broken by centroid. This prevents boundary triangles + // (doorway walls, floor edges) from being assigned to the wrong room. + const float ROOM_MARGIN = 0.5f; // expand AABBs by this much for testing + Bounds[] roomBounds = new Bounds[rooms.Length]; + for (int i = 0; i < rooms.Length; i++) + { + roomBounds[i] = rooms[i].GetWorldBounds(); + roomBounds[i].Expand(ROOM_MARGIN * 2f); // Expand in all directions + } + + for (int objIdx = 0; objIdx < exporters.Length; objIdx++) + { + var exporter = exporters[objIdx]; + if (!exporter.IsActive) continue; + MeshFilter mf = exporter.GetComponent(); + if (mf == null || mf.sharedMesh == null) continue; + Mesh mesh = mf.sharedMesh; + Vector3[] vertices = mesh.vertices; + int[] indices = mesh.triangles; + Matrix4x4 worldMatrix = exporter.transform.localToWorldMatrix; + + for (int i = 0; i < indices.Length; i += 3) + { + Vector3 v0 = worldMatrix.MultiplyPoint3x4(vertices[indices[i]]); + Vector3 v1 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 1]]); + Vector3 v2 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 2]]); + + // Test all 3 vertices against each room, pick room with most hits. + int bestRoom = -1; + int bestHits = 0; + float bestDist = float.MaxValue; + for (int r = 0; r < rooms.Length; r++) + { + int hits = 0; + if (roomBounds[r].Contains(v0)) hits++; + if (roomBounds[r].Contains(v1)) hits++; + if (roomBounds[r].Contains(v2)) hits++; + + if (hits > bestHits) + { + bestHits = hits; + bestRoom = r; + bestDist = (roomBounds[r].center - (v0 + v1 + v2) / 3f).sqrMagnitude; + } + else if (hits == bestHits && hits > 0) + { + // Tie-break: pick room whose center is closest to centroid + float dist = (roomBounds[r].center - (v0 + v1 + v2) / 3f).sqrMagnitude; + if (dist < bestDist) + { + bestRoom = r; + bestDist = dist; + } + } + } + + var triRef = new BVH.TriangleRef(objIdx, i / 3); + if (bestRoom >= 0) + _roomTriRefs[bestRoom].Add(triRef); + else + _catchAllTriRefs.Add(triRef); + } + } + + // Build portals from user-placed PSXPortalLink components. + // (Must happen before boundary duplication so we know which rooms are adjacent.) + BuildPortals(portalLinks); + + // Phase 3: Duplicate boundary triangles into both rooms at portal boundaries. + // When a triangle has vertices in multiple rooms, it was assigned to only + // the "best" room. For triangles near a portal, also add them to the adjacent + // room so doorway/boundary geometry is visible from both sides. + DuplicateBoundaryTriangles(exporters, roomBounds); + + // Sort each room's tri-refs by objectIndex for GTE matrix batching. + for (int i = 0; i < _roomTriRefs.Length; i++) + _roomTriRefs[i].Sort((a, b) => a.objectIndex.CompareTo(b.objectIndex)); + _catchAllTriRefs.Sort((a, b) => a.objectIndex.CompareTo(b.objectIndex)); + + Debug.Log($"PSXRoomBuilder: {rooms.Length} rooms, {_portals.Count} portals, " + + $"{TotalTriRefCount} tri-refs ({_catchAllTriRefs.Count} catch-all)"); + } + + /// + /// For each portal, find triangles assigned to one adjacent room whose vertices + /// also touch the other adjacent room. Duplicate those triangles into the other + /// room so boundary geometry (doorway walls, floor edges) is visible from both sides. + /// + private void DuplicateBoundaryTriangles(PSXObjectExporter[] exporters, Bounds[] roomBounds) + { + if (_portals.Count == 0) return; + + int duplicated = 0; + // Build a set of existing tri-refs per room for O(1) duplicate checking + var roomSets = new HashSet[_rooms.Length]; + for (int i = 0; i < _rooms.Length; i++) + { + roomSets[i] = new HashSet(); + foreach (var tr in _roomTriRefs[i]) + roomSets[i].Add(((long)tr.objectIndex << 16) | tr.triangleIndex); + } + + foreach (var portal in _portals) + { + int rA = portal.roomA, rB = portal.roomB; + if (rA < 0 || rA >= _rooms.Length || rB < 0 || rB >= _rooms.Length) continue; + + // For each triangle in room A, check if any vertex is inside room B's AABB. + // If so, add a copy to room B (and vice versa). + DuplicateDirection(rA, rB, exporters, roomBounds, roomSets, ref duplicated); + DuplicateDirection(rB, rA, exporters, roomBounds, roomSets, ref duplicated); + } + + if (duplicated > 0) + Debug.Log($"PSXRoomBuilder: Duplicated {duplicated} boundary triangles across portal edges."); + } + + private void DuplicateDirection(int srcRoom, int dstRoom, + PSXObjectExporter[] exporters, Bounds[] roomBounds, + HashSet[] roomSets, ref int duplicated) + { + var srcList = new List(_roomTriRefs[srcRoom]); + foreach (var triRef in srcList) + { + long key = ((long)triRef.objectIndex << 16) | triRef.triangleIndex; + if (roomSets[dstRoom].Contains(key)) continue; // Already in dst + + if (triRef.objectIndex >= exporters.Length) continue; + var exporter = exporters[triRef.objectIndex]; + MeshFilter mf = exporter.GetComponent(); + if (mf == null || mf.sharedMesh == null) continue; + Mesh mesh = mf.sharedMesh; + Vector3[] vertices = mesh.vertices; + int[] indices = mesh.triangles; + Matrix4x4 worldMatrix = exporter.transform.localToWorldMatrix; + + int triStart = triRef.triangleIndex * 3; + if (triStart + 2 >= indices.Length) continue; + Vector3 v0 = worldMatrix.MultiplyPoint3x4(vertices[indices[triStart]]); + Vector3 v1 = worldMatrix.MultiplyPoint3x4(vertices[indices[triStart + 1]]); + Vector3 v2 = worldMatrix.MultiplyPoint3x4(vertices[indices[triStart + 2]]); + + // Check if any vertex is inside the destination room's AABB + if (roomBounds[dstRoom].Contains(v0) || + roomBounds[dstRoom].Contains(v1) || + roomBounds[dstRoom].Contains(v2)) + { + _roomTriRefs[dstRoom].Add(triRef); + roomSets[dstRoom].Add(key); + duplicated++; + } + } + } + + /// + /// Convert PSXPortalLink components into PSXPortal entries. + /// Maps PSXRoom references to room indices, validates, and stores center positions. + /// + private void BuildPortals(PSXPortalLink[] portalLinks) + { + if (portalLinks == null) return; + + // Build a fast lookup: PSXRoom instance → index. + var roomIndex = new Dictionary(); + for (int i = 0; i < _rooms.Length; i++) + roomIndex[_rooms[i]] = i; + + foreach (var link in portalLinks) + { + if (link == null) continue; + if (link.RoomA == null || link.RoomB == null) + { + Debug.LogWarning($"PSXPortalLink '{link.name}' has unassigned room references — skipped."); + continue; + } + if (link.RoomA == link.RoomB) + { + Debug.LogWarning($"PSXPortalLink '{link.name}' references the same room twice — skipped."); + continue; + } + if (!roomIndex.TryGetValue(link.RoomA, out int idxA)) + { + Debug.LogWarning($"PSXPortalLink '{link.name}': RoomA '{link.RoomA.name}' is not a known PSXRoom — skipped."); + continue; + } + if (!roomIndex.TryGetValue(link.RoomB, out int idxB)) + { + Debug.LogWarning($"PSXPortalLink '{link.name}': RoomB '{link.RoomB.name}' is not a known PSXRoom — skipped."); + continue; + } + + _portals.Add(new PSXPortal + { + roomA = idxA, + roomB = idxB, + center = link.transform.position, + portalSize = link.PortalSize, + normal = link.transform.forward, + right = link.transform.right, + up = link.transform.up + }); + } + } + + /// + /// Write room/portal data to the splashpack binary. + /// + public void WriteToBinary(System.IO.BinaryWriter writer, float gteScaling) + { + if (_rooms == null || _rooms.Length == 0) return; + + // Per-room data (32 bytes each): AABB (24) + firstTriRef (2) + triRefCount (2) + pad (4) + int runningTriRefOffset = 0; + for (int i = 0; i < _rooms.Length; i++) + { + Bounds wb = _rooms[i].GetWorldBounds(); + // PS1 coordinate space (negate Y, swap min/max Y) + writer.Write(PSXTrig.ConvertWorldToFixed12(wb.min.x / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-wb.max.y / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(wb.min.z / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(wb.max.x / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-wb.min.y / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(wb.max.z / gteScaling)); + + writer.Write((ushort)runningTriRefOffset); + writer.Write((ushort)_roomTriRefs[i].Count); + writer.Write((uint)0); // padding + runningTriRefOffset += _roomTriRefs[i].Count; + } + + // Catch-all room (always rendered) — written as an extra "room" entry + { + // Catch-all AABB: max world extents + writer.Write(PSXTrig.ConvertWorldToFixed12(-1000f / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-1000f / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-1000f / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(1000f / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(1000f / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(1000f / gteScaling)); + writer.Write((ushort)runningTriRefOffset); + writer.Write((ushort)_catchAllTriRefs.Count); + writer.Write((uint)0); + } + + // Per-portal data (40 bytes each): + // roomA(2) + roomB(2) + center(12) + halfW(2) + halfH(2) + + // normal(6) + pad(2) + right(6) + up(6) + foreach (var portal in _portals) + { + writer.Write((ushort)portal.roomA); + writer.Write((ushort)portal.roomB); + // Center of portal (PS1 coords: negate Y) + writer.Write(PSXTrig.ConvertWorldToFixed12(portal.center.x / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-portal.center.y / gteScaling)); + writer.Write(PSXTrig.ConvertWorldToFixed12(portal.center.z / gteScaling)); + // Portal half-size in GTE units (fp12) + float halfW = portal.portalSize.x * 0.5f; + float halfH = portal.portalSize.y * 0.5f; + writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(halfW / gteScaling * 4096f), 1, 32767)); + writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(halfH / gteScaling * 4096f), 1, 32767)); + // Portal facing normal (PS1 coords: negate Y) - 4.12 fixed-point unit vector + writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(portal.normal.x * 4096f), -32768, 32767)); + writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(-portal.normal.y * 4096f), -32768, 32767)); + writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(portal.normal.z * 4096f), -32768, 32767)); + writer.Write((short)0); // pad + // Portal right axis (PS1 coords: negate Y) - 4.12 fixed-point unit vector + writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(portal.right.x * 4096f), -32768, 32767)); + writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(-portal.right.y * 4096f), -32768, 32767)); + writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(portal.right.z * 4096f), -32768, 32767)); + // Portal up axis (PS1 coords: negate Y) - 4.12 fixed-point unit vector + writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(portal.up.x * 4096f), -32768, 32767)); + writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(-portal.up.y * 4096f), -32768, 32767)); + writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(portal.up.z * 4096f), -32768, 32767)); + } + + // Triangle refs (4 bytes each) — rooms in order, then catch-all + for (int i = 0; i < _rooms.Length; i++) + { + foreach (var triRef in _roomTriRefs[i]) + { + writer.Write(triRef.objectIndex); + writer.Write(triRef.triangleIndex); + } + } + foreach (var triRef in _catchAllTriRefs) + { + writer.Write(triRef.objectIndex); + writer.Write(triRef.triangleIndex); + } + } + + public int GetBinarySize() + { + if (_rooms == null || _rooms.Length == 0) return 0; + int roomDataSize = (_rooms.Length + 1) * 32; // +1 for catch-all + int portalDataSize = _portals.Count * 40; + int triRefSize = TotalTriRefCount * 4; + return roomDataSize + portalDataSize + triRefSize; + } + } +} diff --git a/Runtime/PSXRoom.cs.meta b/Runtime/PSXRoom.cs.meta new file mode 100644 index 0000000..20d8af8 --- /dev/null +++ b/Runtime/PSXRoom.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a3c7d8e2f41b5a94b826e91f3d7c0a5e diff --git a/Runtime/PSXSceneExporter.cs b/Runtime/PSXSceneExporter.cs index 5bd8914..b8e0645 100644 --- a/Runtime/PSXSceneExporter.cs +++ b/Runtime/PSXSceneExporter.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; -using System.Text; -using Splashedit.RuntimeCode; +using SplashEdit.RuntimeCode; +#if UNITY_EDITOR using UnityEditor; +#endif using UnityEngine; namespace SplashEdit.RuntimeCode @@ -13,13 +13,43 @@ namespace SplashEdit.RuntimeCode [ExecuteInEditMode] public class PSXSceneExporter : MonoBehaviour { + /// + /// Editor code sets this delegate so the Runtime assembly can convert + /// audio without directly referencing the Editor assembly. + /// Signature: (AudioClip clip, int sampleRate, bool loop) => byte[] adpcm + /// + public static Func AudioConvertDelegate; + public float GTEScaling = 100.0f; public LuaFile SceneLuaFile; + + [Header("Fog Configuration")] + [Tooltip("Enable distance fog. Fog color is also used as the GPU clear color.")] + public bool FogEnabled = false; + [Tooltip("Fog color (RGB). Also used as the sky/clear color.")] + public Color FogColor = new Color(0.5f, 0.5f, 0.6f); + [Tooltip("Fog density (1 = light haze, 10 = pea soup).")] + [Range(1, 10)] + public int FogDensity = 5; + + [Header("Scene Type")] + [Tooltip("Exterior uses BVH frustum culling. Interior uses room/portal occlusion.")] + public int SceneType = 0; // 0=exterior, 1=interior private PSXObjectExporter[] _exporters; private TextureAtlas[] _atlases; - private PSXNavMesh[] _navmeshes; + + // Component arrays + private PSXInteractable[] _interactables; + private PSXAudioSource[] _audioSources; + + // Phase 3+4: World collision and nav regions + private PSXCollisionExporter _collisionExporter; + private PSXNavRegionBuilder _navRegionBuilder; + + // Phase 5: Room/portal system (interior scenes) + private PSXRoomBuilder _roomBuilder; private PSXData _psxData; @@ -31,14 +61,32 @@ namespace SplashEdit.RuntimeCode private Vector3 _playerPos; private Quaternion _playerRot; private float _playerHeight; + private float _playerRadius; + private float _moveSpeed; + private float _sprintSpeed; + private float _jumpHeight; + private float _gravity; - private BSP _bsp; + private BVH _bvh; - public bool PreviewBSP = true; - public int BSPPreviewDepth = 9999; + public bool PreviewBVH = true; + public int BVHPreviewDepth = 9999; + /// + /// Export with a file dialog (legacy workflow). + /// public void Export() { + ExportToPath(null); + } + + /// + /// Export to the given file path. If path is null, shows a file dialog. + /// Called by the Control Panel pipeline for automated exports. + /// + public void ExportToPath(string outputPath) + { +#if UNITY_EDITOR _psxData = DataStorage.LoadData(out selectedResolution, out dualBuffering, out verticalLayout, out prohibitedAreas); _exporters = FindObjectsByType(FindObjectsSortMode.None); @@ -47,16 +95,12 @@ namespace SplashEdit.RuntimeCode PSXObjectExporter exp = _exporters[i]; EditorUtility.DisplayProgressBar($"{nameof(PSXSceneExporter)}", $"Export {nameof(PSXObjectExporter)}", ((float)i) / _exporters.Length); exp.CreatePSXTextures2D(); - exp.CreatePSXMesh(GTEScaling, true); - } - - _navmeshes = FindObjectsByType(FindObjectsSortMode.None); - for (int i = 0; i < _navmeshes.Length; i++) - { - PSXNavMesh navmesh = _navmeshes[i]; - EditorUtility.DisplayProgressBar($"{nameof(PSXSceneExporter)}", $"Export {nameof(PSXNavMesh)}", ((float)i) / _navmeshes.Length); - navmesh.CreateNavmesh(GTEScaling); + exp.CreatePSXMesh(GTEScaling); } + + // Collect components + _interactables = FindObjectsByType(FindObjectsSortMode.None); + _audioSources = FindObjectsByType(FindObjectsSortMode.None); EditorUtility.ClearProgressBar(); @@ -68,13 +112,73 @@ namespace SplashEdit.RuntimeCode player.FindNavmesh(); _playerPos = player.CamPoint; _playerHeight = player.PlayerHeight; + _playerRadius = player.PlayerRadius; + _moveSpeed = player.MoveSpeed; + _sprintSpeed = player.SprintSpeed; + _jumpHeight = player.JumpHeight; + _gravity = player.Gravity; _playerRot = player.transform.rotation; } - _bsp = new BSP(_exporters.ToList()); - _bsp.Build(); + _bvh = new BVH(_exporters.ToList()); + _bvh.Build(); - ExportFile(); + // Phase 3: Build world collision soup + _collisionExporter = new PSXCollisionExporter(); + _collisionExporter.Build(_exporters, GTEScaling); + if (_collisionExporter.MeshCount == 0) + Debug.LogWarning("No collision meshes! Set CollisionType=Solid on your floor/wall objects."); + + // Phase 4+5: Room volumes are needed by BOTH the nav region builder + // (for spatial room assignment) and the room builder (for triangle assignment). + // Collect them early so both systems use the same room indices. + PSXRoom[] rooms = null; + PSXPortalLink[] portalLinks = null; + if (SceneType == 1) + { + rooms = FindObjectsByType(FindObjectsSortMode.None); + portalLinks = FindObjectsByType(FindObjectsSortMode.None); + } + + // Phase 4: Build nav regions + _navRegionBuilder = new PSXNavRegionBuilder(); + _navRegionBuilder.AgentRadius = _playerRadius; + _navRegionBuilder.AgentHeight = _playerHeight; + if (player != null) + { + _navRegionBuilder.MaxStepHeight = player.MaxStepHeight; + _navRegionBuilder.WalkableSlopeAngle = player.WalkableSlopeAngle; + _navRegionBuilder.CellSize = player.NavCellSize; + _navRegionBuilder.CellHeight = player.NavCellHeight; + } + // Pass PSXRoom volumes so nav regions get spatial room assignment + // instead of BFS connectivity. This ensures nav region roomIndex + // matches the PSXRoomBuilder room indices used by the renderer. + if (rooms != null && rooms.Length > 0) + _navRegionBuilder.PSXRooms = rooms; + _navRegionBuilder.Build(_exporters, _playerPos); + if (_navRegionBuilder.RegionCount == 0) + Debug.LogWarning("No nav regions! Enable 'Generate Navigation' on your floor meshes."); + + // Phase 5: Build room/portal system (for interior scenes) + _roomBuilder = new PSXRoomBuilder(); + if (SceneType == 1) + { + if (rooms != null && rooms.Length > 0) + { + _roomBuilder.Build(rooms, portalLinks, _exporters, GTEScaling); + if (portalLinks == null || portalLinks.Length == 0) + Debug.LogWarning("Interior scene has rooms but no PSXPortalLink components! " + + "Place PSXPortalLink objects between rooms for portal culling."); + } + else + { + Debug.LogWarning("Interior scene type but no PSXRoom volumes found! Place PSXRoom components."); + } + } + + ExportFile(outputPath); +#endif } void PackTextures() @@ -94,362 +198,74 @@ namespace SplashEdit.RuntimeCode } - void ExportFile() + void ExportFile(string outputPath = null) { - string path = EditorUtility.SaveFilePanel("Select Output File", "", "output", "bin"); - int totalFaces = 0; +#if UNITY_EDITOR + string path = outputPath; + if (string.IsNullOrEmpty(path)) + path = EditorUtility.SaveFilePanel("Select Output File", "", "output", "bin"); + if (string.IsNullOrEmpty(path)) + return; - // Lists for lua data offsets. - OffsetData luaOffset = new(); - - // Lists for mesh data offsets. - OffsetData meshOffset = new(); - - // Lists for atlas data offsets. - OffsetData atlasOffset = new(); - - // Lists for clut data offsets. - OffsetData clutOffset = new(); - - // Lists for navmesh data offsets. - OffsetData navmeshOffset = new(); - - int clutCount = 0; - List luaFiles = new List(); - - // Cluts - foreach (TextureAtlas atlas in _atlases) + // Convert audio clips to ADPCM (Editor-only, before passing to Runtime writer) + AudioClipExport[] audioExports = null; + if (_audioSources != null && _audioSources.Length > 0) { - foreach (var texture in atlas.ContainedTextures) + var list = new List(); + foreach (var src in _audioSources) { - if (texture.ColorPalette != null) + if (src.Clip != null) { - clutCount++; - } - } - } - - // Lua files - foreach (PSXObjectExporter exporter in _exporters) - { - if (exporter.LuaFile != null) - { - //if not contains - if (!luaFiles.Contains(exporter.LuaFile)) - { - luaFiles.Add(exporter.LuaFile); - } - } - } - if (SceneLuaFile != null) - { - if (!luaFiles.Contains(SceneLuaFile)) - { - luaFiles.Add(SceneLuaFile); - } - } - - using (BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create))) - { - // Header - writer.Write('S'); // 1 byte // 1 - writer.Write('P'); // 1 byte // 2 - writer.Write((ushort)1); // 2 bytes - version // 4 - writer.Write((ushort)luaFiles.Count); // 2 bytes - padding // 6 - writer.Write((ushort)_exporters.Length); // 2 bytes // 6 - writer.Write((ushort)_navmeshes.Length); // 8 - writer.Write((ushort)_atlases.Length); // 2 bytes // 10 - writer.Write((ushort)clutCount); // 2 bytes // 12 - writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(_playerPos.x, GTEScaling)); // 14 - writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(-_playerPos.y, GTEScaling)); // 16 - writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(_playerPos.z, GTEScaling)); // 18 - - writer.Write((ushort)PSXTrig.ConvertToFixed12(_playerRot.eulerAngles.x * Mathf.Deg2Rad)); // 20 - writer.Write((ushort)PSXTrig.ConvertToFixed12(_playerRot.eulerAngles.y * Mathf.Deg2Rad)); // 22 - writer.Write((ushort)PSXTrig.ConvertToFixed12(_playerRot.eulerAngles.z * Mathf.Deg2Rad)); // 24 - - writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(_playerHeight, GTEScaling)); // 26 - - if (SceneLuaFile != null) - { - int index = luaFiles.IndexOf(SceneLuaFile); - writer.Write((short)index); - } - else - { - writer.Write((short)-1); - } - writer.Write((ushort)0); - - // Lua file section - foreach (LuaFile luaFile in luaFiles) - { - // Write placeholder for lua file data offset and record its position. - luaOffset.OffsetPlaceholderPositions.Add(writer.BaseStream.Position); - writer.Write((int)0); // 4-byte placeholder for mesh data offset. - writer.Write((uint)luaFile.LuaScript.Length); - } - - // GameObject section (exporters) - foreach (PSXObjectExporter exporter in _exporters) - { - // Write placeholder for mesh data offset and record its position. - meshOffset.OffsetPlaceholderPositions.Add(writer.BaseStream.Position); - writer.Write((int)0); // 4-byte placeholder for mesh data offset. - - // Write object's transform - writer.Write((int)PSXTrig.ConvertCoordinateToPSX(exporter.transform.localToWorldMatrix.GetPosition().x, GTEScaling)); - writer.Write((int)PSXTrig.ConvertCoordinateToPSX(-exporter.transform.localToWorldMatrix.GetPosition().y, GTEScaling)); - writer.Write((int)PSXTrig.ConvertCoordinateToPSX(exporter.transform.localToWorldMatrix.GetPosition().z, GTEScaling)); - int[,] rotationMatrix = PSXTrig.ConvertRotationToPSXMatrix(exporter.transform.rotation); - - writer.Write((int)rotationMatrix[0, 0]); - writer.Write((int)rotationMatrix[0, 1]); - writer.Write((int)rotationMatrix[0, 2]); - writer.Write((int)rotationMatrix[1, 0]); - writer.Write((int)rotationMatrix[1, 1]); - writer.Write((int)rotationMatrix[1, 2]); - writer.Write((int)rotationMatrix[2, 0]); - writer.Write((int)rotationMatrix[2, 1]); - writer.Write((int)rotationMatrix[2, 2]); - - writer.Write((ushort)exporter.Mesh.Triangles.Count); - if (exporter.LuaFile != null) - { - int index = luaFiles.IndexOf(exporter.LuaFile); - writer.Write((short)index); + if (AudioConvertDelegate == null) + throw new InvalidOperationException("AudioConvertDelegate not set. Ensure PSXAudioConverter registers it."); + byte[] adpcm = AudioConvertDelegate(src.Clip, src.SampleRate, src.Loop); + list.Add(new AudioClipExport { adpcmData = adpcm, sampleRate = src.SampleRate, loop = src.Loop, clipName = src.ClipName }); } else { - writer.Write((short)-1); - } - - // Write 4-byte bitfield with LSB as exporter.isActive - int bitfield = exporter.IsActive ? 0b1 : 0b0; - writer.Write(bitfield); - } - - // Navmesh metadata section - foreach (PSXNavMesh navmesh in _navmeshes) - { - // Write placeholder for navmesh raw data offset. - navmeshOffset.OffsetPlaceholderPositions.Add(writer.BaseStream.Position); - writer.Write((int)0); // 4-byte placeholder for navmesh data offset. - - writer.Write((ushort)navmesh.Navmesh.Count); - writer.Write((ushort)0); - } - - // Atlas metadata section - foreach (TextureAtlas atlas in _atlases) - { - // Write placeholder for texture atlas raw data offset. - atlasOffset.OffsetPlaceholderPositions.Add(writer.BaseStream.Position); - writer.Write((int)0); // 4-byte placeholder for atlas data offset. - - writer.Write((ushort)atlas.Width); - writer.Write((ushort)TextureAtlas.Height); - writer.Write((ushort)atlas.PositionX); - writer.Write((ushort)atlas.PositionY); - } - - // Cluts - foreach (TextureAtlas atlas in _atlases) - { - foreach (var texture in atlas.ContainedTextures) - { - if (texture.ColorPalette != null) - { - clutOffset.OffsetPlaceholderPositions.Add(writer.BaseStream.Position); - writer.Write((int)0); // 4-byte placeholder for clut data offset. - writer.Write((ushort)texture.ClutPackingX); // 2 bytes - writer.Write((ushort)texture.ClutPackingY); // 2 bytes - writer.Write((ushort)texture.ColorPalette.Count); // 2 bytes - writer.Write((ushort)0); // 2 bytes - } + Debug.LogWarning($"Audio source on {src.gameObject.name} has no clip assigned."); + list.Add(new AudioClipExport { adpcmData = null, sampleRate = src.SampleRate, loop = src.Loop, clipName = src.ClipName }); } } - - // Start of data section - - // Lua data section: Write lua file data for each exporter. - foreach (LuaFile luaFile in luaFiles) - { - AlignToFourBytes(writer); - // Record the current offset for this lua file's data. - long luaDataOffset = writer.BaseStream.Position; - luaOffset.DataOffsets.Add(luaDataOffset); - - writer.Write(Encoding.UTF8.GetBytes(luaFile.LuaScript)); - } - - void writeVertexPosition(PSXVertex v) - { - writer.Write((short)v.vx); - writer.Write((short)v.vy); - writer.Write((short)v.vz); - } - void writeVertexNormals(PSXVertex v) - { - writer.Write((short)v.nx); - writer.Write((short)v.ny); - writer.Write((short)v.nz); - } - void writeVertexColor(PSXVertex v) - { - writer.Write((byte)v.r); - writer.Write((byte)v.g); - writer.Write((byte)v.b); - writer.Write((byte)0); // padding - } - void writeVertexUV(PSXVertex v, PSXTexture2D t, int expander) - { - writer.Write((byte)(v.u + t.PackingX * expander)); - writer.Write((byte)(v.v + t.PackingY)); - } - void foreachVertexDo(Tri tri, Action action) - { - for (int i = 0; i < tri.Vertexes.Length; i++) - { - action(tri.Vertexes[i]); - } - } - - // Mesh data section: Write mesh data for each exporter. - foreach (PSXObjectExporter exporter in _exporters) - { - AlignToFourBytes(writer); - // Record the current offset for this exporter's mesh data. - long meshDataOffset = writer.BaseStream.Position; - meshOffset.DataOffsets.Add(meshDataOffset); - - totalFaces += exporter.Mesh.Triangles.Count; - - - foreach (Tri tri in exporter.Mesh.Triangles) - { - int expander = 16 / ((int)exporter.GetTexture(tri.TextureIndex).BitDepth); - // Write vertices coordinates - foreachVertexDo(tri, (v) => writeVertexPosition(v)); - - // Write vertex normals for v0 only - writeVertexNormals(tri.v0); - - // Write vertex colors with padding - foreachVertexDo(tri, (v) => writeVertexColor(v)); - - // Write UVs for each vertex, adjusting for texture packing - foreachVertexDo(tri, (v) => writeVertexUV(v, exporter.GetTexture(tri.TextureIndex), expander)); - - writer.Write((ushort)0); // padding - - - TPageAttr tpage = new TPageAttr(); - tpage.SetPageX(exporter.GetTexture(tri.TextureIndex).TexpageX); - tpage.SetPageY(exporter.GetTexture(tri.TextureIndex).TexpageY); - tpage.Set(exporter.GetTexture(tri.TextureIndex).BitDepth.ToColorMode()); - tpage.SetDithering(true); - writer.Write((ushort)tpage.info); - writer.Write((ushort)exporter.GetTexture(tri.TextureIndex).ClutPackingX); - writer.Write((ushort)exporter.GetTexture(tri.TextureIndex).ClutPackingY); - writer.Write((ushort)0); - } - } - - foreach (PSXNavMesh navmesh in _navmeshes) - { - AlignToFourBytes(writer); - long navmeshDataOffset = writer.BaseStream.Position; - navmeshOffset.DataOffsets.Add(navmeshDataOffset); - - foreach (PSXNavMeshTri tri in navmesh.Navmesh) - { - // Write vertices coordinates - writer.Write((int)tri.v0.vx); - writer.Write((int)tri.v0.vy); - writer.Write((int)tri.v0.vz); - - writer.Write((int)tri.v1.vx); - writer.Write((int)tri.v1.vy); - writer.Write((int)tri.v1.vz); - - writer.Write((int)tri.v2.vx); - writer.Write((int)tri.v2.vy); - writer.Write((int)tri.v2.vz); - } - - } - - // Atlas data section: Write raw texture data for each atlas. - foreach (TextureAtlas atlas in _atlases) - { - AlignToFourBytes(writer); - // Record the current offset for this atlas's data. - long atlasDataOffset = writer.BaseStream.Position; - atlasOffset.DataOffsets.Add(atlasDataOffset); - - // Write the atlas's raw texture data. - 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 section - foreach (TextureAtlas atlas in _atlases) - { - foreach (var texture in atlas.ContainedTextures) - { - if (texture.ColorPalette != null) - { - AlignToFourBytes(writer); - long clutDataOffset = writer.BaseStream.Position; - clutOffset.DataOffsets.Add(clutDataOffset); - - foreach (VRAMPixel color in texture.ColorPalette) - { - writer.Write((ushort)color.Pack()); - } - } - } - - } - - writeOffset(writer, luaOffset, "lua"); - writeOffset(writer, meshOffset, "mesh"); - writeOffset(writer, navmeshOffset, "navmesh"); - writeOffset(writer, atlasOffset, "atlas"); - writeOffset(writer, clutOffset, "clut"); + audioExports = list.ToArray(); } - Debug.Log(totalFaces); - } - private void writeOffset(BinaryWriter writer, OffsetData data, string type) - { - // Backfill the data offsets into the metadata section. - if (data.OffsetPlaceholderPositions.Count == data.DataOffsets.Count) + var scene = new PSXSceneWriter.SceneData { - for (int i = 0; i < data.OffsetPlaceholderPositions.Count; i++) - { - writer.Seek((int)data.OffsetPlaceholderPositions[i], SeekOrigin.Begin); - writer.Write((int)data.DataOffsets[i]); - } - } - else + exporters = _exporters, + atlases = _atlases, + interactables = _interactables, + audioClips = audioExports, + collisionExporter = _collisionExporter, + navRegionBuilder = _navRegionBuilder, + roomBuilder = _roomBuilder, + bvh = _bvh, + sceneLuaFile = SceneLuaFile, + gteScaling = GTEScaling, + playerPos = _playerPos, + playerRot = _playerRot, + playerHeight = _playerHeight, + playerRadius = _playerRadius, + moveSpeed = _moveSpeed, + sprintSpeed = _sprintSpeed, + jumpHeight = _jumpHeight, + gravity = _gravity, + sceneType = SceneType, + fogEnabled = FogEnabled, + fogColor = FogColor, + fogDensity = FogDensity, + }; + + PSXSceneWriter.Write(path, in scene, (msg, type) => { - Debug.LogError("Mismatch between clut offset placeholders and clut data blocks!"); - } - } - - - void AlignToFourBytes(BinaryWriter writer) - { - long position = writer.BaseStream.Position; - int padding = (int)(4 - (position % 4)) % 4; // Compute needed padding - writer.Write(new byte[padding]); // Write zero padding + switch (type) + { + case LogType.Error: Debug.LogError(msg); break; + case LogType.Warning: Debug.LogWarning(msg); break; + default: Debug.Log(msg); break; + } + }); +#endif } void OnDrawGizmos() @@ -459,16 +275,9 @@ namespace SplashEdit.RuntimeCode Gizmos.color = Color.red; Gizmos.DrawWireCube(sceneOrigin, cubeSize); - if (_bsp == null || !PreviewBSP) return; - _bsp.DrawGizmos(BSPPreviewDepth); - + if (_bvh == null || !PreviewBVH) return; + _bvh.DrawGizmos(BVHPreviewDepth); } } - - public class OffsetData - { - public List OffsetPlaceholderPositions = new List(); - public List DataOffsets = new List(); - } } diff --git a/Runtime/PSXSceneExporter.cs.meta b/Runtime/PSXSceneExporter.cs.meta index 3cf8f3c..d12f4de 100644 --- a/Runtime/PSXSceneExporter.cs.meta +++ b/Runtime/PSXSceneExporter.cs.meta @@ -1,11 +1,2 @@ fileFormatVersion: 2 -guid: ab5195ad94fd173cfb6d48ee06eaf245 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {fileID: 2800000, guid: 0be7a2d4700082dbc83b9274837c70bc, type: 3} - userData: - assetBundleName: - assetBundleVariant: +guid: 3efc1583a56e9024f8d08551ddfbea56 \ No newline at end of file diff --git a/Runtime/PSXSceneWriter.cs b/Runtime/PSXSceneWriter.cs new file mode 100644 index 0000000..5766261 --- /dev/null +++ b/Runtime/PSXSceneWriter.cs @@ -0,0 +1,693 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// Pure binary serializer for the splashpack v8 format. + /// All I/O extracted from PSXSceneExporter so the MonoBehaviour stays thin. + /// + public static class PSXSceneWriter + { + /// + /// All scene data needed to produce a .bin file. + /// Populated by PSXSceneExporter before calling . + /// + public struct SceneData + { + public PSXObjectExporter[] exporters; + public TextureAtlas[] atlases; + public PSXInteractable[] interactables; + public AudioClipExport[] audioClips; + public PSXCollisionExporter collisionExporter; + public PSXNavRegionBuilder navRegionBuilder; + public PSXRoomBuilder roomBuilder; + public BVH bvh; + public LuaFile sceneLuaFile; + public float gteScaling; + + // Player + public Vector3 playerPos; + public Quaternion playerRot; + public float playerHeight; + public float playerRadius; + public float moveSpeed; + public float sprintSpeed; + public float jumpHeight; + public float gravity; + + // Scene configuration (v11) + public int sceneType; // 0=exterior, 1=interior + public bool fogEnabled; + public Color fogColor; + public int fogDensity; // 1-10 + } + + // ─── Offset bookkeeping ─── + + private sealed class OffsetData + { + public readonly List PlaceholderPositions = new List(); + public readonly List DataOffsets = new List(); + } + + // ═══════════════════════════════════════════════════════════════ + // Public API + // ═══════════════════════════════════════════════════════════════ + + /// + /// Serialize the scene to a splashpack v8 binary file. + /// + /// Absolute file path to write. + /// Pre-built scene data. + /// Optional callback for progress messages. + public static void Write(string path, in SceneData scene, Action log = null) + { + float gte = scene.gteScaling; + int totalFaces = 0; + + OffsetData luaOffset = new OffsetData(); + OffsetData meshOffset = new OffsetData(); + OffsetData atlasOffset = new OffsetData(); + OffsetData clutOffset = new OffsetData(); + + int clutCount = 0; + List luaFiles = new List(); + + // Count CLUTs + foreach (TextureAtlas atlas in scene.atlases) + { + foreach (var texture in atlas.ContainedTextures) + { + if (texture.ColorPalette != null) + clutCount++; + } + } + + // Collect unique Lua files + foreach (PSXObjectExporter exporter in scene.exporters) + { + if (exporter.LuaFile != null && !luaFiles.Contains(exporter.LuaFile)) + luaFiles.Add(exporter.LuaFile); + } + if (scene.sceneLuaFile != null && !luaFiles.Contains(scene.sceneLuaFile)) + luaFiles.Add(scene.sceneLuaFile); + + using (BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create))) + { + int colliderCount = 0; + foreach (var e in scene.exporters) + { + if (e.CollisionType != PSXCollisionType.None) + colliderCount++; + } + + // Build exporter index lookup for components + Dictionary exporterIndex = new Dictionary(); + for (int i = 0; i < scene.exporters.Length; i++) + exporterIndex[scene.exporters[i]] = i; + + // ────────────────────────────────────────────────────── + // Header (72 bytes total — splashpack v8) + // ────────────────────────────────────────────────────── + writer.Write('S'); + writer.Write('P'); + writer.Write((ushort)11); // version + 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)PSXTrig.ConvertCoordinateToPSX(scene.playerPos.x, gte)); + writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(-scene.playerPos.y, gte)); + writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerPos.z, gte)); + + writer.Write((ushort)PSXTrig.ConvertToFixed12(scene.playerRot.eulerAngles.x * Mathf.Deg2Rad)); + writer.Write((ushort)PSXTrig.ConvertToFixed12(scene.playerRot.eulerAngles.y * Mathf.Deg2Rad)); + writer.Write((ushort)PSXTrig.ConvertToFixed12(scene.playerRot.eulerAngles.z * Mathf.Deg2Rad)); + + 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) + + // 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) + { + const float fps = 30f; + float movePerFrame = scene.moveSpeed / fps / gte; + float sprintPerFrame = scene.sprintSpeed / fps / gte; + writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(movePerFrame * 4096f), 0, 65535)); + writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(sprintPerFrame * 4096f), 0, 65535)); + + float jumpVel = Mathf.Sqrt(2f * scene.gravity * scene.jumpHeight) / gte; + writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(jumpVel * 4096f), 0, 65535)); + + float gravPsx = scene.gravity / gte; + writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(gravPsx * 4096f), 0, 65535)); + + writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerRadius, gte)); + writer.Write((ushort)0); // padding + } + + // Name table offset placeholder (version 9, 4 bytes) + long nameTableOffsetPos = writer.BaseStream.Position; + writer.Write((uint)0); // placeholder for name table offset + + // Audio clip info (version 10, 8 bytes) + int audioClipCount = scene.audioClips?.Length ?? 0; + writer.Write((ushort)audioClipCount); + writer.Write((ushort)0); // padding + long audioTableOffsetPos = writer.BaseStream.Position; + writer.Write((uint)0); // placeholder for audio table offset + + // 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 + 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); + } + + // ────────────────────────────────────────────────────── + // Lua file metadata + // ────────────────────────────────────────────────────── + foreach (LuaFile luaFile in luaFiles) + { + luaOffset.PlaceholderPositions.Add(writer.BaseStream.Position); + writer.Write((int)0); // placeholder + writer.Write((uint)Encoding.UTF8.GetByteCount(luaFile.LuaScript)); + } + + // ────────────────────────────────────────────────────── + // GameObject section + // ────────────────────────────────────────────────────── + Dictionary interactableIndices = new Dictionary(); + for (int i = 0; i < scene.interactables.Length; i++) + { + var exp = scene.interactables[i].GetComponent(); + if (exp != null) interactableIndices[exp] = i; + } + + foreach (PSXObjectExporter exporter in scene.exporters) + { + meshOffset.PlaceholderPositions.Add(writer.BaseStream.Position); + writer.Write((int)0); // placeholder + + // Transform — position as 20.12 fixed-point + Vector3 pos = exporter.transform.localToWorldMatrix.GetPosition(); + writer.Write(PSXTrig.ConvertWorldToFixed12(pos.x / gte)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-pos.y / gte)); + writer.Write(PSXTrig.ConvertWorldToFixed12(pos.z / gte)); + + int[,] rot = PSXTrig.ConvertRotationToPSXMatrix(exporter.transform.rotation); + for (int r = 0; r < 3; r++) + for (int c = 0; c < 3; c++) + writer.Write((int)rot[r, c]); + + writer.Write((ushort)exporter.Mesh.Triangles.Count); + + if (exporter.LuaFile != null) + writer.Write((short)luaFiles.IndexOf(exporter.LuaFile)); + else + writer.Write((short)-1); + + // Bitfield (LSB = isActive) + writer.Write(exporter.IsActive ? 1 : 0); + + // Component indices (8 bytes) + writer.Write(interactableIndices.TryGetValue(exporter, out int interactIdx) ? (ushort)interactIdx : (ushort)0xFFFF); + writer.Write((ushort)0xFFFF); // _reserved0 (legacy healthIndex) + writer.Write((uint)0); // eventMask (runtime-only, must be zero) + + // World-space AABB (24 bytes) + WriteObjectAABB(writer, exporter, gte); + } + + // ────────────────────────────────────────────────────── + // Collider metadata (32 bytes each) + // ────────────────────────────────────────────────────── + for (int exporterIdx = 0; exporterIdx < scene.exporters.Length; exporterIdx++) + { + PSXObjectExporter exporter = scene.exporters[exporterIdx]; + if (exporter.CollisionType == PSXCollisionType.None) + continue; + + MeshFilter meshFilter = exporter.GetComponent(); + Mesh collisionMesh = exporter.CustomCollisionMesh != null + ? exporter.CustomCollisionMesh + : meshFilter?.sharedMesh; + + if (collisionMesh == null) + continue; + + WriteWorldAABB(writer, exporter, collisionMesh.bounds, gte); + + // Collision metadata (8 bytes) + writer.Write((byte)exporter.CollisionType); + writer.Write((byte)(1 << (exporter.CollisionLayer - 1))); + writer.Write((ushort)exporterIdx); + writer.Write((uint)0); // padding + } + + // ────────────────────────────────────────────────────── + // BVH data (inline) + // ────────────────────────────────────────────────────── + AlignToFourBytes(writer); + scene.bvh.WriteToBinary(writer, gte); + + // ────────────────────────────────────────────────────── + // Interactable components (24 bytes each) + // ────────────────────────────────────────────────────── + AlignToFourBytes(writer); + foreach (PSXInteractable interactable in scene.interactables) + { + var exp = interactable.GetComponent(); + int goIndex = exporterIndex.TryGetValue(exp, out int idx) ? idx : 0xFFFF; + + float radiusSq = interactable.InteractionRadius * interactable.InteractionRadius; + writer.Write(PSXTrig.ConvertWorldToFixed12(radiusSq / (gte * gte))); + + Vector3 offset = interactable.InteractionOffset; + writer.Write(PSXTrig.ConvertWorldToFixed12(offset.x / gte)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-offset.y / gte)); + writer.Write(PSXTrig.ConvertWorldToFixed12(offset.z / gte)); + + writer.Write((byte)interactable.InteractButton); + byte flags = 0; + if (interactable.IsRepeatable) flags |= 0x01; + if (interactable.ShowPrompt) flags |= 0x02; + if (interactable.RequireLineOfSight) flags |= 0x04; + writer.Write(flags); + writer.Write(interactable.CooldownFrames); + + writer.Write((ushort)0); // currentCooldown (runtime) + writer.Write((ushort)goIndex); + } + + // ────────────────────────────────────────────────────── + // World collision soup (version 7+) + // ────────────────────────────────────────────────────── + if (scene.collisionExporter.MeshCount > 0) + { + AlignToFourBytes(writer); + scene.collisionExporter.WriteToBinary(writer, gte); + } + + // ────────────────────────────────────────────────────── + // Nav region data (version 7+) + // ────────────────────────────────────────────────────── + if (scene.navRegionBuilder.RegionCount > 0) + { + AlignToFourBytes(writer); + scene.navRegionBuilder.WriteToBinary(writer, gte); + } + + // ────────────────────────────────────────────────────── + // Room/portal data (version 11, interior scenes) + // Must be in the sequential cursor section (after nav regions, + // before atlas metadata) so the C++ reader can find it. + // ────────────────────────────────────────────────────── + if (scene.roomBuilder != null && scene.roomBuilder.RoomCount > 0) + { + AlignToFourBytes(writer); + scene.roomBuilder.WriteToBinary(writer, scene.gteScaling); + log?.Invoke($"Room/portal data: {scene.roomBuilder.RoomCount} rooms, {scene.roomBuilder.PortalCount} portals, {scene.roomBuilder.TotalTriRefCount} tri-refs.", LogType.Log); + } + + // ────────────────────────────────────────────────────── + // Atlas metadata + // ────────────────────────────────────────────────────── + foreach (TextureAtlas atlas in scene.atlases) + { + atlasOffset.PlaceholderPositions.Add(writer.BaseStream.Position); + writer.Write((int)0); // placeholder + writer.Write((ushort)atlas.Width); + writer.Write((ushort)TextureAtlas.Height); + writer.Write((ushort)atlas.PositionX); + writer.Write((ushort)atlas.PositionY); + } + + // ────────────────────────────────────────────────────── + // CLUT metadata + // ────────────────────────────────────────────────────── + foreach (TextureAtlas atlas in scene.atlases) + { + foreach (var texture in atlas.ContainedTextures) + { + if (texture.ColorPalette != null) + { + clutOffset.PlaceholderPositions.Add(writer.BaseStream.Position); + writer.Write((int)0); // placeholder + writer.Write((ushort)texture.ClutPackingX); + writer.Write((ushort)texture.ClutPackingY); + writer.Write((ushort)texture.ColorPalette.Count); + writer.Write((ushort)0); + } + } + } + + // ══════════════════════════════════════════════════════ + // Data sections + // ══════════════════════════════════════════════════════ + + // Lua data + foreach (LuaFile luaFile in luaFiles) + { + AlignToFourBytes(writer); + luaOffset.DataOffsets.Add(writer.BaseStream.Position); + writer.Write(Encoding.UTF8.GetBytes(luaFile.LuaScript)); + } + + // Mesh data + foreach (PSXObjectExporter exporter in scene.exporters) + { + AlignToFourBytes(writer); + meshOffset.DataOffsets.Add(writer.BaseStream.Position); + totalFaces += exporter.Mesh.Triangles.Count; + + foreach (Tri tri in exporter.Mesh.Triangles) + { + // Vertex positions (3 × 6 bytes) + WriteVertexPosition(writer, tri.v0); + WriteVertexPosition(writer, tri.v1); + WriteVertexPosition(writer, tri.v2); + + // Normal for v0 only + WriteVertexNormals(writer, tri.v0); + + // Vertex colors (3 × 4 bytes) + WriteVertexColor(writer, tri.v0); + WriteVertexColor(writer, tri.v1); + WriteVertexColor(writer, tri.v2); + + if (tri.IsUntextured) + { + // Zero UVs + writer.Write((byte)0); writer.Write((byte)0); + writer.Write((byte)0); writer.Write((byte)0); + writer.Write((byte)0); writer.Write((byte)0); + writer.Write((ushort)0); // padding + + // Sentinel tpage = 0xFFFF marks untextured + writer.Write((ushort)0xFFFF); + writer.Write((ushort)0); + writer.Write((ushort)0); + writer.Write((ushort)0); + } + else + { + PSXTexture2D tex = exporter.GetTexture(tri.TextureIndex); + int expander = 16 / (int)tex.BitDepth; + + WriteVertexUV(writer, tri.v0, tex, expander); + WriteVertexUV(writer, tri.v1, tex, expander); + WriteVertexUV(writer, tri.v2, tex, expander); + writer.Write((ushort)0); // padding + + TPageAttr tpage = new TPageAttr(); + tpage.SetPageX(tex.TexpageX); + tpage.SetPageY(tex.TexpageY); + tpage.Set(tex.BitDepth.ToColorMode()); + tpage.SetDithering(true); + writer.Write((ushort)tpage.info); + writer.Write((ushort)tex.ClutPackingX); + writer.Write((ushort)tex.ClutPackingY); + writer.Write((ushort)0); // padding + } + } + } + + // 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) + // ────────────────────────────────────────────────────── + AlignToFourBytes(writer); + long nameTableStart = writer.BaseStream.Position; + foreach (PSXObjectExporter exporter in scene.exporters) + { + string objName = exporter.gameObject.name; + if (objName.Length > 24) objName = objName.Substring(0, 24); + byte[] nameBytes = Encoding.UTF8.GetBytes(objName); + writer.Write((byte)nameBytes.Length); + writer.Write(nameBytes); + writer.Write((byte)0); // null terminator + } + + // Backfill name table offset + { + long endPos = writer.BaseStream.Position; + writer.Seek((int)nameTableOffsetPos, SeekOrigin.Begin); + writer.Write((uint)nameTableStart); + writer.Seek((int)endPos, SeekOrigin.Begin); + } + + // ────────────────────────────────────────────────────── + // Audio clip data (version 10) + // ────────────────────────────────────────────────────── + 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(); + 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 ?? ""; + writer.Write((byte)(clip.loop ? 1 : 0)); + writer.Write((byte)System.Math.Min(name.Length, 255)); + audioNameOffsetPositions.Add(writer.BaseStream.Position); + writer.Write((uint)0); // nameOffset placeholder + } + + // Second pass: write ADPCM data and backfill offsets + 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); + + // 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); + } + } + + // Backfill audio table offset in header + { + long curPos = writer.BaseStream.Position; + writer.Seek((int)audioTableOffsetPos, SeekOrigin.Begin); + 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); + } + + // Backfill offsets + BackfillOffsets(writer, luaOffset, "lua", log); + BackfillOffsets(writer, meshOffset, "mesh", log); + BackfillOffsets(writer, atlasOffset, "atlas", log); + BackfillOffsets(writer, clutOffset, "clut", log); + } + + log?.Invoke($"{totalFaces} faces written to {Path.GetFileName(path)}", LogType.Log); + } + + // ═══════════════════════════════════════════════════════════════ + // Static helpers + // ═══════════════════════════════════════════════════════════════ + + private static void WriteVertexPosition(BinaryWriter w, PSXVertex v) + { + w.Write((short)v.vx); + w.Write((short)v.vy); + w.Write((short)v.vz); + } + + private static void WriteVertexNormals(BinaryWriter w, PSXVertex v) + { + w.Write((short)v.nx); + w.Write((short)v.ny); + w.Write((short)v.nz); + } + + private static void WriteVertexColor(BinaryWriter w, PSXVertex v) + { + w.Write((byte)v.r); + w.Write((byte)v.g); + w.Write((byte)v.b); + w.Write((byte)0); // padding + } + + private static void WriteVertexUV(BinaryWriter w, PSXVertex v, PSXTexture2D t, int expander) + { + w.Write((byte)(v.u + t.PackingX * expander)); + w.Write((byte)(v.v + t.PackingY)); + } + + private static void WriteObjectAABB(BinaryWriter writer, PSXObjectExporter exporter, float gte) + { + MeshFilter mf = exporter.GetComponent(); + Mesh mesh = mf?.sharedMesh; + if (mesh != null) + { + WriteWorldAABB(writer, exporter, mesh.bounds, gte); + } + else + { + for (int z = 0; z < 6; z++) writer.Write((int)0); + } + } + + private static void WriteWorldAABB(BinaryWriter writer, PSXObjectExporter exporter, Bounds localBounds, float gte) + { + Vector3 ext = localBounds.extents; + Vector3 center = localBounds.center; + Vector3 aabbMin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue); + Vector3 aabbMax = new Vector3(float.MinValue, float.MinValue, float.MinValue); + + // Compute world-space AABB from 8 transformed corners + for (int i = 0; i < 8; i++) + { + Vector3 corner = center + new Vector3( + (i & 1) != 0 ? ext.x : -ext.x, + (i & 2) != 0 ? ext.y : -ext.y, + (i & 4) != 0 ? ext.z : -ext.z + ); + Vector3 world = exporter.transform.TransformPoint(corner); + aabbMin = Vector3.Min(aabbMin, world); + aabbMax = Vector3.Max(aabbMax, world); + } + + // PS1 coordinate space (negate Y, swap min/max) + writer.Write(PSXTrig.ConvertWorldToFixed12(aabbMin.x / gte)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-aabbMax.y / gte)); + writer.Write(PSXTrig.ConvertWorldToFixed12(aabbMin.z / gte)); + writer.Write(PSXTrig.ConvertWorldToFixed12(aabbMax.x / gte)); + writer.Write(PSXTrig.ConvertWorldToFixed12(-aabbMin.y / gte)); + writer.Write(PSXTrig.ConvertWorldToFixed12(aabbMax.z / gte)); + } + + private static void AlignToFourBytes(BinaryWriter writer) + { + long pos = writer.BaseStream.Position; + int padding = (int)(4 - (pos % 4)) % 4; + if (padding > 0) + writer.Write(new byte[padding]); + } + + private static void BackfillOffsets(BinaryWriter writer, OffsetData data, string sectionName, Action log) + { + if (data.PlaceholderPositions.Count != data.DataOffsets.Count) + { + log?.Invoke($"Offset mismatch in {sectionName}: {data.PlaceholderPositions.Count} placeholders vs {data.DataOffsets.Count} data blocks", LogType.Error); + return; + } + + for (int i = 0; i < data.PlaceholderPositions.Count; i++) + { + writer.Seek((int)data.PlaceholderPositions[i], SeekOrigin.Begin); + writer.Write((int)data.DataOffsets[i]); + } + } + } +} diff --git a/Runtime/PSXSceneWriter.cs.meta b/Runtime/PSXSceneWriter.cs.meta new file mode 100644 index 0000000..6ce5fb7 --- /dev/null +++ b/Runtime/PSXSceneWriter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c213ee895b048d04088d60920fbf4bfc \ No newline at end of file diff --git a/Runtime/PSXTexture2D.cs b/Runtime/PSXTexture2D.cs index ecb347d..53ec79d 100644 --- a/Runtime/PSXTexture2D.cs +++ b/Runtime/PSXTexture2D.cs @@ -277,15 +277,5 @@ namespace SplashEdit.RuntimeCode return vramTexture; } - /// - /// Check if we need to update stored texture - /// - /// new settings for color bit depth - /// new texture - /// return true if sored texture is different from a new one - internal bool NeedUpdate(PSXBPP bitDepth, Texture2D texture) - { - return BitDepth != bitDepth || texture.GetInstanceID() != texture.GetInstanceID(); - } } } \ No newline at end of file diff --git a/Runtime/PSXTexture2D.cs.meta b/Runtime/PSXTexture2D.cs.meta index 0e3e359..b1c1c20 100644 --- a/Runtime/PSXTexture2D.cs.meta +++ b/Runtime/PSXTexture2D.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: ac29cfb818d45b12dba84e61317c794b \ No newline at end of file +guid: 0ee2ed052f09f7f419c794493d751029 \ No newline at end of file diff --git a/Runtime/TexturePacker.cs b/Runtime/TexturePacker.cs index 7eb4c41..31c61d1 100644 --- a/Runtime/TexturePacker.cs +++ b/Runtime/TexturePacker.cs @@ -251,7 +251,6 @@ namespace SplashEdit.RuntimeCode atlas.PositionY = y; _finalizedAtlases.Add(atlas); placed = true; - Debug.Log($"Placed an atlas at: {x},{y}"); break; } } diff --git a/Runtime/TexturePacker.cs.meta b/Runtime/TexturePacker.cs.meta index ed468a6..27ad4bf 100644 --- a/Runtime/TexturePacker.cs.meta +++ b/Runtime/TexturePacker.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: ae72963f3a7f0820cbc31cb4764c51cb \ No newline at end of file +guid: 2a5f290a3e0de24448841847f2039f58 \ No newline at end of file diff --git a/Runtime/Utils.cs b/Runtime/Utils.cs index 6393dd3..f6fc111 100644 --- a/Runtime/Utils.cs +++ b/Runtime/Utils.cs @@ -1,11 +1,13 @@ +#if UNITY_EDITOR using UnityEditor; +#endif using System.Collections.Generic; using UnityEngine; using System.Linq; namespace SplashEdit.RuntimeCode { - +#if UNITY_EDITOR public static class DataStorage { private static readonly string psxDataPath = "Assets/PSXData.asset"; @@ -54,6 +56,7 @@ namespace SplashEdit.RuntimeCode } } } +#endif /// /// Represents a prohibited area in PlayStation 2D VRAM where textures should not be packed. @@ -101,15 +104,18 @@ namespace SplashEdit.RuntimeCode public static class PSXTrig { /// - /// Converts a floating-point coordinate to a PSX-compatible 3.12 fixed-point format. - /// The value is clamped to the range [-4, 3.999] and scaled by the provided GTEScaling factor. + /// Converts a floating-point coordinate to a PSX-compatible 4.12 fixed-point format (int16). + /// The value is divided by GTEScaling, then scaled to 4.12 fixed-point and clamped to int16 range. + /// Usable range: [-8.0, ~8.0) in GTE units (i.e. [-8*GTEScaling, 8*GTEScaling) in world units). + /// Use this for mesh vertex positions (local space) and other data that fits in int16. /// - /// The coordinate value to convert. - /// A scaling factor for the value (default is 1.0f). - /// The converted coordinate in 3.12 fixed-point format. + /// The coordinate value in world units. + /// World-to-GTE scaling factor (default 1.0f). Value is divided by this. + /// The converted coordinate in 4.12 fixed-point format as a 16-bit signed integer. public static short ConvertCoordinateToPSX(float value, float GTEScaling = 1.0f) { - return (short)(Mathf.Clamp(value / GTEScaling, -4f, 3.999f) * 4096); + int fixedValue = Mathf.RoundToInt((value / GTEScaling) * 4096.0f); + return (short)Mathf.Clamp(fixedValue, -32768, 32767); } /// @@ -162,15 +168,31 @@ namespace SplashEdit.RuntimeCode } /// - /// Converts a floating-point value to a 3.12 fixed-point format (PSX format). - /// The value is scaled by a factor of 4096 and clamped to the range of a signed 16-bit integer. + /// Converts a floating-point value to 4.12 fixed-point format (int16). + /// Suitable for values in [-8.0, ~8.0) range, such as rotation matrix elements or normals. /// /// The floating-point value to convert. - /// The converted value in 3.12 fixed-point format as a 16-bit signed integer. + /// The converted value in 4.12 fixed-point format as a 16-bit signed integer. public static short ConvertToFixed12(float value) { - int fixedValue = Mathf.RoundToInt(value * 4096.0f); // Scale to 3.12 format - return (short)Mathf.Clamp(fixedValue, -32768, 32767); // Clamp to signed 16-bit + int fixedValue = Mathf.RoundToInt(value * 4096.0f); + return (short)Mathf.Clamp(fixedValue, -32768, 32767); + } + + /// + /// Converts a GTE-space value to 20.12 fixed-point format (int32). + /// Use this for world-space positions, collision AABBs, BVH bounds, and any data + /// that needs the full int32 range. Caller must divide by GTEScaling BEFORE calling, + /// i.e. pass (worldValue / GTEScaling). This matches ConvertCoordinateToPSX's + /// coordinate space but returns int32 instead of clamping to int16. + /// Usable range: approximately [-524288.0, 524288.0). + /// + /// The value to convert (in GTE units, i.e. worldValue / GTEScaling). + /// The converted value in 20.12 fixed-point format as a 32-bit signed integer. + public static int ConvertWorldToFixed12(float value) + { + long fixedValue = (long)Mathf.RoundToInt(value * 4096.0f); + return (int)Mathf.Clamp(fixedValue, int.MinValue, int.MaxValue); } } /// @@ -334,6 +356,7 @@ namespace SplashEdit.RuntimeCode public static byte ColorUnityToPSX(float v) => (byte)(Mathf.Clamp(v * 255, 0, 255)); +#if UNITY_EDITOR public static void SetTextureImporterFormat(Texture2D texture, bool isReadable) { if (texture == null) @@ -362,6 +385,7 @@ namespace SplashEdit.RuntimeCode } } } +#endif } } diff --git a/Runtime/Utils.cs.meta b/Runtime/Utils.cs.meta index ed98df9..2250c19 100644 --- a/Runtime/Utils.cs.meta +++ b/Runtime/Utils.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: 8c9b0581c1e4eeb6296f4c162359043f \ No newline at end of file +guid: eed25c4c241a9114fa75776896d726a7 \ No newline at end of file diff --git a/Runtime/net.psxsplash.splashedit.Runtime.asmdef b/Runtime/net.psxsplash.splashedit.Runtime.asmdef index d825807..3183297 100644 --- a/Runtime/net.psxsplash.splashedit.Runtime.asmdef +++ b/Runtime/net.psxsplash.splashedit.Runtime.asmdef @@ -7,8 +7,11 @@ "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, - "overrideReferences": false, - "precompiledReferences": [], + "overrideReferences": true, + "precompiledReferences": [ + "DotRecast.Core.dll", + "DotRecast.Recast.dll" + ], "autoReferenced": true, "defineConstraints": [], "versionDefines": [], diff --git a/Runtime/net.psxsplash.splashedit.Runtime.asmdef.meta b/Runtime/net.psxsplash.splashedit.Runtime.asmdef.meta index 3ea92ec..74d57d6 100644 --- a/Runtime/net.psxsplash.splashedit.Runtime.asmdef.meta +++ b/Runtime/net.psxsplash.splashedit.Runtime.asmdef.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 1afaf17520143848ea52af093808349d +guid: 4db83d7a3934bae48ae5d04fda8335bb AssemblyDefinitionImporter: externalObjects: {} userData: diff --git a/Sample.meta b/Sample.meta index 9ab8c24..149e5f7 100644 --- a/Sample.meta +++ b/Sample.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 03c1f3626c09eb44eb79759ab2675f9e +guid: 45b95f68e129e6f478d509d59f39bc6e folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Sample/Lua.meta b/Sample/Lua.meta index 4b2a10a..907c076 100644 --- a/Sample/Lua.meta +++ b/Sample/Lua.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 83f98f1f40209b141a597a83c862a61f +guid: f9337852f7cf29848b42f674d396732d folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Sample/Lua/example.lua b/Sample/Lua/example.lua index 32ce2fc..a2510bc 100644 --- a/Sample/Lua/example.lua +++ b/Sample/Lua/example.lua @@ -1,21 +1,95 @@ -local dir = true -local minY = -300 -local maxY = -25 +-- ============================================================================ +-- PSXSplash Example Script +-- Demonstrates all working Lua events and API calls. +-- Attach this to a PSXObjectExporter via the "Lua File" field. +-- ============================================================================ -function doSomething(first, other) - local pos = first.position +-- Per-object state (each object gets its own copy of these locals) +local bobAmplitude = 50 -- vertical bob range +local bobSpeed = 2 -- bob increment per frame +local bobPhase = 0 - if dir then - pos.y = pos.y + 10 - if pos.y >= maxY then - dir = false - end - else - pos.y = pos.y - 10 - if pos.y <= minY then - dir = true - end +-- ============================================================================ +-- LIFECYCLE EVENTS +-- ============================================================================ + +--- Called once when the object is first loaded into the scene. +function onCreate(self) + Debug.Log("Object created!") + local pos = Entity.GetPosition(self) + Debug.Log(" start pos: " .. pos.x .. ", " .. pos.y .. ", " .. pos.z) +end + +--- Called every frame while the object is active. +--- WARNING: This runs on a 33 MHz MIPS R3000 — keep it light! +function onUpdate(self) + -- Simple vertical bob + bobPhase = bobPhase + bobSpeed + if bobPhase > 360 then bobPhase = bobPhase - 360 end + + local pos = Entity.GetPosition(self) + pos.y = pos.y + PSXMath.Sign(180 - bobPhase) * bobAmplitude / 60 + Entity.SetPosition(self, pos) +end + +--- Called when the object is destroyed or the scene unloads. +function onDestroy(self) + Debug.Log("Object destroyed!") +end + +-- onEnable / onDisable are available but omitted here to reduce log noise. +-- function onEnable(self) Debug.Log("Object enabled") end +-- function onDisable(self) Debug.Log("Object disabled") end + +-- ============================================================================ +-- INTERACTION EVENTS (requires PSXInteractable component) +-- ============================================================================ + +--- Called when the player interacts with this object. +function onInteract(self) + Debug.Log("Player interacted!") + local active = Entity.IsActive(self) + Entity.SetActive(self, not active) +end + +-- ============================================================================ +-- COLLISION / TRIGGER EVENTS +-- ============================================================================ + +--- Called when this object's collider overlaps another. +function onCollision(self, other) + Debug.Log("Collision with another object") +end + +--- Called on the first frame two triggers overlap. +function onTriggerEnter(self, other) + Debug.Log("Trigger enter") +end + +--- Called every frame while two triggers continue to overlap. +function onTriggerStay(self, other) + -- Expensive! Avoid heavy work here. +end + +--- Called when two triggers stop overlapping. +function onTriggerExit(self, other) + Debug.Log("Trigger exit") +end + +-- ============================================================================ +-- INPUT EVENTS +-- ============================================================================ + +--- Called when any button is pressed this frame. +function onButtonPress(self, button) + if button == Input.CROSS then + Debug.Log("Cross pressed!") end +end - first.position = pos +--- Called when any button is released this frame. +function onButtonRelease(self, button) + if button == Input.CROSS then + Debug.Log("Cross released!") + end end \ No newline at end of file diff --git a/Sample/Lua/example.lua.meta b/Sample/Lua/example.lua.meta index 6f508d6..23dbd8b 100644 --- a/Sample/Lua/example.lua.meta +++ b/Sample/Lua/example.lua.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 2080a81a20451b74bb4fed47cde1eb64 +guid: c993fd0fc3a7b2e4db45e291e983ac47 ScriptedImporter: internalIDToNameTable: [] externalObjects: {} @@ -7,4 +7,4 @@ ScriptedImporter: userData: assetBundleName: assetBundleVariant: - script: {fileID: 11500000, guid: d364a1392e3bccd77aca824ac471f89c, type: 3} + script: {fileID: 11500000, guid: 74e983e6cf3376944af7b469023d6e4d, type: 3} diff --git a/Sample/Material.meta b/Sample/Material.meta index 2740ad7..03fdfe7 100644 --- a/Sample/Material.meta +++ b/Sample/Material.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 98c8fecb427143042be70f00ac1ebb42 +guid: e5eb77d7823c1c944ba10953ae01f796 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Sample/Material/PSXDefault.mat b/Sample/Material/PSXDefault.mat index 661d116..cb924af 100644 --- a/Sample/Material/PSXDefault.mat +++ b/Sample/Material/PSXDefault.mat @@ -134,4 +134,4 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3} m_Name: m_EditorClassIdentifier: - version: 9 + version: 10 diff --git a/Sample/Material/PSXDefault.mat.meta b/Sample/Material/PSXDefault.mat.meta index 42a6ced..4dba36f 100644 --- a/Sample/Material/PSXDefault.mat.meta +++ b/Sample/Material/PSXDefault.mat.meta @@ -1,8 +1,8 @@ fileFormatVersion: 2 -guid: ef90866ae3c8e3241995606c20a6f335 +guid: 6bf1caff36887d44fa357159c84fe954 NativeFormatImporter: externalObjects: {} - mainObjectFileID: 2100000 + mainObjectFileID: 0 userData: assetBundleName: assetBundleVariant: diff --git a/Sample/Scene.meta b/Sample/Scene.meta index 5377593..0dc61e9 100644 --- a/Sample/Scene.meta +++ b/Sample/Scene.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 6a2d025695ecb074a811681d20c569b0 +guid: 2ca98fa730fe5fa4ea239123eb09887b folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Sample/Scene/Demo.unity.meta b/Sample/Scene/Demo.unity.meta index ba8ad78..1085384 100644 --- a/Sample/Scene/Demo.unity.meta +++ b/Sample/Scene/Demo.unity.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: dbf5265ad7d363446800065929646d36 +guid: 5688ba03f531c3245a838793c0ae7f93 DefaultImporter: externalObjects: {} userData: diff --git a/doc.meta b/doc.meta index 4bc1249..43741c2 100644 --- a/doc.meta +++ b/doc.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 311ff9868024b5078bd12a6c2252a4ef +guid: 2241c29d16cfcf2418683f24bf7c3238 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/doc/splashbundle.md b/doc/splashbundle.md deleted file mode 100644 index 75e811e..0000000 --- a/doc/splashbundle.md +++ /dev/null @@ -1,137 +0,0 @@ -# SPLASHPACK Binary File Format Specification - -All numeric values are stored in little‐endian format. All offsets are counted from the beginning of the file. - ---- - -## 1. File Header (32 bytes) - -| Offset | Size | Type | Description | -| ------ | ---- | ------ | ----------------------------------- | -| 0x00 | 2 | char | `'SP'` – File magic | -| 0x02 | 2 | uint16 | Version number | -| 0x04 | 2 | uint16 | Number of Lua Files | -| 0x06 | 2 | uint16 | Number of GameObjects | -| 0x08 | 2 | uint16 | Number of Navmeshes | -| 0x0A | 2 | uint16 | Number of Texture Atlases | -| 0x0C | 2 | uint16 | Number of CLUTs | -| 0x0E | 2 | uint16 | Player Start X (Fixed-point) | -| 0x10 | 2 | uint16 | Player Start Y (Fixed-point) | -| 0x12 | 2 | uint16 | Player Start Z (Fixed-point) | -| 0x14 | 2 | uint16 | Player Rotation X (Fixed-point) | -| 0x16 | 2 | uint16 | Player Rotation Y (Fixed-point) | -| 0x18 | 2 | uint16 | Player Rotation Z (Fixed-point) | -| 0x1A | 2 | uint16 | Player Height (Fixed-point) | -| 0x1C | 4 | uint32 | Reserved (always 0) | - ---- - -## 2. Metadata Section - -### 2.1 Lua File Descriptors (8 bytes each) - -| Offset (per entry) | Size | Type | Description | -| ------------------ | ---- | ------ | --------------------------------- | -| 0x00 | 4 | uint32 | Lua File Data Offset | -| 0x04 | 4 | uint32 | Lua File Size | - -### 2.2 GameObject Descriptors (56 bytes each) - -| Offset (per entry) | Size | Type | Description | -| ------------------ | ---- | -------- | --------------------------------- | -| 0x00 | 4 | uint32 | Mesh Data Offset | -| 0x04 | 4 | int32 | X position (Fixed-point) | -| 0x08 | 4 | int32 | Y position (Fixed-point) | -| 0x0C | 4 | int32 | Z position (Fixed-point) | -| 0x10 | 36 | int32[9] | 3×3 Rotation Matrix (Fixed-point) | -| 0x34 | 2 | uint16 | Triangle count | -| 0x36 | 2 | int16 | Lua File Index (-1 if none) | - -### 2.3 Navmesh Descriptors (8 bytes each) - -| Offset (per entry) | Size | Type | Description | -| ------------------ | ---- | ------ | --------------------------------- | -| 0x00 | 4 | uint32 | Navmesh Data Offset | -| 0x04 | 2 | uint16 | Triangle count | -| 0x06 | 2 | uint16 | Padding | - -### 2.4 Texture Atlas Descriptors (12 bytes each) - -| Offset (per entry) | Size | Type | Description | -| ------------------ | ---- | ------ | -------------------------------- | -| 0x00 | 4 | uint32 | Pixel Data Offset | -| 0x04 | 2 | uint16 | Atlas Width | -| 0x06 | 2 | uint16 | Atlas Height | -| 0x08 | 2 | uint16 | Atlas Position X (VRAM origin) | -| 0x0A | 2 | uint16 | Atlas Position Y (VRAM origin) | - -### 2.5 CLUT Descriptors (12 bytes each) - -| Offset (per entry) | Size | Type | Description | -| ------------------ | ---- | ------ | ----------------------------------------------------- | -| 0x00 | 4 | uint32 | CLUT Data Offset | -| 0x04 | 2 | uint16 | CLUT Packing X (in 16-pixel units) | -| 0x06 | 2 | uint16 | CLUT Packing Y | -| 0x08 | 2 | uint16 | Palette entry count | -| 0x0A | 2 | uint16 | Padding | - ---- - -## 3. Data Section - -### 3.1 Lua Data Block - -Each Lua file is stored as raw bytes. The size of each Lua file is specified in the Lua File Descriptor. - ---- - -### 3.2 Mesh Data Block (per GameObject) - -Each mesh is made of triangles: - -**Triangle Layout (52 bytes):** - -| Field | Size | Description | -| ------------------- |------|-----------------------------------------------------| -| Vertex v0 | 6 | x, y, z (int16) | -| Vertex v1 | 6 | x, y, z (int16) | -| Vertex v2 | 6 | x, y, z (int16) | -| Normal | 6 | nx, ny, nz (int16) | -| Color v0 | 4 | RGB + padding (uint8 × 4) | -| Color v1 | 4 | RGB + padding (uint8 × 4) | -| Color v2 | 4 | RGB + padding (uint8 × 4) | -| UV v0 | 2 | u, v (uint8 × 2) | -| UV v1 | 2 | u, v (uint8 × 2) | -| UV v2 | 2 | u, v (uint8 × 2) | -| UV padding | 2 | uint16 (always 0) | -| TPage | 2 | Texture page info | -| CLUT X | 2 | Position in VRAM (X / 16) | -| CLUT Y | 2 | Position in VRAM Y | -| Final padding | 2 | uint16 | - ---- - -### 3.3 Navmesh Data Block - -Each triangle is 3 vertices (`int16` x/y/z), total 18 bytes per triangle. - ---- - -### 3.4 Texture Atlas Data Block - -Pixel data stored as `uint16[width * height]`. - ---- - -### 3.5 CLUT Data Block - -Pixel data stored as `uint16[length]`. - ---- - -## Notes - -- All offsets are aligned to 4-byte boundaries. -- Fixed-point values are scaled by the `GTEScaling` factor. -- Lua file indices in GameObject descriptors are `-1` if no Lua file is associated with the object. -- Navmesh triangles are stored as raw vertex data without additional attributes. \ No newline at end of file diff --git a/package.json b/package.json index 3443c9a..7611a39 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,5 @@ "description": "A toolkit for PSX asset management within Unity", "unity": "6000.0", "dependencies": { - "com.unity.ai.navigation": "2.0.6" } } \ No newline at end of file diff --git a/package.json.meta b/package.json.meta index d70bd40..f285160 100644 --- a/package.json.meta +++ b/package.json.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 4ce4c77cd43204cf8bdfca08c2b66f83 +guid: b0d9d39bed997264cb31a93eadb676f8 PackageManifestImporter: externalObjects: {} userData: diff --git a/tools.meta b/tools.meta index b76e2a1..4ad1e97 100644 --- a/tools.meta +++ b/tools.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: cf7149f85c29d3f4d9a7f968d8825ffa +guid: 19e02f51caaa13d4bae3949f98320b09 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/tools/LUA_VSCODE_SETUP.md b/tools/LUA_VSCODE_SETUP.md new file mode 100644 index 0000000..71f3926 --- /dev/null +++ b/tools/LUA_VSCODE_SETUP.md @@ -0,0 +1,49 @@ +# PSXSplash Lua — VS Code Autocomplete Setup + +Get full IntelliSense (autocomplete, hover docs, go-to-definition) for the +PSXSplash Lua API in Visual Studio Code. + +## 1. Install the Lua Language Server extension + +Open VS Code → Extensions → search **sumneko.lua** → Install. + +## 2. Point the language server at the stubs + +Add (or merge) the following into your workspace `.vscode/settings.json`: + +```jsonc +{ + "Lua.workspace.library": [ + // Path to the EmmyLua stubs shipped with SplashEdit + "${workspaceFolder}/splashedit/tools" + ], + "Lua.runtime.version": "Lua 5.2", + "Lua.diagnostics.globals": [ + // Event callbacks the engine calls — not "undefined" globals + "onCreate", "onUpdate", "onDestroy", + "onEnable", "onDisable", + "onCollision", "onInteract", + "onTriggerEnter", "onTriggerStay", "onTriggerExit", + "onButtonPress", "onButtonRelease" + ] +} +``` + +> If your Lua scripts live inside the Unity project +> (`kitchensink/Assets/Lua/`), open that folder as the workspace root, then +> adjust the `library` path to be relative to it, e.g. +> `"../splashedit/tools"`. + +## 3. Verify + +Open any `.lua` script and type `Entity.` — you should see `Find`, +`FindByIndex`, `GetCount`, etc. with full parameter docs. + +Hover over `Input.CROSS` to see its type annotation. Hover over `onUpdate` +to see the performance warning. + +## Updating the stubs + +When the C++ API changes, regenerate `splash_api.lua` from the +`RegisterAll()` function in `psxsplash/src/luaapi.cpp`. The stubs file is +the single source of truth for editor autocomplete. diff --git a/doc/splashbundle.md.meta b/tools/LUA_VSCODE_SETUP.md.meta similarity index 75% rename from doc/splashbundle.md.meta rename to tools/LUA_VSCODE_SETUP.md.meta index f30ba70..14e3669 100644 --- a/doc/splashbundle.md.meta +++ b/tools/LUA_VSCODE_SETUP.md.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 1944ac962a00b23c2a880b5134cdc7ab +guid: dacec9280b4291d4c9a93b1522687267 TextScriptImporter: externalObjects: {} userData: diff --git a/tools/imhex.hexproj.meta b/tools/imhex.hexproj.meta index d51da29..41e9cc2 100644 --- a/tools/imhex.hexproj.meta +++ b/tools/imhex.hexproj.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 871e1f08910e9f329ad3fce5d77c8785 +guid: 2e825620cbb189b42965419f16e3fbcf DefaultImporter: externalObjects: {} userData: diff --git a/tools/splash_api.lua b/tools/splash_api.lua new file mode 100644 index 0000000..b73e84a --- /dev/null +++ b/tools/splash_api.lua @@ -0,0 +1,383 @@ +-- ============================================================================ +-- PSXSplash Lua API — EmmyLua Stubs +-- Generated for SplashEdit. DO NOT EDIT — regenerate from luaapi.cpp. +-- Place this file in your workspace root so the Lua Language Server picks it up. +-- ============================================================================ + +--- @meta + +-- ============================================================================ +-- Types +-- ============================================================================ + +--- A 3-component vector in 20.12 fixed-point space. +--- @class Vec3Table +--- @field x number +--- @field y number +--- @field z number + +--- Opaque handle returned by Entity.Find / Entity.FindByIndex. +--- @alias EntityHandle table + +-- ============================================================================ +-- Entity API +-- ============================================================================ + +--- @class Entity +Entity = {} + +--- Find a game object by its Lua script index. +--- @param scriptIndex number The lua file index (0-based) assigned during export. +--- @return EntityHandle|nil Handle for use with other Entity functions, or nil. +function Entity.Find(scriptIndex) end + +--- Find a game object by its global object index. +--- @param index number Object index (0-based) in the scene's game-object array. +--- @return EntityHandle|nil +function Entity.FindByIndex(index) end + +--- Return the total number of game objects in the scene. +--- @return number +function Entity.GetCount() end + +--- Activate or deactivate a game object. +--- @param entity EntityHandle +--- @param active boolean +function Entity.SetActive(entity, active) end + +--- Check whether a game object is active. +--- @param entity EntityHandle +--- @return boolean +function Entity.IsActive(entity) end + +--- Get the world-space position of an entity (20.12 fixed-point). +--- @param entity EntityHandle +--- @return Vec3Table +function Entity.GetPosition(entity) end + +--- Set the world-space position of an entity. +--- @param entity EntityHandle +--- @param pos Vec3Table +function Entity.SetPosition(entity, pos) end + +-- ============================================================================ +-- Vec3 API +-- ============================================================================ + +--- @class Vec3 +Vec3 = {} + +--- Create a new vector. +--- @param x number +--- @param y number +--- @param z number +--- @return Vec3Table +function Vec3.new(x, y, z) end + +--- Component-wise addition. +--- @param a Vec3Table +--- @param b Vec3Table +--- @return Vec3Table +function Vec3.add(a, b) end + +--- Component-wise subtraction (a - b). +--- @param a Vec3Table +--- @param b Vec3Table +--- @return Vec3Table +function Vec3.sub(a, b) end + +--- Scalar multiply. +--- @param v Vec3Table +--- @param s number +--- @return Vec3Table +function Vec3.mul(v, s) end + +--- Dot product. +--- @param a Vec3Table +--- @param b Vec3Table +--- @return number +function Vec3.dot(a, b) end + +--- Cross product. +--- @param a Vec3Table +--- @param b Vec3Table +--- @return Vec3Table +function Vec3.cross(a, b) end + +--- Magnitude (Euclidean length). +--- @param v Vec3Table +--- @return number +function Vec3.length(v) end + +--- Squared magnitude (cheaper than length). +--- @param v Vec3Table +--- @return number +function Vec3.lengthSq(v) end + +--- Return a unit-length copy of v. +--- @param v Vec3Table +--- @return Vec3Table +function Vec3.normalize(v) end + +--- Distance between two points. +--- @param a Vec3Table +--- @param b Vec3Table +--- @return number +function Vec3.distance(a, b) end + +--- Squared distance (cheaper than distance). +--- @param a Vec3Table +--- @param b Vec3Table +--- @return number +function Vec3.distanceSq(a, b) end + +--- Linear interpolation between a and b. +--- @param a Vec3Table +--- @param b Vec3Table +--- @param t number 0..1 +--- @return Vec3Table +function Vec3.lerp(a, b, t) end + +-- ============================================================================ +-- Input API +-- ============================================================================ + +--- @class Input +Input = {} + +--- Button constants (bitmask values matching psyqo::AdvancedPad::Button). +--- @type number +Input.CROSS = 0 +Input.CIRCLE = 0 +Input.SQUARE = 0 +Input.TRIANGLE = 0 +Input.L1 = 0 +Input.R1 = 0 +Input.L2 = 0 +Input.R2 = 0 +Input.START = 0 +Input.SELECT = 0 +Input.UP = 0 +Input.DOWN = 0 +Input.LEFT = 0 +Input.RIGHT = 0 +Input.L3 = 0 +Input.R3 = 0 + +--- True on the single frame the button was pressed. +--- @param button number One of Input.CROSS, Input.CIRCLE, … +--- @return boolean +function Input.IsPressed(button) end + +--- True on the single frame the button was released. +--- @param button number +--- @return boolean +function Input.IsReleased(button) end + +--- True every frame the button is held down. +--- @param button number +--- @return boolean +function Input.IsHeld(button) end + +--- Get left analog stick axes. +--- @return number x -128..127 (0 if digital pad) +--- @return number y -128..127 +function Input.GetAnalog() end + +-- ============================================================================ +-- Timer API +-- ============================================================================ + +--- @class Timer +Timer = {} + +--- Frames elapsed since the scene was loaded. +--- @return number +function Timer.GetFrameCount() end + +-- ============================================================================ +-- Camera API +-- ============================================================================ + +--- @class Camera +Camera = {} + +--- Get the camera's world-space position. +--- @return Vec3Table +function Camera.GetPosition() end + +--- Set the camera's world-space position. +--- @param pos Vec3Table +function Camera.SetPosition(pos) end + +--- Get the camera's rotation (currently returns {0,0,0}). +--- @return Vec3Table Euler angles in radians +function Camera.GetRotation() end + +--- Set the camera's rotation (not yet implemented). +--- @param rot Vec3Table Euler angles in radians +function Camera.SetRotation(rot) end + +--- Point the camera at a world position (not yet implemented). +--- @param target Vec3Table +function Camera.LookAt(target) end + +-- ============================================================================ +-- Audio API — SPU ADPCM playback +-- ============================================================================ + +--- @class Audio +Audio = {} + +--- Play a sound clip by index. Returns the SPU voice used (1-23), or -1 on failure. +--- @param clipIndex number 0-based clip index (order of PSXAudioSource in scene) +--- @param volume? number 0..127 (default 100) +--- @param pan? number 0=left, 64=center, 127=right (default 64) +--- @return number voiceId +function Audio.Play(clipIndex, volume, pan) end + +--- Stop a specific SPU voice (returned from Audio.Play). +--- @param voiceId number +function Audio.Stop(voiceId) end + +--- Set volume (and optional pan) on a playing voice. +--- @param voiceId number +--- @param volume number 0..127 +--- @param pan? number 0..127 (default 64) +function Audio.SetVolume(voiceId, volume, pan) end + +--- Stop all playing sounds. +function Audio.StopAll() end + +-- ============================================================================ +-- Debug API +-- ============================================================================ + +--- @class Debug +Debug = {} + +--- Print a message to the TTY / debug console. +--- @param message string +function Debug.Log(message) end + +--- Draw a debug line (not yet implemented on PS1). +--- @param fromX number +--- @param fromY number +--- @param fromZ number +--- @param toX number +--- @param toY number +--- @param toZ number +function Debug.DrawLine(fromX, fromY, fromZ, toX, toY, toZ) end + +--- Draw a debug box (not yet implemented on PS1). +--- @param minX number +--- @param minY number +--- @param minZ number +--- @param maxX number +--- @param maxY number +--- @param maxZ number +function Debug.DrawBox(minX, minY, minZ, maxX, maxY, maxZ) end + +-- ============================================================================ +-- PSXMath API +-- ============================================================================ + +--- @class PSXMath +PSXMath = {} + +--- Clamp a value between min and max. +--- @param value number +--- @param min number +--- @param max number +--- @return number +function PSXMath.Clamp(value, min, max) end + +--- Linear interpolation (a + (b-a)*t). +--- @param a number +--- @param b number +--- @param t number 0..1 +--- @return number +function PSXMath.Lerp(a, b, t) end + +--- Return -1, 0, or 1. +--- @param x number +--- @return number +function PSXMath.Sign(x) end + +--- Absolute value. +--- @param x number +--- @return number +function PSXMath.Abs(x) end + +--- Minimum of two values. +--- @param a number +--- @param b number +--- @return number +function PSXMath.Min(a, b) end + +--- Maximum of two values. +--- @param a number +--- @param b number +--- @return number +function PSXMath.Max(a, b) end + +-- ============================================================================ +-- Event Callbacks +-- These are global functions you define in your script. The engine calls them +-- automatically based on the event mask resolved at load time. +-- ============================================================================ + +--- Called once when the game object is created. +--- @param self EntityHandle +function onCreate(self) end + +--- Called every frame while the object is active. +--- WARNING: 33 MHz CPU — keep this function fast! +--- @param self EntityHandle +function onUpdate(self) end + +--- Called when the object is destroyed / scene unloads. +--- @param self EntityHandle +function onDestroy(self) end + +--- Called when the object is activated. +--- @param self EntityHandle +function onEnable(self) end + +--- Called when the object is deactivated. +--- @param self EntityHandle +function onDisable(self) end + +--- Called when this object's collider overlaps another. +--- @param self EntityHandle +--- @param other EntityHandle +function onCollision(self, other) end + +--- Called when the player interacts with this object (PSXInteractable). +--- @param self EntityHandle +function onInteract(self) end + +--- Called on the first frame two trigger volumes overlap. +--- @param self EntityHandle +--- @param other EntityHandle +function onTriggerEnter(self, other) end + +--- Called every frame two trigger volumes continue overlapping. +--- @param self EntityHandle +--- @param other EntityHandle +function onTriggerStay(self, other) end + +--- Called when two trigger volumes stop overlapping. +--- @param self EntityHandle +--- @param other EntityHandle +function onTriggerExit(self, other) end + +--- Called when any controller button is pressed. +--- @param self EntityHandle +--- @param button number One of Input.CROSS, Input.CIRCLE, … +function onButtonPress(self, button) end + +--- Called when any controller button is released. +--- @param self EntityHandle +--- @param button number +function onButtonRelease(self, button) end diff --git a/tools/splash_api.lua.meta b/tools/splash_api.lua.meta new file mode 100644 index 0000000..62b1dbb --- /dev/null +++ b/tools/splash_api.lua.meta @@ -0,0 +1,10 @@ +fileFormatVersion: 2 +guid: f306660f23f57394496050343c2335f8 +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 11500000, guid: 74e983e6cf3376944af7b469023d6e4d, type: 3}