Broken RUntime
This commit is contained in:
230
Editor/Core/MkpsxisoDownloader.cs
Normal file
230
Editor/Core/MkpsxisoDownloader.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Debug = UnityEngine.Debug;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Downloads and manages mkpsxiso — the tool that builds PlayStation CD images
|
||||
/// from an XML catalog. Used for the ISO build target.
|
||||
/// https://github.com/Lameguy64/mkpsxiso
|
||||
/// </summary>
|
||||
public static class MkpsxisoDownloader
|
||||
{
|
||||
private const string MKPSXISO_VERSION = "2.20";
|
||||
private const string MKPSXISO_RELEASE_BASE =
|
||||
"https://github.com/Lameguy64/mkpsxiso/releases/download/v" + MKPSXISO_VERSION + "/";
|
||||
|
||||
private static readonly HttpClient _http = new HttpClient();
|
||||
|
||||
/// <summary>
|
||||
/// Install directory for mkpsxiso inside .tools/
|
||||
/// </summary>
|
||||
public static string MkpsxisoDir =>
|
||||
Path.Combine(SplashBuildPaths.ToolsDir, "mkpsxiso");
|
||||
|
||||
/// <summary>
|
||||
/// Path to the mkpsxiso binary.
|
||||
/// </summary>
|
||||
public static string MkpsxisoBinary
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Application.platform == RuntimePlatform.WindowsEditor)
|
||||
return Path.Combine(MkpsxisoDir, "mkpsxiso.exe");
|
||||
return Path.Combine(MkpsxisoDir, "mkpsxiso");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if mkpsxiso is installed and ready to use.
|
||||
/// </summary>
|
||||
public static bool IsInstalled() => File.Exists(MkpsxisoBinary);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads and installs mkpsxiso from the official GitHub releases.
|
||||
/// </summary>
|
||||
public static async Task<bool> DownloadAndInstall(Action<string> log = null)
|
||||
{
|
||||
string archiveName;
|
||||
switch (Application.platform)
|
||||
{
|
||||
case RuntimePlatform.WindowsEditor:
|
||||
archiveName = $"mkpsxiso-{MKPSXISO_VERSION}-win64.zip";
|
||||
break;
|
||||
case RuntimePlatform.LinuxEditor:
|
||||
archiveName = $"mkpsxiso-{MKPSXISO_VERSION}-Linux.zip";
|
||||
break;
|
||||
case RuntimePlatform.OSXEditor:
|
||||
archiveName = $"mkpsxiso-{MKPSXISO_VERSION}-Darwin.zip";
|
||||
break;
|
||||
default:
|
||||
log?.Invoke("Unsupported platform for mkpsxiso.");
|
||||
return false;
|
||||
}
|
||||
|
||||
string downloadUrl = $"{MKPSXISO_RELEASE_BASE}{archiveName}";
|
||||
log?.Invoke($"Downloading mkpsxiso: {downloadUrl}");
|
||||
|
||||
try
|
||||
{
|
||||
string tempFile = Path.Combine(Path.GetTempPath(), archiveName);
|
||||
EditorUtility.DisplayProgressBar("Downloading mkpsxiso", "Downloading...", 0.1f);
|
||||
|
||||
using (var response = await _http.GetAsync(downloadUrl,
|
||||
HttpCompletionOption.ResponseHeadersRead))
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
long? totalBytes = response.Content.Headers.ContentLength;
|
||||
long downloaded = 0;
|
||||
|
||||
using (var fs = File.Create(tempFile))
|
||||
using (var stream = await response.Content.ReadAsStreamAsync())
|
||||
{
|
||||
byte[] buffer = new byte[81920];
|
||||
int bytesRead;
|
||||
while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
await fs.WriteAsync(buffer, 0, bytesRead);
|
||||
downloaded += bytesRead;
|
||||
if (totalBytes.HasValue)
|
||||
{
|
||||
float progress = (float)downloaded / totalBytes.Value;
|
||||
EditorUtility.DisplayProgressBar("Downloading mkpsxiso",
|
||||
$"{downloaded / 1024}/{totalBytes.Value / 1024} KB", progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log?.Invoke("Extracting...");
|
||||
EditorUtility.DisplayProgressBar("Installing mkpsxiso", "Extracting...", 0.9f);
|
||||
|
||||
string installDir = MkpsxisoDir;
|
||||
if (Directory.Exists(installDir))
|
||||
Directory.Delete(installDir, true);
|
||||
Directory.CreateDirectory(installDir);
|
||||
|
||||
System.IO.Compression.ZipFile.ExtractToDirectory(tempFile, installDir);
|
||||
|
||||
// Fix nested directory (archives often have one extra level)
|
||||
FixNestedDirectory(installDir);
|
||||
|
||||
try { File.Delete(tempFile); } catch { }
|
||||
|
||||
EditorUtility.ClearProgressBar();
|
||||
|
||||
if (IsInstalled())
|
||||
{
|
||||
// Make executable on Linux/Mac
|
||||
if (Application.platform != RuntimePlatform.WindowsEditor)
|
||||
{
|
||||
var chmod = Process.Start("chmod", $"+x \"{MkpsxisoBinary}\"");
|
||||
chmod?.WaitForExit();
|
||||
}
|
||||
log?.Invoke("mkpsxiso installed successfully!");
|
||||
return true;
|
||||
}
|
||||
|
||||
log?.Invoke($"mkpsxiso binary not found at: {MkpsxisoBinary}");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log?.Invoke($"mkpsxiso download failed: {ex.Message}");
|
||||
EditorUtility.ClearProgressBar();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void FixNestedDirectory(string dir)
|
||||
{
|
||||
// If extraction created exactly one subdirectory, flatten it
|
||||
var subdirs = Directory.GetDirectories(dir);
|
||||
if (subdirs.Length == 1)
|
||||
{
|
||||
string nested = subdirs[0];
|
||||
foreach (string file in Directory.GetFiles(nested))
|
||||
{
|
||||
string dest = Path.Combine(dir, Path.GetFileName(file));
|
||||
if (!File.Exists(dest)) File.Move(file, dest);
|
||||
}
|
||||
foreach (string sub in Directory.GetDirectories(nested))
|
||||
{
|
||||
string dest = Path.Combine(dir, Path.GetFileName(sub));
|
||||
if (!Directory.Exists(dest)) Directory.Move(sub, dest);
|
||||
}
|
||||
try { Directory.Delete(nested, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs mkpsxiso with the given XML catalog to produce a BIN/CUE image.
|
||||
/// </summary>
|
||||
/// <param name="xmlPath">Path to the mkpsxiso XML catalog.</param>
|
||||
/// <param name="outputBin">Override output .bin path (optional, uses XML default if null).</param>
|
||||
/// <param name="outputCue">Override output .cue path (optional, uses XML default if null).</param>
|
||||
/// <param name="log">Logging callback.</param>
|
||||
/// <returns>True if mkpsxiso succeeded.</returns>
|
||||
public static bool BuildISO(string xmlPath, string outputBin = null,
|
||||
string outputCue = null, Action<string> log = null)
|
||||
{
|
||||
if (!IsInstalled())
|
||||
{
|
||||
log?.Invoke("mkpsxiso is not installed.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build arguments
|
||||
string args = $"-y \"{xmlPath}\"";
|
||||
if (!string.IsNullOrEmpty(outputBin))
|
||||
args += $" -o \"{outputBin}\"";
|
||||
if (!string.IsNullOrEmpty(outputCue))
|
||||
args += $" -c \"{outputCue}\"";
|
||||
|
||||
log?.Invoke($"Running: mkpsxiso {args}");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = MkpsxisoBinary,
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var process = Process.Start(psi);
|
||||
string stdout = process.StandardOutput.ReadToEnd();
|
||||
string stderr = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
|
||||
if (!string.IsNullOrEmpty(stdout))
|
||||
log?.Invoke(stdout.Trim());
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stderr))
|
||||
log?.Invoke($"mkpsxiso error: {stderr.Trim()}");
|
||||
log?.Invoke($"mkpsxiso exited with code {process.ExitCode}");
|
||||
return false;
|
||||
}
|
||||
|
||||
log?.Invoke("ISO image built successfully.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log?.Invoke($"mkpsxiso execution failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/Core/MkpsxisoDownloader.cs.meta
Normal file
2
Editor/Core/MkpsxisoDownloader.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 45aea686b641c474dba05b83956d8947
|
||||
@@ -52,17 +52,14 @@ namespace SplashEdit.EditorCode
|
||||
/// </summary>
|
||||
public static async Task<bool> DownloadAndInstall(Action<string> log = null)
|
||||
{
|
||||
string platformSuffix;
|
||||
string archiveName;
|
||||
switch (Application.platform)
|
||||
{
|
||||
case RuntimePlatform.WindowsEditor:
|
||||
platformSuffix = "x86_64-pc-windows-msvc";
|
||||
archiveName = $"psxavenc-{PSXAVENC_VERSION}-{platformSuffix}.zip";
|
||||
archiveName = $"psxavenc-windows.zip";
|
||||
break;
|
||||
case RuntimePlatform.LinuxEditor:
|
||||
platformSuffix = "x86_64-unknown-linux-gnu";
|
||||
archiveName = $"psxavenc-{PSXAVENC_VERSION}-{platformSuffix}.tar.gz";
|
||||
archiveName = $"psxavenc-linux.zip";
|
||||
break;
|
||||
default:
|
||||
log?.Invoke("Only Windows and Linux are supported.");
|
||||
|
||||
@@ -130,6 +130,24 @@ namespace SplashEdit.EditorCode
|
||||
public static string CUEOutputPath =>
|
||||
Path.Combine(BuildOutputDir, "psxsplash.cue");
|
||||
|
||||
/// <summary>
|
||||
/// XML catalog path used by mkpsxiso to build the ISO image.
|
||||
/// </summary>
|
||||
public static string ISOCatalogPath =>
|
||||
Path.Combine(BuildOutputDir, "psxsplash.xml");
|
||||
|
||||
/// <summary>
|
||||
/// SYSTEM.CNF file path generated for the ISO image.
|
||||
/// The PS1 BIOS reads this to find and launch the executable.
|
||||
/// </summary>
|
||||
public static string SystemCnfPath =>
|
||||
Path.Combine(BuildOutputDir, "SYSTEM.CNF");
|
||||
|
||||
/// <summary>
|
||||
/// Checks if mkpsxiso is installed in the tools directory.
|
||||
/// </summary>
|
||||
public static bool IsMkpsxisoInstalled() => MkpsxisoDownloader.IsInstalled();
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the build output and tools directories exist.
|
||||
/// Also appends entries to the project .gitignore if not present.
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Ports;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
@@ -46,6 +47,7 @@ namespace SplashEdit.EditorCode
|
||||
private bool _hasRedux;
|
||||
private bool _hasNativeProject;
|
||||
private bool _hasPsxavenc;
|
||||
private bool _hasMkpsxiso;
|
||||
private string _reduxVersion = "";
|
||||
|
||||
// ───── Native project installer ─────
|
||||
@@ -486,6 +488,22 @@ namespace SplashEdit.EditorCode
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
// mkpsxiso (ISO builder)
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
DrawStatusIcon(_hasMkpsxiso);
|
||||
GUILayout.Label("mkpsxiso (ISO)", GUILayout.Width(160));
|
||||
GUILayout.FlexibleSpace();
|
||||
if (!_hasMkpsxiso)
|
||||
{
|
||||
if (GUILayout.Button("Download", GUILayout.Width(80)))
|
||||
DownloadMkpsxiso();
|
||||
}
|
||||
else
|
||||
{
|
||||
GUILayout.Label("Installed", EditorStyles.miniLabel);
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
// Refresh button
|
||||
EditorGUILayout.Space(2);
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
@@ -653,14 +671,11 @@ namespace SplashEdit.EditorCode
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
// GTE Scaling
|
||||
EditorGUILayout.LabelField("Export Settings", EditorStyles.boldLabel);
|
||||
SplashSettings.DefaultGTEScaling = EditorGUILayout.FloatField("Default GTE Scaling", SplashSettings.DefaultGTEScaling);
|
||||
SplashSettings.AutoValidateOnExport = EditorGUILayout.Toggle("Auto-Validate on Export", SplashSettings.AutoValidateOnExport);
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
|
||||
// Open dedicated VRAM windows
|
||||
EditorGUILayout.LabelField("Advanced Tools", EditorStyles.boldLabel);
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("Open VRAM Editor", GUILayout.Height(24)))
|
||||
@@ -673,11 +688,6 @@ namespace SplashEdit.EditorCode
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
if (GUILayout.Button("Open Scene Validator", EditorStyles.miniButton))
|
||||
{
|
||||
PSXSceneValidatorWindow.ShowWindow();
|
||||
}
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
|
||||
@@ -711,6 +721,34 @@ namespace SplashEdit.EditorCode
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
// ISO settings (only for ISO build target)
|
||||
if (SplashSettings.Target == BuildTarget.ISO)
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.Label("Volume Label:", GUILayout.Width(80));
|
||||
SplashSettings.ISOVolumeLabel = EditorGUILayout.TextField(SplashSettings.ISOVolumeLabel);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.Label("License File:", GUILayout.Width(80));
|
||||
string licensePath = SplashSettings.LicenseFilePath;
|
||||
string displayPath = string.IsNullOrEmpty(licensePath) ? "(none — homebrew)" : Path.GetFileName(licensePath);
|
||||
GUILayout.Label(displayPath, EditorStyles.miniLabel, GUILayout.ExpandWidth(true));
|
||||
if (GUILayout.Button("Browse", EditorStyles.miniButton, GUILayout.Width(60)))
|
||||
{
|
||||
string path = EditorUtility.OpenFilePanel(
|
||||
"Select Sony License File", "", "dat");
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
SplashSettings.LicenseFilePath = path;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(licensePath) &&
|
||||
GUILayout.Button("Clear", EditorStyles.miniButton, GUILayout.Width(40)))
|
||||
{
|
||||
SplashSettings.LicenseFilePath = "";
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
|
||||
// Big Build & Run button
|
||||
@@ -759,7 +797,7 @@ namespace SplashEdit.EditorCode
|
||||
}
|
||||
if (GUILayout.Button("Compile Only", EditorStyles.miniButton, GUILayout.Width(100)))
|
||||
{
|
||||
CompileNative();
|
||||
CompileOnly();
|
||||
}
|
||||
GUILayout.FlexibleSpace();
|
||||
EditorGUILayout.EndHorizontal();
|
||||
@@ -774,12 +812,11 @@ namespace SplashEdit.EditorCode
|
||||
/// <summary>
|
||||
/// The main pipeline: Validate → Export all scenes → Compile → Launch.
|
||||
/// </summary>
|
||||
public void BuildAndRun()
|
||||
public async void BuildAndRun()
|
||||
{
|
||||
if (_isBuilding) return;
|
||||
_isBuilding = true;
|
||||
|
||||
// Open the PSX Console so build output is visible immediately
|
||||
var console = EditorWindow.GetWindow<PSXConsoleWindow>();
|
||||
console.titleContent = new GUIContent("PSX Console", EditorGUIUtility.IconContent("d_UnityEditor.ConsoleWindow").image);
|
||||
console.minSize = new Vector2(400, 200);
|
||||
@@ -787,7 +824,6 @@ namespace SplashEdit.EditorCode
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Validate
|
||||
Log("Validating toolchain...", LogType.Log);
|
||||
if (!ValidateToolchain())
|
||||
{
|
||||
@@ -796,7 +832,6 @@ namespace SplashEdit.EditorCode
|
||||
}
|
||||
Log("Toolchain OK.", LogType.Log);
|
||||
|
||||
// Step 2: Export all scenes
|
||||
Log("Exporting scenes...", LogType.Log);
|
||||
if (!ExportAllScenes())
|
||||
{
|
||||
@@ -805,16 +840,15 @@ namespace SplashEdit.EditorCode
|
||||
}
|
||||
Log($"Exported {_sceneList.Count} scene(s).", LogType.Log);
|
||||
|
||||
// Step 3: Compile native
|
||||
Log("Compiling native code...", LogType.Log);
|
||||
if (!CompileNative())
|
||||
EditorUtility.DisplayProgressBar("SplashEdit", "Compiling native code...", 0.6f);
|
||||
if (!await CompileNativeAsync())
|
||||
{
|
||||
Log("Compilation failed. Check build log.", LogType.Error);
|
||||
return;
|
||||
}
|
||||
Log("Compile succeeded.", LogType.Log);
|
||||
|
||||
// Step 4: Launch
|
||||
Log("Launching...", LogType.Log);
|
||||
Launch();
|
||||
}
|
||||
@@ -852,6 +886,11 @@ namespace SplashEdit.EditorCode
|
||||
Log("PCSX-Redux not found. Click Download in the Toolchain section.", LogType.Error);
|
||||
return false;
|
||||
}
|
||||
if (SplashSettings.Target == BuildTarget.ISO && !_hasMkpsxiso)
|
||||
{
|
||||
Log("mkpsxiso not found. Click Download in the Toolchain section.", LogType.Error);
|
||||
return false;
|
||||
}
|
||||
|
||||
string nativeDir = SplashBuildPaths.NativeSourceDir;
|
||||
if (string.IsNullOrEmpty(nativeDir) || !Directory.Exists(nativeDir))
|
||||
@@ -902,7 +941,7 @@ namespace SplashEdit.EditorCode
|
||||
EditorSceneManager.OpenScene(scene.path, OpenSceneMode.Single);
|
||||
|
||||
// Find the exporter
|
||||
var exporter = UnityEngine.Object.FindObjectOfType<PSXSceneExporter>();
|
||||
var exporter = UnityEngine.Object.FindFirstObjectByType<PSXSceneExporter>();
|
||||
if (exporter == null)
|
||||
{
|
||||
Log($"Scene '{scene.name}' has no PSXSceneExporter. Skipping.", LogType.Warning);
|
||||
@@ -1051,10 +1090,29 @@ namespace SplashEdit.EditorCode
|
||||
|
||||
// ───── Step 3: Compile ─────
|
||||
|
||||
/// <summary>
|
||||
/// Runs make in the native project directory.
|
||||
/// </summary>
|
||||
public bool CompileNative()
|
||||
private async void CompileOnly()
|
||||
{
|
||||
if (_isBuilding) return;
|
||||
_isBuilding = true;
|
||||
Repaint();
|
||||
try
|
||||
{
|
||||
Log("Compiling native code...", LogType.Log);
|
||||
EditorUtility.DisplayProgressBar("SplashEdit", "Compiling native code...", 0.5f);
|
||||
if (await CompileNativeAsync())
|
||||
Log("Compile succeeded.", LogType.Log);
|
||||
else
|
||||
Log("Compilation failed. Check build log.", LogType.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isBuilding = false;
|
||||
EditorUtility.ClearProgressBar();
|
||||
Repaint();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> CompileNativeAsync()
|
||||
{
|
||||
string nativeDir = SplashBuildPaths.NativeSourceDir;
|
||||
if (string.IsNullOrEmpty(nativeDir))
|
||||
@@ -1064,9 +1122,11 @@ namespace SplashEdit.EditorCode
|
||||
}
|
||||
|
||||
string buildArg = SplashSettings.Mode == BuildMode.Debug ? "BUILD=Debug" : "";
|
||||
// Run clean first, THEN build — "make clean all -jN" races clean vs build in sub-makes
|
||||
string makeCmd = $"make clean && make all -j{SystemInfo.processorCount} {buildArg}".Trim();
|
||||
|
||||
if (SplashSettings.Target == BuildTarget.ISO)
|
||||
buildArg += " LOADER=cdrom";
|
||||
|
||||
string makeCmd = $"make clean && make all -j{SystemInfo.processorCount} {buildArg}".Trim();
|
||||
Log($"Running: {makeCmd}", LogType.Log);
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
@@ -1084,45 +1144,57 @@ namespace SplashEdit.EditorCode
|
||||
|
||||
try
|
||||
{
|
||||
var process = Process.Start(psi);
|
||||
string stdout = process.StandardOutput.ReadToEnd();
|
||||
string stderr = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||||
var stdoutBuf = new System.Text.StringBuilder();
|
||||
var stderrBuf = new System.Text.StringBuilder();
|
||||
|
||||
// Log output to panel only (no Unity console spam)
|
||||
if (!string.IsNullOrEmpty(stdout))
|
||||
process.OutputDataReceived += (s, e) =>
|
||||
{
|
||||
foreach (string line in stdout.Split('\n'))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(line))
|
||||
LogToPanel(line.Trim(), LogType.Log);
|
||||
}
|
||||
if (e.Data != null) stdoutBuf.AppendLine(e.Data);
|
||||
};
|
||||
process.ErrorDataReceived += (s, e) =>
|
||||
{
|
||||
if (e.Data != null) stderrBuf.AppendLine(e.Data);
|
||||
};
|
||||
|
||||
var tcs = new TaskCompletionSource<int>();
|
||||
process.Exited += (s, e) => tcs.TrySetResult(process.ExitCode);
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
int exitCode = await tcs.Task;
|
||||
process.Dispose();
|
||||
|
||||
string stdout = stdoutBuf.ToString();
|
||||
string stderr = stderrBuf.ToString();
|
||||
|
||||
foreach (string line in stdout.Split('\n'))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(line))
|
||||
LogToPanel(line.Trim(), LogType.Log);
|
||||
}
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
if (exitCode != 0)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stderr))
|
||||
foreach (string line in stderr.Split('\n'))
|
||||
{
|
||||
foreach (string line in stderr.Split('\n'))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(line))
|
||||
LogToPanel(line.Trim(), LogType.Error);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(line))
|
||||
LogToPanel(line.Trim(), LogType.Error);
|
||||
}
|
||||
Log($"Make exited with code {process.ExitCode}", LogType.Error);
|
||||
Log($"Make exited with code {exitCode}", LogType.Error);
|
||||
|
||||
// Write build log file
|
||||
File.WriteAllText(SplashBuildPaths.BuildLogPath,
|
||||
$"=== STDOUT ===\n{stdout}\n=== STDERR ===\n{stderr}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Copy the compiled exe to PSXBuild/
|
||||
string exeSource = FindCompiledExe(nativeDir);
|
||||
if (!string.IsNullOrEmpty(exeSource))
|
||||
{
|
||||
File.Copy(exeSource, SplashBuildPaths.CompiledExePath, true);
|
||||
Log($"Copied .ps-exe to PSXBuild/", LogType.Log);
|
||||
Log("Copied .ps-exe to PSXBuild/", LogType.Log);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1172,7 +1244,7 @@ namespace SplashEdit.EditorCode
|
||||
LaunchToHardware();
|
||||
break;
|
||||
case BuildTarget.ISO:
|
||||
Log("ISO build not yet implemented.", LogType.Warning);
|
||||
BuildAndLaunchISO();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1274,6 +1346,235 @@ namespace SplashEdit.EditorCode
|
||||
}
|
||||
}
|
||||
|
||||
// ───── ISO Build ─────
|
||||
|
||||
private void BuildAndLaunchISO()
|
||||
{
|
||||
if (!_hasMkpsxiso)
|
||||
{
|
||||
Log("mkpsxiso not installed. Click Download in the Toolchain section.", LogType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
string exePath = SplashBuildPaths.CompiledExePath;
|
||||
if (!File.Exists(exePath))
|
||||
{
|
||||
Log("Compiled .ps-exe not found in PSXBuild/.", LogType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ask user for output location
|
||||
string defaultDir = SplashBuildPaths.BuildOutputDir;
|
||||
string savePath = EditorUtility.SaveFilePanel(
|
||||
"Save ISO Image", defaultDir, "psxsplash", "bin");
|
||||
if (string.IsNullOrEmpty(savePath))
|
||||
{
|
||||
Log("ISO build cancelled.", LogType.Log);
|
||||
return;
|
||||
}
|
||||
|
||||
string outputBin = savePath;
|
||||
string outputCue = Path.ChangeExtension(savePath, ".cue");
|
||||
|
||||
// Step 1: Generate SYSTEM.CNF
|
||||
Log("Generating SYSTEM.CNF...", LogType.Log);
|
||||
if (!GenerateSystemCnf())
|
||||
{
|
||||
Log("Failed to generate SYSTEM.CNF.", LogType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Generate XML catalog for mkpsxiso
|
||||
Log("Generating ISO catalog...", LogType.Log);
|
||||
string xmlPath = GenerateISOCatalog(outputBin, outputCue);
|
||||
if (string.IsNullOrEmpty(xmlPath))
|
||||
{
|
||||
Log("Failed to generate ISO catalog.", LogType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Delete existing .bin/.cue — mkpsxiso won't overwrite them
|
||||
try
|
||||
{
|
||||
if (File.Exists(outputBin)) File.Delete(outputBin);
|
||||
if (File.Exists(outputCue)) File.Delete(outputCue);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Could not remove old ISO files: {ex.Message}", LogType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: Run mkpsxiso
|
||||
Log("Building ISO image...", LogType.Log);
|
||||
bool success = MkpsxisoDownloader.BuildISO(xmlPath, outputBin, outputCue,
|
||||
msg => Log(msg, LogType.Log));
|
||||
|
||||
if (success)
|
||||
{
|
||||
long fileSize = new FileInfo(outputBin).Length;
|
||||
Log($"ISO image written: {outputBin} ({fileSize:N0} bytes)", LogType.Log);
|
||||
Log($"CUE sheet written: {outputCue}", LogType.Log);
|
||||
|
||||
// Offer to reveal in explorer
|
||||
EditorUtility.RevealInFinder(outputBin);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("ISO build failed.", LogType.Error);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derive the executable name on disc from the volume label.
|
||||
/// Uppercase, no extension, trimmed to 12 characters (ISO9660 limit).
|
||||
/// </summary>
|
||||
private static string GetISOExeName()
|
||||
{
|
||||
string label = SplashSettings.ISOVolumeLabel;
|
||||
if (string.IsNullOrEmpty(label)) label = "PSXSPLASH";
|
||||
|
||||
// Uppercase, strip anything not A-Z / 0-9 / underscore
|
||||
label = label.ToUpperInvariant();
|
||||
var sb = new System.Text.StringBuilder(label.Length);
|
||||
foreach (char c in label)
|
||||
{
|
||||
if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_')
|
||||
sb.Append(c);
|
||||
}
|
||||
string name = sb.ToString();
|
||||
if (name.Length == 0) name = "PSXSPLASH";
|
||||
if (name.Length > 12) name = name.Substring(0, 12);
|
||||
return name;
|
||||
}
|
||||
|
||||
private bool GenerateSystemCnf()
|
||||
{
|
||||
try
|
||||
{
|
||||
string cnfPath = SplashBuildPaths.SystemCnfPath;
|
||||
|
||||
// The executable name on disc — no extension, max 12 chars
|
||||
string exeName = GetISOExeName();
|
||||
|
||||
// SYSTEM.CNF content — the BIOS reads this to launch the executable.
|
||||
// BOOT: path to the executable on disc (cdrom:\path;1)
|
||||
// TCB: number of thread control blocks (4 is standard)
|
||||
// EVENT: number of event control blocks (10 is standard)
|
||||
// STACK: initial stack pointer address (top of RAM minus a small margin)
|
||||
string content =
|
||||
$"BOOT = cdrom:\\{exeName};1\r\n" +
|
||||
"TCB = 4\r\n" +
|
||||
"EVENT = 10\r\n" +
|
||||
"STACK = 801FFF00\r\n";
|
||||
|
||||
File.WriteAllText(cnfPath, content, new System.Text.UTF8Encoding(false));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"SYSTEM.CNF generation error: {ex.Message}", LogType.Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the mkpsxiso XML catalog describing the ISO filesystem layout.
|
||||
/// Includes SYSTEM.CNF, the executable, all splashpacks, loading packs, and manifest.
|
||||
/// </summary>
|
||||
private string GenerateISOCatalog(string outputBin, string outputCue)
|
||||
{
|
||||
try
|
||||
{
|
||||
string xmlPath = SplashBuildPaths.ISOCatalogPath;
|
||||
string buildDir = SplashBuildPaths.BuildOutputDir;
|
||||
string volumeLabel = SplashSettings.ISOVolumeLabel;
|
||||
if (string.IsNullOrEmpty(volumeLabel)) volumeLabel = "PSXSPLASH";
|
||||
|
||||
// Sanitize volume label (ISO9660: uppercase, max 31 chars)
|
||||
volumeLabel = volumeLabel.ToUpperInvariant();
|
||||
if (volumeLabel.Length > 31) volumeLabel = volumeLabel.Substring(0, 31);
|
||||
|
||||
var xml = new System.Text.StringBuilder();
|
||||
xml.AppendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
|
||||
xml.AppendLine("<iso_project image_name=\"psxsplash.bin\" cue_sheet=\"psxsplash.cue\">");
|
||||
xml.AppendLine(" <track type=\"data\">");
|
||||
xml.AppendLine(" <identifiers");
|
||||
xml.AppendLine(" system=\"PLAYSTATION\"");
|
||||
xml.AppendLine(" application=\"PLAYSTATION\"");
|
||||
xml.AppendLine($" volume=\"{EscapeXml(volumeLabel)}\"");
|
||||
xml.AppendLine($" volume_set=\"{EscapeXml(volumeLabel)}\"");
|
||||
xml.AppendLine(" publisher=\"SPLASHEDIT\"");
|
||||
xml.AppendLine(" data_preparer=\"MKPSXISO\"");
|
||||
xml.AppendLine(" />");
|
||||
|
||||
// License file (optional)
|
||||
string licensePath = SplashSettings.LicenseFilePath;
|
||||
if (!string.IsNullOrEmpty(licensePath) && File.Exists(licensePath))
|
||||
{
|
||||
xml.AppendLine($" <license file=\"{EscapeXml(licensePath)}\"/>");
|
||||
}
|
||||
|
||||
xml.AppendLine(" <directory_tree>");
|
||||
|
||||
// SYSTEM.CNF — must be first for BIOS to find it
|
||||
string cnfPath = SplashBuildPaths.SystemCnfPath;
|
||||
xml.AppendLine($" <file name=\"SYSTEM.CNF\" source=\"{EscapeXml(cnfPath)}\"/>");
|
||||
|
||||
// The executable — renamed to match what SYSTEM.CNF points to
|
||||
string exePath = SplashBuildPaths.CompiledExePath;
|
||||
string isoExeName = GetISOExeName();
|
||||
xml.AppendLine($" <file name=\"{isoExeName}\" source=\"{EscapeXml(exePath)}\"/>");
|
||||
|
||||
// Manifest
|
||||
string manifestPath = SplashBuildPaths.ManifestPath;
|
||||
if (File.Exists(manifestPath))
|
||||
{
|
||||
xml.AppendLine($" <file name=\"MANIFEST.BIN\" source=\"{EscapeXml(manifestPath)}\"/>");
|
||||
}
|
||||
|
||||
// Scene splashpacks and loading packs
|
||||
for (int i = 0; i < _sceneList.Count; i++)
|
||||
{
|
||||
string splashpack = SplashBuildPaths.GetSceneSplashpackPath(i, _sceneList[i].name);
|
||||
if (File.Exists(splashpack))
|
||||
{
|
||||
string isoName = $"SCENE_{i}.SPK";
|
||||
xml.AppendLine($" <file name=\"{isoName}\" source=\"{EscapeXml(splashpack)}\"/>");
|
||||
}
|
||||
|
||||
string loadingPack = SplashBuildPaths.GetSceneLoaderPackPath(i, _sceneList[i].name);
|
||||
if (File.Exists(loadingPack))
|
||||
{
|
||||
string isoName = $"SCENE_{i}.LDG";
|
||||
xml.AppendLine($" <file name=\"{isoName}\" source=\"{EscapeXml(loadingPack)}\"/>");
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing dummy sectors to prevent drive runaway
|
||||
xml.AppendLine(" <dummy sectors=\"128\"/>");
|
||||
xml.AppendLine(" </directory_tree>");
|
||||
xml.AppendLine(" </track>");
|
||||
xml.AppendLine("</iso_project>");
|
||||
|
||||
File.WriteAllText(xmlPath, xml.ToString(), new System.Text.UTF8Encoding(false));
|
||||
Log($"ISO catalog written: {xmlPath}", LogType.Log);
|
||||
return xmlPath;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"ISO catalog generation error: {ex.Message}", LogType.Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string EscapeXml(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return "";
|
||||
return s.Replace("&", "&").Replace("<", "<")
|
||||
.Replace(">", ">").Replace("\"", """);
|
||||
}
|
||||
|
||||
private void StopPCdrvHost()
|
||||
{
|
||||
if (_pcdrvHost != null)
|
||||
@@ -1339,6 +1640,8 @@ namespace SplashEdit.EditorCode
|
||||
|
||||
_hasPsxavenc = PSXAudioConverter.IsInstalled();
|
||||
|
||||
_hasMkpsxiso = MkpsxisoDownloader.IsInstalled();
|
||||
|
||||
string nativeDir = SplashBuildPaths.NativeSourceDir;
|
||||
_hasNativeProject = !string.IsNullOrEmpty(nativeDir) && Directory.Exists(nativeDir);
|
||||
}
|
||||
@@ -1418,6 +1721,22 @@ namespace SplashEdit.EditorCode
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private async void DownloadMkpsxiso()
|
||||
{
|
||||
Log("Downloading mkpsxiso ISO builder...", LogType.Log);
|
||||
bool success = await MkpsxisoDownloader.DownloadAndInstall(msg => Log(msg, LogType.Log));
|
||||
if (success)
|
||||
{
|
||||
RefreshToolchainStatus();
|
||||
Log("mkpsxiso ready!", LogType.Log);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("mkpsxiso download failed. ISO builds will not work.", LogType.Error);
|
||||
}
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private void ScanSerialPorts()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -124,12 +124,6 @@ namespace SplashEdit.EditorCode
|
||||
set => EditorPrefs.SetFloat(Prefix + "GTEScaling", value);
|
||||
}
|
||||
|
||||
public static bool AutoValidateOnExport
|
||||
{
|
||||
get => EditorPrefs.GetBool(Prefix + "AutoValidate", true);
|
||||
set => EditorPrefs.SetBool(Prefix + "AutoValidate", value);
|
||||
}
|
||||
|
||||
// --- Play Mode Intercept ---
|
||||
public static bool InterceptPlayMode
|
||||
{
|
||||
@@ -137,6 +131,27 @@ namespace SplashEdit.EditorCode
|
||||
set => EditorPrefs.SetBool(Prefix + "InterceptPlayMode", value);
|
||||
}
|
||||
|
||||
// --- ISO Build ---
|
||||
/// <summary>
|
||||
/// Optional path to a Sony license file (.dat) for the ISO image.
|
||||
/// If empty, the ISO will be built without license data (homebrew-only).
|
||||
/// The file must be in raw 2336-byte sector format (from PsyQ SDK LCNSFILE).
|
||||
/// </summary>
|
||||
public static string LicenseFilePath
|
||||
{
|
||||
get => EditorPrefs.GetString(Prefix + "LicenseFilePath", "");
|
||||
set => EditorPrefs.SetString(Prefix + "LicenseFilePath", value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Volume label for the ISO image (up to 31 characters, uppercase).
|
||||
/// </summary>
|
||||
public static string ISOVolumeLabel
|
||||
{
|
||||
get => EditorPrefs.GetString(Prefix + "ISOVolumeLabel", "PSXSPLASH");
|
||||
set => EditorPrefs.SetString(Prefix + "ISOVolumeLabel", value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets all settings to defaults by deleting all prefixed keys.
|
||||
/// </summary>
|
||||
@@ -147,7 +162,8 @@ namespace SplashEdit.EditorCode
|
||||
"Target", "Mode", "NativeProjectPath", "MIPSToolchainPath",
|
||||
"PCSXReduxPath", "PCSXReduxPCdrvBase", "SerialPort", "SerialBaudRate",
|
||||
"ResWidth", "ResHeight", "DualBuffering", "VerticalLayout",
|
||||
"GTEScaling", "AutoValidate", "InterceptPlayMode"
|
||||
"GTEScaling", "AutoValidate", "InterceptPlayMode",
|
||||
"LicenseFilePath", "ISOVolumeLabel"
|
||||
};
|
||||
|
||||
foreach (string key in keys)
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace SplashEdit.EditorCode
|
||||
[MenuItem("GameObject/PlayStation 1/Scene Exporter", false, 10)]
|
||||
public static void CreateSceneExporter(MenuCommand menuCommand)
|
||||
{
|
||||
var existing = Object.FindObjectOfType<PSXSceneExporter>();
|
||||
var existing = Object.FindFirstObjectByType<PSXSceneExporter>();
|
||||
if (existing != null)
|
||||
{
|
||||
EditorUtility.DisplayDialog(
|
||||
|
||||
@@ -2,512 +2,233 @@ using UnityEngine;
|
||||
using UnityEditor;
|
||||
using SplashEdit.RuntimeCode;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom inspector for PSXObjectExporter with enhanced UX.
|
||||
/// Shows mesh info, texture preview, collision visualization, and validation.
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(PSXObjectExporter))]
|
||||
[CanEditMultipleObjects]
|
||||
public class PSXObjectExporterEditor : UnityEditor.Editor
|
||||
{
|
||||
// Serialized properties
|
||||
private SerializedProperty isActiveProp;
|
||||
private SerializedProperty bitDepthProp;
|
||||
private SerializedProperty luaFileProp;
|
||||
private SerializedProperty objectFlagsProp;
|
||||
private SerializedProperty collisionTypeProp;
|
||||
private SerializedProperty staticColliderProp;
|
||||
private SerializedProperty exportCollisionMeshProp;
|
||||
private SerializedProperty customCollisionMeshProp;
|
||||
private SerializedProperty collisionLayerProp;
|
||||
private SerializedProperty previewNormalsProp;
|
||||
private SerializedProperty normalPreviewLengthProp;
|
||||
private SerializedProperty showCollisionBoundsProp;
|
||||
private SerializedProperty textureProp;
|
||||
|
||||
// UI State
|
||||
private bool showMeshInfo = true;
|
||||
private bool showTextureInfo = true;
|
||||
private bool showExportSettings = true;
|
||||
private bool showCollisionSettings = true;
|
||||
private bool showGizmoSettings = false;
|
||||
private bool showValidation = true;
|
||||
|
||||
// Cached data
|
||||
private SerializedProperty generateNavigationProp;
|
||||
|
||||
private MeshFilter meshFilter;
|
||||
private MeshRenderer meshRenderer;
|
||||
private int triangleCount;
|
||||
private int vertexCount;
|
||||
private Bounds meshBounds;
|
||||
private List<string> validationErrors = new List<string>();
|
||||
private List<string> validationWarnings = new List<string>();
|
||||
|
||||
// Styles
|
||||
private GUIStyle headerStyle;
|
||||
private GUIStyle errorStyle;
|
||||
private GUIStyle warningStyle;
|
||||
|
||||
// Validation
|
||||
private bool _validationDirty = true;
|
||||
|
||||
|
||||
private bool showExport = true;
|
||||
private bool showCollision = true;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// Get serialized properties
|
||||
isActiveProp = serializedObject.FindProperty("isActive");
|
||||
bitDepthProp = serializedObject.FindProperty("bitDepth");
|
||||
luaFileProp = serializedObject.FindProperty("luaFile");
|
||||
objectFlagsProp = serializedObject.FindProperty("objectFlags");
|
||||
collisionTypeProp = serializedObject.FindProperty("collisionType");
|
||||
staticColliderProp = serializedObject.FindProperty("staticCollider");
|
||||
exportCollisionMeshProp = serializedObject.FindProperty("exportCollisionMesh");
|
||||
customCollisionMeshProp = serializedObject.FindProperty("customCollisionMesh");
|
||||
collisionLayerProp = serializedObject.FindProperty("collisionLayer");
|
||||
previewNormalsProp = serializedObject.FindProperty("previewNormals");
|
||||
normalPreviewLengthProp = serializedObject.FindProperty("normalPreviewLength");
|
||||
showCollisionBoundsProp = serializedObject.FindProperty("showCollisionBounds");
|
||||
textureProp = serializedObject.FindProperty("texture");
|
||||
|
||||
// Cache mesh info
|
||||
generateNavigationProp = serializedObject.FindProperty("generateNavigation");
|
||||
|
||||
CacheMeshInfo();
|
||||
|
||||
// Defer validation to first inspector draw
|
||||
_validationDirty = true;
|
||||
}
|
||||
|
||||
|
||||
private void CacheMeshInfo()
|
||||
{
|
||||
var exporter = target as PSXObjectExporter;
|
||||
if (exporter == null) return;
|
||||
|
||||
meshFilter = exporter.GetComponent<MeshFilter>();
|
||||
meshRenderer = exporter.GetComponent<MeshRenderer>();
|
||||
|
||||
if (meshFilter != null && meshFilter.sharedMesh != null)
|
||||
{
|
||||
var mesh = meshFilter.sharedMesh;
|
||||
triangleCount = mesh.triangles.Length / 3;
|
||||
vertexCount = mesh.vertexCount;
|
||||
meshBounds = mesh.bounds;
|
||||
triangleCount = meshFilter.sharedMesh.triangles.Length / 3;
|
||||
vertexCount = meshFilter.sharedMesh.vertexCount;
|
||||
}
|
||||
}
|
||||
|
||||
private void RunValidation()
|
||||
{
|
||||
validationErrors.Clear();
|
||||
validationWarnings.Clear();
|
||||
|
||||
var exporter = target as PSXObjectExporter;
|
||||
if (exporter == null) return;
|
||||
|
||||
// Check mesh
|
||||
if (meshFilter == null || meshFilter.sharedMesh == null)
|
||||
{
|
||||
validationErrors.Add("No mesh assigned to MeshFilter");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (triangleCount > 100)
|
||||
{
|
||||
validationWarnings.Add($"High triangle count ({triangleCount}). PS1 recommended: <100 per object");
|
||||
}
|
||||
|
||||
// Check vertex bounds
|
||||
var mesh = meshFilter.sharedMesh;
|
||||
var verts = mesh.vertices;
|
||||
bool hasOutOfBounds = false;
|
||||
|
||||
foreach (var v in verts)
|
||||
{
|
||||
var world = exporter.transform.TransformPoint(v);
|
||||
float scaled = Mathf.Max(Mathf.Abs(world.x), Mathf.Abs(world.y), Mathf.Abs(world.z)) * 4096f;
|
||||
if (scaled > 32767f)
|
||||
{
|
||||
hasOutOfBounds = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOutOfBounds)
|
||||
{
|
||||
validationErrors.Add("Vertices exceed PS1 coordinate limits (±8 units from origin)");
|
||||
}
|
||||
}
|
||||
|
||||
// Check renderer
|
||||
if (meshRenderer == null)
|
||||
{
|
||||
validationWarnings.Add("No MeshRenderer - object will not be visible");
|
||||
}
|
||||
else if (meshRenderer.sharedMaterial == null)
|
||||
{
|
||||
validationWarnings.Add("No material assigned - will use default colors");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
|
||||
// Run deferred validation
|
||||
if (_validationDirty)
|
||||
{
|
||||
RunValidation();
|
||||
_validationDirty = false;
|
||||
}
|
||||
|
||||
InitStyles();
|
||||
|
||||
// Active toggle at top
|
||||
EditorGUILayout.PropertyField(isActiveProp, new GUIContent("Export This Object"));
|
||||
|
||||
|
||||
DrawHeader();
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
if (!isActiveProp.boolValue)
|
||||
{
|
||||
EditorGUILayout.HelpBox("This object will be skipped during export.", MessageType.Info);
|
||||
EditorGUILayout.LabelField("Object will be skipped during export.", PSXEditorStyles.InfoBox);
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
// Mesh Info Section
|
||||
DrawMeshInfoSection();
|
||||
|
||||
// Texture Section
|
||||
DrawTextureSection();
|
||||
|
||||
// Export Settings Section
|
||||
DrawExportSettingsSection();
|
||||
|
||||
// Collision Settings Section
|
||||
DrawCollisionSettingsSection();
|
||||
|
||||
// Gizmo Settings Section
|
||||
DrawGizmoSettingsSection();
|
||||
|
||||
// Validation Section
|
||||
DrawValidationSection();
|
||||
|
||||
// Action Buttons
|
||||
DrawActionButtons();
|
||||
|
||||
if (serializedObject.ApplyModifiedProperties())
|
||||
{
|
||||
_validationDirty = true;
|
||||
}
|
||||
|
||||
DrawMeshSummary();
|
||||
PSXEditorStyles.DrawSeparator(6, 6);
|
||||
DrawExportSection();
|
||||
PSXEditorStyles.DrawSeparator(6, 6);
|
||||
DrawCollisionSection();
|
||||
PSXEditorStyles.DrawSeparator(6, 6);
|
||||
DrawActions();
|
||||
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
private void InitStyles()
|
||||
|
||||
private new void DrawHeader()
|
||||
{
|
||||
if (headerStyle == null)
|
||||
{
|
||||
headerStyle = new GUIStyle(EditorStyles.foldoutHeader);
|
||||
}
|
||||
|
||||
if (errorStyle == null)
|
||||
{
|
||||
errorStyle = new GUIStyle(EditorStyles.label);
|
||||
errorStyle.normal.textColor = Color.red;
|
||||
}
|
||||
|
||||
if (warningStyle == null)
|
||||
{
|
||||
warningStyle = new GUIStyle(EditorStyles.label);
|
||||
warningStyle.normal.textColor = new Color(1f, 0.7f, 0f);
|
||||
}
|
||||
EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.PropertyField(isActiveProp, GUIContent.none, GUILayout.Width(18));
|
||||
var exporter = target as PSXObjectExporter;
|
||||
EditorGUILayout.LabelField(exporter.gameObject.name, PSXEditorStyles.CardHeaderStyle);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
|
||||
private void DrawMeshInfoSection()
|
||||
|
||||
private void DrawMeshSummary()
|
||||
{
|
||||
showMeshInfo = EditorGUILayout.BeginFoldoutHeaderGroup(showMeshInfo, "Mesh Information");
|
||||
if (showMeshInfo)
|
||||
if (meshFilter == null || meshFilter.sharedMesh == null)
|
||||
{
|
||||
EditorGUI.indentLevel++;
|
||||
|
||||
if (meshFilter != null && meshFilter.sharedMesh != null)
|
||||
{
|
||||
EditorGUILayout.LabelField("Mesh", meshFilter.sharedMesh.name);
|
||||
EditorGUILayout.LabelField("Triangles", triangleCount.ToString());
|
||||
EditorGUILayout.LabelField("Vertices", vertexCount.ToString());
|
||||
EditorGUILayout.LabelField("Bounds Size", meshBounds.size.ToString("F2"));
|
||||
|
||||
// Triangle budget bar
|
||||
float budgetPercent = triangleCount / 100f;
|
||||
Rect rect = EditorGUILayout.GetControlRect(false, 20);
|
||||
EditorGUI.ProgressBar(rect, Mathf.Clamp01(budgetPercent), $"Triangle Budget: {triangleCount}/100");
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorGUILayout.HelpBox("No mesh assigned", MessageType.Warning);
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||
}
|
||||
|
||||
private void DrawTextureSection()
|
||||
{
|
||||
showTextureInfo = EditorGUILayout.BeginFoldoutHeaderGroup(showTextureInfo, "Texture Settings");
|
||||
if (showTextureInfo)
|
||||
{
|
||||
EditorGUI.indentLevel++;
|
||||
|
||||
EditorGUILayout.PropertyField(textureProp, new GUIContent("Override Texture"));
|
||||
EditorGUILayout.PropertyField(bitDepthProp, new GUIContent("Bit Depth"));
|
||||
|
||||
// Show texture preview if assigned
|
||||
var tex = textureProp.objectReferenceValue as Texture2D;
|
||||
if (tex != null)
|
||||
{
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.FlexibleSpace();
|
||||
Rect previewRect = GUILayoutUtility.GetRect(64, 64, GUILayout.Width(64));
|
||||
EditorGUI.DrawPreviewTexture(previewRect, tex);
|
||||
GUILayout.FlexibleSpace();
|
||||
}
|
||||
|
||||
EditorGUILayout.LabelField($"Size: {tex.width}x{tex.height}");
|
||||
|
||||
// VRAM estimate
|
||||
int bpp = bitDepthProp.enumValueIndex == 0 ? 4 : (bitDepthProp.enumValueIndex == 1 ? 8 : 16);
|
||||
int vramBytes = (tex.width * tex.height * bpp) / 8;
|
||||
EditorGUILayout.LabelField($"Est. VRAM: {vramBytes} bytes ({bpp}bpp)");
|
||||
}
|
||||
else if (meshRenderer != null && meshRenderer.sharedMaterial != null)
|
||||
{
|
||||
var matTex = meshRenderer.sharedMaterial.mainTexture;
|
||||
if (matTex != null)
|
||||
{
|
||||
EditorGUILayout.HelpBox($"Using material texture: {matTex.name}", MessageType.Info);
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||
}
|
||||
|
||||
private void DrawExportSettingsSection()
|
||||
{
|
||||
showExportSettings = EditorGUILayout.BeginFoldoutHeaderGroup(showExportSettings, "Export Settings");
|
||||
if (showExportSettings)
|
||||
{
|
||||
EditorGUI.indentLevel++;
|
||||
|
||||
EditorGUILayout.PropertyField(objectFlagsProp, new GUIContent("Object Flags"));
|
||||
EditorGUILayout.PropertyField(luaFileProp, new GUIContent("Lua Script"));
|
||||
|
||||
// Quick Lua file buttons
|
||||
if (luaFileProp.objectReferenceValue != null)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("Edit Lua", GUILayout.Width(80)))
|
||||
{
|
||||
AssetDatabase.OpenAsset(luaFileProp.objectReferenceValue);
|
||||
}
|
||||
if (GUILayout.Button("Clear", GUILayout.Width(60)))
|
||||
{
|
||||
luaFileProp.objectReferenceValue = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (GUILayout.Button("Create New Lua Script"))
|
||||
{
|
||||
CreateNewLuaScript();
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||
}
|
||||
|
||||
private void DrawCollisionSettingsSection()
|
||||
{
|
||||
showCollisionSettings = EditorGUILayout.BeginFoldoutHeaderGroup(showCollisionSettings, "Collision Settings");
|
||||
if (showCollisionSettings)
|
||||
{
|
||||
EditorGUI.indentLevel++;
|
||||
|
||||
EditorGUILayout.PropertyField(collisionTypeProp, new GUIContent("Collision Type"));
|
||||
|
||||
var collType = (PSXCollisionType)collisionTypeProp.enumValueIndex;
|
||||
if (collType != PSXCollisionType.None)
|
||||
{
|
||||
EditorGUILayout.PropertyField(exportCollisionMeshProp, new GUIContent("Export Collision Mesh"));
|
||||
EditorGUILayout.PropertyField(customCollisionMeshProp, new GUIContent("Custom Collision Mesh"));
|
||||
EditorGUILayout.PropertyField(collisionLayerProp, new GUIContent("Collision Layer"));
|
||||
|
||||
// Collision info
|
||||
EditorGUILayout.Space(5);
|
||||
string collisionInfo = collType switch
|
||||
{
|
||||
PSXCollisionType.Solid => "Solid: Blocks movement, fires onCollision",
|
||||
PSXCollisionType.Trigger => "Trigger: Fires onTriggerEnter/Exit, doesn't block",
|
||||
PSXCollisionType.Platform => "Platform: Solid from above only",
|
||||
_ => ""
|
||||
};
|
||||
EditorGUILayout.HelpBox(collisionInfo, MessageType.Info);
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||
}
|
||||
|
||||
private void DrawGizmoSettingsSection()
|
||||
{
|
||||
showGizmoSettings = EditorGUILayout.BeginFoldoutHeaderGroup(showGizmoSettings, "Gizmo Settings");
|
||||
if (showGizmoSettings)
|
||||
{
|
||||
EditorGUI.indentLevel++;
|
||||
|
||||
EditorGUILayout.PropertyField(previewNormalsProp, new GUIContent("Preview Normals"));
|
||||
if (previewNormalsProp.boolValue)
|
||||
{
|
||||
EditorGUILayout.PropertyField(normalPreviewLengthProp, new GUIContent("Normal Length"));
|
||||
}
|
||||
|
||||
EditorGUILayout.PropertyField(showCollisionBoundsProp, new GUIContent("Show Collision Bounds"));
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||
}
|
||||
|
||||
private void DrawValidationSection()
|
||||
{
|
||||
if (validationErrors.Count == 0 && validationWarnings.Count == 0)
|
||||
EditorGUILayout.HelpBox("No mesh on this object.", MessageType.Warning);
|
||||
return;
|
||||
|
||||
showValidation = EditorGUILayout.BeginFoldoutHeaderGroup(showValidation, "Validation");
|
||||
if (showValidation)
|
||||
{
|
||||
foreach (var error in validationErrors)
|
||||
{
|
||||
EditorGUILayout.HelpBox(error, MessageType.Error);
|
||||
}
|
||||
|
||||
foreach (var warning in validationWarnings)
|
||||
{
|
||||
EditorGUILayout.HelpBox(warning, MessageType.Warning);
|
||||
}
|
||||
|
||||
if (GUILayout.Button("Refresh Validation"))
|
||||
{
|
||||
CacheMeshInfo();
|
||||
RunValidation();
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.LabelField($"{triangleCount} tris", PSXEditorStyles.RichLabel, GUILayout.Width(60));
|
||||
EditorGUILayout.LabelField($"{vertexCount} verts", PSXEditorStyles.RichLabel, GUILayout.Width(70));
|
||||
|
||||
int subMeshCount = meshFilter.sharedMesh.subMeshCount;
|
||||
if (subMeshCount > 1)
|
||||
EditorGUILayout.LabelField($"{subMeshCount} submeshes", PSXEditorStyles.RichLabel, GUILayout.Width(90));
|
||||
|
||||
int matCount = meshRenderer != null ? meshRenderer.sharedMaterials.Length : 0;
|
||||
int textured = meshRenderer != null
|
||||
? meshRenderer.sharedMaterials.Count(m => m != null && m.mainTexture != null)
|
||||
: 0;
|
||||
if (textured > 0)
|
||||
EditorGUILayout.LabelField($"{textured}/{matCount} textured", PSXEditorStyles.RichLabel);
|
||||
else
|
||||
EditorGUILayout.LabelField("untextured", PSXEditorStyles.RichLabel);
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
private void DrawActionButtons()
|
||||
|
||||
private void DrawExportSection()
|
||||
{
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
showExport = EditorGUILayout.Foldout(showExport, "Export", true, PSXEditorStyles.FoldoutHeader);
|
||||
if (!showExport) return;
|
||||
|
||||
EditorGUI.indentLevel++;
|
||||
|
||||
EditorGUILayout.PropertyField(bitDepthProp, new GUIContent("Bit Depth"));
|
||||
EditorGUILayout.PropertyField(luaFileProp, new GUIContent("Lua Script"));
|
||||
|
||||
if (luaFileProp.objectReferenceValue != null)
|
||||
{
|
||||
if (GUILayout.Button("Select Scene Exporter"))
|
||||
{
|
||||
var exporter = FindObjectOfType<PSXSceneExporter>();
|
||||
if (exporter != null)
|
||||
{
|
||||
Selection.activeGameObject = exporter.gameObject;
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorUtility.DisplayDialog("Not Found", "No PSXSceneExporter in scene.", "OK");
|
||||
}
|
||||
}
|
||||
|
||||
if (GUILayout.Button("Open Scene Validator"))
|
||||
{
|
||||
PSXSceneValidatorWindow.ShowWindow();
|
||||
}
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.Space(EditorGUI.indentLevel * 15);
|
||||
if (GUILayout.Button("Edit", EditorStyles.miniButtonLeft, GUILayout.Width(50)))
|
||||
AssetDatabase.OpenAsset(luaFileProp.objectReferenceValue);
|
||||
if (GUILayout.Button("Clear", EditorStyles.miniButtonRight, GUILayout.Width(50)))
|
||||
luaFileProp.objectReferenceValue = null;
|
||||
GUILayout.FlexibleSpace();
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.Space(EditorGUI.indentLevel * 15);
|
||||
if (GUILayout.Button("Create Lua Script", EditorStyles.miniButton, GUILayout.Width(130)))
|
||||
CreateNewLuaScript();
|
||||
GUILayout.FlexibleSpace();
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
|
||||
private void DrawCollisionSection()
|
||||
{
|
||||
showCollision = EditorGUILayout.Foldout(showCollision, "Collision", true, PSXEditorStyles.FoldoutHeader);
|
||||
if (!showCollision) return;
|
||||
|
||||
EditorGUI.indentLevel++;
|
||||
|
||||
EditorGUILayout.PropertyField(collisionTypeProp, new GUIContent("Type"));
|
||||
|
||||
var collType = (PSXCollisionType)collisionTypeProp.enumValueIndex;
|
||||
if (collType != PSXCollisionType.None)
|
||||
{
|
||||
EditorGUILayout.PropertyField(staticColliderProp, new GUIContent("Static"));
|
||||
|
||||
bool isStatic = staticColliderProp.boolValue;
|
||||
if (isStatic)
|
||||
{
|
||||
EditorGUILayout.LabelField(
|
||||
"<color=#88cc88>Baked into world collision mesh. No runtime cost.</color>",
|
||||
PSXEditorStyles.RichLabel);
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorGUILayout.LabelField(
|
||||
"<color=#88aaff>Runtime AABB collider. Fires Lua collision events.</color>",
|
||||
PSXEditorStyles.RichLabel);
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(2);
|
||||
EditorGUILayout.PropertyField(exportCollisionMeshProp, new GUIContent("Export Collision Mesh"));
|
||||
EditorGUILayout.PropertyField(customCollisionMeshProp, new GUIContent("Custom Mesh"));
|
||||
EditorGUILayout.PropertyField(collisionLayerProp, new GUIContent("Layer"));
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
EditorGUILayout.PropertyField(generateNavigationProp, new GUIContent("Generate Navigation"));
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
private void DrawActions()
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("Select Scene Exporter", EditorStyles.miniButton))
|
||||
{
|
||||
var se = FindFirstObjectByType<PSXSceneExporter>();
|
||||
if (se != null)
|
||||
Selection.activeGameObject = se.gameObject;
|
||||
else
|
||||
EditorUtility.DisplayDialog("Not Found", "No PSXSceneExporter in scene.", "OK");
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
private void CreateNewLuaScript()
|
||||
{
|
||||
var exporter = target as PSXObjectExporter;
|
||||
string defaultName = exporter.gameObject.name.ToLower().Replace(" ", "_");
|
||||
string path = EditorUtility.SaveFilePanelInProject(
|
||||
"Create Lua Script",
|
||||
defaultName + ".lua",
|
||||
"lua",
|
||||
"Create Lua Script", defaultName + ".lua", "lua",
|
||||
"Create a new Lua script for this object");
|
||||
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
|
||||
if (string.IsNullOrEmpty(path)) return;
|
||||
|
||||
string template =
|
||||
$"function onCreate(self)\nend\n\nfunction onUpdate(self, dt)\nend\n";
|
||||
System.IO.File.WriteAllText(path, template);
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
var luaFile = AssetDatabase.LoadAssetAtPath<LuaFile>(path);
|
||||
if (luaFile != null)
|
||||
{
|
||||
string template = $@"-- Lua script for {exporter.gameObject.name}
|
||||
--
|
||||
-- Available globals: Entity, Vec3, Input, Timer, Camera, Audio,
|
||||
-- Debug, Math, Scene, Persist
|
||||
--
|
||||
-- Available events:
|
||||
-- onCreate(self) — called once when the object is registered
|
||||
-- onUpdate(self, dt) — called every frame (dt = delta frames, usually 1)
|
||||
-- onEnable(self) — called when the object becomes active
|
||||
-- onDisable(self) — called when the object becomes inactive
|
||||
-- onCollision(self, other) — called on collision with another object
|
||||
-- onTriggerEnter(self, other)
|
||||
-- onTriggerStay(self, other)
|
||||
-- onTriggerExit(self, other)
|
||||
-- onInteract(self) — called when the player interacts
|
||||
-- onButtonPress(self, btn) — called on button press (btn = Input.CROSS etc.)
|
||||
-- onButtonRelease(self, btn)
|
||||
-- onDestroy(self) — called before the object is destroyed
|
||||
--
|
||||
-- Properties: self.position (Vec3), self.rotationY (pi-units), self.active (bool)
|
||||
|
||||
function onCreate(self)
|
||||
-- Called once when this object is registered in the scene
|
||||
end
|
||||
|
||||
function onUpdate(self, dt)
|
||||
-- Called every frame. dt = number of elapsed frames (usually 1).
|
||||
end
|
||||
|
||||
function onInteract(self)
|
||||
-- Called when the player interacts with this object
|
||||
end
|
||||
";
|
||||
System.IO.File.WriteAllText(path, template);
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
var luaFile = AssetDatabase.LoadAssetAtPath<LuaFile>(path);
|
||||
if (luaFile != null)
|
||||
{
|
||||
luaFileProp.objectReferenceValue = luaFile;
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[MenuItem("CONTEXT/PSXObjectExporter/Copy Settings to Selected")]
|
||||
private static void CopySettingsToSelected(MenuCommand command)
|
||||
{
|
||||
var source = command.context as PSXObjectExporter;
|
||||
if (source == null) return;
|
||||
|
||||
foreach (var go in Selection.gameObjects)
|
||||
{
|
||||
var target = go.GetComponent<PSXObjectExporter>();
|
||||
if (target != null && target != source)
|
||||
{
|
||||
Undo.RecordObject(target, "Copy PSX Settings");
|
||||
// Copy via serialized object
|
||||
EditorUtility.CopySerialized(source, target);
|
||||
}
|
||||
luaFileProp.objectReferenceValue = luaFile;
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,49 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using SplashEdit.RuntimeCode;
|
||||
using System.Linq;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom inspector for PSXSceneExporter.
|
||||
/// When the component is selected and fog is enabled, activates a Unity scene-view
|
||||
/// fog preview that approximates the PS1 linear fog distances.
|
||||
///
|
||||
/// Fog distance mapping:
|
||||
/// fogFarSZ = 8000 / FogDensity (GTE SZ units)
|
||||
/// fogNearSZ = fogFarSZ / 3
|
||||
/// SZ is 20.12 fixed-point: SZ = (unityCoord / GTEScaling) * 4096
|
||||
/// => unityDist = SZ * GTEScaling / 4096
|
||||
/// => Unity fog near = (8000 / (FogDensity * 3)) * GTEScaling / 4096
|
||||
/// => Unity fog far = (8000 / FogDensity) * GTEScaling / 4096
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(PSXSceneExporter))]
|
||||
public class PSXSceneExporterEditor : UnityEditor.Editor
|
||||
{
|
||||
// Saved RenderSettings state so we can restore it on deselect.
|
||||
private SerializedProperty gteScalingProp;
|
||||
private SerializedProperty sceneLuaProp;
|
||||
private SerializedProperty fogEnabledProp;
|
||||
private SerializedProperty fogColorProp;
|
||||
private SerializedProperty fogDensityProp;
|
||||
private SerializedProperty sceneTypeProp;
|
||||
private SerializedProperty cutscenesProp;
|
||||
private SerializedProperty loadingScreenProp;
|
||||
private SerializedProperty previewBVHProp;
|
||||
private SerializedProperty bvhDepthProp;
|
||||
|
||||
private bool _savedFog;
|
||||
private Color _savedFogColor;
|
||||
private FogMode _savedFogMode;
|
||||
private float _savedFogStart;
|
||||
private float _savedFogEnd;
|
||||
|
||||
private bool _previewActive = false;
|
||||
|
||||
private bool showFog = true;
|
||||
private bool showCutscenes = true;
|
||||
private bool showDebug = false;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
gteScalingProp = serializedObject.FindProperty("GTEScaling");
|
||||
sceneLuaProp = serializedObject.FindProperty("SceneLuaFile");
|
||||
fogEnabledProp = serializedObject.FindProperty("FogEnabled");
|
||||
fogColorProp = serializedObject.FindProperty("FogColor");
|
||||
fogDensityProp = serializedObject.FindProperty("FogDensity");
|
||||
sceneTypeProp = serializedObject.FindProperty("SceneType");
|
||||
cutscenesProp = serializedObject.FindProperty("Cutscenes");
|
||||
loadingScreenProp = serializedObject.FindProperty("LoadingScreenPrefab");
|
||||
previewBVHProp = serializedObject.FindProperty("PreviewBVH");
|
||||
bvhDepthProp = serializedObject.FindProperty("BVHPreviewDepth");
|
||||
|
||||
SaveAndApplyFogPreview();
|
||||
// Re-apply whenever the scene is repainted (handles inspector value changes).
|
||||
EditorApplication.update += OnEditorUpdate;
|
||||
}
|
||||
|
||||
@@ -44,11 +55,155 @@ namespace SplashEdit.EditorCode
|
||||
|
||||
private void OnEditorUpdate()
|
||||
{
|
||||
// Keep the preview in sync when the user tweaks values in the inspector.
|
||||
if (_previewActive)
|
||||
ApplyFogPreview();
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
var exporter = (PSXSceneExporter)target;
|
||||
|
||||
DrawHeader();
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
DrawSceneSettings();
|
||||
PSXEditorStyles.DrawSeparator(6, 6);
|
||||
DrawFogSection(exporter);
|
||||
PSXEditorStyles.DrawSeparator(6, 6);
|
||||
DrawCutscenesSection();
|
||||
PSXEditorStyles.DrawSeparator(6, 6);
|
||||
DrawLoadingSection();
|
||||
PSXEditorStyles.DrawSeparator(6, 6);
|
||||
DrawDebugSection();
|
||||
PSXEditorStyles.DrawSeparator(6, 6);
|
||||
DrawSceneStats();
|
||||
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
private void DrawHeader()
|
||||
{
|
||||
EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle);
|
||||
EditorGUILayout.LabelField("Scene Exporter", PSXEditorStyles.CardHeaderStyle);
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
|
||||
private void DrawSceneSettings()
|
||||
{
|
||||
EditorGUILayout.PropertyField(sceneTypeProp, new GUIContent("Scene Type"));
|
||||
|
||||
bool isInterior = (PSXSceneType)sceneTypeProp.enumValueIndex == PSXSceneType.Interior;
|
||||
EditorGUILayout.LabelField(
|
||||
isInterior
|
||||
? "<color=#88aaff>Room/portal occlusion culling.</color>"
|
||||
: "<color=#88cc88>BVH frustum culling.</color>",
|
||||
PSXEditorStyles.RichLabel);
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
EditorGUILayout.PropertyField(gteScalingProp, new GUIContent("GTE Scaling"));
|
||||
EditorGUILayout.PropertyField(sceneLuaProp, new GUIContent("Scene Lua"));
|
||||
|
||||
if (sceneLuaProp.objectReferenceValue != null)
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.Space(EditorGUI.indentLevel * 15);
|
||||
if (GUILayout.Button("Edit", EditorStyles.miniButtonLeft, GUILayout.Width(50)))
|
||||
AssetDatabase.OpenAsset(sceneLuaProp.objectReferenceValue);
|
||||
if (GUILayout.Button("Clear", EditorStyles.miniButtonRight, GUILayout.Width(50)))
|
||||
sceneLuaProp.objectReferenceValue = null;
|
||||
GUILayout.FlexibleSpace();
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawFogSection(PSXSceneExporter exporter)
|
||||
{
|
||||
showFog = EditorGUILayout.Foldout(showFog, "Fog", true, PSXEditorStyles.FoldoutHeader);
|
||||
if (!showFog) return;
|
||||
|
||||
EditorGUI.indentLevel++;
|
||||
|
||||
EditorGUILayout.PropertyField(fogEnabledProp, new GUIContent("Enabled"));
|
||||
|
||||
if (fogEnabledProp.boolValue)
|
||||
{
|
||||
EditorGUILayout.PropertyField(fogColorProp, new GUIContent("Color"));
|
||||
EditorGUILayout.PropertyField(fogDensityProp, new GUIContent("Density"));
|
||||
|
||||
float gteScale = exporter.GTEScaling;
|
||||
int density = Mathf.Clamp(exporter.FogDensity, 1, 10);
|
||||
float fogFarUnity = (8000f / density) * gteScale / 4096f;
|
||||
float fogNearUnity = fogFarUnity / 3f;
|
||||
|
||||
EditorGUILayout.Space(2);
|
||||
EditorGUILayout.LabelField(
|
||||
$"<color=#aaaaaa>Preview: {fogNearUnity:F1} – {fogFarUnity:F1} units | " +
|
||||
$"GTE: {8000f / (density * 3f):F0} – {8000f / density:F0} SZ</color>",
|
||||
PSXEditorStyles.RichLabel);
|
||||
|
||||
ApplyFogPreview();
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderSettings.fog = false;
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
private void DrawCutscenesSection()
|
||||
{
|
||||
showCutscenes = EditorGUILayout.Foldout(showCutscenes, "Cutscenes", true, PSXEditorStyles.FoldoutHeader);
|
||||
if (!showCutscenes) return;
|
||||
|
||||
EditorGUI.indentLevel++;
|
||||
EditorGUILayout.PropertyField(cutscenesProp, new GUIContent("Clips"), true);
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
private void DrawLoadingSection()
|
||||
{
|
||||
EditorGUILayout.PropertyField(loadingScreenProp, new GUIContent("Loading Screen Prefab"));
|
||||
if (loadingScreenProp.objectReferenceValue != null)
|
||||
{
|
||||
var go = loadingScreenProp.objectReferenceValue as GameObject;
|
||||
if (go != null && go.GetComponentInChildren<PSXCanvas>() == null)
|
||||
{
|
||||
EditorGUILayout.LabelField(
|
||||
"<color=#ffaa44>Prefab has no PSXCanvas component.</color>",
|
||||
PSXEditorStyles.RichLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawDebugSection()
|
||||
{
|
||||
showDebug = EditorGUILayout.Foldout(showDebug, "Debug", true, PSXEditorStyles.FoldoutHeader);
|
||||
if (!showDebug) return;
|
||||
|
||||
EditorGUI.indentLevel++;
|
||||
EditorGUILayout.PropertyField(previewBVHProp, new GUIContent("Preview BVH"));
|
||||
if (previewBVHProp.boolValue)
|
||||
EditorGUILayout.PropertyField(bvhDepthProp, new GUIContent("BVH Depth"));
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
private void DrawSceneStats()
|
||||
{
|
||||
var exporters = FindObjectsOfType<PSXObjectExporter>();
|
||||
int total = exporters.Length;
|
||||
int active = exporters.Count(e => e.IsActive);
|
||||
int withCollision = exporters.Count(e => e.CollisionType != PSXCollisionType.None);
|
||||
int staticCol = exporters.Count(e => e.StaticCollider && e.CollisionType != PSXCollisionType.None);
|
||||
|
||||
EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle);
|
||||
EditorGUILayout.LabelField(
|
||||
$"<b>{active}</b>/{total} objects | <b>{withCollision}</b> colliders ({staticCol} static)",
|
||||
PSXEditorStyles.RichLabel);
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
|
||||
private void SaveAndApplyFogPreview()
|
||||
{
|
||||
_savedFog = RenderSettings.fog;
|
||||
@@ -56,7 +211,6 @@ namespace SplashEdit.EditorCode
|
||||
_savedFogMode = RenderSettings.fogMode;
|
||||
_savedFogStart = RenderSettings.fogStartDistance;
|
||||
_savedFogEnd = RenderSettings.fogEndDistance;
|
||||
|
||||
_previewActive = true;
|
||||
ApplyFogPreview();
|
||||
}
|
||||
@@ -68,76 +222,31 @@ namespace SplashEdit.EditorCode
|
||||
|
||||
if (!exporter.FogEnabled)
|
||||
{
|
||||
// Fog disabled on the component - turn off the preview.
|
||||
RenderSettings.fog = false;
|
||||
return;
|
||||
}
|
||||
|
||||
float gteScale = exporter.GTEScaling;
|
||||
int density = Mathf.Clamp(exporter.FogDensity, 1, 10);
|
||||
|
||||
// fogFarSZ in GTE SZ units (20.12 fp); convert to Unity world-space.
|
||||
// SZ = (unityDist / GTEScaling) * 4096, so unityDist = SZ * GTEScaling / 4096
|
||||
float fogFarSZ = 8000f / density;
|
||||
float fogNearSZ = fogFarSZ / 3f;
|
||||
|
||||
float fogFarUnity = fogFarSZ * gteScale / 4096f;
|
||||
float fogNearUnity = fogNearSZ * gteScale / 4096f;
|
||||
|
||||
RenderSettings.fog = true;
|
||||
RenderSettings.fogColor = exporter.FogColor;
|
||||
RenderSettings.fogMode = FogMode.Linear;
|
||||
RenderSettings.fogStartDistance = fogNearUnity;
|
||||
RenderSettings.fogEndDistance = fogFarUnity;
|
||||
RenderSettings.fogStartDistance = fogNearSZ * gteScale / 4096f;
|
||||
RenderSettings.fogEndDistance = fogFarSZ * gteScale / 4096f;
|
||||
}
|
||||
|
||||
private void RestoreFog()
|
||||
{
|
||||
if (!_previewActive) return;
|
||||
_previewActive = false;
|
||||
|
||||
RenderSettings.fog = _savedFog;
|
||||
RenderSettings.fogColor = _savedFogColor;
|
||||
RenderSettings.fogMode = _savedFogMode;
|
||||
RenderSettings.fogStartDistance = _savedFogStart;
|
||||
RenderSettings.fogEndDistance = _savedFogEnd;
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
|
||||
DrawDefaultInspector();
|
||||
|
||||
// Show computed fog distances when fog is enabled, so the user
|
||||
// can see exactly what range the preview represents.
|
||||
var exporter = (PSXSceneExporter)target;
|
||||
if (exporter.FogEnabled)
|
||||
{
|
||||
EditorGUILayout.Space(4);
|
||||
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
|
||||
GUILayout.Label("Fog Preview (active in Scene view)", EditorStyles.boldLabel);
|
||||
|
||||
float gteScale = exporter.GTEScaling;
|
||||
int density = Mathf.Clamp(exporter.FogDensity, 1, 10);
|
||||
float fogFarUnity = (8000f / density) * gteScale / 4096f;
|
||||
float fogNearUnity = fogFarUnity / 3f;
|
||||
|
||||
EditorGUILayout.LabelField("Near distance", $"{fogNearUnity:F1} Unity units");
|
||||
EditorGUILayout.LabelField("Far distance", $"{fogFarUnity:F1} Unity units");
|
||||
EditorGUILayout.LabelField("(PS1 SZ range)", $"{8000f / (density * 3f):F0} - {8000f / density:F0} GTE units");
|
||||
EditorGUILayout.EndVertical();
|
||||
|
||||
// Keep preview applied as values may have changed.
|
||||
ApplyFogPreview();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Make sure preview is off when fog is disabled.
|
||||
RenderSettings.fog = false;
|
||||
}
|
||||
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,496 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using SplashEdit.RuntimeCode;
|
||||
|
||||
namespace SplashEdit.EditorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Scene Validator Window - Validates the current scene for PS1 compatibility.
|
||||
/// Checks for common issues that would cause problems on real hardware.
|
||||
/// </summary>
|
||||
public class PSXSceneValidatorWindow : EditorWindow
|
||||
{
|
||||
private Vector2 scrollPosition;
|
||||
private List<ValidationResult> validationResults = new List<ValidationResult>();
|
||||
private bool hasValidated = false;
|
||||
private int errorCount = 0;
|
||||
private int warningCount = 0;
|
||||
private int infoCount = 0;
|
||||
|
||||
// Filter toggles
|
||||
private bool showErrors = true;
|
||||
private bool showWarnings = true;
|
||||
private bool showInfo = true;
|
||||
|
||||
// PS1 Limits
|
||||
private const int MAX_RECOMMENDED_TRIS_PER_OBJECT = 100;
|
||||
private const int MAX_RECOMMENDED_TOTAL_TRIS = 400;
|
||||
private const int MAX_VERTEX_COORD = 32767; // signed 16-bit
|
||||
private const int MIN_VERTEX_COORD = -32768;
|
||||
private const int VRAM_WIDTH = 1024;
|
||||
private const int VRAM_HEIGHT = 512;
|
||||
|
||||
private static readonly Vector2 MinSize = new Vector2(500, 400);
|
||||
|
||||
public static void ShowWindow()
|
||||
{
|
||||
var window = GetWindow<PSXSceneValidatorWindow>("Scene Validator");
|
||||
window.minSize = MinSize;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
validationResults.Clear();
|
||||
hasValidated = false;
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
DrawHeader();
|
||||
DrawFilters();
|
||||
DrawResults();
|
||||
DrawFooter();
|
||||
}
|
||||
|
||||
private void DrawHeader()
|
||||
{
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.Label("PS1 Scene Validator", EditorStyles.boldLabel);
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (GUILayout.Button("Validate Scene", GUILayout.Width(120)))
|
||||
{
|
||||
ValidateScene();
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
// Summary bar
|
||||
if (hasValidated)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope(EditorStyles.helpBox))
|
||||
{
|
||||
var errorStyle = new GUIStyle(EditorStyles.label);
|
||||
errorStyle.normal.textColor = errorCount > 0 ? Color.red : Color.green;
|
||||
GUILayout.Label($"✗ {errorCount} Errors", errorStyle);
|
||||
|
||||
var warnStyle = new GUIStyle(EditorStyles.label);
|
||||
warnStyle.normal.textColor = warningCount > 0 ? new Color(1f, 0.7f, 0f) : Color.green;
|
||||
GUILayout.Label($"⚠ {warningCount} Warnings", warnStyle);
|
||||
|
||||
var infoStyle = new GUIStyle(EditorStyles.label);
|
||||
infoStyle.normal.textColor = Color.cyan;
|
||||
GUILayout.Label($"ℹ {infoCount} Info", infoStyle);
|
||||
|
||||
GUILayout.FlexibleSpace();
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
}
|
||||
|
||||
private void DrawFilters()
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.Label("Show:", GUILayout.Width(40));
|
||||
showErrors = GUILayout.Toggle(showErrors, "Errors", EditorStyles.miniButtonLeft);
|
||||
showWarnings = GUILayout.Toggle(showWarnings, "Warnings", EditorStyles.miniButtonMid);
|
||||
showInfo = GUILayout.Toggle(showInfo, "Info", EditorStyles.miniButtonRight);
|
||||
GUILayout.FlexibleSpace();
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
}
|
||||
|
||||
private void DrawResults()
|
||||
{
|
||||
using (var scrollView = new EditorGUILayout.ScrollViewScope(scrollPosition))
|
||||
{
|
||||
scrollPosition = scrollView.scrollPosition;
|
||||
|
||||
if (!hasValidated)
|
||||
{
|
||||
EditorGUILayout.HelpBox("Click 'Validate Scene' to check for PS1 compatibility issues.", MessageType.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
if (validationResults.Count == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox("No issues found! Your scene looks ready for PS1 export.", MessageType.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var result in validationResults)
|
||||
{
|
||||
if (result.Type == ValidationType.Error && !showErrors) continue;
|
||||
if (result.Type == ValidationType.Warning && !showWarnings) continue;
|
||||
if (result.Type == ValidationType.Info && !showInfo) continue;
|
||||
|
||||
DrawValidationResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawValidationResult(ValidationResult result)
|
||||
{
|
||||
MessageType msgType = result.Type switch
|
||||
{
|
||||
ValidationType.Error => MessageType.Error,
|
||||
ValidationType.Warning => MessageType.Warning,
|
||||
_ => MessageType.Info
|
||||
};
|
||||
|
||||
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
|
||||
{
|
||||
EditorGUILayout.HelpBox(result.Message, msgType);
|
||||
|
||||
if (result.RelatedObject != null)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.Label("Object:", GUILayout.Width(50));
|
||||
|
||||
if (GUILayout.Button(result.RelatedObject.name, EditorStyles.linkLabel))
|
||||
{
|
||||
Selection.activeObject = result.RelatedObject;
|
||||
EditorGUIUtility.PingObject(result.RelatedObject);
|
||||
}
|
||||
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (!string.IsNullOrEmpty(result.FixAction))
|
||||
{
|
||||
if (GUILayout.Button("Fix", GUILayout.Width(50)))
|
||||
{
|
||||
ApplyFix(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(2);
|
||||
}
|
||||
|
||||
private void DrawFooter()
|
||||
{
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("Select All With Errors"))
|
||||
{
|
||||
var errorObjects = validationResults
|
||||
.Where(r => r.Type == ValidationType.Error && r.RelatedObject != null)
|
||||
.Select(r => r.RelatedObject)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
Selection.objects = errorObjects;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateScene()
|
||||
{
|
||||
validationResults.Clear();
|
||||
errorCount = 0;
|
||||
warningCount = 0;
|
||||
infoCount = 0;
|
||||
|
||||
// Check for scene exporter
|
||||
ValidateSceneExporter();
|
||||
|
||||
// Check all PSX objects
|
||||
ValidatePSXObjects();
|
||||
|
||||
// Check textures and VRAM
|
||||
ValidateTextures();
|
||||
|
||||
|
||||
// Check Lua files
|
||||
ValidateLuaFiles();
|
||||
|
||||
// Overall scene stats
|
||||
ValidateSceneStats();
|
||||
|
||||
hasValidated = true;
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private void ValidateSceneExporter()
|
||||
{
|
||||
var exporters = Object.FindObjectsOfType<PSXSceneExporter>();
|
||||
|
||||
if (exporters.Length == 0)
|
||||
{
|
||||
AddResult(ValidationType.Error,
|
||||
"No PSXSceneExporter found in scene. Add one via GameObject > PlayStation 1 > Scene Exporter",
|
||||
null, "AddExporter");
|
||||
}
|
||||
else if (exporters.Length > 1)
|
||||
{
|
||||
AddResult(ValidationType.Warning,
|
||||
$"Multiple PSXSceneExporters found ({exporters.Length}). Only one is needed per scene.",
|
||||
exporters[0].gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidatePSXObjects()
|
||||
{
|
||||
var exporters = Object.FindObjectsOfType<PSXObjectExporter>();
|
||||
|
||||
if (exporters.Length == 0)
|
||||
{
|
||||
AddResult(ValidationType.Info,
|
||||
"No objects marked for PSX export. Add PSXObjectExporter components to GameObjects you want to export.",
|
||||
null);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var exporter in exporters)
|
||||
{
|
||||
ValidateSingleObject(exporter);
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateSingleObject(PSXObjectExporter exporter)
|
||||
{
|
||||
var go = exporter.gameObject;
|
||||
|
||||
// Check for mesh
|
||||
var meshFilter = go.GetComponent<MeshFilter>();
|
||||
if (meshFilter == null || meshFilter.sharedMesh == null)
|
||||
{
|
||||
AddResult(ValidationType.Warning,
|
||||
$"'{go.name}' has no mesh. It will be exported as an empty object.",
|
||||
go);
|
||||
return;
|
||||
}
|
||||
|
||||
var mesh = meshFilter.sharedMesh;
|
||||
int triCount = mesh.triangles.Length / 3;
|
||||
|
||||
// Check triangle count
|
||||
if (triCount > MAX_RECOMMENDED_TRIS_PER_OBJECT)
|
||||
{
|
||||
AddResult(ValidationType.Warning,
|
||||
$"'{go.name}' has {triCount} triangles (recommended max: {MAX_RECOMMENDED_TRIS_PER_OBJECT}). Consider simplifying.",
|
||||
go);
|
||||
}
|
||||
|
||||
// Check vertex coordinates for GTE limits
|
||||
var vertices = mesh.vertices;
|
||||
var transform = go.transform;
|
||||
bool hasOutOfBounds = false;
|
||||
|
||||
foreach (var vert in vertices)
|
||||
{
|
||||
var worldPos = transform.TransformPoint(vert);
|
||||
// Check if fixed-point conversion would overflow (assuming scale factor)
|
||||
float scaledX = worldPos.x * 4096f; // FixedPoint<12> scale
|
||||
float scaledY = worldPos.y * 4096f;
|
||||
float scaledZ = worldPos.z * 4096f;
|
||||
|
||||
if (scaledX > MAX_VERTEX_COORD || scaledX < MIN_VERTEX_COORD ||
|
||||
scaledY > MAX_VERTEX_COORD || scaledY < MIN_VERTEX_COORD ||
|
||||
scaledZ > MAX_VERTEX_COORD || scaledZ < MIN_VERTEX_COORD)
|
||||
{
|
||||
hasOutOfBounds = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOutOfBounds)
|
||||
{
|
||||
AddResult(ValidationType.Error,
|
||||
$"'{go.name}' has vertices that exceed PS1 coordinate limits. Move closer to origin or scale down.",
|
||||
go);
|
||||
}
|
||||
|
||||
// Check for renderer and material
|
||||
var renderer = go.GetComponent<MeshRenderer>();
|
||||
if (renderer == null)
|
||||
{
|
||||
AddResult(ValidationType.Info,
|
||||
$"'{go.name}' has no MeshRenderer. Will be exported without visual rendering.",
|
||||
go);
|
||||
}
|
||||
else if (renderer.sharedMaterial == null)
|
||||
{
|
||||
AddResult(ValidationType.Warning,
|
||||
$"'{go.name}' has no material assigned. Will use default colors.",
|
||||
go);
|
||||
}
|
||||
|
||||
// Check texture settings on exporter
|
||||
if (exporter.texture != null)
|
||||
{
|
||||
ValidateTexture(exporter.texture, go);
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateTextures()
|
||||
{
|
||||
var exporters = Object.FindObjectsOfType<PSXObjectExporter>();
|
||||
var textures = exporters
|
||||
.Where(e => e.texture != null)
|
||||
.Select(e => e.texture)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (textures.Count == 0)
|
||||
{
|
||||
AddResult(ValidationType.Info,
|
||||
"No textures assigned to any PSX objects. Scene will be vertex-colored only.",
|
||||
null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Rough VRAM estimation
|
||||
int estimatedVramUsage = 0;
|
||||
foreach (var tex in textures)
|
||||
{
|
||||
// Rough estimate: width * height * bits/8
|
||||
// This is simplified - actual packing is more complex
|
||||
int bitsPerPixel = 16; // Assume 16bpp worst case
|
||||
estimatedVramUsage += (tex.width * tex.height * bitsPerPixel) / 8;
|
||||
}
|
||||
|
||||
int vramTotal = VRAM_WIDTH * VRAM_HEIGHT * 2; // 16bpp
|
||||
int vramAvailable = vramTotal / 2; // Assume half for framebuffers
|
||||
|
||||
if (estimatedVramUsage > vramAvailable)
|
||||
{
|
||||
AddResult(ValidationType.Warning,
|
||||
$"Estimated texture VRAM usage ({estimatedVramUsage / 1024}KB) may exceed available space (~{vramAvailable / 1024}KB). " +
|
||||
"Consider using lower bit depths or smaller textures.",
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateTexture(Texture2D texture, GameObject relatedObject)
|
||||
{
|
||||
// Check power of 2
|
||||
if (!Mathf.IsPowerOfTwo(texture.width) || !Mathf.IsPowerOfTwo(texture.height))
|
||||
{
|
||||
AddResult(ValidationType.Warning,
|
||||
$"Texture '{texture.name}' dimensions ({texture.width}x{texture.height}) are not power of 2. May cause issues.",
|
||||
relatedObject);
|
||||
}
|
||||
|
||||
// Check max size
|
||||
if (texture.width > 256 || texture.height > 256)
|
||||
{
|
||||
AddResult(ValidationType.Warning,
|
||||
$"Texture '{texture.name}' is large ({texture.width}x{texture.height}). Consider using 256x256 or smaller.",
|
||||
relatedObject);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void ValidateLuaFiles()
|
||||
{
|
||||
var exporters = Object.FindObjectsOfType<PSXObjectExporter>();
|
||||
|
||||
foreach (var exporter in exporters)
|
||||
{
|
||||
if (exporter.LuaFile != null)
|
||||
{
|
||||
// Check if Lua file exists and is valid
|
||||
string path = AssetDatabase.GetAssetPath(exporter.LuaFile);
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
AddResult(ValidationType.Error,
|
||||
$"'{exporter.name}' references an invalid Lua file.",
|
||||
exporter.gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateSceneStats()
|
||||
{
|
||||
var exporters = Object.FindObjectsOfType<PSXObjectExporter>();
|
||||
int totalTris = 0;
|
||||
|
||||
foreach (var exporter in exporters)
|
||||
{
|
||||
var mf = exporter.GetComponent<MeshFilter>();
|
||||
if (mf != null && mf.sharedMesh != null)
|
||||
{
|
||||
totalTris += mf.sharedMesh.triangles.Length / 3;
|
||||
}
|
||||
}
|
||||
|
||||
AddResult(ValidationType.Info,
|
||||
$"Scene statistics: {exporters.Length} objects, {totalTris} total triangles.",
|
||||
null);
|
||||
|
||||
if (totalTris > MAX_RECOMMENDED_TOTAL_TRIS)
|
||||
{
|
||||
AddResult(ValidationType.Warning,
|
||||
$"Total triangle count ({totalTris}) exceeds recommended maximum ({MAX_RECOMMENDED_TOTAL_TRIS}). " +
|
||||
"Performance may be poor on real hardware.",
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddResult(ValidationType type, string message, GameObject relatedObject, string fixAction = null)
|
||||
{
|
||||
validationResults.Add(new ValidationResult
|
||||
{
|
||||
Type = type,
|
||||
Message = message,
|
||||
RelatedObject = relatedObject,
|
||||
FixAction = fixAction
|
||||
});
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case ValidationType.Error: errorCount++; break;
|
||||
case ValidationType.Warning: warningCount++; break;
|
||||
case ValidationType.Info: infoCount++; break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFix(ValidationResult result)
|
||||
{
|
||||
switch (result.FixAction)
|
||||
{
|
||||
case "AddExporter":
|
||||
var go = new GameObject("PSXSceneExporter");
|
||||
go.AddComponent<PSXSceneExporter>();
|
||||
Undo.RegisterCreatedObjectUndo(go, "Create PSX Scene Exporter");
|
||||
Selection.activeGameObject = go;
|
||||
ValidateScene(); // Re-validate
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private enum ValidationType
|
||||
{
|
||||
Error,
|
||||
Warning,
|
||||
Info
|
||||
}
|
||||
|
||||
private class ValidationResult
|
||||
{
|
||||
public ValidationType Type;
|
||||
public string Message;
|
||||
public GameObject RelatedObject;
|
||||
public string FixAction;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0a26bf89301a2554ca287b9e28e44906
|
||||
@@ -176,8 +176,7 @@ namespace SplashEdit.EditorCode
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
// Wait for exit with timeout
|
||||
var timeout = TimeSpan.FromSeconds(30);
|
||||
var timeout = TimeSpan.FromMinutes(10);
|
||||
if (await Task.Run(() => process.WaitForExit((int)timeout.TotalMilliseconds)))
|
||||
{
|
||||
process.WaitForExit(); // Ensure all output is processed
|
||||
|
||||
@@ -84,13 +84,16 @@ namespace SplashEdit.RuntimeCode
|
||||
|
||||
foreach (var exporter in exporters)
|
||||
{
|
||||
// Dynamic objects are handled by the runtime collision system, skip them
|
||||
if (!exporter.StaticCollider && exporter.CollisionType != PSXCollisionType.None)
|
||||
continue;
|
||||
|
||||
PSXCollisionType effectiveType = exporter.CollisionType;
|
||||
|
||||
if (effectiveType == PSXCollisionType.None)
|
||||
{
|
||||
if (autoIncludeSolid)
|
||||
{
|
||||
// Auto-include as Solid so all geometry blocks the player
|
||||
effectiveType = PSXCollisionType.Solid;
|
||||
autoIncluded++;
|
||||
}
|
||||
@@ -146,8 +149,7 @@ namespace SplashEdit.RuntimeCode
|
||||
flags = (byte)PSXSurfaceFlag.Solid;
|
||||
|
||||
// Check if stairs (tagged on exporter or steep-ish)
|
||||
if (exporter.ObjectFlags.HasFlag(PSXObjectFlags.Static) &&
|
||||
dotUp < 0.95f && dotUp > cosWalkable)
|
||||
if (dotUp < 0.95f && dotUp > cosWalkable)
|
||||
{
|
||||
flags |= (byte)PSXSurfaceFlag.Stairs;
|
||||
}
|
||||
|
||||
@@ -5,31 +5,12 @@ using UnityEngine.Serialization;
|
||||
|
||||
namespace SplashEdit.RuntimeCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Collision type for PS1 runtime
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Object behavior flags for PS1 runtime
|
||||
/// </summary>
|
||||
[System.Flags]
|
||||
public enum PSXObjectFlags
|
||||
{
|
||||
None = 0,
|
||||
Static = 1 << 0, // Object never moves (can be optimized)
|
||||
Dynamic = 1 << 1, // Object can move
|
||||
Visible = 1 << 2, // Object is rendered
|
||||
CastsShadow = 1 << 3, // Object casts shadows (future)
|
||||
ReceivesShadow = 1 << 4, // Object receives shadows (future)
|
||||
Interactable = 1 << 5, // Player can interact with this
|
||||
AlwaysRender = 1 << 6, // Skip frustum culling for this object
|
||||
Solid = 1,
|
||||
Trigger = 2,
|
||||
Platform = 3
|
||||
}
|
||||
|
||||
[RequireComponent(typeof(Renderer))]
|
||||
@@ -43,184 +24,70 @@ namespace SplashEdit.RuntimeCode
|
||||
|
||||
public List<PSXTexture2D> Textures { get; set; } = new List<PSXTexture2D>();
|
||||
public PSXMesh Mesh { get; protected set; }
|
||||
|
||||
[Header("Export Settings")]
|
||||
|
||||
[FormerlySerializedAs("BitDepth")]
|
||||
[SerializeField] private PSXBPP bitDepth = PSXBPP.TEX_8BIT;
|
||||
[SerializeField] private LuaFile luaFile;
|
||||
|
||||
[Header("Object Flags")]
|
||||
[SerializeField] private PSXObjectFlags objectFlags = PSXObjectFlags.Static | PSXObjectFlags.Visible;
|
||||
|
||||
[Header("Collision Settings")]
|
||||
|
||||
[SerializeField] private PSXCollisionType collisionType = PSXCollisionType.None;
|
||||
[SerializeField] private bool staticCollider = true;
|
||||
[SerializeField] private bool exportCollisionMesh = false;
|
||||
[SerializeField] private Mesh customCollisionMesh; // Optional simplified collision mesh
|
||||
[Tooltip("Layer mask for collision detection (1-8)")]
|
||||
[SerializeField] private Mesh customCollisionMesh;
|
||||
[Range(1, 8)]
|
||||
[SerializeField] private int collisionLayer = 1;
|
||||
|
||||
[Header("Navigation")]
|
||||
[Tooltip("Include this object's walkable surfaces in nav region generation")]
|
||||
[SerializeField] private bool generateNavigation = false;
|
||||
|
||||
[Header("Gizmo Settings")]
|
||||
[FormerlySerializedAs("PreviewNormals")]
|
||||
[SerializeField] private bool previewNormals = false;
|
||||
[SerializeField] private float normalPreviewLength = 0.5f;
|
||||
[SerializeField] private bool showCollisionBounds = true;
|
||||
|
||||
// Public accessors for editor and export
|
||||
public PSXBPP BitDepth => bitDepth;
|
||||
public PSXCollisionType CollisionType => collisionType;
|
||||
public bool StaticCollider => staticCollider;
|
||||
public bool ExportCollisionMesh => exportCollisionMesh;
|
||||
public Mesh CustomCollisionMesh => customCollisionMesh;
|
||||
public int CollisionLayer => collisionLayer;
|
||||
public PSXObjectFlags ObjectFlags => objectFlags;
|
||||
public bool GenerateNavigation => generateNavigation;
|
||||
|
||||
// For assigning texture from editor
|
||||
public Texture2D texture;
|
||||
|
||||
private readonly Dictionary<(int, PSXBPP), PSXTexture2D> cache = new();
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
if (previewNormals)
|
||||
{
|
||||
MeshFilter filter = GetComponent<MeshFilter>();
|
||||
|
||||
if (filter != null)
|
||||
{
|
||||
Mesh mesh = filter.sharedMesh;
|
||||
Vector3[] vertices = mesh.vertices;
|
||||
Vector3[] normals = mesh.normals;
|
||||
|
||||
Gizmos.color = Color.green;
|
||||
|
||||
for (int i = 0; i < vertices.Length; i++)
|
||||
{
|
||||
Vector3 worldVertex = transform.TransformPoint(vertices[i]);
|
||||
Vector3 worldNormal = transform.TransformDirection(normals[i]);
|
||||
|
||||
Gizmos.DrawLine(worldVertex, worldVertex + worldNormal * normalPreviewLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
// Draw collision bounds when object is selected
|
||||
if (showCollisionBounds && collisionType != PSXCollisionType.None)
|
||||
{
|
||||
MeshFilter filter = GetComponent<MeshFilter>();
|
||||
Mesh collisionMesh = customCollisionMesh != null ? customCollisionMesh : (filter?.sharedMesh);
|
||||
|
||||
if (collisionMesh != null)
|
||||
{
|
||||
Bounds bounds = collisionMesh.bounds;
|
||||
|
||||
// Choose color based on collision type
|
||||
switch (collisionType)
|
||||
{
|
||||
case PSXCollisionType.Solid:
|
||||
Gizmos.color = new Color(1f, 0.3f, 0.3f, 0.5f); // Red
|
||||
break;
|
||||
case PSXCollisionType.Trigger:
|
||||
Gizmos.color = new Color(0.3f, 1f, 0.3f, 0.5f); // Green
|
||||
break;
|
||||
case PSXCollisionType.Platform:
|
||||
Gizmos.color = new Color(0.3f, 0.3f, 1f, 0.5f); // Blue
|
||||
break;
|
||||
}
|
||||
|
||||
// Draw AABB
|
||||
Matrix4x4 oldMatrix = Gizmos.matrix;
|
||||
Gizmos.matrix = transform.localToWorldMatrix;
|
||||
Gizmos.DrawWireCube(bounds.center, bounds.size);
|
||||
|
||||
// Draw filled with lower alpha
|
||||
Color fillColor = Gizmos.color;
|
||||
fillColor.a = 0.1f;
|
||||
Gizmos.color = fillColor;
|
||||
Gizmos.DrawCube(bounds.center, bounds.size);
|
||||
|
||||
Gizmos.matrix = oldMatrix;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void CreatePSXTextures2D()
|
||||
{
|
||||
Renderer renderer = GetComponent<Renderer>();
|
||||
Textures.Clear();
|
||||
if (renderer != null)
|
||||
if (renderer == null) return;
|
||||
|
||||
Material[] materials = renderer.sharedMaterials;
|
||||
foreach (Material mat in materials)
|
||||
{
|
||||
// If an override texture is set, use it for all submeshes
|
||||
if (texture != null)
|
||||
if (mat == null || mat.mainTexture == null) continue;
|
||||
|
||||
Texture mainTexture = mat.mainTexture;
|
||||
Texture2D tex2D = mainTexture is Texture2D existing
|
||||
? existing
|
||||
: ConvertToTexture2D(mainTexture);
|
||||
|
||||
if (tex2D == null) continue;
|
||||
|
||||
if (cache.TryGetValue((tex2D.GetInstanceID(), bitDepth), out var cached))
|
||||
{
|
||||
PSXTexture2D tex;
|
||||
if (cache.ContainsKey((texture.GetInstanceID(), bitDepth)))
|
||||
{
|
||||
tex = cache[(texture.GetInstanceID(), bitDepth)];
|
||||
}
|
||||
else
|
||||
{
|
||||
tex = PSXTexture2D.CreateFromTexture2D(texture, bitDepth);
|
||||
tex.OriginalTexture = texture;
|
||||
cache.Add((texture.GetInstanceID(), bitDepth), tex);
|
||||
}
|
||||
Textures.Add(tex);
|
||||
return;
|
||||
Textures.Add(cached);
|
||||
}
|
||||
|
||||
Material[] materials = renderer.sharedMaterials;
|
||||
|
||||
foreach (Material mat in materials)
|
||||
else
|
||||
{
|
||||
if (mat != null && mat.mainTexture != null)
|
||||
{
|
||||
Texture mainTexture = mat.mainTexture;
|
||||
Texture2D tex2D = null;
|
||||
|
||||
if (mainTexture is Texture2D existingTex2D)
|
||||
{
|
||||
tex2D = existingTex2D;
|
||||
}
|
||||
else
|
||||
{
|
||||
tex2D = ConvertToTexture2D(mainTexture);
|
||||
}
|
||||
|
||||
if (tex2D != null)
|
||||
{
|
||||
PSXTexture2D tex;
|
||||
if (cache.ContainsKey((tex2D.GetInstanceID(), bitDepth)))
|
||||
{
|
||||
tex = cache[(tex2D.GetInstanceID(), bitDepth)];
|
||||
}
|
||||
else
|
||||
{
|
||||
tex = PSXTexture2D.CreateFromTexture2D(tex2D, bitDepth);
|
||||
tex.OriginalTexture = tex2D;
|
||||
cache.Add((tex2D.GetInstanceID(), bitDepth), tex);
|
||||
}
|
||||
Textures.Add(tex);
|
||||
}
|
||||
}
|
||||
var tex = PSXTexture2D.CreateFromTexture2D(tex2D, bitDepth);
|
||||
tex.OriginalTexture = tex2D;
|
||||
cache.Add((tex2D.GetInstanceID(), bitDepth), tex);
|
||||
Textures.Add(tex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Texture2D ConvertToTexture2D(Texture texture)
|
||||
private static Texture2D ConvertToTexture2D(Texture src)
|
||||
{
|
||||
Texture2D texture2D = new Texture2D(texture.width, texture.height, TextureFormat.RGBA32, false);
|
||||
Texture2D texture2D = new Texture2D(src.width, src.height, TextureFormat.RGBA32, false);
|
||||
|
||||
RenderTexture currentActiveRT = RenderTexture.active;
|
||||
RenderTexture.active = texture as RenderTexture;
|
||||
RenderTexture.active = src as RenderTexture;
|
||||
|
||||
texture2D.ReadPixels(new Rect(0, 0, texture.width, texture.height), 0, 0);
|
||||
texture2D.ReadPixels(new Rect(0, 0, src.width, src.height), 0, 0);
|
||||
texture2D.Apply();
|
||||
|
||||
RenderTexture.active = currentActiveRT;
|
||||
|
||||
@@ -9,6 +9,11 @@ using UnityEngine;
|
||||
|
||||
namespace SplashEdit.RuntimeCode
|
||||
{
|
||||
public enum PSXSceneType
|
||||
{
|
||||
Exterior = 0,
|
||||
Interior = 1
|
||||
}
|
||||
|
||||
[ExecuteInEditMode]
|
||||
public class PSXSceneExporter : MonoBehaviour
|
||||
@@ -35,7 +40,7 @@ namespace SplashEdit.RuntimeCode
|
||||
|
||||
[Header("Scene Type")]
|
||||
[Tooltip("Exterior uses BVH frustum culling. Interior uses room/portal occlusion.")]
|
||||
public int SceneType = 0; // 0=exterior, 1=interior
|
||||
public PSXSceneType SceneType = PSXSceneType.Exterior;
|
||||
|
||||
[Header("Cutscenes")]
|
||||
[Tooltip("Cutscene clips to include in this scene's splashpack. Only these will be exported.")]
|
||||
@@ -166,7 +171,7 @@ namespace SplashEdit.RuntimeCode
|
||||
// Collect them early so both systems use the same room indices.
|
||||
PSXRoom[] rooms = null;
|
||||
PSXPortalLink[] portalLinks = null;
|
||||
if (SceneType == 1)
|
||||
if (SceneType == PSXSceneType.Interior)
|
||||
{
|
||||
rooms = FindObjectsByType<PSXRoom>(FindObjectsSortMode.None);
|
||||
portalLinks = FindObjectsByType<PSXPortalLink>(FindObjectsSortMode.None);
|
||||
@@ -194,7 +199,7 @@ namespace SplashEdit.RuntimeCode
|
||||
|
||||
// Phase 5: Build room/portal system (for interior scenes)
|
||||
_roomBuilder = new PSXRoomBuilder();
|
||||
if (SceneType == 1)
|
||||
if (SceneType == PSXSceneType.Interior)
|
||||
{
|
||||
if (rooms != null && rooms.Length > 0)
|
||||
{
|
||||
|
||||
@@ -50,7 +50,7 @@ namespace SplashEdit.RuntimeCode
|
||||
public float gravity;
|
||||
|
||||
// Scene configuration (v11)
|
||||
public int sceneType; // 0=exterior, 1=interior
|
||||
public PSXSceneType sceneType;
|
||||
public bool fogEnabled;
|
||||
public Color fogColor;
|
||||
public int fogDensity; // 1-10
|
||||
@@ -111,7 +111,12 @@ namespace SplashEdit.RuntimeCode
|
||||
int colliderCount = 0;
|
||||
foreach (var e in scene.exporters)
|
||||
{
|
||||
if (e.CollisionType != PSXCollisionType.None)
|
||||
if (e.CollisionType == PSXCollisionType.None || e.StaticCollider)
|
||||
continue;
|
||||
Mesh cm = e.CustomCollisionMesh != null
|
||||
? e.CustomCollisionMesh
|
||||
: e.GetComponent<MeshFilter>()?.sharedMesh;
|
||||
if (cm != null)
|
||||
colliderCount++;
|
||||
}
|
||||
|
||||
@@ -121,17 +126,17 @@ namespace SplashEdit.RuntimeCode
|
||||
exporterIndex[scene.exporters[i]] = i;
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Header (72 bytes total — splashpack v8)
|
||||
// Header (104 bytes — splashpack v15)
|
||||
// ──────────────────────────────────────────────────────
|
||||
writer.Write('S');
|
||||
writer.Write('P');
|
||||
writer.Write((ushort)13); // version
|
||||
writer.Write((ushort)15);
|
||||
writer.Write((ushort)luaFiles.Count);
|
||||
writer.Write((ushort)scene.exporters.Length);
|
||||
writer.Write((ushort)0); // navmeshCount (legacy)
|
||||
writer.Write((ushort)scene.atlases.Length);
|
||||
writer.Write((ushort)clutCount);
|
||||
writer.Write((ushort)colliderCount);
|
||||
writer.Write((ushort)scene.interactables.Length);
|
||||
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerPos.x, gte));
|
||||
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(-scene.playerPos.y, gte));
|
||||
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerPos.z, gte));
|
||||
@@ -142,37 +147,23 @@ namespace SplashEdit.RuntimeCode
|
||||
|
||||
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerHeight, gte));
|
||||
|
||||
// Scene Lua index
|
||||
if (scene.sceneLuaFile != null)
|
||||
writer.Write((short)luaFiles.IndexOf(scene.sceneLuaFile));
|
||||
else
|
||||
writer.Write((short)-1);
|
||||
|
||||
// BVH info
|
||||
writer.Write((ushort)scene.bvh.NodeCount);
|
||||
writer.Write((ushort)scene.bvh.TriangleRefCount);
|
||||
|
||||
// Component counts (version 4)
|
||||
writer.Write((ushort)scene.interactables.Length);
|
||||
writer.Write((ushort)0); // healthCount (removed)
|
||||
writer.Write((ushort)0); // timerCount (removed)
|
||||
writer.Write((ushort)0); // spawnerCount (removed)
|
||||
writer.Write((ushort)scene.sceneType);
|
||||
writer.Write((ushort)0); // pad0
|
||||
|
||||
// NavGrid (version 5, legacy)
|
||||
writer.Write((ushort)0);
|
||||
writer.Write((ushort)0);
|
||||
|
||||
// Scene type (version 6)
|
||||
writer.Write((ushort)scene.sceneType); // 0=exterior, 1=interior
|
||||
writer.Write((ushort)0);
|
||||
|
||||
// World collision + nav regions (version 7)
|
||||
writer.Write((ushort)scene.collisionExporter.MeshCount);
|
||||
writer.Write((ushort)scene.collisionExporter.TriangleCount);
|
||||
writer.Write((ushort)scene.navRegionBuilder.RegionCount);
|
||||
writer.Write((ushort)scene.navRegionBuilder.PortalCount);
|
||||
|
||||
// Movement parameters (version 8, 12 bytes)
|
||||
// Movement parameters (12 bytes)
|
||||
{
|
||||
const float fps = 30f;
|
||||
float movePerFrame = scene.moveSpeed / fps / gte;
|
||||
@@ -187,52 +178,49 @@ namespace SplashEdit.RuntimeCode
|
||||
writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(gravPsx * 4096f), 0, 65535));
|
||||
|
||||
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerRadius, gte));
|
||||
writer.Write((ushort)0); // padding
|
||||
writer.Write((ushort)0); // pad1
|
||||
}
|
||||
|
||||
// Name table offset placeholder (version 9, 4 bytes)
|
||||
long nameTableOffsetPos = writer.BaseStream.Position;
|
||||
writer.Write((uint)0); // placeholder for name table offset
|
||||
writer.Write((uint)0);
|
||||
|
||||
// Audio clip info (version 10, 8 bytes)
|
||||
int audioClipCount = scene.audioClips?.Length ?? 0;
|
||||
writer.Write((ushort)audioClipCount);
|
||||
writer.Write((ushort)0); // padding
|
||||
writer.Write((ushort)0); // pad2
|
||||
long audioTableOffsetPos = writer.BaseStream.Position;
|
||||
writer.Write((uint)0); // placeholder for audio table offset
|
||||
writer.Write((uint)0);
|
||||
|
||||
// Fog + room/portal header (version 11, 12 bytes)
|
||||
{
|
||||
writer.Write((byte)(scene.fogEnabled ? 1 : 0));
|
||||
writer.Write((byte)Mathf.Clamp(Mathf.RoundToInt(scene.fogColor.r * 255f), 0, 255));
|
||||
writer.Write((byte)Mathf.Clamp(Mathf.RoundToInt(scene.fogColor.g * 255f), 0, 255));
|
||||
writer.Write((byte)Mathf.Clamp(Mathf.RoundToInt(scene.fogColor.b * 255f), 0, 255));
|
||||
writer.Write((byte)Mathf.Clamp(scene.fogDensity, 1, 10));
|
||||
writer.Write((byte)0); // reserved
|
||||
writer.Write((byte)0); // pad3
|
||||
int roomCount = scene.roomBuilder?.RoomCount ?? 0;
|
||||
int portalCount = scene.roomBuilder?.PortalCount ?? 0;
|
||||
int roomTriRefCount = scene.roomBuilder?.TotalTriRefCount ?? 0;
|
||||
// roomCount is the room count NOT including catch-all; the binary adds +1 for it
|
||||
writer.Write((ushort)(roomCount > 0 ? roomCount + 1 : 0));
|
||||
writer.Write((ushort)portalCount);
|
||||
writer.Write((ushort)roomTriRefCount);
|
||||
}
|
||||
|
||||
// Cutscene header (version 12, 8 bytes)
|
||||
int cutsceneCount = scene.cutscenes?.Length ?? 0;
|
||||
writer.Write((ushort)cutsceneCount);
|
||||
writer.Write((ushort)0); // reserved_cs
|
||||
writer.Write((ushort)0); // pad4
|
||||
long cutsceneTableOffsetPos = writer.BaseStream.Position;
|
||||
writer.Write((uint)0); // cutsceneTableOffset placeholder
|
||||
|
||||
// UI canvas header (version 13, 8 bytes)
|
||||
int uiCanvasCount = scene.canvases?.Length ?? 0;
|
||||
int uiFontCount = scene.fonts?.Length ?? 0;
|
||||
writer.Write((ushort)uiCanvasCount);
|
||||
writer.Write((byte)uiFontCount); // was uiReserved low byte
|
||||
writer.Write((byte)0); // was uiReserved high byte
|
||||
writer.Write((byte)uiFontCount);
|
||||
writer.Write((byte)0); // uiPad5
|
||||
long uiTableOffsetPos = writer.BaseStream.Position;
|
||||
writer.Write((uint)0); // uiTableOffset placeholder
|
||||
writer.Write((uint)0);
|
||||
|
||||
long pixelDataOffsetPos = writer.BaseStream.Position;
|
||||
writer.Write((uint)0); // pixelDataOffset placeholder
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Lua file metadata
|
||||
@@ -295,7 +283,7 @@ namespace SplashEdit.RuntimeCode
|
||||
for (int exporterIdx = 0; exporterIdx < scene.exporters.Length; exporterIdx++)
|
||||
{
|
||||
PSXObjectExporter exporter = scene.exporters[exporterIdx];
|
||||
if (exporter.CollisionType == PSXCollisionType.None)
|
||||
if (exporter.CollisionType == PSXCollisionType.None || exporter.StaticCollider)
|
||||
continue;
|
||||
|
||||
MeshFilter meshFilter = exporter.GetComponent<MeshFilter>();
|
||||
@@ -483,33 +471,6 @@ namespace SplashEdit.RuntimeCode
|
||||
}
|
||||
}
|
||||
|
||||
// Atlas pixel data
|
||||
foreach (TextureAtlas atlas in scene.atlases)
|
||||
{
|
||||
AlignToFourBytes(writer);
|
||||
atlasOffset.DataOffsets.Add(writer.BaseStream.Position);
|
||||
|
||||
for (int y = 0; y < atlas.vramPixels.GetLength(1); y++)
|
||||
for (int x = 0; x < atlas.vramPixels.GetLength(0); x++)
|
||||
writer.Write(atlas.vramPixels[x, y].Pack());
|
||||
}
|
||||
|
||||
// CLUT data
|
||||
foreach (TextureAtlas atlas in scene.atlases)
|
||||
{
|
||||
foreach (var texture in atlas.ContainedTextures)
|
||||
{
|
||||
if (texture.ColorPalette != null)
|
||||
{
|
||||
AlignToFourBytes(writer);
|
||||
clutOffset.DataOffsets.Add(writer.BaseStream.Position);
|
||||
|
||||
foreach (VRAMPixel color in texture.ColorPalette)
|
||||
writer.Write((ushort)color.Pack());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Object name table (version 9)
|
||||
// ──────────────────────────────────────────────────────
|
||||
@@ -535,46 +496,50 @@ namespace SplashEdit.RuntimeCode
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Audio clip data (version 10)
|
||||
// Metadata entries are 16 bytes each, written contiguously.
|
||||
// Name strings follow the metadata block with backfilled offsets.
|
||||
// ADPCM blobs deferred to dead zone.
|
||||
// ──────────────────────────────────────────────────────
|
||||
List<long> audioDataOffsetPositions = new List<long>();
|
||||
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<long> audioDataOffsetPositions = new List<long>();
|
||||
List<long> audioNameOffsetPositions = new List<long>();
|
||||
List<string> audioClipNames = new List<string>();
|
||||
|
||||
// Phase 1: Write all 16-byte metadata entries contiguously
|
||||
for (int i = 0; i < audioClipCount; i++)
|
||||
{
|
||||
var clip = scene.audioClips[i];
|
||||
audioDataOffsetPositions.Add(writer.BaseStream.Position);
|
||||
writer.Write((uint)0); // dataOffset placeholder
|
||||
writer.Write((uint)(clip.adpcmData?.Length ?? 0)); // sizeBytes
|
||||
writer.Write((ushort)clip.sampleRate);
|
||||
string name = clip.clipName ?? "";
|
||||
if (name.Length > 255) name = name.Substring(0, 255);
|
||||
|
||||
audioDataOffsetPositions.Add(writer.BaseStream.Position);
|
||||
writer.Write((uint)0); // dataOffset placeholder (backfilled in dead zone)
|
||||
writer.Write((uint)(clip.adpcmData?.Length ?? 0));
|
||||
writer.Write((ushort)clip.sampleRate);
|
||||
writer.Write((byte)(clip.loop ? 1 : 0));
|
||||
writer.Write((byte)System.Math.Min(name.Length, 255));
|
||||
writer.Write((byte)name.Length);
|
||||
audioNameOffsetPositions.Add(writer.BaseStream.Position);
|
||||
writer.Write((uint)0); // nameOffset placeholder
|
||||
audioClipNames.Add(name);
|
||||
}
|
||||
|
||||
// Second pass: write ADPCM data and backfill offsets
|
||||
// Phase 2: Write name strings (after all metadata entries)
|
||||
for (int i = 0; i < audioClipCount; i++)
|
||||
{
|
||||
byte[] data = scene.audioClips[i].adpcmData;
|
||||
if (data != null && data.Length > 0)
|
||||
{
|
||||
AlignToFourBytes(writer);
|
||||
long dataPos = writer.BaseStream.Position;
|
||||
writer.Write(data);
|
||||
string name = audioClipNames[i];
|
||||
long namePos = writer.BaseStream.Position;
|
||||
byte[] nameBytes = System.Text.Encoding.ASCII.GetBytes(name);
|
||||
writer.Write(nameBytes);
|
||||
writer.Write((byte)0);
|
||||
|
||||
// Backfill data offset
|
||||
long curPos = writer.BaseStream.Position;
|
||||
writer.Seek((int)audioDataOffsetPositions[i], SeekOrigin.Begin);
|
||||
writer.Write((uint)dataPos);
|
||||
writer.Seek((int)curPos, SeekOrigin.Begin);
|
||||
}
|
||||
long curPos = writer.BaseStream.Position;
|
||||
writer.Seek((int)audioNameOffsetPositions[i], SeekOrigin.Begin);
|
||||
writer.Write((uint)namePos);
|
||||
writer.Seek((int)curPos, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
// Backfill audio table offset in header
|
||||
@@ -584,28 +549,6 @@ namespace SplashEdit.RuntimeCode
|
||||
writer.Write((uint)audioTableStart);
|
||||
writer.Seek((int)curPos, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
int totalAudioBytes = 0;
|
||||
foreach (var clip in scene.audioClips)
|
||||
if (clip.adpcmData != null) totalAudioBytes += clip.adpcmData.Length;
|
||||
|
||||
// Third pass: write audio clip names and backfill name offsets
|
||||
for (int i = 0; i < audioClipCount; i++)
|
||||
{
|
||||
string name = scene.audioClips[i].clipName ?? "";
|
||||
if (name.Length > 255) name = name.Substring(0, 255);
|
||||
long namePos = writer.BaseStream.Position;
|
||||
byte[] nameBytes = System.Text.Encoding.ASCII.GetBytes(name);
|
||||
writer.Write(nameBytes);
|
||||
writer.Write((byte)0); // null terminator
|
||||
|
||||
long curPos = writer.BaseStream.Position;
|
||||
writer.Seek((int)audioNameOffsetPositions[i], SeekOrigin.Begin);
|
||||
writer.Write((uint)namePos);
|
||||
writer.Seek((int)curPos, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
log?.Invoke($"{audioClipCount} audio clips ({totalAudioBytes / 1024}KB ADPCM) written.", LogType.Log);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
@@ -635,11 +578,12 @@ namespace SplashEdit.RuntimeCode
|
||||
// ──────────────────────────────────────────────────────
|
||||
// UI canvas + font data (version 13)
|
||||
// Font descriptors: 112 bytes each (before canvas data)
|
||||
// Font pixel data: raw 4bpp (after font descriptors)
|
||||
// Canvas descriptor table: 12 bytes per canvas
|
||||
// Element records: 48 bytes each
|
||||
// Name and text strings follow with offset backfill
|
||||
// Font pixel data is deferred to the dead zone.
|
||||
// ──────────────────────────────────────────────────────
|
||||
List<long> fontDataOffsetPositions = new List<long>();
|
||||
if ((uiCanvasCount > 0 && scene.canvases != null) || uiFontCount > 0)
|
||||
{
|
||||
AlignToFourBytes(writer);
|
||||
@@ -648,7 +592,6 @@ namespace SplashEdit.RuntimeCode
|
||||
// ── Font descriptors (112 bytes each) ──
|
||||
// Layout: glyphW(1) glyphH(1) vramX(2) vramY(2) textureH(2)
|
||||
// dataOffset(4) dataSize(4)
|
||||
List<long> fontDataOffsetPositions = new List<long>();
|
||||
if (scene.fonts != null)
|
||||
{
|
||||
foreach (var font in scene.fonts)
|
||||
@@ -669,32 +612,9 @@ namespace SplashEdit.RuntimeCode
|
||||
}
|
||||
}
|
||||
|
||||
// ── Font pixel data (raw 4bpp) ──
|
||||
if (scene.fonts != null)
|
||||
{
|
||||
for (int fi = 0; fi < scene.fonts.Length; fi++)
|
||||
{
|
||||
var font = scene.fonts[fi];
|
||||
if (font.PixelData == null || font.PixelData.Length == 0) continue;
|
||||
|
||||
AlignToFourBytes(writer);
|
||||
long dataPos = writer.BaseStream.Position;
|
||||
writer.Write(font.PixelData);
|
||||
|
||||
// Backfill data offset
|
||||
long curPos = writer.BaseStream.Position;
|
||||
writer.Seek((int)fontDataOffsetPositions[fi], SeekOrigin.Begin);
|
||||
writer.Write((uint)dataPos);
|
||||
writer.Seek((int)curPos, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
if (scene.fonts.Length > 0)
|
||||
{
|
||||
int totalFontBytes = 0;
|
||||
foreach (var f in scene.fonts) totalFontBytes += f.PixelData?.Length ?? 0;
|
||||
log?.Invoke($"{scene.fonts.Length} custom font(s) written ({totalFontBytes} bytes 4bpp data).", LogType.Log);
|
||||
}
|
||||
}
|
||||
// Font pixel data is deferred to the dead zone (after pixelDataOffset).
|
||||
// The C++ loader reads font pixel data via the dataOffset, uploads to VRAM,
|
||||
// then never accesses it again.
|
||||
|
||||
// ── Canvas descriptor table (12 bytes each) ──
|
||||
// Layout per descriptor:
|
||||
@@ -873,6 +793,96 @@ namespace SplashEdit.RuntimeCode
|
||||
log?.Invoke($"{uiCanvasCount} UI canvases ({totalElements} elements) written.", LogType.Log);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// DEAD ZONE — pixel/audio bulk data (freed after VRAM/SPU upload)
|
||||
// Everything written after this point is not needed at runtime.
|
||||
// ══════════════════════════════════════════════════════
|
||||
AlignToFourBytes(writer);
|
||||
long pixelDataStart = writer.BaseStream.Position;
|
||||
|
||||
// Atlas pixel data
|
||||
foreach (TextureAtlas atlas in scene.atlases)
|
||||
{
|
||||
AlignToFourBytes(writer);
|
||||
atlasOffset.DataOffsets.Add(writer.BaseStream.Position);
|
||||
|
||||
for (int y = 0; y < atlas.vramPixels.GetLength(1); y++)
|
||||
for (int x = 0; x < atlas.vramPixels.GetLength(0); x++)
|
||||
writer.Write(atlas.vramPixels[x, y].Pack());
|
||||
}
|
||||
|
||||
// CLUT data
|
||||
foreach (TextureAtlas atlas in scene.atlases)
|
||||
{
|
||||
foreach (var texture in atlas.ContainedTextures)
|
||||
{
|
||||
if (texture.ColorPalette != null)
|
||||
{
|
||||
AlignToFourBytes(writer);
|
||||
clutOffset.DataOffsets.Add(writer.BaseStream.Position);
|
||||
|
||||
foreach (VRAMPixel color in texture.ColorPalette)
|
||||
writer.Write((ushort)color.Pack());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audio ADPCM data
|
||||
if (audioClipCount > 0 && scene.audioClips != null)
|
||||
{
|
||||
for (int i = 0; i < audioClipCount; i++)
|
||||
{
|
||||
byte[] data = scene.audioClips[i].adpcmData;
|
||||
if (data != null && data.Length > 0)
|
||||
{
|
||||
AlignToFourBytes(writer);
|
||||
long dataPos = writer.BaseStream.Position;
|
||||
writer.Write(data);
|
||||
|
||||
long curPos = writer.BaseStream.Position;
|
||||
writer.Seek((int)audioDataOffsetPositions[i], SeekOrigin.Begin);
|
||||
writer.Write((uint)dataPos);
|
||||
writer.Seek((int)curPos, SeekOrigin.Begin);
|
||||
}
|
||||
}
|
||||
|
||||
int totalAudioBytes = 0;
|
||||
foreach (var clip in scene.audioClips)
|
||||
if (clip.adpcmData != null) totalAudioBytes += clip.adpcmData.Length;
|
||||
log?.Invoke($"{audioClipCount} audio clips ({totalAudioBytes / 1024}KB ADPCM) written.", LogType.Log);
|
||||
}
|
||||
|
||||
// Font pixel data
|
||||
if (scene.fonts != null)
|
||||
{
|
||||
for (int fi = 0; fi < scene.fonts.Length; fi++)
|
||||
{
|
||||
var font = scene.fonts[fi];
|
||||
if (font.PixelData == null || font.PixelData.Length == 0) continue;
|
||||
|
||||
AlignToFourBytes(writer);
|
||||
long dataPos = writer.BaseStream.Position;
|
||||
writer.Write(font.PixelData);
|
||||
|
||||
long curPos = writer.BaseStream.Position;
|
||||
writer.Seek((int)fontDataOffsetPositions[fi], SeekOrigin.Begin);
|
||||
writer.Write((uint)dataPos);
|
||||
writer.Seek((int)curPos, SeekOrigin.Begin);
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill pixelDataOffset in header
|
||||
{
|
||||
long curPos = writer.BaseStream.Position;
|
||||
writer.Seek((int)pixelDataOffsetPos, SeekOrigin.Begin);
|
||||
writer.Write((uint)pixelDataStart);
|
||||
writer.Seek((int)curPos, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
long totalSize = writer.BaseStream.Position;
|
||||
long deadBytes = totalSize - pixelDataStart;
|
||||
log?.Invoke($"Pixel/audio dead zone: {deadBytes / 1024}KB (freed after VRAM/SPU upload).", LogType.Log);
|
||||
|
||||
// Backfill offsets
|
||||
BackfillOffsets(writer, luaOffset, "lua", log);
|
||||
BackfillOffsets(writer, meshOffset, "mesh", log);
|
||||
|
||||
Reference in New Issue
Block a user