diff --git a/Editor/Core/MkpsxisoDownloader.cs b/Editor/Core/MkpsxisoDownloader.cs
index 73a1854..9dcbca0 100644
--- a/Editor/Core/MkpsxisoDownloader.cs
+++ b/Editor/Core/MkpsxisoDownloader.cs
@@ -10,7 +10,7 @@
namespace SplashEdit.EditorCode
{
///
- /// Downloads and manages mkpsxiso — the tool that builds PlayStation CD images
+ /// 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
///
diff --git a/Editor/Core/PCSXReduxDownloader.cs b/Editor/Core/PCSXReduxDownloader.cs
index 41c0213..fce3851 100644
--- a/Editor/Core/PCSXReduxDownloader.cs
+++ b/Editor/Core/PCSXReduxDownloader.cs
@@ -12,8 +12,8 @@ namespace SplashEdit.EditorCode
/// Downloads and installs PCSX-Redux from the official distrib.app CDN.
/// Mirrors the logic from pcsx-redux.js (the official download script).
///
- /// Flow: fetch platform manifest → find latest build ID → fetch build manifest →
- /// get download URL → download zip → extract to .tools/pcsx-redux/
+ /// Flow: fetch platform manifest -> find latest build ID -> fetch build manifest ->
+ /// get download URL -> download zip -> extract to .tools/pcsx-redux/
///
public static class PCSXReduxDownloader
{
@@ -94,7 +94,7 @@ public static async Task DownloadAndInstall(Action log = null)
string downloadUrl = $"https://distrib.app{downloadPath}";
log?.Invoke($"Downloading: {downloadUrl}");
- // Step 3: Download — use the real extension from the URL
+ // Step 3: Download - use the real extension from the URL
string ext = ".zip";
if (downloadPath.EndsWith(".tar.gz")) ext = ".tar.gz";
else if (downloadPath.EndsWith(".dmg")) ext = ".dmg";
@@ -120,7 +120,7 @@ public static async Task DownloadAndInstall(Action log = null)
log?.Invoke($"Downloaded to {tempFile}");
EditorUtility.DisplayProgressBar("Installing PCSX-Redux", "Extracting...", 0.9f);
- // Step 4: Extract — handle zip, tar.gz, and dmg
+ // Step 4: Extract - handle zip, tar.gz, and dmg
string installDir = SplashBuildPaths.PCSXReduxDir;
if (Directory.Exists(installDir))
Directory.Delete(installDir, true);
@@ -166,7 +166,7 @@ public static async Task DownloadAndInstall(Action log = null)
}
else
{
- // The zip might have a nested directory — try to find the exe
+ // The zip might have a nested directory - try to find the exe
SplashEdit.RuntimeCode.Utils.FixNestedDirectory(installDir);
if (SplashBuildPaths.IsPCSXReduxInstalled())
{
@@ -254,7 +254,7 @@ private static void PostInstallMacOS(string installDir, Action log)
// SplashBuildPaths.PCSXReduxBinary expects a flat "pcsx-redux" executable,
// but distrib.app ships a .app bundle. This wrapper bridges the gap.
- // It also handles process cleanup — without pkill, PCSX-Redux sometimes
+ // It also handles process cleanup - without pkill, PCSX-Redux sometimes
// survives Unity's Process.Kill and blocks the port on next launch.
if (File.Exists(appBinary))
{
diff --git a/Editor/Core/PCdrvSerialHost.cs b/Editor/Core/PCdrvSerialHost.cs
index 51218a2..20cc897 100644
--- a/Editor/Core/PCdrvSerialHost.cs
+++ b/Editor/Core/PCdrvSerialHost.cs
@@ -10,11 +10,11 @@
namespace SplashEdit.EditorCode
{
///
- /// PCdrv Host — serves files to a PS1 over serial (Unirom/NOTPSXSerial protocol).
+ /// PCdrv Host - serves files to a PS1 over serial (Unirom/NOTPSXSerial protocol).
///
/// PCdrv uses MIPS `break` instructions to request file I/O from the host.
/// For real hardware running Unirom, we must:
- /// 1. Enter debug mode (DEBG/OKAY) — installs Unirom's kernel-resident SIO handler
+ /// 1. Enter debug mode (DEBG/OKAY) - installs Unirom's kernel-resident SIO handler
/// 2. Continue execution (CONT/OKAY)
/// 3. Monitor serial for escape sequences: 0x00 followed by 'p' = PCDrv command
/// 4. Handle file operations (init, open, read, close, seek, etc.)
@@ -47,7 +47,7 @@ private class PCFile
public FileAccess Mode;
}
- // Protocol escape char — PCDrv commands are prefixed with 0x00 + 'p'
+ // Protocol escape char - PCDrv commands are prefixed with 0x00 + 'p'
private const byte ESCAPE_CHAR = 0x00;
// PCDrv function codes (from Unirom kernel)
@@ -101,9 +101,9 @@ public void Start()
///
/// Starts the PCDrv monitor loop on an already-open serial port.
- /// Use this after UniromUploader.UploadExeForPCdrv() which sends DEBG → SEXE
+ /// Use this after UniromUploader.UploadExeForPCdrv() which sends DEBG -> SEXE
/// and returns the open port. The debug hooks are already installed and the
- /// exe is already running — we just need to listen for escape sequences.
+ /// exe is already running - we just need to listen for escape sequences.
///
public void Start(SerialPort openPort)
{
@@ -148,10 +148,10 @@ public void Stop()
public void Dispose() => Stop();
- // ═══════════════════════════════════════════════════════════════
- // Monitor loop — reads serial byte-by-byte looking for escape sequences
+ // ---------------------------------------------------------------
+ // Monitor loop - reads serial byte-by-byte looking for escape sequences
// Matches NOTPSXSerial's Bridge.MonitorSerial()
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private void MonitorLoop(CancellationToken ct)
{
@@ -214,12 +214,12 @@ private void MonitorLoop(CancellationToken ct)
if (b == 'p')
{
// PCDrv command incoming
- _log?.Invoke("PCdrv monitor: got escape+p → PCDrv command!");
+ _log?.Invoke("PCdrv monitor: got escape+p -> PCDrv command!");
HandlePCDrvCommand(ct);
}
else
{
- // Unknown escape sequence — log it
+ // Unknown escape sequence - log it
_log?.Invoke($"PCdrv monitor: unknown escape seq: 0x00 + 0x{b:X2} ('{(b >= 0x20 && b < 0x7F ? (char)b : '.')}')");
}
@@ -232,7 +232,7 @@ private void MonitorLoop(CancellationToken ct)
continue;
}
- // Regular byte — this is printf output from the PS1
+ // Regular byte - this is printf output from the PS1
if (b == '\n' || b == '\r')
{
if (textBuffer.Length > 0)
@@ -273,10 +273,10 @@ private void MonitorLoop(CancellationToken ct)
}
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// PCDrv command dispatcher
// Matches NOTPSXSerial's PCDrv.ReadCommand()
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private void HandlePCDrvCommand(CancellationToken ct)
{
@@ -297,9 +297,9 @@ private void HandlePCDrvCommand(CancellationToken ct)
}
}
- // ═══════════════════════════════════════════════════════════════
- // Individual PCDrv handlers — match NOTPSXSerial's PCDrv.cs
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
+ // Individual PCDrv handlers - match NOTPSXSerial's PCDrv.cs
+ // ---------------------------------------------------------------
private void HandleInit()
{
@@ -429,7 +429,7 @@ private void HandleClose(CancellationToken ct)
var f = FindOpenFile(handle);
if (f == null)
{
- // No such file — "great success" per NOTPSXSerial
+ // No such file - "great success" per NOTPSXSerial
SendString("OKAY");
WriteInt32(0);
return;
@@ -563,9 +563,9 @@ private void HandleSeek(CancellationToken ct)
}
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// PS1 output routing
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
///
/// Routes PS1 printf output to PSXConsoleWindow (via _psxLog) if available,
@@ -579,11 +579,11 @@ private void EmitPsxLine(string text)
_log?.Invoke($"PS1> {text}");
}
- // ═══════════════════════════════════════════════════════════════
- // Chunked data write — matches NOTPSXSerial's WriteBytes()
+ // ---------------------------------------------------------------
+ // Chunked data write - matches NOTPSXSerial's WriteBytes()
// Sends data in 2048-byte chunks; for protocol V2+ Unirom
// responds with CHEK/MORE/ERR! per chunk.
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private void WriteDataChunked(byte[] data)
{
@@ -622,9 +622,9 @@ private void WriteDataChunked(byte[] data)
}
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// File handle helpers
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private int NextHandle() => _files.Count + 1;
@@ -655,9 +655,9 @@ private string ResolvePath(string filename)
return Path.Combine(_baseDir, filename);
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Low-level serial I/O
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private int ReadInt32(CancellationToken ct)
{
diff --git a/Editor/Core/PSXAudioConverter.cs b/Editor/Core/PSXAudioConverter.cs
index 4190c09..1cb3bfd 100644
--- a/Editor/Core/PSXAudioConverter.cs
+++ b/Editor/Core/PSXAudioConverter.cs
@@ -69,7 +69,7 @@ public static bool CheckMacOSBuildDeps(out string missing)
///
public static async Task DownloadAndInstall(Action log = null)
{
- // macOS: no prebuilt binary available — try to build from source
+ // macOS: no prebuilt binary available - try to build from source
if (Application.platform == RuntimePlatform.OSXEditor)
return await BuildFromSourceMacOS(log);
@@ -123,7 +123,7 @@ public static async Task DownloadAndInstall(Action log = null)
}
else
{
- // tar.gz extraction — use system tar
+ // tar.gz extraction - use system tar
var psi = new ProcessStartInfo
{
FileName = "tar",
@@ -175,7 +175,7 @@ private static async Task BuildFromSourceMacOS(Action log)
string srcDir = Path.Combine(installDir, "src");
string buildDir = Path.Combine(srcDir, "build");
- // Safety check — the UI should prevent getting here without deps,
+ // Safety check - the UI should prevent getting here without deps,
// but guard against direct calls
string missingDeps;
if (!CheckMacOSBuildDeps(out missingDeps))
@@ -419,7 +419,8 @@ private static void AugmentPath(ProcessStartInfo psi)
/// Converts a Unity AudioClip to PS1 SPU ADPCM format using psxavenc.
/// Returns the ADPCM byte array, or null on failure.
///
- public static byte[] ConvertToADPCM(AudioClip clip, int targetSampleRate, bool loop)
+ public static byte[] ConvertToADPCM(AudioClip clip, int targetSampleRate, bool loop,
+ bool trimLeadingSilence = false)
{
if (!IsInstalled())
{
@@ -439,7 +440,7 @@ public static byte[] ConvertToADPCM(AudioClip clip, int targetSampleRate, bool l
try
{
- ExportWav(clip, tempWav);
+ ExportWav(clip, tempWav, trimLeadingSilence);
// Run psxavenc: convert WAV to SPU ADPCM
// -t spu: raw SPU ADPCM output (no header, ready for DMA upload)
@@ -474,7 +475,7 @@ public static byte[] ConvertToADPCM(AudioClip clip, int targetSampleRate, bool l
return null;
}
- // -t spu outputs raw SPU ADPCM blocks (no header) — use directly.
+ // -t spu outputs raw SPU ADPCM blocks (no header) - use directly.
byte[] adpcm = File.ReadAllBytes(tempVag);
if (adpcm.Length == 0)
{
@@ -493,10 +494,106 @@ public static byte[] ConvertToADPCM(AudioClip clip, int targetSampleRate, bool l
///
/// Exports a Unity AudioClip to a 16-bit mono WAV file.
///
- private static void ExportWav(AudioClip clip, string path)
+ /// Amplitude below which a sample counts as silence.
+ ///
+ /// About -54 dBFS: inaudible through a television, but comfortably above
+ /// the residue that dithering and lossy source formats leave behind in a
+ /// passage that looks silent in a waveform editor. A threshold of exactly
+ /// zero trims nothing at all off a real recording.
+ private const float SilenceFloor = 1f / 512f;
+
+ /// Kept either side of the audible region. The threshold necessarily cuts
+ /// partway up an attack, and starting a sample ON its transient is what
+ /// makes a trimmed effect click.
+ private const int GuardMillis = 4;
+
+ /// Strip silence from the START of a clip before it is encoded.
+ ///
+ /// OPT-IN, per clip, off by default. This rewrites the author's asset on
+ /// the way to the disc, and a lead-in somebody put there deliberately is
+ /// not the exporter's to remove - a tool that silently edits your audio
+ /// is a tool you cannot trust with the rest of it.
+ ///
+ /// What it is FOR, when you do want it: leading silence is latency. It is
+ /// dead time between the frame that asks for a sound and the frame it is
+ /// audible on, and exported clips routinely carry half a second of it or
+ /// more. Past roughly 100ms the effect stops reading as feedback and
+ /// starts reading as an echo. It is also SPU RAM: a pack measured at 55%
+ /// silence spent 206 KB of its 373 KB, out of the 511 KB the hardware has,
+ /// on nothing.
+ ///
+ /// The TAIL is never touched. On a one-shot it is inaudible either way,
+ /// and on a LOOPING clip it is the rhythm: the hardware loops back to the
+ /// start of the sample, so the trailing silence IS the interval the sound
+ /// repeats at. Cutting it would turn a measured klaxon into a flat tone.
+ private static float[] TrimLeadingSilence(float[] mono, int frequency, string name)
{
+ if (mono == null || mono.Length == 0 || frequency <= 0) return mono;
+
+ int first = -1;
+ for (int i = 0; i < mono.Length; i++)
+ {
+ if (mono[i] > SilenceFloor || mono[i] < -SilenceFloor) { first = i; break; }
+ }
+
+ // Nothing above the floor anywhere: the clip really is silent. Leave
+ // it exactly as it is rather than handing psxavenc an empty buffer
+ // and getting back a zero-length clip that `loadClip` will refuse.
+ if (first < 0)
+ {
+ Debug.LogWarning($"[SplashEdit] '{name}' has Trim Leading Silence on but is "
+ + "silent all the way through; left untouched.");
+ return mono;
+ }
+
+ int guard = (frequency * GuardMillis) / 1000;
+ first -= guard;
+ if (first < 0) first = 0;
+ if (first == 0) return mono;
+
+ int len = mono.Length - first;
+ float[] trimmed = new float[len];
+ Array.Copy(mono, first, trimmed, 0, len);
+
+ Debug.Log($"[SplashEdit] '{name}': trimmed {first / (float)frequency:0.00}s "
+ + "of leading silence.");
+ return trimmed;
+ }
+
+ private static void ExportWav(AudioClip clip, string path,
+ bool trimLeadingSilence = false)
+ {
+ // LOAD THE SAMPLES FIRST.
+ //
+ // `GetData` on a clip whose audio data is not loaded returns false and
+ // leaves the buffer as it found it - all zeros. It does not throw and
+ // it does not warn, so the export runs to completion and writes a
+ // perfectly well-formed ADPCM clip of pure silence.
+ //
+ // That failure is close to undiagnosable from the game: the clip has a
+ // non-zero size, it lands in SPU RAM, its name resolves, `Audio.Play`
+ // accepts it and returns a voice, and the console plays it. Everything
+ // reports success and nothing comes out of the television.
+ //
+ // Unity's default importer sets `preloadAudioData: 0`, so this is the
+ // NORMAL state of a freshly imported clip rather than a misconfigured
+ // one. Loading it here means the export does not depend on an import
+ // setting nobody would think to check.
+ bool loadedHere = false;
+ if (clip.loadState != AudioDataLoadState.Loaded)
+ {
+ loadedHere = clip.LoadAudioData();
+ if (!loadedHere)
+ Debug.LogWarning($"PSXAudioConverter: could not load audio data for "
+ + $"'{clip.name}'; it will export as silence.");
+ }
+
float[] samples = new float[clip.samples * clip.channels];
- clip.GetData(samples, 0);
+ if (!clip.GetData(samples, 0))
+ Debug.LogWarning($"PSXAudioConverter: GetData failed for '{clip.name}'; "
+ + "it will export as silence.");
+
+ if (loadedHere) clip.UnloadAudioData();
// Downmix to mono if stereo
float[] mono;
@@ -516,6 +613,9 @@ private static void ExportWav(AudioClip clip, string path)
mono = samples;
}
+ if (trimLeadingSilence)
+ mono = TrimLeadingSilence(mono, clip.frequency, clip.name);
+
// Write WAV
using (var fs = new FileStream(path, FileMode.Create))
using (var writer = new BinaryWriter(fs))
diff --git a/Editor/Core/PSXConsoleWindow.cs b/Editor/Core/PSXConsoleWindow.cs
index e5fa139..70df3d9 100644
--- a/Editor/Core/PSXConsoleWindow.cs
+++ b/Editor/Core/PSXConsoleWindow.cs
@@ -19,14 +19,14 @@ public class PSXConsoleWindow : EditorWindow
private const int MAX_LINES = 2000;
private const int TRIM_AMOUNT = 500;
- // ── Shared state (set by SplashControlPanel) ──
+ // -- Shared state (set by SplashControlPanel) --
private static Process _process;
private static readonly List _lines = new List();
private static readonly object _lock = new object();
private static volatile bool _autoScroll = true;
private static volatile bool _reading;
- // ── Instance state ──
+ // -- Instance state --
private Vector2 _scrollPos;
private string _filterText = "";
private bool _showStdout = true;
@@ -37,7 +37,7 @@ public class PSXConsoleWindow : EditorWindow
private GUIStyle _monoStyleSelected;
private int _lastLineCount;
- // ── Selection state (for shift-click range and right-click copy) ──
+ // -- Selection state (for shift-click range and right-click copy) --
private int _selectionAnchor = -1; // first clicked line index (into _lines)
private int _selectionEnd = -1; // last shift-clicked line index (into _lines)
@@ -48,9 +48,9 @@ private struct LogLine
public string timestamp;
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Menu
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
[MenuItem(MENU_PATH, false, 10)]
public static void ShowWindow()
@@ -61,9 +61,9 @@ public static void ShowWindow()
window.Show();
}
- // ═══════════════════════════════════════════════════════════════
- // Public API — called by SplashControlPanel
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
+ // Public API - called by SplashControlPanel
+ // ---------------------------------------------------------------
///
/// Adds a line to the console from any source (serial host, emulator fallback, etc.).
@@ -127,9 +127,9 @@ public static void Detach()
_process = null;
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Async readers
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private static void StartReader(System.IO.StreamReader reader, bool isError)
{
@@ -161,7 +161,7 @@ private static void StartReader(System.IO.StreamReader reader, bool isError)
}
catch (Exception)
{
- // Stream closed — normal when process exits
+ // Stream closed - normal when process exits
}
})
{
@@ -171,9 +171,9 @@ private static void StartReader(System.IO.StreamReader reader, bool isError)
thread.Start();
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Window lifecycle
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private void OnEnable()
{
@@ -197,9 +197,9 @@ private void OnEditorUpdate()
}
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// GUI
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private void EnsureStyles()
{
@@ -278,7 +278,7 @@ private void DrawToolbar()
bool alive = _process != null && !_process.HasExited;
var statusColor = GUI.contentColor;
GUI.contentColor = alive ? Color.green : Color.gray;
- GUILayout.Label(alive ? "● Live" : "● Stopped", EditorStyles.toolbarButton, GUILayout.Width(60));
+ GUILayout.Label(alive ? "* Live" : "* Stopped", EditorStyles.toolbarButton, GUILayout.Width(60));
GUI.contentColor = statusColor;
// Filter
@@ -293,7 +293,7 @@ private void DrawToolbar()
_wrapLines = GUILayout.Toggle(_wrapLines, "Wrap", EditorStyles.toolbarButton, GUILayout.Width(40));
// Auto-scroll
- _autoScroll = GUILayout.Toggle(_autoScroll, "Auto↓", EditorStyles.toolbarButton, GUILayout.Width(50));
+ _autoScroll = GUILayout.Toggle(_autoScroll, new GUIContent("Auto", "Scroll to the newest line"), EditorStyles.toolbarButton, GUILayout.Width(50));
// Clear
if (GUILayout.Button("Clear", EditorStyles.toolbarButton, GUILayout.Width(45)))
diff --git a/Editor/Core/SceneMemoryReport.cs b/Editor/Core/SceneMemoryReport.cs
index 1a88e2c..3140560 100644
--- a/Editor/Core/SceneMemoryReport.cs
+++ b/Editor/Core/SceneMemoryReport.cs
@@ -13,13 +13,13 @@ public class SceneMemoryReport
{
public string sceneName;
- // ─── Main RAM ───
+ // --- Main RAM ---
public long splashpackFileSize; // Total file on disc
public long splashpackLiveSize; // Bytes kept in RAM at runtime (before bulk data freed)
public int triangleCount;
public int gameObjectCount;
- // ─── VRAM (1024 x 512 x 2 = 1,048,576 bytes) ───
+ // --- VRAM (1024 x 512 x 2 = 1,048,576 bytes) ---
public long framebufferSize; // 2 x W x H x 2
public long textureAtlasSize; // Sum of atlas pixel data
public long clutSize; // Sum of CLUT entries x 2
@@ -27,14 +27,14 @@ public class SceneMemoryReport
public int atlasCount;
public int clutCount;
- // ─── SPU RAM (512KB, 0x1010 reserved) ───
+ // --- SPU RAM (512KB, 0x1010 reserved) ---
public long audioDataSize;
public int audioClipCount;
- // ─── CD Storage ───
+ // --- CD Storage ---
public long loaderPackSize;
- // ─── Constants ───
+ // --- Constants ---
public const long TOTAL_RAM = 2 * 1024 * 1024;
public const long KERNEL_RESERVED = 0x10000; // 64KB kernel area
public const long USABLE_RAM = TOTAL_RAM - KERNEL_RESERVED;
@@ -103,7 +103,7 @@ public static SceneMemoryReport Analyze(
{
var r = new SceneMemoryReport { sceneName = sceneName };
- // ── File sizes ──
+ // -- File sizes --
if (File.Exists(splashpackPath))
r.splashpackFileSize = new FileInfo(splashpackPath).Length;
if (!string.IsNullOrEmpty(loaderPackPath) && File.Exists(loaderPackPath))
@@ -111,20 +111,20 @@ public static SceneMemoryReport Analyze(
r.triangleCount = triangleCount;
- // ── Parse splashpack header for counts and pixelDataOffset ──
+ // -- Parse splashpack header for counts and pixelDataOffset --
if (File.Exists(splashpackPath))
{
try { ReadHeader(splashpackPath, r); }
catch (Exception e) { Debug.LogWarning($"Memory report: failed to read header: {e.Message}"); }
}
- // ── Framebuffers ──
+ // -- Framebuffers --
int fbW = SplashSettings.ResolutionWidth;
int fbH = SplashSettings.ResolutionHeight;
int fbCount = SplashSettings.DualBuffering ? 2 : 1;
r.framebufferSize = fbW * fbH * 2L * fbCount;
- // ── VRAM: Texture atlases + CLUTs ──
+ // -- VRAM: Texture atlases + CLUTs --
if (atlases != null)
{
r.atlasCount = atlases.Length;
@@ -142,7 +142,7 @@ public static SceneMemoryReport Analyze(
}
}
- // ── VRAM: Custom fonts ──
+ // -- VRAM: Custom fonts --
if (fonts != null)
{
foreach (var font in fonts)
@@ -152,7 +152,7 @@ public static SceneMemoryReport Analyze(
}
}
- // ── SPU: Audio ──
+ // -- SPU: Audio --
if (audioExportSizes != null)
{
r.audioClipCount = audioExportSizes.Length;
diff --git a/Editor/Core/SplashBuildPaths.cs b/Editor/Core/SplashBuildPaths.cs
index f20e7da..e9d47e6 100644
--- a/Editor/Core/SplashBuildPaths.cs
+++ b/Editor/Core/SplashBuildPaths.cs
@@ -184,7 +184,7 @@ public static void EnsureDirectories()
EnsureGitIgnore();
}
- // ───── Lua bytecode compilation paths ─────
+ // ----- Lua bytecode compilation paths -----
///
/// Directory for Lua source files extracted during export.
diff --git a/Editor/Core/SplashControlPanel.cs b/Editor/Core/SplashControlPanel.cs
index 21f4781..ca736c4 100644
--- a/Editor/Core/SplashControlPanel.cs
+++ b/Editor/Core/SplashControlPanel.cs
@@ -15,16 +15,16 @@
namespace SplashEdit.EditorCode
{
///
- /// SplashEdit Control Panel — the single unified window for the entire pipeline.
+ /// SplashEdit Control Panel - the single unified window for the entire pipeline.
/// One window. One button. Everything works.
///
public class SplashControlPanel : EditorWindow
{
- // ───── Constants ─────
+ // ----- Constants -----
private const string WINDOW_TITLE = "SplashEdit Control Panel";
private const string MENU_PATH = "PlayStation 1/SplashEdit Control Panel %#l";
- // ───── UI State ─────
+ // ----- UI State -----
private Vector2 _scrollPos;
private int _selectedTab = 0;
private static readonly string[] _tabNames = { "Dependencies", "Scenes", "Music (CD-DA)", "Build", "Memory Card" };
@@ -36,23 +36,23 @@ public class SplashControlPanel : EditorWindow
private bool _showBuildSection = true;
private bool _showMemCardSection = true;
- // ───── Build State ─────
+ // ----- Build State -----
private static bool _isBuilding;
private static bool _isRunning;
private static bool _luaBytecodeCompiled;
private static Process _emulatorProcess;
- // ───── Scene List ─────
+ // ----- Scene List -----
private List _sceneList = new List();
- // ───── Music List ─────
+ // ----- Music List -----
private List _musicList = new List();
- // ───── Memory Reports ─────
+ // ----- Memory Reports -----
private List _memoryReports = new List();
private bool _showMemoryReport = true;
- // ───── Toolchain Cache ─────
+ // ----- Toolchain Cache -----
private bool _hasMIPS;
private bool _hasMake;
private bool _hasRedux;
@@ -65,19 +65,19 @@ public class SplashControlPanel : EditorWindow
private bool _hasXcodeCLT;
private bool _hasBrew;
- // ───── Native project installer ─────
+ // ----- Native project installer -----
private bool _isInstallingNative;
private string _nativeInstallStatus = "";
private string _manualNativePath = "";
- // ───── Release selector ─────
+ // ----- Release selector -----
private int _selectedReleaseIndex = 0;
private string[] _releaseDisplayNames = new string[0];
private bool _isFetchingReleases;
private string _currentTag = "";
private bool _isSwitchingRelease;
- // ───── Native status cache (avoid expensive checks every repaint) ─────
+ // ----- Native status cache (avoid expensive checks every repaint) -----
private bool _isGitAvailable;
private bool _isNativeRepoInstalled;
private double _nextNativeStatusRefreshTime;
@@ -100,9 +100,9 @@ private struct MusicEntry
public string name;
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Menu & Window Lifecycle
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
[MenuItem(MENU_PATH, false, 0)]
public static void ShowWindow()
@@ -135,9 +135,9 @@ private void OnFocus()
RefreshNativeProjectStatus(force: true);
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Main GUI
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private void OnGUI()
{
@@ -185,9 +185,9 @@ private void OnGUI()
EditorGUILayout.EndScrollView();
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Header
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private void DrawHeader()
{
@@ -205,12 +205,12 @@ private void DrawHeader()
if (!_hasMIPS)
{
- statusText = "Setup required — install the MIPS toolchain to get started";
+ statusText = "Setup required - install the MIPS toolchain to get started";
statusColor = PSXEditorStyles.Warning;
}
else if (!_hasNativeProject)
{
- statusText = "Native project not found — clone or set path below";
+ statusText = "Native project not found - clone or set path below";
statusColor = PSXEditorStyles.Warning;
}
else if (_isBuilding)
@@ -238,9 +238,9 @@ private void DrawHeader()
}
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Native Project Section
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private void DrawNativeProjectSection()
{
@@ -262,13 +262,13 @@ private void DrawNativeProjectSection()
}
else
{
- GUILayout.Label("Not found — download from GitHub or set path manually", EditorStyles.miniLabel);
+ GUILayout.Label("Not found - download from GitHub or set path manually", EditorStyles.miniLabel);
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(6);
- // ── Option 1: Download a release from GitHub ──
+ // -- Option 1: Download a release from GitHub --
PSXEditorStyles.DrawSeparator(4, 4);
GUILayout.Label("Download from GitHub", PSXEditorStyles.SectionHeader);
@@ -297,7 +297,7 @@ private void DrawNativeProjectSection()
else
{
_selectedReleaseIndex = EditorGUILayout.Popup(_selectedReleaseIndex, _releaseDisplayNames);
- if (GUILayout.Button("↻", EditorStyles.miniButton, GUILayout.Width(22)))
+ if (GUILayout.Button(new GUIContent("R", "Refresh the release list"), EditorStyles.miniButton, GUILayout.Width(22)))
FetchGitHubReleases();
}
EditorGUILayout.EndHorizontal();
@@ -315,7 +315,7 @@ private void DrawNativeProjectSection()
EditorGUILayout.BeginHorizontal();
if (!_isNativeRepoInstalled)
{
- // Not installed yet — show Clone button
+ // Not installed yet - show Clone button
EditorGUI.BeginDisabledGroup(
_isInstallingNative || _releaseDisplayNames.Length == 0 ||
!_isGitAvailable);
@@ -327,7 +327,7 @@ private void DrawNativeProjectSection()
}
else
{
- // Already installed — show Switch and Open buttons
+ // Already installed - show Switch and Open buttons
EditorGUI.BeginDisabledGroup(
_isSwitchingRelease || _isInstallingNative ||
_releaseDisplayNames.Length == 0 || !_isGitAvailable);
@@ -366,7 +366,7 @@ private void DrawNativeProjectSection()
EditorGUILayout.Space(6);
- // ── Option 2: Manual path ──
+ // -- Option 2: Manual path --
PSXEditorStyles.DrawSeparator(4, 4);
GUILayout.Label("Or set path manually", PSXEditorStyles.SectionHeader);
EditorGUILayout.BeginHorizontal();
@@ -412,16 +412,16 @@ private void DrawNativeProjectSection()
{
var prevColor = GUI.contentColor;
GUI.contentColor = PSXEditorStyles.Success;
- GUILayout.Label("✓ Path is set and valid", EditorStyles.miniLabel);
+ GUILayout.Label("Path is set and valid", EditorStyles.miniLabel);
GUI.contentColor = prevColor;
}
EditorGUILayout.EndVertical();
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Toolchain Section
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private void DrawToolchainSection()
{
@@ -432,7 +432,7 @@ private void DrawToolchainSection()
bool isMac = Application.platform == RuntimePlatform.OSXEditor;
- // GNU Make (before MIPS — it's a prerequisite for everything on macOS)
+ // GNU Make (before MIPS - it's a prerequisite for everything on macOS)
EditorGUILayout.BeginHorizontal();
DrawStatusIcon(_hasMake);
GUILayout.Label("GNU Make", GUILayout.Width(160));
@@ -516,7 +516,7 @@ private void DrawToolchainSection()
"curl -LO https://raw.githubusercontent.com/grumpycoders/pcsx-redux/main/tools/macos-mips/mipsel-none-elf-gcc.rb && " +
"brew install-path ./mipsel-none-elf-binutils.rb && " +
"brew install-path ./mipsel-none-elf-gcc.rb",
- "Builds GCC from source — expect 15-30 minutes.");
+ "Builds GCC from source - expect 15-30 minutes.");
}
}
@@ -596,9 +596,9 @@ private void DrawToolchainSection()
EditorGUILayout.EndVertical();
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Scenes Section
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private void DrawScenesSection()
{
@@ -646,17 +646,17 @@ private void DrawScenesSection()
// Move buttons
EditorGUI.BeginDisabledGroup(i == 0);
- if (GUILayout.Button("▲", EditorStyles.miniButtonLeft, GUILayout.Width(22)))
+ if (GUILayout.Button("^", EditorStyles.miniButtonLeft, GUILayout.Width(22)))
moveUp = i;
EditorGUI.EndDisabledGroup();
EditorGUI.BeginDisabledGroup(i == _sceneList.Count - 1);
- if (GUILayout.Button("▼", EditorStyles.miniButtonRight, GUILayout.Width(22)))
+ if (GUILayout.Button("v", EditorStyles.miniButtonRight, GUILayout.Width(22)))
moveDown = i;
EditorGUI.EndDisabledGroup();
// Remove
- if (GUILayout.Button("×", EditorStyles.miniButton, GUILayout.Width(20)))
+ if (GUILayout.Button("x", EditorStyles.miniButton, GUILayout.Width(20)))
removeIndex = i;
EditorGUILayout.EndHorizontal();
@@ -710,9 +710,9 @@ private void DrawScenesSection()
EditorGUILayout.EndVertical();
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Memory Card Section (global project save settings)
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private void DrawMemoryCardSection()
{
@@ -761,7 +761,7 @@ private void DrawMemoryCardSection()
if (!string.IsNullOrEmpty(data.MemCardTitle) && data.MemCardTitle.Length > 32)
EditorGUILayout.HelpBox("Save title is longer than 32 characters and will be truncated.", MessageType.Warning);
if (data.MemCardIcons == null || data.MemCardIcons.Length == 0)
- EditorGUILayout.HelpBox("No icon assigned — a blank icon will be shown in the BIOS. Assign a 16x16 texture.", MessageType.Info);
+ EditorGUILayout.HelpBox("No icon assigned - a blank icon will be shown in the BIOS. Assign a 16x16 texture.", MessageType.Info);
else if (data.MemCardIcons.Length > 3)
EditorGUILayout.HelpBox("Only the first 3 icon frames are used.", MessageType.Info);
}
@@ -769,9 +769,9 @@ private void DrawMemoryCardSection()
EditorGUILayout.EndVertical();
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Music Section
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private void DrawMusicSection()
{
@@ -820,17 +820,17 @@ private void DrawMusicSection()
// Move buttons
EditorGUI.BeginDisabledGroup(i == 0);
- if (GUILayout.Button("▲", EditorStyles.miniButtonLeft, GUILayout.Width(22)))
+ if (GUILayout.Button("^", EditorStyles.miniButtonLeft, GUILayout.Width(22)))
moveUp = i;
EditorGUI.EndDisabledGroup();
EditorGUI.BeginDisabledGroup(i == _musicList.Count - 1);
- if (GUILayout.Button("▼", EditorStyles.miniButtonRight, GUILayout.Width(22)))
+ if (GUILayout.Button("v", EditorStyles.miniButtonRight, GUILayout.Width(22)))
moveDown = i;
EditorGUI.EndDisabledGroup();
// Remove
- if (GUILayout.Button("×", EditorStyles.miniButton, GUILayout.Width(20)))
+ if (GUILayout.Button("x", EditorStyles.miniButton, GUILayout.Width(20)))
removeIndex = i;
EditorGUILayout.EndHorizontal();
@@ -880,9 +880,9 @@ private void DrawMusicSection()
EditorGUILayout.EndVertical();
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// VRAM & Textures Section
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private void DrawVRAMSection()
{
@@ -912,9 +912,9 @@ private void DrawVRAMSection()
EditorGUILayout.EndVertical();
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Build & Run Section
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private void DrawBuildSection()
{
@@ -981,7 +981,7 @@ private void DrawBuildSection()
EditorGUILayout.BeginHorizontal();
GUILayout.Label("License File:", GUILayout.Width(80));
string licensePath = SplashSettings.LicenseFilePath;
- string displayPath = string.IsNullOrEmpty(licensePath) ? "(none — homebrew)" : Path.GetFileName(licensePath);
+ 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)))
{
@@ -1063,9 +1063,9 @@ private void DrawBuildSection()
}
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Memory Reports
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private void DrawMemoryReports()
{
@@ -1079,7 +1079,7 @@ private void DrawMemoryReports()
GUILayout.Label($"Scene: {report.sceneName}", PSXEditorStyles.SectionHeader);
EditorGUILayout.Space(4);
- // Main RAM bar — segmented: OT | Bump | Scene | Heap
+ // Main RAM bar - segmented: OT | Bump | Scene | Heap
DrawSegmentedMemoryBar("Main RAM",
SceneMemoryReport.USABLE_RAM,
report.RamPercent,
@@ -1264,12 +1264,12 @@ private static string FormatBytes(long bytes)
return $"{bytes / (1024f * 1024f):F2} MB";
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Pipeline Actions
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
///
- /// The main pipeline: Validate → Export all scenes → Compile → Launch.
+ /// The main pipeline: Validate -> Export all scenes -> Compile -> Launch.
///
public async void BuildAndRun()
{
@@ -1367,7 +1367,7 @@ public async void BuildAndRun()
}
}
- // ───── Step 1: Validate ─────
+ // ----- Step 1: Validate -----
private bool ValidateToolchain()
{
@@ -1410,7 +1410,7 @@ private bool ValidateToolchain()
return true;
}
- // ───── Step 2: Export ─────
+ // ----- Step 2: Export -----
///
/// Exports all scenes in the scene list to splashpack files in PSXBuild/.
@@ -1455,7 +1455,7 @@ public bool ExportAllScenes()
string outputPath = SplashBuildPaths.GetSceneSplashpackPath(i, scene.name);
string loaderPath = null;
exporter.ExportToPath(outputPath);
- Log($"Exported '{scene.name}' → {Path.GetFileName(outputPath)}", LogType.Log);
+ Log($"Exported '{scene.name}' -> {Path.GetFileName(outputPath)}", LogType.Log);
// Export loading screen if assigned
if (exporter.LoadingScreenPrefab != null)
@@ -1535,7 +1535,7 @@ private void ExportLoaderPack(GameObject prefab, string outputPath, int sceneInd
if (File.Exists(cachedPath))
{
File.Copy(cachedPath, outputPath, true);
- Log($"Loading screen for '{sceneName}' → {Path.GetFileName(outputPath)} (deduped from {Path.GetFileName(cachedPath)})", LogType.Log);
+ Log($"Loading screen for '{sceneName}' -> {Path.GetFileName(outputPath)} (deduped from {Path.GetFileName(cachedPath)})", LogType.Log);
return;
}
}
@@ -1586,7 +1586,7 @@ private void ExportLoaderPack(GameObject prefab, string outputPath, int sceneInd
(msg, type) => Log(msg, type));
if (ok)
{
- Log($"Loading screen for '{sceneName}' → {Path.GetFileName(outputPath)}", LogType.Log);
+ Log($"Loading screen for '{sceneName}' -> {Path.GetFileName(outputPath)}", LogType.Log);
if (!string.IsNullOrEmpty(guid))
_loaderPackCache[guid] = outputPath;
}
@@ -1623,7 +1623,7 @@ private void WriteManifest()
Log("Wrote scene manifest.", LogType.Log);
}
- // ───── Lua bytecode compilation ─────
+ // ----- Lua bytecode compilation -----
///
/// Scan all scenes in the scene list and collect unique Lua source files.
@@ -1960,7 +1960,7 @@ private async Task BuildLuacPsxAsync(string luacDir)
}
}
- // ───── Step 3: Compile ─────
+ // ----- Step 3: Compile -----
private async void CompileOnly()
{
@@ -2126,7 +2126,7 @@ private string FindCompiledExe(string nativeDir)
return null;
}
- // ───── Step 4: Launch ─────
+ // ----- Step 4: Launch -----
private void Launch()
{
@@ -2222,7 +2222,7 @@ private void LaunchToHardware()
// Stop any previous run (emulator or PCdrv) without clearing the console
StopAllQuiet();
- // Upload the exe with debug hooks (DEBG → SEXE on the same port).
+ // Upload the exe with debug hooks (DEBG -> SEXE on the same port).
// DEBG installs kernel-resident break handlers BEFORE the exe auto-starts.
// The returned port stays open so PCDrv monitoring can begin immediately.
Log($"Uploading to {port}...", LogType.Log);
@@ -2234,7 +2234,7 @@ private void LaunchToHardware()
return;
}
- // Start PCdrv host on the same open port — no re-open, no DEBG/CONT needed
+ // Start PCdrv host on the same open port - no re-open, no DEBG/CONT needed
try
{
_pcdrvHost = new PCdrvSerialHost(port, baud, SplashBuildPaths.BuildOutputDir,
@@ -2252,7 +2252,7 @@ private void LaunchToHardware()
}
}
- // ───── ISO Build ─────
+ // ----- ISO Build -----
private void BuildAndLaunchISO()
{
@@ -2299,7 +2299,7 @@ private void BuildAndLaunchISO()
return;
}
- // Step 3: Delete existing .bin/.cue — mkpsxiso won't overwrite them
+ // Step 3: Delete existing .bin/.cue - mkpsxiso won't overwrite them
try
{
if (File.Exists(outputBin)) File.Delete(outputBin);
@@ -2360,10 +2360,10 @@ private bool GenerateSystemCnf()
{
string cnfPath = SplashBuildPaths.SystemCnfPath;
- // The executable name on disc — no extension, max 12 chars
+ // The executable name on disc - no extension, max 12 chars
string exeName = GetISOExeName();
- // SYSTEM.CNF content — the BIOS reads this to launch the executable.
+ // 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)
@@ -2423,13 +2423,13 @@ private string GenerateISOCatalog(string outputBin, string outputCue)
xml.AppendLine(" ");
- // SYSTEM.CNF — the BIOS locates this by scanning only the first
+ // SYSTEM.CNF - the BIOS locates this by scanning only the first
// sector of the root directory, so the root must stay small enough
// that SYSTEM.CNF's record fits there (see the SCENES/ dir below).
string cnfPath = SplashBuildPaths.SystemCnfPath;
xml.AppendLine($" ");
- // The executable — renamed to match what SYSTEM.CNF points to
+ // The executable - renamed to match what SYSTEM.CNF points to
string exePath = SplashBuildPaths.CompiledExePath;
string isoExeName = GetISOExeName();
xml.AppendLine($" ");
@@ -2449,18 +2449,18 @@ private string GenerateISOCatalog(string outputBin, string outputCue)
//
// 1. Boot: the BIOS boot ROM only scans the *first* 2048-byte
// sector of the ROOT directory when looking for SYSTEM.CNF.
- // A flat root with many scenes grows past one sector and —
- // because ISO9660 sorts entries alphabetically — "SYSTEM.CNF"
+ // A flat root with many scenes grows past one sector and -
+ // because ISO9660 sorts entries alphabetically - "SYSTEM.CNF"
// (which sorts after every "SCENE_*") spills into the second
// sector, becomes invisible, and the console falls back to
// cdrom:\PSX.EXE;1 (black screen, no boot).
//
// 2. Runtime: psyqo's ISO9660Parser caches one directory sector
// at a time. Any single directory that spans MORE than one
- // sector forces its multi-sector "continuation" read — it reads
+ // sector forces its multi-sector "continuation" read - it reads
// directory sector N, then from that read's completion callback
// immediately reads the physically-adjacent sector N+1. That
- // READN→PAUSE→immediate-adjacent-READN pattern desyncs the CD
+ // READN->PAUSE->immediate-adjacent-READN pattern desyncs the CD
// drive and aborts ("ReadSectorsAction got CDROM acknowledge in
// wrong state"). A single shared SCENES/ folder with all files
// is 2+ sectors and hits this; per-scene folders never do.
@@ -2548,7 +2548,7 @@ private void StopPCdrvHost()
}
///
- /// Stops everything (emulator, PCdrv host, console reader) — used by the STOP button.
+ /// Stops everything (emulator, PCdrv host, console reader) - used by the STOP button.
///
private void StopAll()
{
@@ -2601,9 +2601,9 @@ private void StopEmulatorProcess()
}
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Toolchain Detection & Install
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private static string WrapCommandForMacOS(string dir, string makeCmd)
{
@@ -2784,7 +2784,7 @@ private void ScanSerialPorts()
}
}
- // ───── Release fetching & management ─────
+ // ----- Release fetching & management -----
private async void FetchGitHubReleases()
{
@@ -2801,7 +2801,7 @@ private async void FetchGitHubReleases()
{
string label = r.TagName;
if (!string.IsNullOrEmpty(r.Name) && r.Name != r.TagName)
- label += $" — {r.Name}";
+ label += $" - {r.Name}";
if (r.IsPrerelease)
label += " (pre-release)";
return label;
@@ -2838,7 +2838,7 @@ private void RefreshCurrentTag()
_currentTag = PSXSplashInstaller.GetCurrentTag() ?? "";
}
- // ───── Native Project Clone/Switch ─────
+ // ----- Native Project Clone/Switch -----
private async void CloneNativeProject()
{
@@ -2872,7 +2872,7 @@ private async void CloneNativeProject()
else
{
Log("Download failed. Check console for errors.", LogType.Error);
- _nativeInstallStatus = "Download failed — check console for details.";
+ _nativeInstallStatus = "Download failed - check console for details.";
}
}
catch (Exception ex)
@@ -2924,7 +2924,7 @@ private async void SwitchNativeRelease()
else
{
Log($"Failed to switch to {tag}.", LogType.Error);
- _nativeInstallStatus = "Switch failed — check console for details.";
+ _nativeInstallStatus = "Switch failed - check console for details.";
}
}
catch (Exception ex)
@@ -2939,9 +2939,9 @@ private async void SwitchNativeRelease()
}
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Scene List Persistence (EditorPrefs)
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private void LoadSceneList()
{
@@ -3038,9 +3038,9 @@ private void HandleSceneDragDrop()
}
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Music List Persistence (EditorPrefs)
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private void LoadMusicList()
{
@@ -3127,9 +3127,9 @@ private void HandleMusicDragDrop()
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Utilities
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private static void Log(string message, LogType type)
{
diff --git a/Editor/Core/UniromUploader.cs b/Editor/Core/UniromUploader.cs
index 8aa293c..5413b64 100644
--- a/Editor/Core/UniromUploader.cs
+++ b/Editor/Core/UniromUploader.cs
@@ -10,7 +10,7 @@ namespace SplashEdit.EditorCode
///
/// Uploads a .ps-exe to a PS1 running Unirom 8 via serial.
/// Implements the NOTPSXSerial / Unirom protocol:
- /// Challenge/Response handshake → header → metadata → chunked data with checksums.
+ /// Challenge/Response handshake -> header -> metadata -> chunked data with checksums.
/// Reference: https://github.com/JonathanDotCel/NOTPSXSerial
///
public static class UniromUploader
@@ -22,7 +22,7 @@ public static class UniromUploader
private const int HEADER_SIZE = 0x800; // 2048
private const int SERIAL_TIMEOUT_MS = 5000;
- // Protocol version — negotiated during handshake
+ // Protocol version - negotiated during handshake
private static int _protocolVersion = 1;
///
@@ -42,8 +42,8 @@ public static bool UploadExe(string portName, int baudRate, string exePath, Acti
/// Uploads a .ps-exe with Unirom debug hooks installed, using SBIN+JUMP
/// instead of SEXE to avoid BIOS Exec() clobbering the debug handler.
///
- /// Flow: DEBG (install kernel-resident debug hooks) → SBIN (raw binary to address)
- /// → JUMP (start execution at entry point). This bypasses BIOS Exec() entirely,
+ /// Flow: DEBG (install kernel-resident debug hooks) -> SBIN (raw binary to address)
+ /// -> JUMP (start execution at entry point). This bypasses BIOS Exec() entirely,
/// so the exception vector table patched by DEBG survives into the running program.
///
/// Returns the open SerialPort for the caller to use for PCDrv monitoring.
@@ -104,7 +104,7 @@ private static SerialPort DoUpload(string portName, int baudRate, string exePath
while (port.BytesToRead > 0)
port.ReadByte();
- // ── Step 0 (PCDrv only): Install debug hooks while Unirom is still in command mode ──
+ // -- Step 0 (PCDrv only): Install debug hooks while Unirom is still in command mode --
if (installDebugHooks)
{
log?.Invoke("Installing debug hooks (DEBG)...");
@@ -122,7 +122,7 @@ private static SerialPort DoUpload(string portName, int baudRate, string exePath
port.ReadByte();
}
- // ── Step 1: Challenge/Response handshake ──
+ // -- Step 1: Challenge/Response handshake --
log?.Invoke("Sending SEXE challenge...");
if (!ChallengeResponse(port, CHALLENGE_SEND_EXE, RESPONSE_OK, log))
{
@@ -132,19 +132,19 @@ private static SerialPort DoUpload(string portName, int baudRate, string exePath
}
log?.Invoke($"Unirom responded (protocol V{_protocolVersion}). Starting transfer...");
- // ── Step 2: Calculate checksum (skip first 0x800 header sector) ──
+ // -- Step 2: Calculate checksum (skip first 0x800 header sector) --
uint checksum = CalculateChecksum(exeData, skipFirstSector: true);
- // ── Step 3: Send the 2048-byte header sector ──
+ // -- Step 3: Send the 2048-byte header sector --
port.Write(exeData, 0, HEADER_SIZE);
- // ── Step 4: Send metadata ──
+ // -- Step 4: Send metadata --
port.Write(exeData, 0x10, 4); // Jump/PC address
port.Write(exeData, 0x18, 4); // Base/write address
port.Write(BitConverter.GetBytes(exeData.Length - HEADER_SIZE), 0, 4); // Data length
port.Write(BitConverter.GetBytes(checksum), 0, 4); // Checksum
- // ── Step 5: Send data chunks (skip first sector) ──
+ // -- Step 5: Send data chunks (skip first sector) --
if (!WriteChunked(port, exeData, skipFirstSector: true, log))
{
log?.Invoke("Data transfer failed.");
@@ -176,9 +176,9 @@ private static SerialPort DoUpload(string portName, int baudRate, string exePath
/// the BIOS. This preserves the break-instruction handler that PCDrv depends on.
///
/// Protocol:
- /// 1. DEBG → OKAY: Install kernel-resident SIO debug stub
- /// 2. SBIN → OKAY: addr(4 LE) + len(4 LE) + checksum(4 LE) + raw program data
- /// 3. JUMP → OKAY: addr(4 LE) — jump to entry point
+ /// 1. DEBG -> OKAY: Install kernel-resident SIO debug stub
+ /// 2. SBIN -> OKAY: addr(4 LE) + len(4 LE) + checksum(4 LE) + raw program data
+ /// 3. JUMP -> OKAY: addr(4 LE) - jump to entry point
///
private static SerialPort DoUploadSBIN(string portName, int baudRate, string exePath, Action log)
{
@@ -249,7 +249,7 @@ private static SerialPort DoUploadSBIN(string portName, int baudRate, string exe
while (port.BytesToRead > 0)
port.ReadByte();
- // ── Step 1: DEBG — Install kernel-resident debug hooks ──
+ // -- Step 1: DEBG - Install kernel-resident debug hooks --
log?.Invoke("Installing debug hooks (DEBG)...");
if (!ChallengeResponse(port, "DEBG", "OKAY", log))
{
@@ -259,12 +259,12 @@ private static SerialPort DoUploadSBIN(string portName, int baudRate, string exe
}
log?.Invoke("Debug hooks installed.");
- // Drain + settle — Unirom may send extra bytes after DEBG
+ // Drain + settle - Unirom may send extra bytes after DEBG
Thread.Sleep(100);
while (port.BytesToRead > 0)
port.ReadByte();
- // ── Step 2: SBIN — Upload raw program data to target address ──
+ // -- Step 2: SBIN - Upload raw program data to target address --
log?.Invoke($"Sending SBIN to 0x{destAddr:X8} ({progData.Length} bytes)...");
if (!ChallengeResponse(port, "SBIN", "OKAY", log))
{
@@ -295,7 +295,7 @@ private static SerialPort DoUploadSBIN(string portName, int baudRate, string exe
while (port.BytesToRead > 0)
port.ReadByte();
- // ── Step 3: JUMP — Start execution at entry point ──
+ // -- Step 3: JUMP - Start execution at entry point --
log?.Invoke($"Sending JUMP to 0x{entryPoint:X8}...");
if (!ChallengeResponse(port, "JUMP", "OKAY", log))
{
@@ -321,9 +321,9 @@ private static SerialPort DoUploadSBIN(string portName, int baudRate, string exe
}
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Challenge / Response with protocol negotiation
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private static bool ChallengeResponse(SerialPort port, string challenge, string expectedResponse, Action log)
{
@@ -352,7 +352,7 @@ private static bool WaitResponse(SerialPort port, string expected, Action1 from a prior DEBG exchange.
if (buffer == "OKV3")
{
@@ -395,9 +395,9 @@ private static bool WaitResponse(SerialPort port, string expected, Action log)
{
@@ -441,7 +441,7 @@ private static bool WriteChunked(SerialPort port, byte[] data, bool skipFirstSec
return false;
}
if (retry)
- continue; // Don't advance offset — resend this chunk
+ continue; // Don't advance offset - resend this chunk
}
offset += thisChunk;
@@ -505,7 +505,7 @@ private static bool HandleChunkAck(SerialPort port, ulong chunkChecksum, byte[]
if (cmdBuffer == "ERR!")
{
- log?.Invoke("Checksum error — retrying chunk...");
+ log?.Invoke("Checksum error - retrying chunk...");
retry = true;
return true;
}
@@ -520,9 +520,9 @@ private static bool HandleChunkAck(SerialPort port, ulong chunkChecksum, byte[]
return false;
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Checksum calculation
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private static uint CalculateChecksum(byte[] data, bool skipFirstSector)
{
diff --git a/Editor/Inspectors/PSXComponentEditors2.cs b/Editor/Inspectors/PSXComponentEditors2.cs
index 3e2e57b..5068b99 100644
--- a/Editor/Inspectors/PSXComponentEditors2.cs
+++ b/Editor/Inspectors/PSXComponentEditors2.cs
@@ -32,6 +32,8 @@ public override void OnInspectorGUI()
if (audioClip.Loop)
PSXEditorStyles.DrawStatusBadge("Loop", PSXEditorStyles.AccentCyan, 50);
+ if (audioClip.TrimLeadingSilence)
+ PSXEditorStyles.DrawStatusBadge("Trimmed", PSXEditorStyles.Warning, 70);
EditorGUILayout.EndHorizontal();
PSXEditorStyles.EndCard();
@@ -53,6 +55,16 @@ public override void OnInspectorGUI()
"Whether this clip should loop when played."));
EditorGUILayout.PropertyField(serializedObject.FindProperty("DefaultVolume"), new GUIContent("Volume",
"Default playback volume (0-127)."));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("TrimLeadingSilence"),
+ new GUIContent("Trim Leading Silence",
+ "Cut silence off the START of the clip at export.\n\n" +
+ "Off by default: this rewrites your asset on the way to the disc, " +
+ "and a lead-in you put there on purpose is not the exporter's to remove.\n\n" +
+ "Turn it on for a clip whose file begins with dead air. That silence " +
+ "is latency between the frame that asks for the sound and the frame " +
+ "you hear it, and it costs SPU RAM for the whole round.\n\n" +
+ "The tail is always kept - on a looping clip it is the interval the " +
+ "sound repeats at."));
PSXEditorStyles.EndCard();
@@ -112,9 +124,9 @@ public override void OnInspectorGUI()
serializedObject.Update();
PSXPlayer player = (PSXPlayer)target;
- // ────────────────────────────────────────────────────────
+ // --------------------------------------------------------
// Header
- // ────────────────────────────────────────────────────────
+ // --------------------------------------------------------
PSXEditorStyles.BeginCard();
EditorGUILayout.LabelField("PSX Player", PSXEditorStyles.CardHeaderStyle);
EditorGUILayout.LabelField("First-person player controller for PS1", PSXEditorStyles.RichLabel);
@@ -122,9 +134,9 @@ public override void OnInspectorGUI()
EditorGUILayout.Space(4);
- // ────────────────────────────────────────────────────────
+ // --------------------------------------------------------
// Player Dimensions
- // ────────────────────────────────────────────────────────
+ // --------------------------------------------------------
_dimensionsFoldout = PSXEditorStyles.DrawFoldoutCard("Player Dimensions", _dimensionsFoldout, () =>
{
EditorGUILayout.PropertyField(serializedObject.FindProperty("playerHeight"), new GUIContent("Height",
@@ -135,9 +147,9 @@ public override void OnInspectorGUI()
EditorGUILayout.Space(2);
- // ────────────────────────────────────────────────────────
+ // --------------------------------------------------------
// Movement
- // ────────────────────────────────────────────────────────
+ // --------------------------------------------------------
_movementFoldout = PSXEditorStyles.DrawFoldoutCard("Movement", _movementFoldout, () =>
{
EditorGUILayout.PropertyField(serializedObject.FindProperty("moveSpeed"), new GUIContent("Walk Speed",
@@ -148,9 +160,9 @@ public override void OnInspectorGUI()
EditorGUILayout.Space(2);
- // ────────────────────────────────────────────────────────
+ // --------------------------------------------------------
// Navigation (fully merged from PSXNavRegionEditor)
- // ────────────────────────────────────────────────────────
+ // --------------------------------------------------------
_navigationFoldout = PSXEditorStyles.DrawFoldoutCard("Navigation", _navigationFoldout, () =>
{
EditorGUILayout.LabelField(
@@ -167,7 +179,7 @@ public override void OnInspectorGUI()
EditorGUILayout.Space(6);
- // ── Agent settings ──
+ // -- Agent settings --
EditorGUILayout.LabelField("Agent", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(serializedObject.FindProperty("maxStepHeight"), new GUIContent("Max Step Height",
"Maximum vertical step the agent can climb. If your stairs are taller than this, the navmesh won't connect across them."));
@@ -176,7 +188,7 @@ public override void OnInspectorGUI()
PSXEditorStyles.DrawSeparator(4, 4);
- // ── Voxelization ──
+ // -- Voxelization --
EditorGUILayout.LabelField("Voxelization", EditorStyles.boldLabel);
EditorGUILayout.LabelField(
"Controls the 3D grid resolution used to analyze your geometry. " +
@@ -208,7 +220,7 @@ public override void OnInspectorGUI()
PSXEditorStyles.DrawSeparator(4, 4);
- // ── Region Partitioning ──
+ // -- Region Partitioning --
EditorGUILayout.LabelField("Region Partitioning", EditorStyles.boldLabel);
EditorGUILayout.LabelField(
"Controls how walkable voxels are grouped into convex regions. " +
@@ -234,7 +246,7 @@ public override void OnInspectorGUI()
PSXEditorStyles.DrawSeparator(4, 4);
- // ── Contour & Edge ──
+ // -- Contour & Edge --
EditorGUILayout.LabelField("Contour & Edge", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(serializedObject.FindProperty("navMaxSimplifyError"), new GUIContent("Max Simplify Error",
"Maximum distance (world units) that polygon edges can deviate from the voxelized boundary during contour simplification. " +
@@ -245,18 +257,18 @@ public override void OnInspectorGUI()
"Longer edges reduce region count.\n\n" +
"For terrain: 4\u20136. For indoors: 8\u201316."));
- // ── Advanced detail settings ──
+ // -- Advanced detail settings --
DrawAdvancedNavSection(player);
- // ── Presets ──
+ // -- Presets --
DrawPresetsSection(player);
});
EditorGUILayout.Space(2);
- // ────────────────────────────────────────────────────────
+ // --------------------------------------------------------
// Jump & Gravity
- // ────────────────────────────────────────────────────────
+ // --------------------------------------------------------
_physicsFoldout = PSXEditorStyles.DrawFoldoutCard("Jump & Gravity", _physicsFoldout, () =>
{
EditorGUILayout.PropertyField(serializedObject.FindProperty("jumpHeight"), new GUIContent("Jump Height",
@@ -269,9 +281,9 @@ public override void OnInspectorGUI()
EditorGUILayout.Space(4);
- // ────────────────────────────────────────────────────────
+ // --------------------------------------------------------
// Nav Region Preview (build, visualize, stats, validation)
- // ────────────────────────────────────────────────────────
+ // --------------------------------------------------------
DrawNavPreviewSection(player);
}
diff --git a/Editor/Inspectors/PSXUIEditors.cs b/Editor/Inspectors/PSXUIEditors.cs
index c8ddff2..3ba9bb2 100644
--- a/Editor/Inspectors/PSXUIEditors.cs
+++ b/Editor/Inspectors/PSXUIEditors.cs
@@ -25,13 +25,20 @@ static void DrawCanvasGizmo(PSXCanvas canvas, GizmoType gizmoType)
Color border = canvasSelected ? Color.yellow : new Color(1, 1, 0, 0.3f);
Handles.DrawSolidRectangleWithOutline(canvasCorners, Color.clear, border);
- // Draw all children in hierarchy order (first child = back, last child = front)
- var children = canvas.GetComponentsInChildren(true).Reverse();
+ // Hierarchy order, NOT reversed: the first child is behind and the
+ // last child is in front, which is Unity's own convention and (since
+ // UISystem::renderOT was fixed to walk its arrays backwards) the
+ // console's too. This used to be `.Reverse()` to match an engine that
+ // had the ordering-table insertion the wrong way round.
+ var children = canvas.GetComponentsInChildren(true);
foreach (var child in children)
{
if (child == canvas.transform) continue;
bool childSelected = Selection.Contains(child.gameObject);
+ var sprite = child.GetComponent();
+ if (sprite != null) { DrawSprite(sprite, childSelected); continue; }
+
var box = child.GetComponent();
if (box != null) { DrawBox(box, childSelected); continue; }
@@ -135,6 +142,31 @@ static void DrawImage(PSXUIImage image, bool selected)
}
}
+ ///
+ /// One cell of a sheet, in the scene view. The tint goes through
+ /// PSXScreenPreview.TexTint so a 128-grey element looks like the art
+ /// rather than half-dark - see the note there.
+ ///
+ static void DrawSprite(PSXUISprite sprite, bool selected)
+ {
+ RectTransform rt = sprite.GetComponent();
+ if (rt == null) return;
+ Vector3[] corners = new Vector3[4];
+ rt.GetWorldCorners(corners);
+
+ Handles.BeginGUI();
+ Vector2 min = HandleUtility.WorldToGUIPoint(corners[1]); // top-left
+ Vector2 max = HandleUtility.WorldToGUIPoint(corners[3]); // bottom-right
+ var screenRect = new Rect(Mathf.Min(min.x, max.x), Mathf.Min(min.y, max.y),
+ Mathf.Abs(max.x - min.x), Mathf.Abs(max.y - min.y));
+ if (screenRect.width > 1 && screenRect.height > 1)
+ PSXScreenPreview.DrawSprite(sprite, screenRect, selected ? 1f : 0.9f);
+ Handles.EndGUI();
+
+ if (selected)
+ Handles.DrawSolidRectangleWithOutline(corners, Color.clear, Color.cyan);
+ }
+
static void DrawText(PSXUIText text, bool selected)
{
RectTransform rt = text.GetComponent();
@@ -296,20 +328,49 @@ public override void OnInspectorGUI()
// Element summary card
PSXCanvas canvas = (PSXCanvas)target;
int imageCount = canvas.GetComponentsInChildren(true).Length;
+ int spriteCount = canvas.GetComponentsInChildren(true).Length;
int boxCount = canvas.GetComponentsInChildren(true).Length;
int textCount = canvas.GetComponentsInChildren(true).Length;
int progressCount = canvas.GetComponentsInChildren(true).Length;
- int total = imageCount + boxCount + textCount + progressCount;
+ int lineCount = canvas.GetComponentsInChildren(true).Length;
+ int total = imageCount + spriteCount + boxCount + textCount + progressCount + lineCount;
PSXEditorStyles.BeginCard();
EditorGUILayout.LabelField(
- $"Elements: {total} total\n" +
- $" Images: {imageCount} | Boxes: {boxCount}\n" +
- $" Texts: {textCount} | Progress Bars: {progressCount}",
+ $"Elements: {total} in this canvas\n" +
+ $" Sprites: {spriteCount} | Images: {imageCount} | Boxes: {boxCount}\n" +
+ $" Texts: {textCount} | Progress Bars: {progressCount} | Lines: {lineCount}",
+ PSXEditorStyles.InfoBox);
+
+ // The pool is shared by every canvas in the scene, so this canvas's
+ // own count says nothing about whether the scene fits. Elements past
+ // the cap are dropped SILENTLY at load (uisystem.cpp clamps the count),
+ // which reads as "the bottom half of my panel did not export".
+ int sceneTotal = 0;
+ var allCanvases = FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None);
+ foreach (var c in allCanvases)
+ {
+ sceneTotal += c.GetComponentsInChildren(true).Length
+ + c.GetComponentsInChildren(true).Length
+ + c.GetComponentsInChildren(true).Length
+ + c.GetComponentsInChildren(true).Length
+ + c.GetComponentsInChildren(true).Length
+ + c.GetComponentsInChildren(true).Length;
+ }
+ EditorGUILayout.LabelField(
+ $"Scene: {sceneTotal} / {PSXUIBudget.MaxElements} elements, " +
+ $"{allCanvases.Length} / {PSXUIBudget.MaxCanvases} canvases",
PSXEditorStyles.InfoBox);
- if (total > 128)
- EditorGUILayout.LabelField("PS1 UI system supports max 128 elements total across all canvases.", PSXEditorStyles.InfoBox);
+ if (sceneTotal > PSXUIBudget.MaxElements)
+ EditorGUILayout.HelpBox(
+ $"The scene has {sceneTotal} UI elements but the runtime pool holds " +
+ $"{PSXUIBudget.MaxElements}. Everything past the cap is dropped at load, " +
+ "without an error.", MessageType.Error);
+ if (allCanvases.Length > PSXUIBudget.MaxCanvases)
+ EditorGUILayout.HelpBox(
+ $"The scene has {allCanvases.Length} canvases but the runtime pool holds " +
+ $"{PSXUIBudget.MaxCanvases}.", MessageType.Error);
PSXEditorStyles.EndCard();
serializedObject.ApplyModifiedProperties();
diff --git a/Editor/Inspectors/PSXUISpriteEditor.cs b/Editor/Inspectors/PSXUISpriteEditor.cs
new file mode 100644
index 0000000..a56dacf
--- /dev/null
+++ b/Editor/Inspectors/PSXUISpriteEditor.cs
@@ -0,0 +1,144 @@
+using SplashEdit.RuntimeCode;
+using UnityEditor;
+using UnityEngine;
+
+namespace SplashEdit.EditorCode
+{
+ ///
+ /// Inspector for : a cell picker rather than an int
+ /// field.
+ ///
+ ///
+ /// Typing "17" into a Cell field and rebuilding a disc to find out whether 17
+ /// is the chevron or the lamp is the authoring loop this component exists to
+ /// end. So the sheet is drawn, the current cell is highlighted, and clicking a
+ /// cell picks it.
+ ///
+ [CustomEditor(typeof(PSXUISprite))]
+ [CanEditMultipleObjects]
+ public class PSXUISpriteEditor : Editor
+ {
+ public override void OnInspectorGUI()
+ {
+ serializedObject.Update();
+
+ PSXEditorStyles.BeginCard();
+ EditorGUILayout.LabelField("PSX UI Sprite", PSXEditorStyles.CardHeaderStyle);
+ PSXEditorStyles.EndCard();
+
+ EditorGUILayout.Space(4);
+
+ PSXEditorStyles.BeginCard();
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("elementName"), new GUIContent(
+ "Element Name", "Name used from Lua: UI.FindElement(canvas, \"name\"). Max 24 chars."));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("sheet"), new GUIContent(
+ "Sprite Sheet", "The sheet this cell comes from. Packed into VRAM once, however " +
+ "many elements draw from it."));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("cell"), new GUIContent(
+ "Cell", "Cell index, left-to-right then top-to-bottom from 0. " +
+ "Change at run time with UI.SetFrame()."));
+
+ SerializedProperty tint = serializedObject.FindProperty("tint");
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ EditorGUILayout.PropertyField(tint, new GUIContent(
+ "Tint", "The PS1 computes texel * colour / 128, so 128 grey is NO tint " +
+ "and white is double brightness."));
+ if (GUILayout.Button(new GUIContent("128", "Reset to the tint identity (no colour change)."),
+ EditorStyles.miniButton, GUILayout.Width(34)))
+ tint.colorValue = PSXUISprite.TintIdentity;
+ }
+
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("startVisible"));
+ PSXEditorStyles.EndCard();
+
+ var spr = (PSXUISprite)target;
+
+ if (!serializedObject.isEditingMultipleObjects && spr.Sheet != null)
+ {
+ EditorGUILayout.Space(4);
+ PSXEditorStyles.BeginCard();
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ EditorGUILayout.LabelField("Pick a cell", PSXEditorStyles.CardHeaderStyle);
+ if (GUILayout.Button(new GUIContent("Snap size",
+ "Resize the RectTransform to the sheet's cell size."),
+ EditorStyles.miniButton, GUILayout.Width(74)))
+ {
+ Undo.RecordObject(spr.GetComponent(), "Snap to cell size");
+ spr.SnapToCellSize();
+ }
+ }
+ PSXEditorStyles.DrawSeparator(2, 4);
+ DrawCellPicker(spr);
+ PSXEditorStyles.EndCard();
+ }
+
+ if (spr.Sheet == null)
+ {
+ EditorGUILayout.Space(4);
+ EditorGUILayout.HelpBox(
+ "No sheet assigned. This element still exports and Lua can still find it, " +
+ "but it draws whatever happens to be at UV (0,0) in the atlas.",
+ MessageType.Warning);
+ }
+
+ serializedObject.ApplyModifiedProperties();
+ }
+
+ /// The sheet drawn as a clickable grid, current cell highlighted.
+ private void DrawCellPicker(PSXUISprite spr)
+ {
+ PSXSpriteSheet sheet = spr.Sheet;
+ Texture2D tex = sheet.SourceTexture;
+ if (tex == null)
+ {
+ EditorGUILayout.HelpBox("The sheet has no source texture.", MessageType.Warning);
+ return;
+ }
+
+ int cols = Mathf.Max(1, sheet.Columns);
+ int rows = Mathf.Max(1, sheet.Rows);
+
+ float avail = EditorGUIUtility.currentViewWidth - 60f;
+ // Whole-number zoom only: a tile grid at 1.37x has seams in it, and
+ // the seam looks exactly like a one-pixel gap in the art.
+ int zoom = Mathf.Max(1, Mathf.FloorToInt(avail / Mathf.Max(1, tex.width)));
+ float w = tex.width * zoom, h = tex.height * zoom;
+
+ Rect area = GUILayoutUtility.GetRect(w, h, GUILayout.ExpandWidth(false));
+ EditorGUI.DrawRect(area, new Color(0.1f, 0.1f, 0.12f, 1f));
+ GUI.DrawTexture(area, tex, ScaleMode.StretchToFill, true);
+
+ float cw = area.width / cols, ch = area.height / rows;
+
+ Handles.BeginGUI();
+ Handles.color = new Color(1f, 1f, 1f, 0.15f);
+ for (int c = 1; c < cols; c++)
+ Handles.DrawLine(new Vector3(area.x + c * cw, area.y), new Vector3(area.x + c * cw, area.yMax));
+ for (int r = 1; r < rows; r++)
+ Handles.DrawLine(new Vector3(area.x, area.y + r * ch), new Vector3(area.xMax, area.y + r * ch));
+
+ int cur = spr.ResolvedCell;
+ var sel = new Rect(area.x + (cur % cols) * cw, area.y + (cur / cols) * ch, cw, ch);
+ Handles.color = Color.cyan;
+ Handles.DrawAAPolyLine(2f,
+ new Vector3(sel.xMin, sel.yMin), new Vector3(sel.xMax, sel.yMin),
+ new Vector3(sel.xMax, sel.yMax), new Vector3(sel.xMin, sel.yMax), new Vector3(sel.xMin, sel.yMin));
+ Handles.EndGUI();
+
+ Event e = Event.current;
+ if (e.type == EventType.MouseDown && e.button == 0 && area.Contains(e.mousePosition))
+ {
+ int cx = Mathf.Clamp(Mathf.FloorToInt((e.mousePosition.x - area.x) / cw), 0, cols - 1);
+ int cy = Mathf.Clamp(Mathf.FloorToInt((e.mousePosition.y - area.y) / ch), 0, rows - 1);
+ serializedObject.FindProperty("cell").intValue = cy * cols + cx;
+ e.Use();
+ }
+
+ EditorGUILayout.LabelField(
+ $"Cell {cur} of {sheet.CellCount} ({sheet.CellWidth}x{sheet.CellHeight}px, {cols}x{rows} grid)",
+ PSXEditorStyles.InfoBox);
+ }
+ }
+}
diff --git a/Editor/Inspectors/PSXUISpriteEditor.cs.meta b/Editor/Inspectors/PSXUISpriteEditor.cs.meta
new file mode 100644
index 0000000..b9c6be8
--- /dev/null
+++ b/Editor/Inspectors/PSXUISpriteEditor.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 20f9b0e9364ba79fa19a96880a378b04
\ No newline at end of file
diff --git a/Editor/PSXAgentEditor.cs b/Editor/PSXAgentEditor.cs
new file mode 100644
index 0000000..fa1edb6
--- /dev/null
+++ b/Editor/PSXAgentEditor.cs
@@ -0,0 +1,276 @@
+using UnityEditor;
+using UnityEngine;
+using SplashEdit.RuntimeCode;
+
+namespace SplashEdit.EditorCode
+{
+ [CustomEditor(typeof(PSXAgent))]
+ [CanEditMultipleObjects]
+ public class PSXAgentEditor : UnityEditor.Editor
+ {
+ private SerializedProperty _startEnabledProp;
+ private SerializedProperty _moveSpeedProp;
+ private SerializedProperty _stopDistanceProp;
+ private SerializedProperty _hasVisionProp;
+ private SerializedProperty _visionRangeProp;
+ private SerializedProperty _visionFovDegreesProp;
+ private SerializedProperty _visionRegionDepthProp;
+ private SerializedProperty _hasHearingProp;
+ private SerializedProperty _hearingRangeProp;
+ private SerializedProperty _alertTimeoutFramesProp;
+ private SerializedProperty _patrolWaypointsProp;
+ private SerializedProperty _stateAnimsProp;
+
+ private bool _moveFoldout = true;
+ private bool _visionFoldout = true;
+ private bool _hearFoldout = true;
+ private bool _alertFoldout = false;
+ private bool _patrolFoldout = true;
+ private bool _animFoldout = false;
+ private bool _luaFoldout = false;
+ private bool _runtimeFoldout = false;
+
+ private static readonly string[] s_stateLabels =
+ { "Idle","Patrol","Seek","Flee","Attack","Wander","Investigate","Custom" };
+
+ private void OnEnable()
+ {
+ _startEnabledProp = serializedObject.FindProperty("startEnabled");
+ _moveSpeedProp = serializedObject.FindProperty("moveSpeed");
+ _stopDistanceProp = serializedObject.FindProperty("stopDistance");
+ _hasVisionProp = serializedObject.FindProperty("hasVision");
+ _visionRangeProp = serializedObject.FindProperty("visionRange");
+ _visionFovDegreesProp = serializedObject.FindProperty("visionFovDegrees");
+ _visionRegionDepthProp = serializedObject.FindProperty("visionRegionDepth");
+ _hasHearingProp = serializedObject.FindProperty("hasHearing");
+ _hearingRangeProp = serializedObject.FindProperty("hearingRange");
+ _alertTimeoutFramesProp = serializedObject.FindProperty("alertTimeoutFrames");
+ _patrolWaypointsProp = serializedObject.FindProperty("patrolWaypoints");
+ _stateAnimsProp = serializedObject.FindProperty("stateAnims");
+ }
+
+ public override void OnInspectorGUI()
+ {
+ serializedObject.Update();
+
+ var agent = (PSXAgent)target;
+ var exporter = agent.GetComponent();
+ var sceneExp = FindFirstObjectByType();
+ var navSettings = FindFirstObjectByType();
+ var player = FindFirstObjectByType();
+
+ DrawHeader(agent, exporter);
+ EditorGUILayout.Space(4);
+
+ _moveFoldout = PSXEditorStyles.DrawFoldoutCard("Movement", _moveFoldout, () =>
+ {
+ EditorGUILayout.PropertyField(_startEnabledProp, new GUIContent("Start Enabled"));
+ EditorGUILayout.Slider(_moveSpeedProp, 0.01f, 12f, new GUIContent("Move Speed (u/s)"));
+ EditorGUILayout.Slider(_stopDistanceProp, 0f, 2f, new GUIContent("Stop Distance (u)"));
+ EditorGUILayout.Space(2);
+ EditorGUILayout.LabelField(
+ "Speed converts to fp12 per-frame at 30 fps. Same scale as PSXPlayer walk speed.",
+ PSXEditorStyles.RichLabel);
+ });
+ EditorGUILayout.Space(2);
+
+ _visionFoldout = PSXEditorStyles.DrawFoldoutCard("Vision", _visionFoldout, () =>
+ {
+ EditorGUILayout.PropertyField(_hasVisionProp, new GUIContent("Enable Vision"));
+ using (new EditorGUI.DisabledScope(!_hasVisionProp.boolValue))
+ {
+ EditorGUILayout.Slider(_visionRangeProp, 0f, 30f, new GUIContent("Range (u)"));
+ EditorGUILayout.Slider(_visionFovDegreesProp, 1f, 360f,
+ new GUIContent("FOV (degrees, full cone)",
+ "Total angular width of the vision cone. 90 = 45 each side of forward."));
+ EditorGUILayout.IntSlider(_visionRegionDepthProp, 0, 8,
+ new GUIContent("Region Hop Depth",
+ "Max nav-region portal hops for portal-graph LOS. 0=same region only. 2-3 covers most interiors."));
+ float fov = _visionFovDegreesProp != null ? _visionFovDegreesProp.floatValue : 90f;
+ int fp12 = UnityEngine.Mathf.Clamp(UnityEngine.Mathf.RoundToInt(UnityEngine.Mathf.Cos(fov * 0.5f * UnityEngine.Mathf.Deg2Rad) * 4096f), -4096, 4096);
+ EditorGUILayout.LabelField("visionCosAngle (fp12)", fp12.ToString(), EditorStyles.miniLabel);
+ }
+ EditorGUILayout.Space(2);
+ EditorGUILayout.LabelField("Yellow cone arc shown in Scene view when selected.", PSXEditorStyles.RichLabel);
+ });
+ EditorGUILayout.Space(2);
+
+ _hearFoldout = PSXEditorStyles.DrawFoldoutCard("Hearing", _hearFoldout, () =>
+ {
+ EditorGUILayout.PropertyField(_hasHearingProp, new GUIContent("Enable Hearing"));
+ using (new EditorGUI.DisabledScope(!_hasHearingProp.boolValue))
+ {
+ EditorGUILayout.Slider(_hearingRangeProp, 0f, 30f, new GUIContent("Range (u)"));
+ EditorGUILayout.LabelField("Pure range check, no geometry occlusion. Cyan disc in Scene view.", PSXEditorStyles.RichLabel);
+ }
+ });
+ EditorGUILayout.Space(2);
+
+ _alertFoldout = PSXEditorStyles.DrawFoldoutCard("Alert", _alertFoldout, () =>
+ {
+ if (_alertTimeoutFramesProp != null)
+ {
+ EditorGUILayout.PropertyField(_alertTimeoutFramesProp,
+ new GUIContent("Alert Timeout (frames)",
+ "Frames the agent stays alert after losing sight/sound before onTargetLost fires. 60=2s@30fps."));
+ float secs = _alertTimeoutFramesProp.intValue / 30f;
+ EditorGUILayout.LabelField($"approx {secs:F1} s at 30 fps", EditorStyles.miniLabel);
+ }
+ });
+ EditorGUILayout.Space(2);
+
+ _patrolFoldout = PSXEditorStyles.DrawFoldoutCard("Patrol Waypoints", _patrolFoldout, () =>
+ {
+ if (_patrolWaypointsProp == null) return;
+ int count = _patrolWaypointsProp.arraySize;
+ EditorGUILayout.LabelField($"{count} / 8 waypoints", EditorStyles.miniLabel);
+ for (int i = 0; i < count; i++)
+ {
+ EditorGUILayout.BeginHorizontal();
+ var elem = _patrolWaypointsProp.GetArrayElementAtIndex(i);
+ EditorGUILayout.PropertyField(elem, new GUIContent($"[{i}]"));
+ using (new EditorGUI.DisabledScope(i == 0))
+ if (GUILayout.Button("^", GUILayout.Width(22)))
+ _patrolWaypointsProp.MoveArrayElement(i, i - 1);
+ using (new EditorGUI.DisabledScope(i >= count - 1))
+ if (GUILayout.Button("v", GUILayout.Width(22)))
+ _patrolWaypointsProp.MoveArrayElement(i, i + 1);
+ if (GUILayout.Button("X", GUILayout.Width(22)))
+ { _patrolWaypointsProp.DeleteArrayElementAtIndex(i); break; }
+ EditorGUILayout.EndHorizontal();
+ }
+ EditorGUILayout.Space(2);
+ using (new EditorGUI.DisabledScope(count >= 8))
+ {
+ EditorGUILayout.BeginHorizontal();
+ if (GUILayout.Button("+ Agent Position"))
+ {
+ _patrolWaypointsProp.arraySize++;
+ _patrolWaypointsProp.GetArrayElementAtIndex(_patrolWaypointsProp.arraySize - 1).vector3Value = agent.transform.position;
+ }
+ if (GUILayout.Button("+ World Origin"))
+ {
+ _patrolWaypointsProp.arraySize++;
+ _patrolWaypointsProp.GetArrayElementAtIndex(_patrolWaypointsProp.arraySize - 1).vector3Value = Vector3.zero;
+ }
+ EditorGUILayout.EndHorizontal();
+ }
+ EditorGUILayout.Space(2);
+ EditorGUILayout.LabelField(
+ "Exported as absolute world-space fp12 positions (12 bytes each). Sets AGENT_FLAG_HAS_PATROL. Draggable handles in Scene view.",
+ PSXEditorStyles.RichLabel);
+ });
+ EditorGUILayout.Space(2);
+
+ _animFoldout = PSXEditorStyles.DrawFoldoutCard("State Animations", _animFoldout, () =>
+ {
+ EditorGUILayout.LabelField(
+ "Clip played when the agent enters each state. Leave empty to keep the previous clip. " +
+ "Resolved to index in PSXSceneExporter Animations list at export time.",
+ PSXEditorStyles.RichLabel);
+ EditorGUILayout.Space(4);
+ if (_stateAnimsProp != null)
+ {
+ int len = UnityEngine.Mathf.Min(_stateAnimsProp.arraySize, 8);
+ for (int i = 0; i < len; i++)
+ {
+ var elem = _stateAnimsProp.GetArrayElementAtIndex(i);
+ var clipProp = elem.FindPropertyRelative("clip");
+ if (clipProp != null)
+ EditorGUILayout.PropertyField(clipProp,
+ new GUIContent(s_stateLabels[i], $"Clip for {s_stateLabels[i]} state (stateAnimClip[{i}])"));
+ }
+ }
+ });
+ EditorGUILayout.Space(2);
+
+ _runtimeFoldout = PSXEditorStyles.DrawFoldoutCard("Runtime Checks", _runtimeFoldout, () =>
+ {
+ if (sceneExp == null)
+ EditorGUILayout.HelpBox("No PSXSceneExporter in this scene. Agent will not export.", MessageType.Error);
+ if (navSettings == null && player == null)
+ EditorGUILayout.HelpBox("No PSXNavigationSettings or PSXPlayer found. Agents need nav regions to pathfind.", MessageType.Error);
+ else if (navSettings != null && player == null)
+ EditorGUILayout.HelpBox("Nav regions baked from PSXNavigationSettings. Agents run without a player.", MessageType.Info);
+ else if (player != null)
+ EditorGUILayout.HelpBox("PSXPlayer present - agents share the scene nav data.", MessageType.Info);
+ if (exporter != null && exporter.LuaFile == null)
+ EditorGUILayout.HelpBox("No Lua script assigned. Agent can be driven by scene scripts or cutscenes.", MessageType.None);
+ int wpCount = agent.PatrolWaypoints != null ? agent.PatrolWaypoints.Count : 0;
+ EditorGUILayout.LabelField("Export Cost", $"{28 + wpCount * 12} bytes (28 struct + {wpCount}x12 waypoints)", EditorStyles.miniLabel);
+ EditorGUILayout.LabelField("Movement Model", "Native nav-region path following", EditorStyles.miniLabel);
+ EditorGUILayout.Space(4);
+ EditorGUILayout.BeginHorizontal();
+ if (sceneExp != null && GUILayout.Button("Scene Exporter", PSXEditorStyles.SecondaryButton))
+ Selection.activeGameObject = sceneExp.gameObject;
+ if (navSettings != null && GUILayout.Button("Nav Settings", PSXEditorStyles.SecondaryButton))
+ Selection.activeGameObject = navSettings.gameObject;
+ else if (player != null && GUILayout.Button("PSX Player", PSXEditorStyles.SecondaryButton))
+ Selection.activeGameObject = player.gameObject;
+ EditorGUILayout.EndHorizontal();
+ });
+ EditorGUILayout.Space(2);
+
+ _luaFoldout = PSXEditorStyles.DrawFoldoutCard("Lua Quick Start", _luaFoldout, () =>
+ {
+ EditorGUILayout.LabelField(
+ "Events: onStateEnter(newState,oldState) onStateExit(state) onTargetSeen(target) " +
+ "onTargetLost(target) onTargetReached() onPatrolPoint(idx) onPathBlocked(). Fire on transitions only.",
+ PSXEditorStyles.RichLabel);
+ EditorGUILayout.Space(4);
+ EditorGUILayout.SelectableLabel(
+ "-- AGENT_IDLE AGENT_PATROL AGENT_SEEK AGENT_FLEE\n" +
+ "-- AGENT_ATTACK AGENT_WANDER AGENT_INVESTIGATE AGENT_CUSTOM\n\n" +
+ "function self:onTargetSeen(target)\n" +
+ " Agent.SetState(self, AGENT_SEEK)\n" +
+ "end\n\n" +
+ "function self:onTargetLost(target)\n" +
+ " Agent.SetState(self, AGENT_PATROL)\n" +
+ "end\n\n" +
+ "function self:onStateEnter(newState, oldState)\n" +
+ " -- react to state change\n" +
+ "end",
+ EditorStyles.textArea, GUILayout.MinHeight(140));
+ if (exporter != null && exporter.LuaFile != null &&
+ GUILayout.Button("Open Lua Script", PSXEditorStyles.SecondaryButton))
+ AssetDatabase.OpenAsset(exporter.LuaFile);
+ });
+
+ serializedObject.ApplyModifiedProperties();
+ }
+
+ private void OnSceneGUI()
+ {
+ var agent = (PSXAgent)target;
+ var wpProp = serializedObject.FindProperty("patrolWaypoints");
+ if (wpProp == null || wpProp.arraySize == 0) return;
+ serializedObject.Update();
+ bool changed = false;
+ Handles.color = new Color(0.3f, 1f, 0.4f, 0.9f);
+ for (int i = 0; i < wpProp.arraySize; i++)
+ {
+ var elem = wpProp.GetArrayElementAtIndex(i);
+ Vector3 pos = elem.vector3Value;
+ EditorGUI.BeginChangeCheck();
+ Vector3 newPos = Handles.PositionHandle(pos, Quaternion.identity);
+ if (EditorGUI.EndChangeCheck()) { elem.vector3Value = newPos; changed = true; }
+ Handles.Label(pos + Vector3.up * 0.55f, $"WP {i}", EditorStyles.miniLabel);
+ }
+ if (changed) serializedObject.ApplyModifiedProperties();
+ }
+
+ private static void DrawHeader(PSXAgent agent, PSXObjectExporter exporter)
+ {
+ PSXEditorStyles.BeginCard();
+ EditorGUILayout.LabelField("PSX Agent", PSXEditorStyles.CardHeaderStyle);
+ EditorGUILayout.LabelField(
+ "Native nav agent with vision, hearing, patrol waypoints and per-state animations. " +
+ "Serialized as SPLASHPACKAgentV2 (28 bytes + waypoints).",
+ PSXEditorStyles.RichLabel);
+ EditorGUILayout.Space(4);
+ EditorGUILayout.LabelField("GameObject", agent.gameObject.name, EditorStyles.miniLabel);
+ EditorGUILayout.LabelField("Exporter", exporter != null ? "Attached" : "Missing", EditorStyles.miniLabel);
+ PSXEditorStyles.EndCard();
+ }
+ }
+}
diff --git a/Editor/PSXAgentEditor.cs.meta b/Editor/PSXAgentEditor.cs.meta
new file mode 100644
index 0000000..b457e78
--- /dev/null
+++ b/Editor/PSXAgentEditor.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 623b9b02fc77f094897657badad3cb1f
\ No newline at end of file
diff --git a/Editor/PSXMenuItems.cs b/Editor/PSXMenuItems.cs
index e2fae1d..1b56903 100644
--- a/Editor/PSXMenuItems.cs
+++ b/Editor/PSXMenuItems.cs
@@ -6,14 +6,14 @@
namespace SplashEdit.EditorCode
{
///
- /// Minimal menu items — everything goes through the unified Control Panel.
+ /// Minimal menu items - everything goes through the unified Control Panel.
/// Only keeps: Control Panel shortcut + GameObject creation helpers.
///
public static class PSXMenuItems
{
private const string MENU_ROOT = "PlayStation 1/";
- // ───── Main Entry Point ─────
+ // ----- Main Entry Point -----
[MenuItem(MENU_ROOT + "SplashEdit Control Panel %#l", false, 0)]
public static void OpenControlPanel()
@@ -27,7 +27,7 @@ public static void OpenAboutWindow()
PSXAboutWindow.ShowWindow();
}
- // ───── GameObject Menu ─────
+ // ----- GameObject Menu -----
[MenuItem("GameObject/PlayStation 1/Scene Exporter", false, 10)]
public static void CreateSceneExporter(MenuCommand menuCommand)
@@ -61,7 +61,7 @@ public static void CreateExportableObject(MenuCommand menuCommand)
Selection.activeGameObject = go;
}
- // ───── Context Menu ─────
+ // ----- Context Menu -----
[MenuItem("CONTEXT/MeshFilter/Add PSX Object Exporter")]
public static void AddPSXObjectExporterFromMesh(MenuCommand command)
diff --git a/Editor/PSXNavRegionEditor.cs b/Editor/PSXNavRegionEditor.cs
index aa2f3ba..838ac19 100644
--- a/Editor/PSXNavRegionEditor.cs
+++ b/Editor/PSXNavRegionEditor.cs
@@ -6,27 +6,36 @@ namespace SplashEdit.EditorCode
{
///
/// Legacy editor window kept for menu-bar convenience.
- /// All navigation settings and the build/preview workflow have been merged
- /// into the PSXPlayer inspector. This window simply selects the PSXPlayer
- /// so users who click the old menu item land on the right place.
+ /// Navigation can now live on a dedicated PSXNavigationSettings component.
+ /// This window simply selects the most relevant scene object so users who
+ /// click the old menu item land on the right place.
///
public class PSXNavRegionEditor : EditorWindow
{
[MenuItem("PlayStation 1/Nav Region Builder")]
public static void ShowWindow()
{
- // Find the PSXPlayer in scene and select it so the inspector shows nav settings
+ var navSettings = FindObjectsByType(FindObjectsSortMode.None);
+ if (navSettings.Length > 0)
+ {
+ Selection.activeGameObject = navSettings[0].gameObject;
+ EditorGUIUtility.PingObject(navSettings[0]);
+ Debug.Log("[Nav] Selecting PSXNavigationSettings.");
+ return;
+ }
+
+ // Fallback to the legacy PSXPlayer-hosted nav settings.
var players = FindObjectsByType(FindObjectsSortMode.None);
if (players.Length > 0)
{
Selection.activeGameObject = players[0].gameObject;
EditorGUIUtility.PingObject(players[0]);
- Debug.Log("[Nav] Navigation settings are now on the PSXPlayer inspector. Selecting PSXPlayer.");
+ Debug.Log("[Nav] Selecting legacy PSXPlayer navigation settings.");
}
else
{
EditorUtility.DisplayDialog("Nav Region Builder",
- "No PSXPlayer found in the scene.\n\nAdd a PSXPlayer component to a GameObject to configure navigation settings.",
+ "No PSXNavigationSettings or PSXPlayer found in the scene.\n\nAdd a PSXNavigationSettings component for scene-level navigation, or use the legacy PSXPlayer workflow.",
"OK");
}
}
diff --git a/Editor/PSXNavigationSettingsEditor.cs b/Editor/PSXNavigationSettingsEditor.cs
new file mode 100644
index 0000000..b1271b8
--- /dev/null
+++ b/Editor/PSXNavigationSettingsEditor.cs
@@ -0,0 +1,87 @@
+using UnityEditor;
+using UnityEngine;
+using SplashEdit.RuntimeCode;
+
+namespace SplashEdit.EditorCode
+{
+ [CustomEditor(typeof(PSXNavigationSettings))]
+ public class PSXNavigationSettingsEditor : UnityEditor.Editor
+ {
+ private bool _agentFoldout = true;
+ private bool _voxelFoldout = true;
+ private bool _advancedFoldout = true;
+ private bool _presetsFoldout = true;
+
+ public override void OnInspectorGUI()
+ {
+ serializedObject.Update();
+ var settings = (PSXNavigationSettings)target;
+
+ PSXEditorStyles.BeginCard();
+ EditorGUILayout.LabelField("Navigation Settings", PSXEditorStyles.CardHeaderStyle);
+ EditorGUILayout.LabelField("Scene-level nav bake settings for player and AI agents.", PSXEditorStyles.RichLabel);
+ PSXEditorStyles.EndCard();
+
+ EditorGUILayout.Space(4);
+
+ _agentFoldout = PSXEditorStyles.DrawFoldoutCard("Agent", _agentFoldout, () =>
+ {
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("agentHeight"), new GUIContent("Agent Height"));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("agentRadius"), new GUIContent("Agent Radius"));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("maxStepHeight"), new GUIContent("Max Step Height"));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("walkableSlopeAngle"), new GUIContent("Walkable Slope"));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("spawnAnchor"), new GUIContent("Spawn Anchor"));
+ });
+
+ EditorGUILayout.Space(2);
+
+ _voxelFoldout = PSXEditorStyles.DrawFoldoutCard("Voxelization", _voxelFoldout, () =>
+ {
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("navCellSize"), new GUIContent("Cell Size (XZ)"));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("navCellHeight"), new GUIContent("Cell Height (Y)"));
+ });
+
+ EditorGUILayout.Space(2);
+
+ _advancedFoldout = PSXEditorStyles.DrawFoldoutCard("Advanced", _advancedFoldout, () =>
+ {
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("navMinRegionArea"), new GUIContent("Min Region Area"));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("navMergeRegionArea"), new GUIContent("Merge Region Area"));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("navMaxSimplifyError"), new GUIContent("Max Simplify Error"));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("navMaxEdgeLength"), new GUIContent("Max Edge Length"));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("navPartitionMethod"), new GUIContent("Partition Method"));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("navDetailSampleDist"), new GUIContent("Detail Sample Dist"));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("navDetailMaxError"), new GUIContent("Detail Max Error"));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("navMaxPlaneError"), new GUIContent("Max Plane Error"));
+ });
+
+ EditorGUILayout.Space(2);
+
+ _presetsFoldout = PSXEditorStyles.DrawFoldoutCard("Quick Presets", _presetsFoldout, () =>
+ {
+ EditorGUILayout.BeginHorizontal();
+ if (GUILayout.Button("Indoor", GUILayout.Height(24)))
+ {
+ Undo.RecordObject(settings, "Apply Indoor Nav Preset");
+ settings.ApplyIndoorPreset();
+ EditorUtility.SetDirty(settings);
+ }
+ if (GUILayout.Button("Terrain", GUILayout.Height(24)))
+ {
+ Undo.RecordObject(settings, "Apply Terrain Nav Preset");
+ settings.ApplyTerrainPreset();
+ EditorUtility.SetDirty(settings);
+ }
+ if (GUILayout.Button("Multi-Level", GUILayout.Height(24)))
+ {
+ Undo.RecordObject(settings, "Apply Multi-Level Nav Preset");
+ settings.ApplyMultiLevelPreset();
+ EditorUtility.SetDirty(settings);
+ }
+ EditorGUILayout.EndHorizontal();
+ });
+
+ serializedObject.ApplyModifiedProperties();
+ }
+ }
+}
diff --git a/Editor/PSXNavigationSettingsEditor.cs.meta b/Editor/PSXNavigationSettingsEditor.cs.meta
new file mode 100644
index 0000000..438d5e4
--- /dev/null
+++ b/Editor/PSXNavigationSettingsEditor.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 06a68da4a9be63b43b86778ba20f2a3b
\ No newline at end of file
diff --git a/Editor/PSXSceneExporterEditor.cs b/Editor/PSXSceneExporterEditor.cs
index fcea397..8b119c6 100644
--- a/Editor/PSXSceneExporterEditor.cs
+++ b/Editor/PSXSceneExporterEditor.cs
@@ -14,6 +14,7 @@ public class PSXSceneExporterEditor : UnityEditor.Editor
private SerializedProperty fogColorProp;
private SerializedProperty fogDensityProp;
private SerializedProperty sceneTypeProp;
+ private SerializedProperty sceneNetworkIdProp; // Added: Networking ID prop
private SerializedProperty cutscenesProp;
private SerializedProperty animationsProp;
private SerializedProperty loadingScreenProp;
@@ -22,6 +23,7 @@ public class PSXSceneExporterEditor : UnityEditor.Editor
private SerializedProperty bvhDepthProp;
private bool showFog = true;
+ private bool showNetworking = true; // Added: Foldout toggle for Networking
private bool showCutscenes = true;
private bool showDebug = false;
@@ -33,6 +35,7 @@ private void OnEnable()
fogColorProp = serializedObject.FindProperty("FogColor");
fogDensityProp = serializedObject.FindProperty("FogDensity");
sceneTypeProp = serializedObject.FindProperty("SceneType");
+ sceneNetworkIdProp = serializedObject.FindProperty("SceneNetworkId"); // Added: Find networking prop
cutscenesProp = serializedObject.FindProperty("Cutscenes");
animationsProp = serializedObject.FindProperty("Animations");
loadingScreenProp = serializedObject.FindProperty("LoadingScreenPrefab");
@@ -55,6 +58,8 @@ public override void OnInspectorGUI()
DrawSceneSettings();
PSXEditorStyles.DrawSeparator(6, 6);
+ DrawNetworkingSection(); // Added: Draw Networking section
+ PSXEditorStyles.DrawSeparator(6, 6);
DrawFogSection(exporter);
PSXEditorStyles.DrawSeparator(6, 6);
DrawCutscenesSection();
@@ -65,7 +70,7 @@ public override void OnInspectorGUI()
PSXEditorStyles.DrawSeparator(6, 6);
DrawDebugSection();
PSXEditorStyles.DrawSeparator(6, 6);
- DrawSceneStats();
+ DrawSceneStats(exporter); // Updated: Pass exporter to access post-export stats
serializedObject.ApplyModifiedProperties();
}
@@ -105,6 +110,31 @@ private void DrawSceneSettings()
}
}
+ // Added: Dedicated Networking Section
+ private void DrawNetworkingSection()
+ {
+ showNetworking = EditorGUILayout.Foldout(showNetworking, "Networking", true, PSXEditorStyles.FoldoutHeader);
+ if (!showNetworking) return;
+
+ EditorGUI.indentLevel++;
+ EditorGUILayout.PropertyField(sceneNetworkIdProp, new GUIContent("Scene Network Id",
+ "Stable id for this scene on the network. Leave empty to fall back to the derived hash."));
+
+ if (string.IsNullOrEmpty(sceneNetworkIdProp.stringValue))
+ {
+ EditorGUILayout.LabelField(
+ "Using derived hash (changes when scene objects change).",
+ PSXEditorStyles.RichLabel);
+ }
+ else
+ {
+ EditorGUILayout.LabelField(
+ $"Locked ID: {sceneNetworkIdProp.stringValue}",
+ PSXEditorStyles.RichLabel);
+ }
+ EditorGUI.indentLevel--;
+ }
+
private void DrawFogSection(PSXSceneExporter exporter)
{
showFog = EditorGUILayout.Foldout(showFog, "Fog & Background", true, PSXEditorStyles.FoldoutHeader);
@@ -187,7 +217,8 @@ private void DrawDebugSection()
EditorGUI.indentLevel--;
}
- private void DrawSceneStats()
+ // Updated: Added post-export triangle count display
+ private void DrawSceneStats(PSXSceneExporter exporter)
{
var exporters = FindObjectsByType(FindObjectsSortMode.None);
int total = exporters.Length;
@@ -200,8 +231,16 @@ private void DrawSceneStats()
EditorGUILayout.LabelField(
$"{active}/{total} objects | {staticCol} static {dynamicCol} dynamic {triggerBoxes} triggers",
PSXEditorStyles.RichLabel);
+
+ // Display Last Export stats if an export has been run
+ if (exporter.LastExportTriangleCount > 0)
+ {
+ EditorGUILayout.LabelField(
+ $"Last Export: {exporter.LastExportTriangleCount} triangles",
+ PSXEditorStyles.RichLabel);
+ }
EditorGUILayout.EndVertical();
}
}
-}
+}
\ No newline at end of file
diff --git a/Editor/PSXScreenOverlay.cs b/Editor/PSXScreenOverlay.cs
new file mode 100644
index 0000000..09495dd
--- /dev/null
+++ b/Editor/PSXScreenOverlay.cs
@@ -0,0 +1,123 @@
+using System.Collections.Generic;
+using SplashEdit.RuntimeCode;
+using UnityEditor;
+using UnityEditor.Overlays;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace SplashEdit.EditorCode
+{
+ ///
+ /// A live 320x240 view of the scene, docked inside the scene view.
+ ///
+ ///
+ /// Toggle it from the scene view's overlay menu (the three dots, top right)
+ /// under "PSX Screen".
+ ///
+ /// It is the scene view's answer to a question the 3D viewport structurally
+ /// cannot answer: a canvas lives in the XY plane and the tilemap in XZ, so
+ /// "what will the television show" is not a camera angle. Click an element in
+ /// here to select it in the hierarchy - which is how you find the one row out
+ /// of nine that is three pixels too low.
+ ///
+ [Overlay(typeof(SceneView), k_Id, "PSX Screen", false)]
+ public class PSXScreenOverlay : Overlay
+ {
+ private const string k_Id = "psxsplash-screen-preview";
+
+ private IMGUIContainer m_Container;
+
+ public override VisualElement CreatePanelContent()
+ {
+ m_Container = new IMGUIContainer(OnGUI);
+ Resize();
+ // The preview must follow the scene, not the repaint clock: a colour
+ // typed into an inspector should show up here immediately.
+ m_Container.onGUIHandler = OnGUI;
+ return m_Container;
+ }
+
+ private void Resize()
+ {
+ if (m_Container == null) return;
+ Vector2 size = PSXScreenPreview.ScreenSize(PSXScreenPreview.Zoom);
+ m_Container.style.width = size.x + 8;
+ m_Container.style.height = size.y + 30;
+ }
+
+ private void OnGUI()
+ {
+ int zoom = PSXScreenPreview.Zoom;
+
+ // --- toolbar ---
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ int newZoom = EditorGUILayout.IntPopup(zoom,
+ new[] { "1x", "2x", "3x", "4x" }, new[] { 1, 2, 3, 4 },
+ GUILayout.Width(46));
+ if (newZoom != zoom)
+ {
+ PSXScreenPreview.Zoom = newZoom;
+ Resize();
+ }
+
+ PSXScreenPreview.ShowTilemap = GUILayout.Toggle(
+ PSXScreenPreview.ShowTilemap, new GUIContent("Map", "Draw the scene's tilemap behind the UI."),
+ EditorStyles.miniButton, GUILayout.Width(38));
+
+ PSXScreenPreview.ShowHiddenCanvases = GUILayout.Toggle(
+ PSXScreenPreview.ShowHiddenCanvases,
+ new GUIContent("Hidden", "Also draw canvases whose Start Visible is off, dimmed."),
+ EditorStyles.miniButton, GUILayout.Width(50));
+
+ PSXScreenPreview.ShowOutlines = GUILayout.Toggle(
+ PSXScreenPreview.ShowOutlines,
+ new GUIContent("Bounds", "Outline every element's rect."),
+ EditorStyles.miniButton, GUILayout.Width(50));
+ }
+
+ Vector2 size = PSXScreenPreview.ScreenSize(zoom);
+ Rect area = GUILayoutUtility.GetRect(size.x, size.y, GUILayout.ExpandWidth(false));
+
+ List hits = PSXScreenPreview.Draw(area, zoom);
+
+ HandleInput(area, size, hits);
+ }
+
+ private void HandleInput(Rect area, Vector2 size, List hits)
+ {
+ Event e = Event.current;
+ var screen = new Rect(area.x, area.y, size.x, size.y);
+ if (!screen.Contains(e.mousePosition)) return;
+
+ // Middle-drag (or alt-drag) scrolls the tilemap window, which is the
+ // engine's Sprite.SetViewOffset - the same thing the camera does at
+ // run time. Without it the preview only ever shows the top-left
+ // corner of a map, which is rarely where the UI has to work.
+ if (e.type == EventType.MouseDrag && (e.button == 2 || e.alt))
+ {
+ float scale = Mathf.Max(1, PSXScreenPreview.Zoom);
+ Vector2Int v = PSXScreenPreview.TileView;
+ PSXScreenPreview.TileView = new Vector2Int(
+ Mathf.Max(0, v.x - Mathf.RoundToInt(e.delta.x / scale)),
+ Mathf.Max(0, v.y - Mathf.RoundToInt(e.delta.y / scale)));
+ e.Use();
+ return;
+ }
+
+ if (e.type == EventType.MouseDown && e.button == 0)
+ {
+ // Backwards: the list is back-to-front, so the last match is the
+ // one actually on top - which is the one the click meant.
+ for (int i = hits.Count - 1; i >= 0; i--)
+ {
+ if (!hits[i].ScreenRect.Contains(e.mousePosition)) continue;
+ Selection.activeGameObject = hits[i].Target;
+ EditorGUIUtility.PingObject(hits[i].Target);
+ e.Use();
+ return;
+ }
+ }
+ }
+ }
+}
diff --git a/Editor/PSXScreenOverlay.cs.meta b/Editor/PSXScreenOverlay.cs.meta
new file mode 100644
index 0000000..4b818f4
--- /dev/null
+++ b/Editor/PSXScreenOverlay.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4e1fae22b550e5fc949389c1c5ff7d13
\ No newline at end of file
diff --git a/Editor/PSXScreenPreview.cs b/Editor/PSXScreenPreview.cs
new file mode 100644
index 0000000..dfae939
--- /dev/null
+++ b/Editor/PSXScreenPreview.cs
@@ -0,0 +1,345 @@
+using System.Collections.Generic;
+using System.Linq;
+using SplashEdit.RuntimeCode;
+using UnityEditor;
+using UnityEngine;
+
+namespace SplashEdit.EditorCode
+{
+ ///
+ /// Composites the whole PS1 screen - the tilemap floor and every UI canvas
+ /// over it - into a rect, at exact PS1 pixels.
+ ///
+ ///
+ /// This is the answer to authoring UI blind. A PSXCanvas is a world-space
+ /// Canvas in the XY plane and a tilemap lies in the XZ plane, so the 3D scene
+ /// view can never show them together no matter how good the gizmos are - and
+ /// "together" is exactly the question you have while placing a HUD: does this
+ /// label sit on top of the corridor, does the task panel cover the prompt bar.
+ ///
+ /// Everything here goes through the same code the exporter uses
+ /// () and mirrors what uisystem.cpp draws, including
+ /// the two things that are easiest to get wrong by eye:
+ ///
+ /// * draw order - lower sortOrder behind, earlier sibling behind;
+ /// * tint - the PS1 computes texel * colour / 128, so a 128
+ /// grey is "no tint" and Unity's white is DOUBLE brightness.
+ ///
+ public static class PSXScreenPreview
+ {
+ /// One drawn element, kept so the preview can be clicked.
+ public struct Hit
+ {
+ public GameObject Target;
+ public Rect ScreenRect; // in GUI space
+ public RectInt PixelRect; // in PS1 pixels
+ }
+
+ // --- persisted view options -----------------------------------------
+
+ private const string k_Prefix = "PSXSplash.ScreenPreview.";
+
+ public static bool ShowTilemap
+ {
+ get => EditorPrefs.GetBool(k_Prefix + "Tilemap", true);
+ set => EditorPrefs.SetBool(k_Prefix + "Tilemap", value);
+ }
+
+ public static bool ShowHiddenCanvases
+ {
+ get => EditorPrefs.GetBool(k_Prefix + "Hidden", true);
+ set => EditorPrefs.SetBool(k_Prefix + "Hidden", value);
+ }
+
+ public static bool ShowOutlines
+ {
+ get => EditorPrefs.GetBool(k_Prefix + "Outlines", false);
+ set => EditorPrefs.SetBool(k_Prefix + "Outlines", value);
+ }
+
+ public static int Zoom
+ {
+ get => Mathf.Clamp(EditorPrefs.GetInt(k_Prefix + "Zoom", 1), 1, 4);
+ set => EditorPrefs.SetInt(k_Prefix + "Zoom", Mathf.Clamp(value, 1, 4));
+ }
+
+ public static Vector2Int TileView
+ {
+ get => new Vector2Int(EditorPrefs.GetInt(k_Prefix + "ViewX", 0),
+ EditorPrefs.GetInt(k_Prefix + "ViewY", 0));
+ set
+ {
+ EditorPrefs.SetInt(k_Prefix + "ViewX", value.x);
+ EditorPrefs.SetInt(k_Prefix + "ViewY", value.y);
+ }
+ }
+
+ /// The PS1 clear colour behind everything. Matches a black TV.
+ private static readonly Color k_Clear = new Color(0.02f, 0.02f, 0.03f, 1f);
+
+ ///
+ /// texel * colour / 128: 128 grey leaves art alone, 255 doubles it.
+ /// Applied to every TEXTURED draw so the preview is not systematically
+ /// half as bright as the console.
+ ///
+ public static Color TexTint(Color c) =>
+ new Color(Mathf.Clamp01(c.r * 2f), Mathf.Clamp01(c.g * 2f), Mathf.Clamp01(c.b * 2f), 1f);
+
+ ///
+ /// Draw the composite. Returns what was drawn, in back-to-front order, so
+ /// a caller can hit-test a click (walk it backwards to pick the topmost).
+ ///
+ public static List Draw(Rect area, int zoom)
+ {
+ var hits = new List();
+ Vector2 res = PSXCanvas.PSXResolution;
+ float scale = Mathf.Max(1, zoom);
+ var screen = new Rect(area.x, area.y, res.x * scale, res.y * scale);
+
+ EditorGUI.DrawRect(screen, k_Clear);
+
+ if (ShowTilemap)
+ {
+ var renderer = Object.FindFirstObjectByType();
+ if (renderer != null && renderer.tilemap != null)
+ {
+ Vector2Int v = TileView;
+ GUI.BeginClip(screen);
+ PSXTilemapPreview.DrawScreen(renderer.tilemap, new Rect(0, 0, screen.width, screen.height),
+ scale, v.x, v.y, res);
+ GUI.EndClip();
+ }
+ }
+
+ // Ascending sortOrder: the engine draws the lowest first, so the
+ // highest ends up in front. See UISystem::renderOT.
+ var canvases = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)
+ .OrderBy(c => c.SortOrder)
+ .ToList();
+
+ GUI.BeginClip(screen);
+ var inner = new Rect(0, 0, screen.width, screen.height);
+ foreach (PSXCanvas canvas in canvases)
+ {
+ if (!canvas.StartVisible && !ShowHiddenCanvases) continue;
+ DrawCanvas(canvas, inner, scale, res, hits, screen.position);
+ }
+ GUI.EndClip();
+
+ // Border last, so it is never painted over.
+ Handles.BeginGUI();
+ Handles.color = new Color(1f, 1f, 1f, 0.4f);
+ Handles.DrawSolidRectangleWithOutline(
+ new Vector3[]
+ {
+ new Vector3(screen.xMin, screen.yMax), new Vector3(screen.xMin, screen.yMin),
+ new Vector3(screen.xMax, screen.yMin), new Vector3(screen.xMax, screen.yMax),
+ }, Color.clear, new Color(1f, 1f, 1f, 0.4f));
+ Handles.EndGUI();
+
+ return hits;
+ }
+
+ /// The size needs, for laying out the caller.
+ public static Vector2 ScreenSize(int zoom)
+ {
+ Vector2 res = PSXCanvas.PSXResolution;
+ float scale = Mathf.Max(1, zoom);
+ return new Vector2(res.x * scale, res.y * scale);
+ }
+
+ private static void DrawCanvas(PSXCanvas canvas, Rect clip, float scale, Vector2 res,
+ List hits, Vector2 clipOrigin)
+ {
+ RectTransform canvasRect = canvas.GetComponent();
+ if (canvasRect == null) return;
+
+ bool dim = !canvas.StartVisible;
+
+ // Hierarchy order: depth-first in sibling order, which is what
+ // PSXUIExporter collects and therefore what the element array is.
+ // Earlier = behind, so painting in order is correct.
+ foreach (Transform t in canvas.GetComponentsInChildren(true))
+ {
+ if (t == canvas.transform) continue;
+ var rt = t as RectTransform;
+ if (rt == null) continue;
+
+ RectInt px = PSXUILayout.ScreenRect(rt, canvasRect, res);
+ var r = new Rect(px.x * scale, px.y * scale, px.width * scale, px.height * scale);
+
+ bool drew = DrawElement(t, rt, canvasRect, r, scale, res, dim);
+ if (drew)
+ hits.Add(new Hit { Target = t.gameObject, ScreenRect = new Rect(r.position + clipOrigin, r.size), PixelRect = px });
+ }
+ }
+
+ private static bool DrawElement(Transform t, RectTransform rt, RectTransform canvasRect,
+ Rect r, float scale, Vector2 res, bool dim)
+ {
+ float alpha = dim ? 0.35f : 1f;
+
+ var spr = t.GetComponent();
+ if (spr != null) { DrawSprite(spr, r, alpha); Outline(r, Color.cyan); return true; }
+
+ var img = t.GetComponent();
+ if (img != null)
+ {
+ if (img.SourceTexture != null)
+ {
+ Color c = TexTint(img.TintColor); c.a = alpha;
+ GUI.color = c;
+ GUI.DrawTexture(r, img.SourceTexture, ScaleMode.StretchToFill);
+ GUI.color = Color.white;
+ }
+ else EditorGUI.DrawRect(r, new Color(0.3f, 0.3f, 0.6f, 0.6f * alpha));
+ Outline(r, Color.cyan);
+ return true;
+ }
+
+ var box = t.GetComponent();
+ if (box != null)
+ {
+ // A Box is an untextured primitive: the PS1 uses its colour
+ // directly, no /128 halving. Do NOT run it through TexTint.
+ Color c = box.BoxColor; c.a = alpha;
+ EditorGUI.DrawRect(r, c);
+ Outline(r, Color.green);
+ return true;
+ }
+
+ var bar = t.GetComponent();
+ if (bar != null)
+ {
+ Color bg = bar.BackgroundColor; bg.a = alpha;
+ EditorGUI.DrawRect(r, bg);
+ float f = Mathf.Clamp01(bar.InitialValue / 100f);
+ if (f > 0f)
+ {
+ Color fc = bar.FillColor; fc.a = alpha;
+ EditorGUI.DrawRect(new Rect(r.x, r.y, r.width * f, r.height), fc);
+ }
+ Outline(r, Color.yellow);
+ return true;
+ }
+
+ var line = t.GetComponent();
+ if (line != null)
+ {
+ // A Line's endpoints are absolute PS1 pixels, not its rect - the
+ // exporter writes Point1/Point2 straight into the record.
+ Handles.BeginGUI();
+ Color c = line.LineColor; c.a = alpha;
+ Handles.color = c;
+ Handles.DrawLine(new Vector3(line.Point1.x * scale, line.Point1.y * scale),
+ new Vector3(line.Point2.x * scale, line.Point2.y * scale));
+ Handles.EndGUI();
+ return true;
+ }
+
+ var txt = t.GetComponent();
+ if (txt != null)
+ {
+ DrawText(txt, r, scale, alpha);
+ Outline(r, new Color(1f, 1f, 1f, 0.5f));
+ return true;
+ }
+
+ return false;
+ }
+
+ private static void Outline(Rect r, Color c)
+ {
+ if (!ShowOutlines) return;
+ Handles.BeginGUI();
+ Handles.color = new Color(c.r, c.g, c.b, 0.5f);
+ Handles.DrawAAPolyLine(1f,
+ new Vector3(r.xMin, r.yMin), new Vector3(r.xMax, r.yMin),
+ new Vector3(r.xMax, r.yMax), new Vector3(r.xMin, r.yMax), new Vector3(r.xMin, r.yMin));
+ Handles.EndGUI();
+ }
+
+ /// One cell of a sheet, with the cell's tex coords.
+ public static void DrawSprite(PSXUISprite spr, Rect r, float alpha = 1f)
+ {
+ PSXSpriteSheet sheet = spr.Sheet;
+ if (sheet == null || sheet.SourceTexture == null)
+ {
+ EditorGUI.DrawRect(r, new Color(0.8f, 0.2f, 0.8f, 0.5f * alpha));
+ return;
+ }
+
+ Texture2D tex = sheet.SourceTexture;
+ RectInt cell = spr.CellRect;
+ var uv = new Rect((float)cell.x / tex.width,
+ // CellRect counts down from the top; UVs count up.
+ 1f - (float)(cell.y + cell.height) / tex.height,
+ (float)cell.width / tex.width,
+ (float)cell.height / tex.height);
+
+ Color c = TexTint(spr.Tint); c.a = alpha;
+ GUI.color = c;
+ GUI.DrawTextureWithTexCoords(r, tex, uv, true);
+ GUI.color = Color.white;
+ }
+
+ ///
+ /// Text, glyph by glyph with the authored font's advance widths - the same
+ /// walk UISystem::renderProportionalText does. Drawing it with a
+ /// Unity label instead is what let a "32 characters wide" row ship running
+ /// off the right of the screen.
+ ///
+ public static void DrawText(PSXUIText txt, Rect r, float scale, float alpha = 1f)
+ {
+ string label = txt.DefaultText ?? "";
+ PSXFontAsset font = txt.GetEffectiveFont();
+ Color tint = TexTint(txt.TextColor); tint.a = alpha;
+
+ if (font == null || font.FontTexture == null)
+ {
+ // System font (8x16 fixed). No bitmap to sample, so approximate
+ // with a label - and say so, because the sizes differ.
+ var style = new GUIStyle(EditorStyles.label)
+ {
+ fontSize = Mathf.Clamp(Mathf.RoundToInt(11 * scale), 7, 40),
+ clipping = TextClipping.Clip,
+ wordWrap = false,
+ };
+ style.normal.textColor = tint;
+ GUI.Label(r, label, style);
+ return;
+ }
+
+ Texture2D fontTex = font.FontTexture;
+ int glyphW = font.GlyphWidth, glyphH = font.GlyphHeight;
+ int perRow = font.GlyphsPerRow;
+
+ GUI.color = tint;
+ float cursor = r.x;
+ foreach (char ch in label)
+ {
+ char c = (ch < 32 || ch > 126) ? '?' : ch;
+ int idx = c - 32;
+ float advance = (font.AdvanceWidths != null && idx < font.AdvanceWidths.Length)
+ ? font.AdvanceWidths[idx] : glyphW;
+
+ if (c != ' ')
+ {
+ int col = idx % perRow, row = idx / perRow;
+ // The runtime draws `advance` pixels of a left-aligned glyph,
+ // so sample exactly that slice - a full cell would overlap the
+ // next character and read as bold.
+ float drawW = (advance > 0 && advance < glyphW) ? advance : glyphW;
+ var uv = new Rect((float)(col * glyphW) / fontTex.width,
+ 1f - (float)((row + 1) * glyphH) / fontTex.height,
+ drawW / fontTex.width,
+ (float)glyphH / fontTex.height);
+ GUI.DrawTextureWithTexCoords(
+ new Rect(cursor, r.y, drawW * scale, glyphH * scale), fontTex, uv, true);
+ }
+ cursor += advance * scale;
+ }
+ GUI.color = Color.white;
+ }
+ }
+}
diff --git a/Editor/PSXScreenPreview.cs.meta b/Editor/PSXScreenPreview.cs.meta
new file mode 100644
index 0000000..d3f8c66
--- /dev/null
+++ b/Editor/PSXScreenPreview.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e86d6cb919918226887b527c458daa84
\ No newline at end of file
diff --git a/Editor/PSXSkinnedObjectExporterEditor.cs b/Editor/PSXSkinnedObjectExporterEditor.cs
index 99ae5a8..2f25781 100644
--- a/Editor/PSXSkinnedObjectExporterEditor.cs
+++ b/Editor/PSXSkinnedObjectExporterEditor.cs
@@ -80,7 +80,7 @@ public override void OnInspectorGUI()
EditorGUILayout.Space(4);
EditorGUILayout.LabelField("Estimated RAM",
- $"{totalBytes / 1024f:F1} KB ({totalFrames} frames × {usedBones} bones × 24 B + {boneIndexBytes} B indices)");
+ $"{totalBytes / 1024f:F1} KB ({totalFrames} frames x {usedBones} bones x 24 B + {boneIndexBytes} B indices)");
if (skinExp.AnimationClips.Length > 16)
{
diff --git a/Editor/PSXSplashInstaller.cs b/Editor/PSXSplashInstaller.cs
index 3aa09d3..c9dbe03 100644
--- a/Editor/PSXSplashInstaller.cs
+++ b/Editor/PSXSplashInstaller.cs
@@ -16,7 +16,7 @@ namespace SplashEdit.EditorCode
///
public static class PSXSplashInstaller
{
- // ───── Public config ─────
+ // ----- Public config -----
public static readonly string RepoOwner = "psxsplash";
public static readonly string RepoName = "psxsplash";
public static readonly string RepoUrl = "https://github.com/psxsplash/psxsplash.git";
@@ -26,7 +26,7 @@ public static class PSXSplashInstaller
private static readonly string GitHubApiReleasesUrl =
$"https://api.github.com/repos/{RepoOwner}/{RepoName}/releases";
- // ───── Cached release list ─────
+ // ----- Cached release list -----
private static List _cachedReleases = new List();
private static bool _isFetchingReleases;
@@ -49,9 +49,9 @@ static PSXSplashInstaller()
FullInstallPath = Path.Combine(Application.dataPath, "psxsplash");
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Queries
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
/// Is the native project cloned on disk?
public static bool IsInstalled()
@@ -83,13 +83,13 @@ public static string GetCurrentTag()
}
}
- // ═══════════════════════════════════════════════════════════════
- // Fetch Releases (HTTP — no git required)
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
+ // Fetch Releases (HTTP - no git required)
+ // ---------------------------------------------------------------
///
/// Fetches the list of releases from the GitHub REST API.
- /// Does NOT require git — uses UnityWebRequest.
+ /// Does NOT require git - uses UnityWebRequest.
///
public static async Task> FetchReleasesAsync()
{
@@ -124,9 +124,9 @@ public static async Task> FetchReleasesAsync()
}
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Install / Clone at a specific release tag
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
///
/// Clones the repository at the specified release tag with --recursive.
@@ -185,7 +185,7 @@ public static async Task SwitchToReleaseAsync(string tag, Action o
{
if (!IsInstalled())
{
- UnityEngine.Debug.LogError("[PSXSplashInstaller] Not installed — clone first.");
+ UnityEngine.Debug.LogError("[PSXSplashInstaller] Not installed - clone first.");
return false;
}
@@ -245,9 +245,9 @@ public static async Task FetchLatestAsync()
}
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// Git helpers
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
///
/// Checks whether git is available on the system PATH.
@@ -369,9 +369,9 @@ private static async Task RunGitCommandAsync(
}
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// HTTP helpers (no git needed)
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
private static Task HttpGetAsync(string url)
{
@@ -396,9 +396,9 @@ private static Task HttpGetAsync(string url)
return tcs.Task;
}
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
// JSON parsing (minimal, avoids external dependency)
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
///
/// Minimal JSON parser for the GitHub releases API response.
diff --git a/Editor/PSXTilemapPreview.cs b/Editor/PSXTilemapPreview.cs
new file mode 100644
index 0000000..440d008
--- /dev/null
+++ b/Editor/PSXTilemapPreview.cs
@@ -0,0 +1,305 @@
+using System.Collections.Generic;
+using SplashEdit.RuntimeCode;
+using UnityEditor;
+using UnityEngine;
+
+namespace SplashEdit.EditorCode
+{
+ ///
+ /// Draws a painted the way the console draws it -
+ /// in the scene view, in the world, and in the 2D screen preview.
+ ///
+ ///
+ /// Before this existed a tilemap was invisible everywhere except the Tile
+ /// Painter window, so laying anything out against the map - a spawn point, a
+ /// task station, a HUD element that must not cover the corridor the player
+ /// walks down - meant painting, exporting, building a disc and looking at a
+ /// television. The whole point of authoring in Unity is that the answer is on
+ /// screen while you are still authoring.
+ ///
+ /// World mapping matches the engine exactly: tile (0,0) is world (0,0), one
+ /// world unit is one pixel (Tile.MoveActor works in pos.x.integer()
+ /// pixels), the grid lies in the XZ plane and +Z is DOWN the screen.
+ ///
+ [InitializeOnLoad]
+ public static class PSXTilemapPreview
+ {
+ private const string k_ShowWorldKey = "PSXSplash.Tilemap.ShowInScene";
+
+ /// Draw the tilemap in the 3D scene view (menu-toggleable).
+ public static bool ShowInSceneView
+ {
+ get => EditorPrefs.GetBool(k_ShowWorldKey, true);
+ set { EditorPrefs.SetBool(k_ShowWorldKey, value); SceneView.RepaintAll(); }
+ }
+
+ static PSXTilemapPreview()
+ {
+ SceneView.duringSceneGui += OnSceneGui;
+ }
+
+ // --- mesh cache -----------------------------------------------------
+ //
+ // A 64x64 map is 4096 quads. Rebuilding that every repaint would make the
+ // scene view crawl, so the mesh is cached and rebuilt only when the map's
+ // contents actually change. The change check walks the cells, which is
+ // cheap next to allocating 16k vertices.
+
+ private class Entry
+ {
+ public Mesh Mesh;
+ public int Hash;
+ public Texture2D Texture;
+ }
+
+ private static readonly Dictionary s_cache = new Dictionary();
+ private static Material s_material;
+
+ /// Throw away every cached mesh (the painter calls this).
+ public static void Invalidate()
+ {
+ foreach (var e in s_cache.Values)
+ if (e.Mesh != null) Object.DestroyImmediate(e.Mesh);
+ s_cache.Clear();
+ SceneView.RepaintAll();
+ }
+
+ private static Material PreviewMaterial()
+ {
+ if (s_material != null) return s_material;
+
+ // Unlit/Transparent is the closest built-in match for how the console
+ // draws a tile: no lighting, alpha cut straight through, no fog. The
+ // fallbacks matter because which built-in shaders are present depends
+ // on the render pipeline the project happens to use.
+ Shader shader = Shader.Find("Unlit/Transparent")
+ ?? Shader.Find("Sprites/Default")
+ ?? Shader.Find("Unlit/Texture");
+ if (shader == null) return null;
+
+ s_material = new Material(shader) { hideFlags = HideFlags.HideAndDontSave };
+ return s_material;
+ }
+
+ private static int ContentHash(PSXTilemap map)
+ {
+ unchecked
+ {
+ int h = 17;
+ h = h * 31 + map.Width;
+ h = h * 31 + map.Height;
+ h = h * 31 + (map.Tileset != null ? map.Tileset.GetInstanceID() : 0);
+ h = h * 31 + (map.Tileset != null ? map.Tileset.CellWidth * 397 + map.Tileset.CellHeight : 0);
+ foreach (var b in map.Brushes) h = h * 31 + b.tilesetCell * 7 + (b.walkable ? 1 : 0);
+ for (int y = 0; y < map.Height; y++)
+ for (int x = 0; x < map.Width; x++)
+ h = h * 31 + map.GetCell(x, y);
+ return h;
+ }
+ }
+
+ /// The cached world-space mesh for a map, rebuilt when it changes.
+ public static Mesh MeshFor(PSXTilemap map)
+ {
+ if (map == null || map.Tileset == null || map.Tileset.SourceTexture == null) return null;
+
+ int key = map.GetInstanceID();
+ int hash = ContentHash(map);
+ if (s_cache.TryGetValue(key, out Entry e) && e.Mesh != null && e.Hash == hash)
+ return e.Mesh;
+
+ if (e != null && e.Mesh != null) Object.DestroyImmediate(e.Mesh);
+ e = new Entry { Hash = hash, Texture = map.Tileset.SourceTexture, Mesh = BuildMesh(map) };
+ s_cache[key] = e;
+ return e.Mesh;
+ }
+
+ private static Mesh BuildMesh(PSXTilemap map)
+ {
+ PSXSpriteSheet set = map.Tileset;
+ Texture2D tex = set.SourceTexture;
+ int tw = map.TileWidth, th = map.TileHeight;
+ int cols = Mathf.Max(1, set.Columns);
+
+ var verts = new List();
+ var uvs = new List();
+ var tris = new List();
+
+ float uStep = (float)set.CellWidth / tex.width;
+ float vStep = (float)set.CellHeight / tex.height;
+
+ for (int y = 0; y < map.Height; y++)
+ {
+ for (int x = 0; x < map.Width; x++)
+ {
+ PSXTileBrush brush = map.BrushAt(x, y);
+ if (brush == null || brush.tilesetCell < 0) continue;
+
+ int cell = brush.tilesetCell;
+ float u0 = (cell % cols) * uStep;
+ // Cells count from the TOP-left; Unity's V axis runs from the
+ // BOTTOM. Flip once here rather than in three draw sites.
+ float v1 = 1f - (cell / cols) * vStep;
+ float v0 = v1 - vStep;
+
+ float x0 = x * tw, x1 = x0 + tw;
+ float z0 = y * th, z1 = z0 + th;
+
+ int b = verts.Count;
+ verts.Add(new Vector3(x0, 0, z0));
+ verts.Add(new Vector3(x1, 0, z0));
+ verts.Add(new Vector3(x1, 0, z1));
+ verts.Add(new Vector3(x0, 0, z1));
+
+ uvs.Add(new Vector2(u0, v1));
+ uvs.Add(new Vector2(u0 + uStep, v1));
+ uvs.Add(new Vector2(u0 + uStep, v0));
+ uvs.Add(new Vector2(u0, v0));
+
+ tris.Add(b); tris.Add(b + 1); tris.Add(b + 2);
+ tris.Add(b); tris.Add(b + 2); tris.Add(b + 3);
+ }
+ }
+
+ var mesh = new Mesh { hideFlags = HideFlags.HideAndDontSave, name = "PSXTilemapPreview" };
+ // A 256x256 map is 65536 quads: past the 16-bit index limit, so say so
+ // rather than silently drawing the first 16384 tiles.
+ mesh.indexFormat = verts.Count > 65000
+ ? UnityEngine.Rendering.IndexFormat.UInt32
+ : UnityEngine.Rendering.IndexFormat.UInt16;
+ mesh.SetVertices(verts);
+ mesh.SetUVs(0, uvs);
+ mesh.SetTriangles(tris, 0);
+ mesh.RecalculateBounds();
+ return mesh;
+ }
+
+ // --- 3D scene view ---------------------------------------------------
+
+ private static void OnSceneGui(SceneView view)
+ {
+ if (!ShowInSceneView) return;
+ if (Event.current.type != EventType.Repaint) return;
+
+ var renderers = Object.FindObjectsByType(
+ FindObjectsInactive.Exclude, FindObjectsSortMode.None);
+ foreach (var r in renderers)
+ DrawWorld(r);
+ }
+
+ private static void DrawWorld(PSXTilemapRenderer renderer)
+ {
+ if (renderer == null || renderer.tilemap == null) return;
+ PSXTilemap map = renderer.tilemap;
+
+ Mesh mesh = MeshFor(map);
+ Material mat = PreviewMaterial();
+ if (mesh != null && mat != null)
+ {
+ mat.mainTexture = map.Tileset.SourceTexture;
+ mat.SetPass(0);
+ // Identity, not the renderer's transform: the engine ignores the
+ // GameObject's position entirely (tile 0,0 IS world 0,0), so a
+ // preview that followed the transform would lie about where the
+ // floor is the moment somebody nudged the object.
+ Graphics.DrawMeshNow(mesh, Matrix4x4.identity);
+ }
+
+ bool selected = Selection.activeGameObject == renderer.gameObject;
+ if (selected) DrawGridOverlay(map);
+ }
+
+ ///
+ /// Grid, map border and the solid cells, drawn only for the selected
+ /// tilemap so it does not fight the art the rest of the time.
+ ///
+ private static void DrawGridOverlay(PSXTilemap map)
+ {
+ int tw = map.TileWidth, th = map.TileHeight;
+ float w = map.Width * tw, h = map.Height * th;
+
+ Handles.color = new Color(1f, 1f, 1f, 0.25f);
+ Handles.DrawAAPolyLine(2f,
+ new Vector3(0, 0, 0), new Vector3(w, 0, 0),
+ new Vector3(w, 0, h), new Vector3(0, 0, h), new Vector3(0, 0, 0));
+
+ // Solid cells get a red wash. "Where can I walk" is the question a
+ // level layout is actually answering, and it is invisible in the art.
+ Handles.color = new Color(1f, 0.25f, 0.25f, 0.18f);
+ for (int y = 0; y < map.Height; y++)
+ {
+ for (int x = 0; x < map.Width; x++)
+ {
+ PSXTileBrush b = map.BrushAt(x, y);
+ if (b != null && b.walkable) continue;
+ if (b == null && map.GetCell(x, y) < 0) continue; // unpainted: leave it clear
+ Handles.DrawAAConvexPolygon(
+ new Vector3(x * tw, 0, y * th),
+ new Vector3(x * tw + tw, 0, y * th),
+ new Vector3(x * tw + tw, 0, y * th + th),
+ new Vector3(x * tw, 0, y * th + th));
+ }
+ }
+
+ // Object tiles: the addressable ones a game looks up by kind and id.
+ Handles.color = new Color(0.3f, 1f, 0.9f, 0.9f);
+ for (int y = 0; y < map.Height; y++)
+ {
+ for (int x = 0; x < map.Width; x++)
+ {
+ PSXTileBrush b = map.BrushAt(x, y);
+ if (b == null || b.objectKind == 0) continue;
+ var c = new Vector3(x * tw + tw * 0.5f, 0, y * th + th * 0.5f);
+ Handles.DrawWireDisc(c, Vector3.up, Mathf.Min(tw, th) * 0.35f);
+ }
+ }
+ }
+
+ // --- 2D screen preview ----------------------------------------------
+
+ ///
+ /// Draw the tiles visible through a 320x240 window at ,
+ /// world pixels, into a GUI rect. This is the
+ /// same culling window TileSystem::renderOT computes.
+ ///
+ public static void DrawScreen(PSXTilemap map, Rect screen, float scale,
+ int viewX, int viewY, Vector2 resolution)
+ {
+ if (map == null || map.Tileset == null || map.Tileset.SourceTexture == null) return;
+ Texture2D tex = map.Tileset.SourceTexture;
+ PSXSpriteSheet set = map.Tileset;
+
+ int tw = map.TileWidth, th = map.TileHeight;
+ if (tw <= 0 || th <= 0) return;
+ int cols = Mathf.Max(1, set.Columns);
+
+ int x0 = Mathf.FloorToInt(viewX / (float)tw);
+ int y0 = Mathf.FloorToInt(viewY / (float)th);
+ int x1 = Mathf.FloorToInt((viewX + resolution.x - 1) / (float)tw);
+ int y1 = Mathf.FloorToInt((viewY + resolution.y - 1) / (float)th);
+ x0 = Mathf.Max(0, x0); y0 = Mathf.Max(0, y0);
+ x1 = Mathf.Min(map.Width - 1, x1); y1 = Mathf.Min(map.Height - 1, y1);
+
+ float uStep = (float)set.CellWidth / tex.width;
+ float vStep = (float)set.CellHeight / tex.height;
+
+ for (int y = y0; y <= y1; y++)
+ {
+ for (int x = x0; x <= x1; x++)
+ {
+ PSXTileBrush b = map.BrushAt(x, y);
+ if (b == null || b.tilesetCell < 0) continue;
+
+ int cell = b.tilesetCell;
+ float u0 = (cell % cols) * uStep;
+ float v0 = 1f - (cell / cols + 1) * vStep;
+
+ var r = new Rect(screen.x + (x * tw - viewX) * scale,
+ screen.y + (y * th - viewY) * scale,
+ tw * scale, th * scale);
+ GUI.DrawTextureWithTexCoords(r, tex, new Rect(u0, v0, uStep, vStep));
+ }
+ }
+ }
+ }
+}
diff --git a/Editor/PSXTilemapPreview.cs.meta b/Editor/PSXTilemapPreview.cs.meta
new file mode 100644
index 0000000..b70c95d
--- /dev/null
+++ b/Editor/PSXTilemapPreview.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: de7fa809ead9e5368d93a3c344053d76
\ No newline at end of file
diff --git a/Editor/PSXTilemapRendererEditor.cs b/Editor/PSXTilemapRendererEditor.cs
new file mode 100644
index 0000000..5a2df33
--- /dev/null
+++ b/Editor/PSXTilemapRendererEditor.cs
@@ -0,0 +1,73 @@
+using SplashEdit.RuntimeCode;
+using UnityEditor;
+using UnityEngine;
+
+namespace SplashEdit.EditorCode
+{
+ [CustomEditor(typeof(PSXTilemapRenderer))]
+ public class PSXTilemapRendererEditor : Editor
+ {
+ public override void OnInspectorGUI()
+ {
+ DrawDefaultInspector();
+
+ var renderer = (PSXTilemapRenderer)target;
+
+ EditorGUILayout.Space(6);
+ using (new EditorGUI.DisabledScope(renderer.tilemap == null))
+ {
+ if (GUILayout.Button("Open in Tile Painter", GUILayout.Height(28)))
+ TilePainterWindow.Open(renderer.tilemap);
+ }
+
+ EditorGUILayout.Space(2);
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ bool show = GUILayout.Toggle(PSXTilemapPreview.ShowInSceneView,
+ new GUIContent("Draw in Scene View",
+ "Render the painted tiles on the XZ plane, where the engine puts them. " +
+ "Select this object to also see the grid, the solid cells and the object tiles."),
+ EditorStyles.miniButtonLeft, GUILayout.Height(20));
+ if (show != PSXTilemapPreview.ShowInSceneView)
+ PSXTilemapPreview.ShowInSceneView = show;
+
+ if (GUILayout.Button(new GUIContent("Refresh",
+ "Rebuild the cached preview mesh. Only needed if the map was changed " +
+ "from outside the Tile Painter."),
+ EditorStyles.miniButtonRight, GUILayout.Height(20), GUILayout.Width(70)))
+ PSXTilemapPreview.Invalidate();
+ }
+
+ if (renderer.tilemap == null)
+ {
+ EditorGUILayout.HelpBox(
+ "Assign a Tilemap asset (create one with PlayStation 1 > Tile Painter > New Tilemap), " +
+ "or this scene exports with no floor.", MessageType.Info);
+ return;
+ }
+
+ var problems = renderer.tilemap.Validate();
+ if (problems.Count > 0)
+ {
+ EditorGUILayout.HelpBox(string.Join("\n", problems), MessageType.Warning);
+ return;
+ }
+
+ PSXTilemap map = renderer.tilemap;
+ EditorGUILayout.HelpBox(
+ $"{map.Width}x{map.Height} tiles, {map.TileWidth}x{map.TileHeight}px each.\n" +
+ // The world extent, spelled out, because everything else in the
+ // scene is placed against it: one world unit is one pixel, tile
+ // (0,0) is the origin, and the map runs along +X and +Z.
+ $"World extent: 0,0 to {map.Width * map.TileWidth},{map.Height * map.TileHeight} " +
+ "(one unit = one pixel, on the XZ plane).\n" +
+ $"Cells: {map.Width * map.Height * 2} bytes in the splashpack.",
+ MessageType.None);
+
+ EditorGUILayout.HelpBox(
+ "The GameObject's transform is ignored on export - tile (0,0) is always world (0,0). " +
+ "The scene-view preview draws it there too, so what you see is where the player walks.",
+ MessageType.Info);
+ }
+ }
+}
diff --git a/Editor/PSXTilemapRendererEditor.cs.meta b/Editor/PSXTilemapRendererEditor.cs.meta
new file mode 100644
index 0000000..b92bedc
--- /dev/null
+++ b/Editor/PSXTilemapRendererEditor.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8ba9e9c65340fae4bb83763ad3a01caa
\ No newline at end of file
diff --git a/Editor/PSXUIMenuItems.cs b/Editor/PSXUIMenuItems.cs
new file mode 100644
index 0000000..4084b83
--- /dev/null
+++ b/Editor/PSXUIMenuItems.cs
@@ -0,0 +1,182 @@
+using SplashEdit.RuntimeCode;
+using UnityEditor;
+using UnityEngine;
+
+namespace SplashEdit.EditorCode
+{
+ ///
+ /// GameObject-menu builders for PSX UI, plus the tilemap preview toggle.
+ ///
+ ///
+ /// Every one of these sets the pivot to the top-left corner and snaps the
+ /// rect to whole pixels, because that is what makes an authored position mean
+ /// what it says: PSXUILayout bakes x = anchoredPosition.x - pivot.x*w,
+ /// so any other pivot turns "put it at 40,44" into arithmetic the author has
+ /// to do in their head.
+ ///
+ public static class PSXUIMenuItems
+ {
+ private const string k_GO = "GameObject/PlayStation 1/UI/";
+ private const string k_Menu = "PlayStation 1/";
+
+ // ----- preview toggle -----
+
+ [MenuItem(k_Menu + "Show Tilemap in Scene View", false, 40)]
+ private static void ToggleTilemapPreview() =>
+ PSXTilemapPreview.ShowInSceneView = !PSXTilemapPreview.ShowInSceneView;
+
+ [MenuItem(k_Menu + "Show Tilemap in Scene View", true)]
+ private static bool ToggleTilemapPreviewValidate()
+ {
+ Menu.SetChecked(k_Menu + "Show Tilemap in Scene View", PSXTilemapPreview.ShowInSceneView);
+ return true;
+ }
+
+ // ----- builders -----
+
+ [MenuItem(k_GO + "Canvas", false, 10)]
+ public static void CreateCanvas(MenuCommand cmd)
+ {
+ GameObject go = NewCanvas("PSX Canvas", cmd.context as GameObject);
+ Selection.activeGameObject = go;
+ }
+
+ [MenuItem(k_GO + "Text", false, 20)]
+ public static void CreateText(MenuCommand cmd) =>
+ Selection.activeGameObject = NewElement("Text", cmd, new Vector2(120, 19));
+
+ [MenuItem(k_GO + "Sprite (sheet cell)", false, 21)]
+ public static void CreateSprite(MenuCommand cmd) =>
+ Selection.activeGameObject = NewElement("Sprite", cmd, new Vector2(16, 16));
+
+ [MenuItem(k_GO + "Box", false, 22)]
+ public static void CreateBox(MenuCommand cmd) =>
+ Selection.activeGameObject = NewElement("Box", cmd, new Vector2(64, 24));
+
+ [MenuItem(k_GO + "Image", false, 23)]
+ public static void CreateImage(MenuCommand cmd) =>
+ Selection.activeGameObject = NewElement("Image", cmd, new Vector2(64, 64));
+
+ [MenuItem(k_GO + "Progress Bar", false, 24)]
+ public static void CreateProgressBar(MenuCommand cmd) =>
+ Selection.activeGameObject = NewElement("ProgressBar", cmd, new Vector2(120, 12));
+
+ ///
+ /// A nine-slice panel: eight edge/corner sprites round a middle, all from
+ /// one sheet, built in one click.
+ ///
+ ///
+ /// This is the shape every authored panel in a PS1 game has, and building
+ /// it by hand is nine components whose rects have to agree to the pixel -
+ /// exactly the arithmetic that used to live in Lua as
+ /// drawFrame(). Cell indices follow the conventional 3x3 block
+ /// starting at the top-left corner; retarget them in the inspector if the
+ /// sheet is laid out differently.
+ ///
+ [MenuItem(k_GO + "Nine-Slice Panel", false, 40)]
+ public static void CreateNineSlice(MenuCommand cmd)
+ {
+ GameObject parent = ResolveParent(cmd);
+ if (parent == null) return;
+
+ var root = new GameObject("Panel", typeof(RectTransform));
+ GameObjectUtility.SetParentAndAlign(root, parent);
+ RectTransform rrt = root.GetComponent();
+ Configure(rrt, new Vector2(0, 0), new Vector2(240, 152));
+ Undo.RegisterCreatedObjectUndo(root, "Create PSX Nine-Slice Panel");
+
+ // 16px is the cell size of every sheet this was written for, and the
+ // builder cannot know better until a sheet is assigned - the inspector
+ // "Snap size" button fixes each piece up afterwards.
+ const int c = 16;
+ float w = rrt.sizeDelta.x, h = rrt.sizeDelta.y;
+
+ // name, x, y, w, h, cell
+ var pieces = new (string name, float x, float y, float w, float h, int cell)[]
+ {
+ ("middle", c, c, w - 2 * c, h - 2 * c, 4),
+ ("edge_top", c, 0, w - 2 * c, c, 1),
+ ("edge_bottom", c, h - c, w - 2 * c, c, 7),
+ ("edge_left", 0, c, c, h - 2 * c, 3),
+ ("edge_right", w - c, c, c, h - 2 * c, 5),
+ ("corner_tl", 0, 0, c, c, 0),
+ ("corner_tr", w - c, 0, c, c, 2),
+ ("corner_bl", 0, h - c, c, c, 6),
+ ("corner_br", w - c, h - c, c, c, 8),
+ };
+
+ // Created in this order deliberately: the middle is FIRST, so it is
+ // the first sibling and therefore behind everything, and the corners
+ // are last so they cover the seams where the edges meet. Draw order
+ // is sibling order (see UISystem::renderOT).
+ foreach (var p in pieces)
+ {
+ var go = new GameObject(p.name, typeof(RectTransform), typeof(PSXUISprite));
+ GameObjectUtility.SetParentAndAlign(go, root);
+ Configure(go.GetComponent(), new Vector2(p.x, -p.y), new Vector2(p.w, p.h));
+
+ var so = new SerializedObject(go.GetComponent());
+ so.FindProperty("elementName").stringValue = p.name;
+ so.FindProperty("cell").intValue = p.cell;
+ so.ApplyModifiedPropertiesWithoutUndo();
+ }
+
+ Selection.activeGameObject = root;
+ }
+
+ // ----- helpers -----
+
+ private static GameObject NewCanvas(string name, GameObject context)
+ {
+ var go = new GameObject(name, typeof(RectTransform), typeof(Canvas), typeof(PSXCanvas));
+ GameObjectUtility.SetParentAndAlign(go, context);
+ go.GetComponent().ConfigureCanvas();
+ Undo.RegisterCreatedObjectUndo(go, "Create PSX Canvas");
+ return go;
+ }
+
+ ///
+ /// The canvas the new element belongs under, creating one if the selection
+ /// is not already inside a PSX canvas. An element outside a canvas is not
+ /// exported at all, and nothing says so.
+ ///
+ private static GameObject ResolveParent(MenuCommand cmd)
+ {
+ var ctx = cmd.context as GameObject ?? Selection.activeGameObject;
+ if (ctx != null && ctx.GetComponentInParent(true) != null)
+ return ctx;
+
+ var existing = Object.FindFirstObjectByType();
+ if (existing != null) return existing.gameObject;
+
+ return NewCanvas("PSX Canvas", null);
+ }
+
+ private static GameObject NewElement(string name, MenuCommand cmd, Vector2 size)
+ where T : Component
+ {
+ GameObject parent = ResolveParent(cmd);
+ var go = new GameObject(name, typeof(RectTransform), typeof(T));
+ GameObjectUtility.SetParentAndAlign(go, parent);
+ Configure(go.GetComponent(), Vector2.zero, size);
+ Undo.RegisterCreatedObjectUndo(go, "Create PSX UI " + name);
+ return go;
+ }
+
+ ///
+ /// Top-left pivot, top-left anchor, whole-pixel rect. Anchoring at the
+ /// canvas's top-left rather than its centre is what makes the inspector's
+ /// Pos X / Pos Y read as PS1 screen pixels straight off.
+ ///
+ private static void Configure(RectTransform rt, Vector2 anchoredPos, Vector2 size)
+ {
+ rt.anchorMin = new Vector2(0f, 1f);
+ rt.anchorMax = new Vector2(0f, 1f);
+ rt.pivot = new Vector2(0f, 1f);
+ rt.localScale = Vector3.one;
+ rt.localRotation = Quaternion.identity;
+ rt.sizeDelta = new Vector2(Mathf.Round(size.x), Mathf.Round(size.y));
+ rt.anchoredPosition = new Vector2(Mathf.Round(anchoredPos.x), Mathf.Round(anchoredPos.y));
+ }
+ }
+}
diff --git a/Editor/PSXUIMenuItems.cs.meta b/Editor/PSXUIMenuItems.cs.meta
new file mode 100644
index 0000000..62f6644
--- /dev/null
+++ b/Editor/PSXUIMenuItems.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: fa88ee62ec14c707a92d2c06efd37043
\ No newline at end of file
diff --git a/Editor/QuantizedPreviewWindow.cs b/Editor/QuantizedPreviewWindow.cs
index c1d3b2c..1f00d13 100644
--- a/Editor/QuantizedPreviewWindow.cs
+++ b/Editor/QuantizedPreviewWindow.cs
@@ -155,9 +155,9 @@ private void DrawCLUT()
// Convert the CLUT colors from 5-bit to float values (0-1 range)
Vector3 color = new Vector3(
- clut[index].R / 31.0f, // Red: bits 0–4
- clut[index].G / 31.0f, // Green: bits 5–9
- clut[index].B / 31.0f // Blue: bits 10–14
+ clut[index].R / 31.0f, // Red: bits 0-4
+ clut[index].G / 31.0f, // Green: bits 5-9
+ clut[index].B / 31.0f // Blue: bits 10-14
);
// Create a small color preview box for each color in the CLUT
diff --git a/Editor/TilePainterWindow.cs b/Editor/TilePainterWindow.cs
new file mode 100644
index 0000000..66b8bbc
--- /dev/null
+++ b/Editor/TilePainterWindow.cs
@@ -0,0 +1,536 @@
+using System.Collections.Generic;
+using System.IO;
+using SplashEdit.RuntimeCode;
+using UnityEditor;
+using UnityEngine;
+
+namespace SplashEdit.EditorCode
+{
+ ///
+ /// A grid tile painter for . The design goal above all
+ /// others is ease of use: pick a brush on the left, paint on the right, and
+ /// the walls, floors and gameplay objects fall out of what you painted. There
+ /// is no per-cell property editing and no separate "collision layer" - a tile's
+ /// walkability and role come from its brush, so you decide once and paint many.
+ ///
+ public class TilePainterWindow : EditorWindow
+ {
+ private enum Tool { Paint, Erase, Fill, Pick }
+
+ private PSXTilemap _map;
+ private int _brush = 0;
+ private Tool _tool = Tool.Paint;
+
+ private float _zoom = 24f; // editor pixels per tile
+ private Vector2 _mapScroll;
+ private Vector2 _paletteScroll;
+ private int _hoverX = -1, _hoverY = -1;
+
+ private bool _showGrid = true;
+ private bool _showWalkable = true;
+ private bool _showObjects = true;
+
+ // A distinct overlay tint per object-kind value, derived from the value so
+ // the painter needs no table of game-specific kinds.
+ private static Color KindColor(int kind)
+ {
+ // Golden-angle hue spread: adjacent kinds get clearly different colours.
+ float h = (kind * 0.61803398875f) % 1f;
+ return Color.HSVToRGB(h, 0.7f, 1f);
+ }
+
+ [MenuItem("PlayStation 1/Tile Painter")]
+ public static void ShowWindow()
+ {
+ var win = GetWindow("Tile Painter");
+ win.minSize = new Vector2(760, 520);
+ win.wantsMouseMove = true;
+ }
+
+ /// Open the painter already pointed at a specific map.
+ public static void Open(PSXTilemap map)
+ {
+ ShowWindow();
+ var win = GetWindow();
+ win._map = map;
+ win._brush = 0;
+ }
+
+ private void OnGUI()
+ {
+ DrawToolbar();
+
+ if (_map == null)
+ {
+ EditorGUILayout.Space(20);
+ EditorGUILayout.HelpBox(
+ "Assign a Tilemap above, or click New Tilemap.\n\n" +
+ "A Tilemap needs a tileset (a PSXSplash Sprite Sheet). The sheet's " +
+ "Cell Width/Height becomes the tile size. Paint brushes onto the grid; " +
+ "walls, floors and objects come from each brush's settings.",
+ MessageType.Info);
+ return;
+ }
+
+ EditorGUILayout.BeginHorizontal();
+ DrawPalette(); // left column
+ DrawCanvas(); // main grid
+ EditorGUILayout.EndHorizontal();
+ }
+
+ // ------------------------------------------------------------------
+ // Toolbar
+ // ------------------------------------------------------------------
+
+ private void DrawToolbar()
+ {
+ EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
+
+ EditorGUI.BeginChangeCheck();
+ _map = (PSXTilemap)EditorGUILayout.ObjectField(_map, typeof(PSXTilemap), false, GUILayout.Width(200));
+ if (EditorGUI.EndChangeCheck()) _brush = 0;
+
+ if (GUILayout.Button("New Tilemap", EditorStyles.toolbarButton, GUILayout.Width(90)))
+ CreateNewTilemap();
+
+ GUILayout.Space(12);
+
+ if (_map != null)
+ {
+ using (new EditorGUI.DisabledScope(true))
+ EditorGUILayout.LabelField($"tile {_map.TileWidth}x{_map.TileHeight}px", GUILayout.Width(90));
+
+ GUILayout.Label("Tool:", GUILayout.Width(34));
+ _tool = (Tool)GUILayout.Toolbar((int)_tool, new[] { "Paint", "Erase", "Fill", "Pick" },
+ EditorStyles.toolbarButton, GUILayout.Width(220));
+
+ GUILayout.Space(8);
+ _showGrid = GUILayout.Toggle(_showGrid, "Grid", EditorStyles.toolbarButton, GUILayout.Width(44));
+ _showWalkable = GUILayout.Toggle(_showWalkable, "Walls", EditorStyles.toolbarButton, GUILayout.Width(50));
+ _showObjects = GUILayout.Toggle(_showObjects, "Objects", EditorStyles.toolbarButton, GUILayout.Width(60));
+ }
+
+ GUILayout.FlexibleSpace();
+ GUILayout.Label("Zoom", GUILayout.Width(38));
+ _zoom = GUILayout.HorizontalSlider(_zoom, 8f, 48f, GUILayout.Width(120));
+
+ EditorGUILayout.EndHorizontal();
+
+ if (_map != null)
+ {
+ EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
+ int w = _map.Width, h = _map.Height;
+ GUILayout.Label("Size", GUILayout.Width(34));
+ int nw = EditorGUILayout.IntField(w, GUILayout.Width(50));
+ GUILayout.Label("x", GUILayout.Width(12));
+ int nh = EditorGUILayout.IntField(h, GUILayout.Width(50));
+ if (GUILayout.Button("Resize", EditorStyles.toolbarButton, GUILayout.Width(60)) && (nw != w || nh != h))
+ {
+ Undo.RecordObject(_map, "Resize Tilemap");
+ _map.Resize(nw, nh);
+ EditorUtility.SetDirty(_map);
+ }
+
+ GUILayout.Space(12);
+ if (GUILayout.Button("Fill All", EditorStyles.toolbarButton, GUILayout.Width(60)))
+ FillAll(_brush);
+ if (GUILayout.Button("Clear All", EditorStyles.toolbarButton, GUILayout.Width(64)))
+ FillAll(-1);
+
+ GUILayout.FlexibleSpace();
+ if (_hoverX >= 0)
+ {
+ var b = _map.BrushAt(_hoverX, _hoverY);
+ GUILayout.Label($"({_hoverX},{_hoverY}) {(b != null ? b.name : "empty")}", GUILayout.Width(200));
+ }
+ EditorGUILayout.EndHorizontal();
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Palette (left)
+ // ------------------------------------------------------------------
+
+ private void DrawPalette()
+ {
+ EditorGUILayout.BeginVertical(GUILayout.Width(240));
+ EditorGUILayout.LabelField("Brushes", EditorStyles.boldLabel);
+
+ var problems = _map.Validate();
+ if (problems.Count > 0)
+ EditorGUILayout.HelpBox(string.Join("\n", problems), MessageType.Warning);
+
+ _paletteScroll = EditorGUILayout.BeginScrollView(_paletteScroll, GUILayout.Height(200));
+ for (int i = 0; i < _map.Brushes.Count; i++)
+ {
+ var brush = _map.Brushes[i];
+ var row = EditorGUILayout.BeginHorizontal();
+ bool selected = (i == _brush);
+ if (selected) EditorGUI.DrawRect(row, new Color(0.24f, 0.44f, 0.9f, 0.35f));
+
+ Rect thumb = GUILayoutUtility.GetRect(24, 24, GUILayout.Width(24), GUILayout.Height(24));
+ DrawTileCell(thumb, brush.tilesetCell, Color.white);
+
+ if (GUILayout.Button($"{brush.name}", EditorStyles.label))
+ _brush = i;
+
+ string badge = brush.walkable ? "" : "WALL ";
+ if (brush.objectKind != 0) badge += _map.KindLabel(brush.objectKind).ToUpper();
+ GUILayout.Label(badge, EditorStyles.miniLabel, GUILayout.Width(80));
+
+ EditorGUILayout.EndHorizontal();
+ }
+ EditorGUILayout.EndScrollView();
+
+ EditorGUILayout.BeginHorizontal();
+ if (GUILayout.Button("Add Brush"))
+ {
+ Undo.RecordObject(_map, "Add Brush");
+ _map.Brushes.Add(new PSXTileBrush { name = "brush " + _map.Brushes.Count });
+ _brush = _map.Brushes.Count - 1;
+ EditorUtility.SetDirty(_map);
+ }
+ using (new EditorGUI.DisabledScope(_map.Brushes.Count == 0))
+ if (GUILayout.Button("Remove"))
+ {
+ Undo.RecordObject(_map, "Remove Brush");
+ _map.Brushes.RemoveAt(_brush);
+ _brush = Mathf.Clamp(_brush, 0, _map.Brushes.Count - 1);
+ EditorUtility.SetDirty(_map);
+ }
+ EditorGUILayout.EndHorizontal();
+
+ EditorGUILayout.Space(8);
+ DrawBrushEditor();
+
+ EditorGUILayout.EndVertical();
+ }
+
+ private void DrawBrushEditor()
+ {
+ if (_brush < 0 || _brush >= _map.Brushes.Count) return;
+ var b = _map.Brushes[_brush];
+
+ EditorGUILayout.LabelField("Selected Brush", EditorStyles.boldLabel);
+ EditorGUI.BeginChangeCheck();
+
+ string name = EditorGUILayout.TextField("Name", b.name);
+ bool walkable = EditorGUILayout.Toggle("Walkable", b.walkable);
+
+ // Kind is an author-named byte: a popup of the tilemap's Object Kind
+ // Names when they exist, a plain number field otherwise. The engine
+ // never sees the names - they only make the dropdown readable.
+ int objectKind = b.objectKind;
+ var kindNames = _map.ObjectKindNames;
+ if (kindNames != null && kindNames.Count > 1)
+ {
+ int clamped = Mathf.Clamp(objectKind, 0, kindNames.Count - 1);
+ objectKind = EditorGUILayout.Popup("Kind", clamped, kindNames.ToArray());
+ }
+ else
+ {
+ objectKind = EditorGUILayout.IntField(
+ new GUIContent("Kind", "0 = plain tile. Any other value tags this as an object. " +
+ "Add friendly names in the Tilemap asset's Object Kind Names list."),
+ objectKind);
+ }
+
+ int objectId = b.objectId;
+ if (objectKind != 0)
+ {
+ objectId = EditorGUILayout.IntField(
+ new GUIContent("Object Id", "-1 auto-numbers this kind in reading order. " +
+ "Set it when the id matters (your game decides what it means)."),
+ b.objectId);
+ }
+
+ Color col = EditorGUILayout.ColorField("Editor Tint", b.editorColor);
+
+ EditorGUILayout.LabelField("Graphic (click a tile):");
+ int tilesetCell = DrawTilesetPicker(b.tilesetCell);
+
+ if (EditorGUI.EndChangeCheck())
+ {
+ Undo.RecordObject(_map, "Edit Brush");
+ b.name = name;
+ b.walkable = walkable;
+ b.objectKind = objectKind;
+ b.objectId = objectId;
+ b.editorColor = col;
+ b.tilesetCell = tilesetCell;
+ EditorUtility.SetDirty(_map);
+ }
+ }
+
+ /// A clickable grid of the tileset; returns the chosen cell index.
+ private int DrawTilesetPicker(int current)
+ {
+ var sheet = _map.Tileset;
+ if (sheet == null || sheet.SourceTexture == null)
+ {
+ EditorGUILayout.HelpBox("Assign a tileset on the Tilemap asset to pick graphics.", MessageType.None);
+ return current;
+ }
+
+ int cols = sheet.Columns, rows = sheet.Rows;
+ const float cellPx = 22f;
+ Rect area = GUILayoutUtility.GetRect(cols * cellPx, rows * cellPx);
+ for (int r = 0; r < rows; r++)
+ {
+ for (int c = 0; c < cols; c++)
+ {
+ int idx = r * cols + c;
+ Rect cell = new Rect(area.x + c * cellPx, area.y + r * cellPx, cellPx - 1, cellPx - 1);
+ DrawTileCell(cell, idx, Color.white);
+ if (idx == current)
+ DrawRectOutline(cell, new Color(0.24f, 0.6f, 1f), 2f);
+ if (Event.current.type == EventType.MouseDown && cell.Contains(Event.current.mousePosition))
+ {
+ current = idx;
+ // The caller wraps this in BeginChangeCheck/EndChangeCheck,
+ // which only fires for controls that raise GUI.changed. A
+ // hand-rolled click does not, so raise it ourselves or the
+ // new cell is computed and then thrown away.
+ GUI.changed = true;
+ Event.current.Use();
+ Repaint();
+ }
+ }
+ }
+ return current;
+ }
+
+ // ------------------------------------------------------------------
+ // Canvas (main grid)
+ // ------------------------------------------------------------------
+
+ private void DrawCanvas()
+ {
+ EditorGUILayout.BeginVertical();
+ _mapScroll = EditorGUILayout.BeginScrollView(_mapScroll);
+
+ int w = _map.Width, h = _map.Height;
+ Rect grid = GUILayoutUtility.GetRect(w * _zoom, h * _zoom, GUILayout.ExpandWidth(false), GUILayout.ExpandHeight(false));
+
+ // Background so empty cells read as "nothing", not as the window.
+ EditorGUI.DrawRect(grid, new Color(0.12f, 0.12f, 0.14f));
+
+ for (int y = 0; y < h; y++)
+ {
+ for (int x = 0; x < w; x++)
+ {
+ Rect cell = new Rect(grid.x + x * _zoom, grid.y + y * _zoom, _zoom, _zoom);
+ var brush = _map.BrushAt(x, y);
+ if (brush != null && brush.tilesetCell >= 0)
+ DrawTileCell(cell, brush.tilesetCell, brush.editorColor);
+
+ if (_showWalkable && brush != null && !brush.walkable)
+ EditorGUI.DrawRect(cell, new Color(1f, 0f, 0f, 0.28f));
+
+ if (_showObjects && brush != null && brush.objectKind != 0)
+ {
+ Rect dot = new Rect(cell.x + cell.width * 0.25f, cell.y + cell.height * 0.25f,
+ cell.width * 0.5f, cell.height * 0.5f);
+ EditorGUI.DrawRect(dot, KindColor(brush.objectKind));
+ }
+ }
+ }
+
+ if (_showGrid && _zoom >= 10f) DrawGridLines(grid, w, h);
+
+ HandleMouse(grid, w, h);
+
+ if (_hoverX >= 0 && _hoverX < w && _hoverY >= 0 && _hoverY < h)
+ {
+ Rect hov = new Rect(grid.x + _hoverX * _zoom, grid.y + _hoverY * _zoom, _zoom, _zoom);
+ DrawRectOutline(hov, Color.white, 1.5f);
+ }
+
+ EditorGUILayout.EndScrollView();
+ EditorGUILayout.EndVertical();
+ }
+
+ private void HandleMouse(Rect grid, int w, int h)
+ {
+ Event e = Event.current;
+ if (!grid.Contains(e.mousePosition))
+ {
+ if (e.type == EventType.MouseMove && (_hoverX != -1 || _hoverY != -1))
+ {
+ _hoverX = _hoverY = -1;
+ Repaint();
+ }
+ return;
+ }
+
+ int x = Mathf.FloorToInt((e.mousePosition.x - grid.x) / _zoom);
+ int y = Mathf.FloorToInt((e.mousePosition.y - grid.y) / _zoom);
+
+ if (e.type == EventType.MouseMove)
+ {
+ _hoverX = x; _hoverY = y;
+ Repaint();
+ }
+
+ bool paintButton = e.button == 0;
+ bool eraseButton = e.button == 1;
+ if ((e.type == EventType.MouseDown || e.type == EventType.MouseDrag) && (paintButton || eraseButton))
+ {
+ ApplyTool(x, y, eraseButton);
+ e.Use();
+ }
+ }
+
+ private void ApplyTool(int x, int y, bool forceErase)
+ {
+ if (x < 0 || y < 0 || x >= _map.Width || y >= _map.Height) return;
+
+ if (forceErase || _tool == Tool.Erase)
+ {
+ Paint(x, y, -1);
+ }
+ else if (_tool == Tool.Pick)
+ {
+ int b = _map.GetCell(x, y);
+ if (b >= 0) { _brush = b; Repaint(); }
+ }
+ else if (_tool == Tool.Fill)
+ {
+ FloodFill(x, y, _brush);
+ }
+ else // Paint
+ {
+ Paint(x, y, _brush);
+ }
+ }
+
+ private void Paint(int x, int y, int brushIndex)
+ {
+ if (_map.GetCell(x, y) == brushIndex) return;
+ Undo.RecordObject(_map, "Paint Tile");
+ _map.SetCell(x, y, brushIndex);
+ Commit();
+ }
+
+ private void FloodFill(int x, int y, int brushIndex)
+ {
+ int target = _map.GetCell(x, y);
+ if (target == brushIndex) return;
+
+ Undo.RecordObject(_map, "Fill Tiles");
+ var stack = new Stack();
+ stack.Push(new Vector2Int(x, y));
+ while (stack.Count > 0)
+ {
+ var p = stack.Pop();
+ if (p.x < 0 || p.y < 0 || p.x >= _map.Width || p.y >= _map.Height) continue;
+ if (_map.GetCell(p.x, p.y) != target) continue;
+ _map.SetCell(p.x, p.y, brushIndex);
+ stack.Push(new Vector2Int(p.x + 1, p.y));
+ stack.Push(new Vector2Int(p.x - 1, p.y));
+ stack.Push(new Vector2Int(p.x, p.y + 1));
+ stack.Push(new Vector2Int(p.x, p.y - 1));
+ }
+ Commit();
+ }
+
+ private void FillAll(int brushIndex)
+ {
+ Undo.RecordObject(_map, "Fill All");
+ for (int y = 0; y < _map.Height; y++)
+ for (int x = 0; x < _map.Width; x++)
+ _map.SetCell(x, y, brushIndex);
+ Commit();
+ }
+
+ ///
+ /// Mark the map changed and refresh everything looking at it.
+ ///
+ ///
+ /// The scene view is included on purpose: PSXTilemapPreview draws the map
+ /// where the player will walk, and a preview that only updates when you
+ /// happen to nudge the camera is worse than none - you stop trusting it.
+ ///
+ private void Commit()
+ {
+ EditorUtility.SetDirty(_map);
+ Repaint();
+ SceneView.RepaintAll();
+ }
+
+ // ------------------------------------------------------------------
+ // Drawing helpers
+ // ------------------------------------------------------------------
+
+ /// Blit tileset cell into .
+ private void DrawTileCell(Rect rect, int cell, Color tint)
+ {
+ var sheet = _map != null ? _map.Tileset : null;
+ if (sheet == null || sheet.SourceTexture == null || cell < 0)
+ {
+ EditorGUI.DrawRect(rect, new Color(0.2f, 0.2f, 0.2f));
+ return;
+ }
+ int cols = sheet.Columns, rows = sheet.Rows;
+ if (cols <= 0 || rows <= 0) return;
+ int cx = cell % cols;
+ int cy = cell / cols;
+ // Unity UV origin is bottom-left; the sheet is addressed top-to-bottom,
+ // so flip the row.
+ float uw = 1f / cols, uh = 1f / rows;
+ var uv = new Rect(cx * uw, 1f - (cy + 1) * uh, uw, uh);
+ Color prev = GUI.color;
+ GUI.color = tint;
+ GUI.DrawTextureWithTexCoords(rect, sheet.SourceTexture, uv, true);
+ GUI.color = prev;
+ }
+
+ private static void DrawGridLines(Rect grid, int w, int h)
+ {
+ Handles.BeginGUI();
+ Handles.color = new Color(1f, 1f, 1f, 0.12f);
+ for (int x = 0; x <= w; x++)
+ {
+ float px = grid.x + x * (grid.width / w);
+ Handles.DrawLine(new Vector3(px, grid.y), new Vector3(px, grid.y + grid.height));
+ }
+ for (int y = 0; y <= h; y++)
+ {
+ float py = grid.y + y * (grid.height / h);
+ Handles.DrawLine(new Vector3(grid.x, py), new Vector3(grid.x + grid.width, py));
+ }
+ Handles.EndGUI();
+ }
+
+ private static void DrawRectOutline(Rect r, Color c, float t)
+ {
+ EditorGUI.DrawRect(new Rect(r.x, r.y, r.width, t), c);
+ EditorGUI.DrawRect(new Rect(r.x, r.yMax - t, r.width, t), c);
+ EditorGUI.DrawRect(new Rect(r.x, r.y, t, r.height), c);
+ EditorGUI.DrawRect(new Rect(r.xMax - t, r.y, t, r.height), c);
+ }
+
+ // ------------------------------------------------------------------
+ // New asset
+ // ------------------------------------------------------------------
+
+ private void CreateNewTilemap()
+ {
+ string path = EditorUtility.SaveFilePanelInProject(
+ "New Tilemap", "Tilemap", "asset", "Where should the tilemap asset live?");
+ if (string.IsNullOrEmpty(path)) return;
+
+ var map = ScriptableObject.CreateInstance();
+ // Seed a sensible default palette so the window is usable immediately.
+ map.Brushes.Add(new PSXTileBrush { name = "floor", tilesetCell = 0, walkable = true, editorColor = Color.white });
+ map.Brushes.Add(new PSXTileBrush { name = "wall", tilesetCell = 1, walkable = false, editorColor = new Color(0.7f, 0.7f, 0.7f) });
+ map.EnsureCells();
+
+ AssetDatabase.CreateAsset(map, path);
+ AssetDatabase.SaveAssets();
+ _map = map;
+ _brush = 0;
+ Selection.activeObject = map;
+ }
+ }
+}
diff --git a/Editor/TilePainterWindow.cs.meta b/Editor/TilePainterWindow.cs.meta
new file mode 100644
index 0000000..41ec48e
--- /dev/null
+++ b/Editor/TilePainterWindow.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4e5a0a1e1d3619a4c9fc8ab773fec0ae
\ No newline at end of file
diff --git a/Editor/Timeline/PSXTimelineDrawer.cs b/Editor/Timeline/PSXTimelineDrawer.cs
index e55fb09..7173063 100644
--- a/Editor/Timeline/PSXTimelineDrawer.cs
+++ b/Editor/Timeline/PSXTimelineDrawer.cs
@@ -310,7 +310,7 @@ public static void DrawTrackLanes(PSXTimelineState state)
GUI.EndClip();
}
- // ── Grid lines ──
+ // -- Grid lines --
private static void DrawGridLines(PSXTimelineState state, Rect localRect)
{
@@ -329,7 +329,7 @@ private static void DrawGridLines(PSXTimelineState state, Rect localRect)
}
}
- // ── Value tracks (position, rotation, etc.) ──
+ // -- Value tracks (position, rotation, etc.) --
private static void DrawValueTrack(PSXTimelineState state, PSXCutsceneTrack track, int trackIdx,
float centerY, Rect localRect)
@@ -368,7 +368,7 @@ private static void DrawValueTrack(PSXTimelineState state, PSXCutsceneTrack trac
}
}
- // ── Bool tracks (active/visible) ──
+ // -- Bool tracks (active/visible) --
private static void DrawBoolTrack(PSXTimelineState state, PSXCutsceneTrack track, int trackIdx,
float laneY, float centerY, Rect localRect)
@@ -407,7 +407,7 @@ private static void DrawBoolTrack(PSXTimelineState state, PSXCutsceneTrack track
}
}
- // ── Audio event lane ──
+ // -- Audio event lane --
private static void DrawAudioEventLane(PSXTimelineState state, float y, Rect localRect)
{
@@ -437,7 +437,7 @@ private static void DrawAudioEventLane(PSXTimelineState state, float y, Rect loc
}
}
- // ── Skin anim event lane ──
+ // -- Skin anim event lane --
private static void DrawSkinAnimEventLane(PSXTimelineState state, float y, Rect localRect)
{
@@ -564,7 +564,7 @@ private static void DrawBorder(Rect rect, Color color)
EditorGUI.DrawRect(new Rect(rect.xMax - 1, rect.y, 1, rect.height), color);
}
- // ── Cached styles ──
+ // -- Cached styles --
private static GUIStyle _rulerLabelStyle;
private static GUIStyle GetRulerLabelStyle()
diff --git a/Editor/Timeline/PSXTimelineState.cs b/Editor/Timeline/PSXTimelineState.cs
index c6f28a0..428ed89 100644
--- a/Editor/Timeline/PSXTimelineState.cs
+++ b/Editor/Timeline/PSXTimelineState.cs
@@ -10,11 +10,11 @@ namespace SplashEdit.EditorCode
///
public class PSXTimelineState
{
- // ── Clip being edited ──
+ // -- Clip being edited --
public ScriptableObject Clip;
public bool IsCutscene;
- // ── View state ──
+ // -- View state --
public float PixelsPerFrame = 5f;
public float ScrollX;
public float ScrollY;
@@ -28,7 +28,7 @@ public class PSXTimelineState
public float InspectorHeight = DefaultInspectorHeight;
public bool InspectorCollapsed;
- // ── Selection ──
+ // -- Selection --
public int SelectedTrackIndex = -1;
public int SelectedKeyframeIndex = -1;
/// 0 = audio event, 1 = skin anim event
@@ -36,30 +36,30 @@ public class PSXTimelineState
public int SelectedEventIndex = -1;
public HashSet<(int track, int kf)> MultiSelection = new HashSet<(int, int)>();
- // ── Transport / Playhead ──
+ // -- Transport / Playhead --
public float PlayheadFrame;
public bool IsPlaying;
public bool IsPreviewing;
public double PlayStartEditorTime;
public float PlayStartFrame;
- // ── Drag state ──
+ // -- Drag state --
public bool IsDraggingKeyframe;
public bool IsDraggingPlayhead;
public int DragOriginalFrame;
- // ── Toolbar action (set by drawer, consumed by window) ──
+ // -- Toolbar action (set by drawer, consumed by window) --
public enum ToolbarAction { None, Play, Pause, Stop, EndPreview }
public ToolbarAction RequestedAction;
- // ── Layout rects (computed each OnGUI) ──
+ // -- Layout rects (computed each OnGUI) --
public Rect ToolbarRect;
public Rect TrackHeaderRect;
public Rect TimeRulerRect;
public Rect TimelineAreaRect;
public Rect KeyframeInspRect;
- // ── Accessors that work for both clip types ──
+ // -- Accessors that work for both clip types --
public int DurationFrames
{
@@ -120,7 +120,7 @@ public List AudioEvents
}
}
- // ── Coordinate conversion ──
+ // -- Coordinate conversion --
/// Convert a frame number to a pixel X offset within the timeline area.
public float FrameToPixelX(float frame) => frame * PixelsPerFrame - ScrollX;
@@ -136,7 +136,7 @@ public List AudioEvents
return (start, end);
}
- // ── Clip setup ──
+ // -- Clip setup --
///
/// Set the clip to edit. Caller must stop preview before calling this
diff --git a/Editor/Timeline/PSXTimelineWindow.cs b/Editor/Timeline/PSXTimelineWindow.cs
index 99119f4..b52dadf 100644
--- a/Editor/Timeline/PSXTimelineWindow.cs
+++ b/Editor/Timeline/PSXTimelineWindow.cs
@@ -645,7 +645,7 @@ private void ShowAddTrackMenu()
var menu = new GenericMenu();
- // ── Camera tracks (cutscene only) ──
+ // -- Camera tracks (cutscene only) --
if (_state.IsCutscene)
{
menu.AddItem(new GUIContent("Camera/Position"), false, () => AddTrack(PSXTrackType.CameraPosition));
@@ -653,7 +653,7 @@ private void ShowAddTrackMenu()
menu.AddItem(new GUIContent("Camera/FOV (H)"), false, () => AddTrack(PSXTrackType.CameraH));
}
- // ── Object tracks: submenu per scene object ──
+ // -- Object tracks: submenu per scene object --
var objects = Object.FindObjectsByType(FindObjectsSortMode.None);
if (objects.Length > 0)
{
@@ -675,7 +675,7 @@ private void ShowAddTrackMenu()
menu.AddDisabledItem(new GUIContent("Object/(no PSXObjectExporters in scene)"));
}
- // ── UI Canvas Visible track ──
+ // -- UI Canvas Visible track --
var canvases = Object.FindObjectsByType(FindObjectsSortMode.None);
if (canvases.Length > 0)
{
@@ -727,7 +727,7 @@ private void ShowAddTrackMenu()
menu.AddDisabledItem(new GUIContent("UI Canvas/(no PSXCanvas in scene)"));
}
- // ── Rumble tracks ──
+ // -- Rumble tracks --
menu.AddItem(new GUIContent("Rumble/Small Motor"), false, () => AddTrack(PSXTrackType.RumbleSmall));
menu.AddItem(new GUIContent("Rumble/Large Motor"), false, () => AddTrack(PSXTrackType.RumbleLarge));
diff --git a/Editor/ToolchainChecker.cs b/Editor/ToolchainChecker.cs
index f72ca36..cbac9f8 100644
--- a/Editor/ToolchainChecker.cs
+++ b/Editor/ToolchainChecker.cs
@@ -41,7 +41,7 @@ public static bool IsToolAvailable(string toolName)
try
{
- // macOS GUI apps have restricted PATH — check common locations directly
+ // macOS GUI apps have restricted PATH - check common locations directly
if (Application.platform == RuntimePlatform.OSXEditor)
{
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
diff --git a/Editor/ToolchainInstaller.cs b/Editor/ToolchainInstaller.cs
index 0956c3f..d4a494a 100644
--- a/Editor/ToolchainInstaller.cs
+++ b/Editor/ToolchainInstaller.cs
@@ -137,7 +137,7 @@ await RunCommandAsync("powershell",
// brew-install-path is needed because Homebrew 4.6.4+ removed
// support for installing from local .rb formula files directly.
// The resulting "nikitabobko/local-tap" is a LOCAL directory on
- // the user's machine, not a GitHub repo — don't reference it
+ // the user's machine, not a GitHub repo - don't reference it
// in install instructions.
bool hasBrew = ToolchainChecker.IsToolAvailable("brew");
string installScript =
@@ -158,7 +158,7 @@ await RunCommandAsync("powershell",
" pcsx-redux/main/tools/macos-mips/mipsel-none-elf-gcc.rb\n" +
" brew install-path ./mipsel-none-elf-binutils.rb\n" +
" brew install-path ./mipsel-none-elf-gcc.rb\n\n" +
- "Builds GCC from source — expect 15-30 minutes.\n" +
+ "Builds GCC from source - expect 15-30 minutes.\n" +
"Then click Refresh in the Dependencies tab.",
"OK");
GUIUtility.systemCopyBuffer = installScript;
@@ -168,15 +168,15 @@ await RunCommandAsync("powershell",
EditorUtility.DisplayDialog(
"macOS: Install Homebrew + MIPS Cross-Compiler",
"Homebrew (the macOS package manager) is required.\n\n" +
- "Step 1 — Install Homebrew:\n" +
+ "Step 1 - Install Homebrew:\n" +
" /bin/bash -c \"$(curl -fsSL\n" +
" https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"\n\n" +
- "Step 2 — Install the MIPS compiler (paste into Terminal):\n" +
+ "Step 2 - Install the MIPS compiler (paste into Terminal):\n" +
" brew install nikitabobko/tap/brew-install-path\n" +
" curl -LO \n" +
" brew install-path ./mipsel-none-elf-binutils.rb\n" +
" brew install-path ./mipsel-none-elf-gcc.rb\n\n" +
- "Step 2 builds GCC from source — expect 15-30 minutes.\n" +
+ "Step 2 builds GCC from source - expect 15-30 minutes.\n" +
"The Homebrew install command has been copied to your clipboard.\n" +
"Then click Refresh in the Dependencies tab.",
"OK");
diff --git a/Runtime/BVH.cs b/Runtime/BVH.cs
index 7321c28..bd63418 100644
--- a/Runtime/BVH.cs
+++ b/Runtime/BVH.cs
@@ -293,7 +293,7 @@ private BVHNode BuildNode(List triangles, int depth)
float leafCost = triangles.Count * INTERSECT_COST;
if (bestAxis < 0 || bestCost >= leafCost)
{
- // No beneficial split found — make a leaf
+ // No beneficial split found - make a leaf
node.triangles = triangles.Select(t => t.reference).ToList();
return node;
}
diff --git a/Runtime/ImageProcessing.cs b/Runtime/ImageProcessing.cs
index 6c84c18..b327d16 100644
--- a/Runtime/ImageProcessing.cs
+++ b/Runtime/ImageProcessing.cs
@@ -28,18 +28,45 @@ public struct QuantizedResult
public List Palette;
}
+ ///
+ /// A source pixel is transparent below this alpha. The PS1 has no alpha
+ /// channel - a texel is either drawn or it is not - so a cutout has to
+ /// pick a threshold somewhere, and halfway is the least surprising place.
+ ///
+ public const float CutoutAlphaThreshold = 0.5f;
+
///
/// Quantizes the given texture into a limited number of colors and dithers it.
///
/// The texture to be quantized.
/// The maximum number of colors allowed in the quantized texture.
+ ///
+ /// Reserve palette index 0 for transparency and map every pixel with
+ /// alpha below to it.
+ ///
+ /// Needed by anything with a hole in it - a sprite, an icon, a decal.
+ /// Without it alpha is simply discarded (the colour channels of a fully
+ /// transparent pixel are quantized like any other), and the result is a
+ /// character in an opaque rectangle. Costs one palette entry: a 4-bit
+ /// cutout gets 15 colours, not 16.
+ ///
/// A QuantizedResult containing the indices of the quantized colors and the palette of unique colors.
- public static QuantizedResult Quantize(Texture2D texture, int maxColors)
+ public static QuantizedResult Quantize(Texture2D texture, int maxColors, bool cutout = false)
{
int width = texture.width, height = texture.height;
Color[] pixels = texture.GetPixels();
int[,] indices = new int[width, height];
+ if (cutout)
+ {
+ // Index 0 is the transparent one, so the opaque pixels share what
+ // is left. Quantizing the transparent pixels' colours alongside
+ // them would waste entries on pixels nobody will ever see, and
+ // would drag the palette toward whatever colour the artist left
+ // in the invisible background.
+ return QuantizeCutout(pixels, width, height, maxColors);
+ }
+
List uniqueColors = pixels.Select(c => new Vector3(c.r, c.g, c.b)).Distinct().ToList();
if (uniqueColors.Count <= maxColors) return ConvertToOutput(pixels, width, height);
@@ -65,6 +92,74 @@ public static QuantizedResult Quantize(Texture2D texture, int maxColors)
return new QuantizedResult { Indices = indices, Palette = palette };
}
+ ///
+ /// Quantize only the opaque pixels, into palette entries 1..maxColors-1.
+ /// Entry 0 is left for the caller to write as the PS1's transparent
+ /// colour (0x0000).
+ ///
+ private static QuantizedResult QuantizeCutout(Color[] pixels, int width, int height, int maxColors)
+ {
+ int[,] indices = new int[width, height];
+ List palette = new List { Vector3.zero }; // [0] = transparent
+
+ int opaqueBudget = maxColors - 1;
+ List opaqueColors = pixels
+ .Where(c => c.a >= CutoutAlphaThreshold)
+ .Select(c => new Vector3(c.r, c.g, c.b))
+ .Distinct()
+ .ToList();
+
+ if (opaqueColors.Count == 0)
+ {
+ // Entirely transparent. Legal, if pointless; do not divide by it.
+ return new QuantizedResult { Indices = indices, Palette = palette };
+ }
+
+ bool exact = opaqueColors.Count <= opaqueBudget;
+ palette.AddRange(exact ? opaqueColors : KMeans(opaqueColors, opaqueBudget));
+
+ // Match against the opaque colours only. Including entry 0 would let
+ // a dark pixel snap to "transparent" and punch a hole in the sprite.
+ List opaquePalette = palette.GetRange(1, palette.Count - 1);
+ KDTree kdTree = exact ? null : new KDTree(opaquePalette);
+ Dictionary exactLookup = null;
+ if (exact)
+ {
+ exactLookup = new Dictionary();
+ for (int i = 0; i < opaquePalette.Count; i++) exactLookup[opaquePalette[i]] = i;
+ }
+
+ for (int y = 0; y < height; y++)
+ {
+ for (int x = 0; x < width; x++)
+ {
+ Color src = pixels[y * width + x];
+ if (src.a < CutoutAlphaThreshold)
+ {
+ indices[x, y] = 0;
+ continue;
+ }
+
+ Vector3 oldColor = new Vector3(src.r, src.g, src.b);
+ int opaqueIndex = exact
+ ? exactLookup[oldColor]
+ : kdTree.FindNearestIndex(oldColor);
+ indices[x, y] = opaqueIndex + 1; // shift past the transparent entry
+
+ if (!exact)
+ {
+ // Diffuse the error, but never into a transparent pixel:
+ // its colour is never drawn, so pushing error there just
+ // loses it.
+ Vector3 error = oldColor - opaquePalette[opaqueIndex];
+ PropagateError(pixels, width, height, x, y, error);
+ }
+ }
+ }
+
+ return new QuantizedResult { Indices = indices, Palette = palette };
+ }
+
private static List KMeans(List colors, int k)
{
List centroids = Enumerable.Range(0, k).Select(i => colors[i * colors.Count / k]).ToList();
diff --git a/Runtime/PSXAgent.cs b/Runtime/PSXAgent.cs
new file mode 100644
index 0000000..0fbc2cd
--- /dev/null
+++ b/Runtime/PSXAgent.cs
@@ -0,0 +1,222 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace SplashEdit.RuntimeCode
+{
+ // -- Agent state indices must match AgentState enum in scenemanager.hh ----------
+ public enum PSXAgentState
+ {
+ Idle = 0,
+ Patrol = 1,
+ Seek = 2,
+ Flee = 3,
+ Attack = 4,
+ Wander = 5,
+ Investigate = 6,
+ Custom = 7,
+ }
+
+ ///
+ /// Per-state animation clip binding for a PSX skinned-mesh agent.
+ /// Set Clip to null to leave that state's animation slot empty (0xFF in binary).
+ ///
+ [System.Serializable]
+ public struct PSXAgentStateAnim
+ {
+ [HideInInspector] public PSXAgentState state;
+ [Tooltip("Animation clip to play when entering this state. Leave empty for no animation.")]
+ public PSXAnimationClip clip;
+ }
+
+ ///
+ /// Marks a PSXObject as a native nav agent and exports its AI defaults into the splashpack.
+ /// Vision, hearing, patrol waypoints, and per-state animation clips are all configured here
+ /// and baked to the 28-byte SPLASHPACKAgentV2 binary format at export time.
+ ///
+ [AddComponentMenu("PSX Splash/PSX Agent")]
+ [RequireComponent(typeof(PSXObjectExporter))]
+ [Icon("Packages/net.psxsplash.splashedit/Icons/PSXObjectExporter.png")]
+ public class PSXAgent : MonoBehaviour
+ {
+ // -- Movement -------------------------------------------------------------
+ [Header("Movement")]
+ [Tooltip("Whether this agent starts active when the scene loads.")]
+ [SerializeField] private bool startEnabled = true;
+
+ [Tooltip("Default native move speed in Unity units/second (same scale as PSXPlayer speed).")]
+ [Min(0.01f)]
+ [SerializeField] private float moveSpeed = 3.0f;
+
+ [Tooltip("Distance in Unity units at which a move-to target is considered reached.")]
+ [Min(0f)]
+ [SerializeField] private float stopDistance = 0.2f;
+
+ // -- Vision ----------------------------------------------------------------
+ [Header("Vision")]
+ [Tooltip("Enable vision sensing. When enabled the agent can see targets within range and FOV.")]
+ [SerializeField] private bool hasVision = false;
+
+ [Tooltip("Maximum line-of-sight range in Unity units. 0 disables vision even if hasVision is true.")]
+ [Min(0f)]
+ [SerializeField] private float visionRange = 8f;
+
+ [Tooltip("Full cone FOV in degrees (e.g. 90 = 45 deg each side). " +
+ "360 = omnidirectional vision inside the range sphere.")]
+ [Range(1f, 360f)]
+ [SerializeField] private float visionFovDegrees = 90f;
+
+ [Tooltip("Maximum number of nav-region portal hops used for portal-graph line-of-sight. " +
+ "0 = same region only. Higher values cost more CPU; 2-3 covers most interior scenes.")]
+ [Range(0, 8)]
+ [SerializeField] private int visionRegionDepth = 2;
+
+ // -- Hearing ----------------------------------------------------------------
+ [Header("Hearing")]
+ [Tooltip("Enable hearing sensing. Targets within hearingRange broadcast noise events to the agent.")]
+ [SerializeField] private bool hasHearing = false;
+
+ [Tooltip("Maximum hearing radius in Unity units. No geometry check is applied; pure range only.")]
+ [Min(0f)]
+ [SerializeField] private float hearingRange = 6f;
+
+ // -- Alert ------------------------------------------------------------------
+ [Header("Alert")]
+ [Tooltip("Number of frames (at 30 fps) the agent stays 'alert' after losing sight/sound of a target " +
+ "before the onTargetLost Lua event fires and the state machine reverts.")]
+ [Range(0, 300)]
+ [SerializeField] private int alertTimeoutFrames = 60;
+
+ // -- Patrol waypoints --------------------------------------------------------
+ [Header("Patrol Waypoints")]
+ [Tooltip("World-space patrol waypoints visited in order. Leave empty for no patrol. " +
+ "When populated the AGENT_FLAG_HAS_PATROL bit is set and the native runtime " +
+ "cycles through these points in the PATROL state. Maximum 8 waypoints.")]
+ [SerializeField] private List patrolWaypoints = new List();
+
+ // -- Per-state animation -----------------------------------------------------
+ [Header("State Animations")]
+ [Tooltip("Animation clip to play for each agent state. Leave a slot empty to keep the " +
+ "previous animation running when that state is entered.")]
+ [SerializeField]
+ private PSXAgentStateAnim[] stateAnims = new PSXAgentStateAnim[8]
+ {
+ new PSXAgentStateAnim { state = PSXAgentState.Idle },
+ new PSXAgentStateAnim { state = PSXAgentState.Patrol },
+ new PSXAgentStateAnim { state = PSXAgentState.Seek },
+ new PSXAgentStateAnim { state = PSXAgentState.Flee },
+ new PSXAgentStateAnim { state = PSXAgentState.Attack },
+ new PSXAgentStateAnim { state = PSXAgentState.Wander },
+ new PSXAgentStateAnim { state = PSXAgentState.Investigate },
+ new PSXAgentStateAnim { state = PSXAgentState.Custom },
+ };
+
+ // -- Public accessors --------------------------------------------------------
+ public bool StartEnabled => startEnabled;
+ public float MoveSpeed => moveSpeed;
+ public float StopDistance => stopDistance;
+ public bool HasVision => hasVision;
+ public float VisionRange => visionRange;
+ public float VisionFovDegrees => visionFovDegrees;
+ public int VisionRegionDepth => visionRegionDepth;
+ public bool HasHearing => hasHearing;
+ public float HearingRange => hearingRange;
+ public int AlertTimeoutFrames => alertTimeoutFrames;
+ public IReadOnlyList PatrolWaypoints => patrolWaypoints;
+ public PSXAgentStateAnim[] StateAnims => stateAnims;
+
+ ///
+ /// Half-FOV cosine in fp12 format, matching the visionCosAngle field in
+ /// SPLASHPACKAgentV2. cos(halfFovRad) * 4096, clamped to int16 range.
+ ///
+ public short VisionCosAngleFp12
+ {
+ get
+ {
+ float halfRad = visionFovDegrees * 0.5f * Mathf.Deg2Rad;
+ float cosVal = Mathf.Cos(halfRad);
+ return (short)Mathf.Clamp(Mathf.RoundToInt(cosVal * 4096f), -4096, 4096);
+ }
+ }
+
+ private void OnValidate()
+ {
+ moveSpeed = Mathf.Max(0.01f, moveSpeed);
+ stopDistance = Mathf.Max(0f, stopDistance);
+ visionRange = Mathf.Max(0f, visionRange);
+ hearingRange = Mathf.Max(0f, hearingRange);
+
+ // Clamp waypoint list
+ if (patrolWaypoints != null && patrolWaypoints.Count > 8)
+ patrolWaypoints.RemoveRange(8, patrolWaypoints.Count - 8);
+
+ // Keep state labels in sync (defensive - editor shouldn't drift these)
+ if (stateAnims != null && stateAnims.Length == 8)
+ for (int i = 0; i < 8; i++)
+ stateAnims[i].state = (PSXAgentState)i;
+ }
+
+#if UNITY_EDITOR
+ private void OnDrawGizmosSelected()
+ {
+ DrawGizmos(true);
+ }
+ private void OnDrawGizmos()
+ {
+ DrawGizmos(false);
+ }
+
+ private void DrawGizmos(bool selected)
+ {
+ if (!selected && !hasVision && !hasHearing && (patrolWaypoints == null || patrolWaypoints.Count == 0))
+ return;
+
+ var t = transform;
+ var center = t.position + Vector3.up * 0.9f;
+ float alpha = selected ? 0.85f : 0.35f;
+
+ // Vision cone (yellow)
+ if (hasVision && visionRange > 0.001f)
+ {
+ UnityEditor.Handles.color = new Color(1f, 0.95f, 0.2f, alpha * 0.18f);
+ float halfRad = visionFovDegrees * 0.5f * Mathf.Deg2Rad;
+ Vector3 left = Quaternion.AngleAxis(-visionFovDegrees * 0.5f, Vector3.up) * t.forward;
+ Vector3 right = Quaternion.AngleAxis( visionFovDegrees * 0.5f, Vector3.up) * t.forward;
+ UnityEditor.Handles.DrawSolidArc(center, Vector3.up, left, visionFovDegrees, visionRange);
+ UnityEditor.Handles.color = new Color(1f, 0.95f, 0.2f, alpha);
+ UnityEditor.Handles.DrawLine(center, center + left * visionRange);
+ UnityEditor.Handles.DrawLine(center, center + right * visionRange);
+ UnityEditor.Handles.DrawWireArc(center, Vector3.up, left, visionFovDegrees, visionRange);
+ }
+
+ // Hearing radius (cyan)
+ if (hasHearing && hearingRange > 0.001f)
+ {
+ UnityEditor.Handles.color = new Color(0.2f, 0.9f, 1f, alpha * 0.12f);
+ UnityEditor.Handles.DrawSolidDisc(center, Vector3.up, hearingRange);
+ UnityEditor.Handles.color = new Color(0.2f, 0.9f, 1f, alpha);
+ UnityEditor.Handles.DrawWireDisc(center, Vector3.up, hearingRange);
+ }
+
+ // Patrol waypoints (green dots + lines)
+ if (patrolWaypoints != null && patrolWaypoints.Count > 0)
+ {
+ UnityEditor.Handles.color = new Color(0.3f, 1f, 0.4f, alpha);
+ Vector3 prev = center;
+ for (int i = 0; i < patrolWaypoints.Count; i++)
+ {
+ Vector3 wp = patrolWaypoints[i];
+ UnityEditor.Handles.DrawLine(prev, wp + Vector3.up * 0.1f);
+ UnityEditor.Handles.DrawSolidDisc(wp + Vector3.up * 0.1f, Vector3.up, 0.12f);
+ UnityEditor.Handles.Label(wp + Vector3.up * 0.45f, i.ToString(),
+ UnityEditor.EditorStyles.miniLabel);
+ prev = wp + Vector3.up * 0.1f;
+ }
+ // Close loop line back to start
+ if (patrolWaypoints.Count > 1)
+ UnityEditor.Handles.DrawLine(prev, patrolWaypoints[0] + Vector3.up * 0.1f);
+ }
+ }
+#endif
+ }
+}
+
diff --git a/Runtime/PSXAgent.cs.meta b/Runtime/PSXAgent.cs.meta
new file mode 100644
index 0000000..68a2a41
--- /dev/null
+++ b/Runtime/PSXAgent.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9e8622b9ef70dec4d8ccb85de1886656
\ No newline at end of file
diff --git a/Runtime/PSXAnimationExporter.cs b/Runtime/PSXAnimationExporter.cs
index 1545879..1252c83 100644
--- a/Runtime/PSXAnimationExporter.cs
+++ b/Runtime/PSXAnimationExporter.cs
@@ -296,7 +296,7 @@ public static void ExportAnimations(
}
}
- // ── Skin anim events (v19) ──
+ // -- Skin anim events (v19) --
AlignToFourBytes(writer);
long skinAnimEventsStart = writer.BaseStream.Position;
diff --git a/Runtime/PSXAudioClip.cs b/Runtime/PSXAudioClip.cs
index 61206e7..c79ee23 100644
--- a/Runtime/PSXAudioClip.cs
+++ b/Runtime/PSXAudioClip.cs
@@ -38,5 +38,17 @@ public class PSXAudioClip : MonoBehaviour
[Tooltip("Default playback volume (0-127).")]
[Range(0, 127)]
public int DefaultVolume = 100;
+
+ [Tooltip("Cut silence off the START of the clip at export.\n\n" +
+ "Off by default: this REWRITES your asset on the way to the " +
+ "disc, and a lead-in you put there on purpose is not the " +
+ "exporter's to remove.\n\n" +
+ "Turn it on for a clip whose file begins with dead air. That " +
+ "silence is latency - the gap between the frame that asks for " +
+ "the sound and the frame you hear it - and it costs SPU RAM " +
+ "for the whole round.\n\n" +
+ "The TAIL is always kept. On a looping clip it is the interval " +
+ "the sound repeats at.")]
+ public bool TrimLeadingSilence = false;
}
}
diff --git a/Runtime/PSXCanvasData.cs b/Runtime/PSXCanvasData.cs
index cb1ff6e..f772edd 100644
--- a/Runtime/PSXCanvasData.cs
+++ b/Runtime/PSXCanvasData.cs
@@ -34,7 +34,7 @@ public struct PSXUIElementData
// Layout (PS1 pixel coords, already Y-inverted)
public short X, Y, W, H;
- // Anchors (8.8 fixed-point: 0=0.0, 128=0.5, 255≈1.0)
+ // Anchors (8.8 fixed-point: 0=0.0, 128=0.5, 255~1.0)
public byte AnchorMinX, AnchorMinY;
public byte AnchorMaxX, AnchorMaxY;
@@ -47,6 +47,15 @@ public struct PSXUIElementData
public byte U0, V0, U1, V1;
public byte BitDepthIndex; // 0=4bit, 1=8bit, 2=16bit
+ // Type-specific: Image authored from a sprite sheet (a PSXUISprite).
+ // These ride in what used to be the Image record's five padding bytes,
+ // so the record does not grow and a pack written before PSXUISprite
+ // existed reads CellW = 0, which the engine takes as "not sheet-backed"
+ // and refuses to SetFrame. Zero for a plain PSXUIImage.
+ public byte CellW, CellH; // cell size in texels
+ public byte SheetCols; // cells per row
+ public byte BaseU, BaseV; // texel of cell 0 within the texture page
+
// Type-specific: Progress
public byte BgR, BgG, BgB;
public byte ProgressValue;
diff --git a/Runtime/PSXCollisionExporter.cs b/Runtime/PSXCollisionExporter.cs
index 4b61693..1c5be42 100644
--- a/Runtime/PSXCollisionExporter.cs
+++ b/Runtime/PSXCollisionExporter.cs
@@ -7,7 +7,7 @@
namespace SplashEdit.RuntimeCode
{
///
- /// Surface flags for collision triangles — must match C++ SurfaceFlag enum
+ /// Surface flags for collision triangles - must match C++ SurfaceFlag enum
///
[Flags]
public enum PSXSurfaceFlag : byte
diff --git a/Runtime/PSXCutsceneClip.cs b/Runtime/PSXCutsceneClip.cs
index 35bac1c..483c31d 100644
--- a/Runtime/PSXCutsceneClip.cs
+++ b/Runtime/PSXCutsceneClip.cs
@@ -5,7 +5,7 @@ namespace SplashEdit.RuntimeCode
{
///
/// A cutscene asset containing keyframed tracks and audio events.
- /// Create via right-click → Create → PSX → Cutscene Clip.
+ /// Create via right-click -> Create -> PSX -> Cutscene Clip.
/// Reference these assets anywhere in the project; the exporter collects
/// all PSXCutsceneClip assets via Resources.FindObjectsOfTypeAll.
///
diff --git a/Runtime/PSXCutsceneExporter.cs b/Runtime/PSXCutsceneExporter.cs
index 7816a41..1e49065 100644
--- a/Runtime/PSXCutsceneExporter.cs
+++ b/Runtime/PSXCutsceneExporter.cs
@@ -38,7 +38,7 @@ private static short DegreesToAngleRaw(float degrees)
/// Binary writer positioned after all prior sections.
/// Cutscene clips to export (may be null/empty).
/// Scene object exporters for name validation.
- /// Audio sources for clip name → index resolution.
+ /// Audio sources for clip name -> index resolution.
/// GTE scaling factor.
/// Returns the file position where the cutscene table starts.
/// Optional log callback.
@@ -65,7 +65,7 @@ public static void ExportCutscenes(
cutscenes = trimmed;
}
- // Build audio source name → index lookup
+ // Build audio source name -> index lookup
Dictionary audioNameToIndex = new Dictionary();
if (audioSources != null)
{
@@ -78,7 +78,7 @@ public static void ExportCutscenes(
AlignToFourBytes(writer);
- // ── Cutscene Table ──
+ // -- Cutscene Table --
cutsceneTableStart = writer.BaseStream.Position;
// SPLASHPACKCutsceneEntry: 12 bytes each
@@ -95,7 +95,7 @@ public static void ExportCutscenes(
writer.Write((uint)0); // nameOffset placeholder
}
- // ── Per-cutscene data ──
+ // -- Per-cutscene data --
for (int ci = 0; ci < cutscenes.Length; ci++)
{
PSXCutsceneClip clip = cutscenes[ci];
@@ -182,7 +182,7 @@ public static void ExportCutscenes(
skinAnimEventsOffsetPlaceholder = writer.BaseStream.Position;
writer.Write((uint)0); // skinAnimEventsOffset placeholder
- // ── Tracks ──
+ // -- Tracks --
AlignToFourBytes(writer);
long tracksStart = writer.BaseStream.Position;
@@ -207,7 +207,7 @@ public static void ExportCutscenes(
writer.Write((uint)0); // keyframesOffset placeholder
}
- // ── Keyframe data (per track) ──
+ // -- Keyframe data (per track) --
for (int ti = 0; ti < trackCount; ti++)
{
PSXCutsceneTrack track = clip.Tracks[ti];
@@ -245,7 +245,7 @@ public static void ExportCutscenes(
case PSXTrackType.CameraRotation:
case PSXTrackType.ObjectRotation:
{
- // Rotation: degrees → psyqo::Angle raw (pi-units)
+ // Rotation: degrees -> psyqo::Angle raw (pi-units)
// Negate X (pitch) and Z (roll) to compensate for the
// Y-axis flip between Unity (Y-up) and PSX (Y-down).
short rx = DegreesToAngleRaw(-kf.Value.x);
@@ -340,7 +340,7 @@ public static void ExportCutscenes(
}
}
- // ── Object / UI target name strings (per track) ──
+ // -- Object / UI target name strings (per track) --
for (int ti = 0; ti < trackCount; ti++)
{
PSXCutsceneTrack track = clip.Tracks[ti];
@@ -361,7 +361,7 @@ public static void ExportCutscenes(
// else: objectNameOffset stays 0
}
- // ── Audio events ──
+ // -- Audio events --
AlignToFourBytes(writer);
long audioEventsStart = writer.BaseStream.Position;
@@ -377,13 +377,13 @@ public static void ExportCutscenes(
writer.Write((byte)0); // pad
}
- // ── Skin anim events (v19) ──
+ // -- Skin anim events (v19) --
AlignToFourBytes(writer);
long skinAnimEventsStart = writer.BaseStream.Position;
foreach (var evt in validSkinAnims)
{
- // Resolve target object → skinned mesh index
+ // Resolve target object -> skinned mesh index
byte meshIdx = 0;
byte clipIdx2 = 0;
if (skinnedExporters != null)
@@ -412,7 +412,7 @@ public static void ExportCutscenes(
writer.Write((byte)0); // pad
}
- // ── Cutscene name string ──
+ // -- Cutscene name string --
string csName = clip.CutsceneName ?? "unnamed";
if (csName.Length > MAX_NAME_LEN) csName = csName.Substring(0, MAX_NAME_LEN);
long nameStartPos = writer.BaseStream.Position;
@@ -420,7 +420,7 @@ public static void ExportCutscenes(
writer.Write(csNameBytes);
writer.Write((byte)0); // null terminator
- // ── Backfill SPLASHPACKCutscene offsets ──
+ // -- Backfill SPLASHPACKCutscene offsets --
{
long curPos = writer.BaseStream.Position;
@@ -446,7 +446,7 @@ public static void ExportCutscenes(
writer.Seek((int)curPos, SeekOrigin.Begin);
}
- // ── Backfill cutscene table entry ──
+ // -- Backfill cutscene table entry --
{
long curPos = writer.BaseStream.Position;
writer.Seek((int)entryPositions[ci], SeekOrigin.Begin);
diff --git a/Runtime/PSXFontAsset.cs b/Runtime/PSXFontAsset.cs
index a51c04a..9a2f71d 100644
--- a/Runtime/PSXFontAsset.cs
+++ b/Runtime/PSXFontAsset.cs
@@ -64,12 +64,12 @@ public void GenerateBitmapFromFont()
return;
}
- // ── Step 1: Populate the font atlas ──
+ // -- Step 1: Populate the font atlas --
string ascii = "";
for (int c = 0x20; c <= 0x7E; c++) ascii += (char)c;
sourceFont.RequestCharactersInTexture(ascii, fontSize, FontStyle.Normal);
- // ── Step 2: Get readable copy of atlas texture ──
+ // -- Step 2: Get readable copy of atlas texture --
// For non-dynamic fonts, the atlas may only be populated at the native size.
// Try the requested size first, then fall back to size=0.
Texture fontTex = sourceFont.material != null ? sourceFont.material.mainTexture : null;
@@ -120,7 +120,7 @@ public void GenerateBitmapFromFont()
return;
}
- // ── Step 3: Get character info ──
+ // -- Step 3: Get character info --
// Non-dynamic fonts only respond to size=0 or their native size.
// Dynamic fonts respond to any size.
CharacterInfo[] charInfos = new CharacterInfo[95];
@@ -179,7 +179,7 @@ public void GenerateBitmapFromFont()
return;
}
- // ── Step 4: Choose glyph cell dimensions ──
+ // -- Step 4: Choose glyph cell dimensions --
// Constraints:
// - glyphWidth must divide 256 (valid: 4, 8, 16, 32)
// - ceil(95 / (256/glyphWidth)) * glyphHeight <= 256 (must fit in one texture page)
@@ -273,7 +273,7 @@ public void GenerateBitmapFromFont()
// This makes the native advance widths match the bitmap exactly for
// proportional rendering. Characters wider than cell get clipped (rare).
- // ── Step 5: Render glyphs into grid ──
+ // -- Step 5: Render glyphs into grid --
// Each glyph is LEFT-ALIGNED at native width for proportional rendering.
// The advance widths from CharacterInfo match native glyph proportions.
Texture2D bmp = new Texture2D(texW, texH, TextureFormat.RGBA32, false);
@@ -387,7 +387,7 @@ public void GenerateBitmapFromFont()
}
}
- // ── Step 6: Save ──
+ // -- Step 6: Save --
string path = AssetDatabase.GetAssetPath(this);
if (string.IsNullOrEmpty(path))
{
diff --git a/Runtime/PSXLoaderPackWriter.cs b/Runtime/PSXLoaderPackWriter.cs
index ec57451..a375816 100644
--- a/Runtime/PSXLoaderPackWriter.cs
+++ b/Runtime/PSXLoaderPackWriter.cs
@@ -14,15 +14,15 @@ namespace SplashEdit.RuntimeCode
/// uint16 version = 2
/// uint8 fontCount
/// uint8 canvasCount (always 1)
- /// uint16 resW — target PS1 resolution width
- /// uint16 resH — target PS1 resolution height
- /// uint8 atlasCount — number of texture atlases
- /// uint8 clutCount — number of CLUTs
- /// uint32 tableOffset — offset to UI table (font descs + canvas data)
+ /// uint16 resW - target PS1 resolution width
+ /// uint16 resH - target PS1 resolution height
+ /// uint8 atlasCount - number of texture atlases
+ /// uint8 clutCount - number of CLUTs
+ /// uint32 tableOffset - offset to UI table (font descs + canvas data)
///
/// After header (at offset 16):
- /// Atlas headers (12 bytes each × atlasCount)
- /// CLUT headers (12 bytes each × clutCount)
+ /// Atlas headers (12 bytes each x atlasCount)
+ /// CLUT headers (12 bytes each x clutCount)
/// Atlas pixel data (referenced by offsets in atlas headers)
/// CLUT pixel data (referenced by offsets in CLUT headers)
///
@@ -86,7 +86,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution,
clutCount++;
}
- // ── Header (16 bytes) ──
+ // -- Header (16 bytes) --
writer.Write((byte)'L');
writer.Write((byte)'P');
writer.Write(LOADER_PACK_VERSION);
@@ -99,7 +99,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution,
long tableOffsetPos = writer.BaseStream.Position;
writer.Write((uint)0); // tableOffset placeholder
- // ── Atlas headers (12 bytes each) ──
+ // -- Atlas headers (12 bytes each) --
List atlasOffsetPlaceholders = new List();
if (atlases != null)
{
@@ -114,7 +114,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution,
}
}
- // ── CLUT headers (12 bytes each) ──
+ // -- CLUT headers (12 bytes each) --
List clutOffsetPlaceholders = new List();
if (atlases != null)
{
@@ -135,7 +135,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution,
}
}
- // ── Atlas pixel data ──
+ // -- Atlas pixel data --
int atlasIdx = 0;
if (atlases != null)
{
@@ -159,7 +159,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution,
}
}
- // ── CLUT pixel data ──
+ // -- CLUT pixel data --
int clutIdx = 0;
if (atlases != null)
{
@@ -187,7 +187,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution,
}
}
- // ── Font pixel data (written BEFORE the UI table, alongside atlas/CLUT data) ──
+ // -- Font pixel data (written BEFORE the UI table, alongside atlas/CLUT data) --
// The C++ parser expects canvas descriptors immediately after font descriptors
// (font pixel data is at absolute offsets, not inline). Write pixel data here
// so it doesn't sit between font descriptors and canvas descriptors.
@@ -211,11 +211,11 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution,
}
}
- // ── UI table (same format as splashpack UI section) ──
+ // -- UI table (same format as splashpack UI section) --
AlignToFourBytes(writer);
long uiTableStart = writer.BaseStream.Position;
- // ── Font descriptors (112 bytes each) ──
+ // -- Font descriptors (112 bytes each) --
if (fonts != null)
{
for (int fi = 0; fi < fonts.Length; fi++)
@@ -238,9 +238,9 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution,
}
// Canvas descriptors now follow immediately after font descriptors
- // (no font pixel data in between — it was written above).
+ // (no font pixel data in between - it was written above).
- // ── Canvas descriptor (12 bytes) ──
+ // -- Canvas descriptor (12 bytes) --
// Must align here: the C++ parser aligns fontDataEnd to 4 bytes
// when skipping past font pixel data to find the canvas descriptor.
AlignToFourBytes(writer);
@@ -259,7 +259,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution,
long canvasNameOffsetPos = writer.BaseStream.Position;
writer.Write((uint)0); // nameOffset placeholder
- // ── Element data (48 bytes per element) ──
+ // -- Element data (48 bytes per element) --
AlignToFourBytes(writer);
long elemDataStart = writer.BaseStream.Position;
@@ -351,7 +351,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution,
textContents.Add("__NAME__" + eName);
}
- // ── String data (text content + element names) ──
+ // -- String data (text content + element names) --
for (int si = 0; si < textOffsetPositions.Count; si++)
{
string content = textContents[si];
@@ -373,7 +373,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution,
writer.Seek((int)cur, SeekOrigin.Begin);
}
- // ── Canvas name ──
+ // -- Canvas name --
{
AlignToFourBytes(writer);
long namePos = writer.BaseStream.Position;
@@ -387,7 +387,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution,
writer.Seek((int)cur, SeekOrigin.Begin);
}
- // ── Backfill header table offset ──
+ // -- Backfill header table offset --
{
long cur = writer.BaseStream.Position;
writer.Seek((int)tableOffsetPos, SeekOrigin.Begin);
diff --git a/Runtime/PSXNavRegionBuilder.cs b/Runtime/PSXNavRegionBuilder.cs
index d890ec8..8c8e95e 100644
--- a/Runtime/PSXNavRegionBuilder.cs
+++ b/Runtime/PSXNavRegionBuilder.cs
@@ -17,38 +17,38 @@ public enum NavSurfaceType : byte { Flat = 0, Ramp = 1, Stairs = 2 }
///
public class PSXNavRegionBuilder
{
- // ────────────────────────────────────────────────────────────────
+ // ----------------------------------------------------------------
// Agent parameters
- // ────────────────────────────────────────────────────────────────
+ // ----------------------------------------------------------------
public float AgentHeight = 1.8f;
public float AgentRadius = 0.3f;
public float MaxStepHeight = 0.35f;
public float WalkableSlopeAngle = 46.0f;
- // ────────────────────────────────────────────────────────────────
+ // ----------------------------------------------------------------
// Voxelization parameters
- // ────────────────────────────────────────────────────────────────
+ // ----------------------------------------------------------------
public float CellSize = 0.05f;
public float CellHeight = 0.025f;
- // ────────────────────────────────────────────────────────────────
+ // ----------------------------------------------------------------
// Region parameters (previously hardcoded)
- // ────────────────────────────────────────────────────────────────
+ // ----------------------------------------------------------------
public int MinRegionArea = 8;
public int MergeRegionArea = 20;
public float MaxSimplifyError = 1.3f;
public float MaxEdgeLength = 12.0f;
public NavPartitionMethod PartitionMethod = NavPartitionMethod.Watershed;
- // ────────────────────────────────────────────────────────────────
+ // ----------------------------------------------------------------
// Detail mesh parameters (decoupled from CellHeight)
- // ────────────────────────────────────────────────────────────────
+ // ----------------------------------------------------------------
public float DetailSampleDist = 6.0f; // Multiplier of CellSize
public float DetailMaxError = 0.025f; // World units, independent of CellHeight
- // ────────────────────────────────────────────────────────────────
+ // ----------------------------------------------------------------
// Plane fit validation
- // ────────────────────────────────────────────────────────────────
+ // ----------------------------------------------------------------
public float MaxPlaneError = 0.15f;
public const int MaxVertsPerRegion = 8;
diff --git a/Runtime/PSXNavigationSettings.cs b/Runtime/PSXNavigationSettings.cs
new file mode 100644
index 0000000..44ea035
--- /dev/null
+++ b/Runtime/PSXNavigationSettings.cs
@@ -0,0 +1,101 @@
+using UnityEngine;
+
+namespace SplashEdit.RuntimeCode
+{
+ ///
+ /// Scene-level navigation bake settings.
+ /// Use this when a scene needs nav regions and AI support without requiring a PSXPlayer.
+ ///
+ [Icon("Packages/net.psxsplash.splashedit/Icons/PSXPlayer.png")]
+ public class PSXNavigationSettings : MonoBehaviour
+ {
+ [Header("Agent")]
+ [Tooltip("Reference agent height used when baking navigation.")]
+ [SerializeField] private float agentHeight = 1.8f;
+
+ [Tooltip("Reference agent radius used when baking navigation.")]
+ [SerializeField] private float agentRadius = 0.5f;
+
+ [Tooltip("Maximum height the baked agent can step up.")]
+ [SerializeField] private float maxStepHeight = 0.35f;
+
+ [Tooltip("Maximum walkable slope angle in degrees.")]
+ [SerializeField] private float walkableSlopeAngle = 46.0f;
+
+ [Header("Voxelization")]
+ [SerializeField] private float navCellSize = 0.05f;
+ [SerializeField] private float navCellHeight = 0.025f;
+
+ [Header("Navigation - Advanced")]
+ [SerializeField] private int navMinRegionArea = 8;
+ [SerializeField] private int navMergeRegionArea = 20;
+ [SerializeField] private float navMaxSimplifyError = 1.3f;
+ [SerializeField] private float navMaxEdgeLength = 12.0f;
+ [SerializeField] private NavPartitionMethod navPartitionMethod = NavPartitionMethod.Watershed;
+ [SerializeField] private float navDetailSampleDist = 6.0f;
+ [SerializeField] private float navDetailMaxError = 0.025f;
+ [SerializeField] private float navMaxPlaneError = 0.15f;
+
+ [Header("Start Region")]
+ [Tooltip("Fallback point used to choose the start nav region when no PSXPlayer exists.")]
+ [SerializeField] private Transform spawnAnchor;
+
+ public float AgentHeight => agentHeight;
+ public float AgentRadius => agentRadius;
+ public float MaxStepHeight => maxStepHeight;
+ public float WalkableSlopeAngle => walkableSlopeAngle;
+ public float NavCellSize => navCellSize;
+ public float NavCellHeight => navCellHeight;
+ public int NavMinRegionArea => navMinRegionArea;
+ public int NavMergeRegionArea => navMergeRegionArea;
+ public float NavMaxSimplifyError => navMaxSimplifyError;
+ public float NavMaxEdgeLength => navMaxEdgeLength;
+ public NavPartitionMethod NavPartitionMethod => navPartitionMethod;
+ public float NavDetailSampleDist => navDetailSampleDist;
+ public float NavDetailMaxError => navDetailMaxError;
+ public float NavMaxPlaneError => navMaxPlaneError;
+ public Vector3 SpawnPoint => spawnAnchor != null ? spawnAnchor.position : transform.position;
+
+ public void ApplyIndoorPreset()
+ {
+ navCellSize = 0.05f;
+ navCellHeight = 0.025f;
+ navMinRegionArea = 8;
+ navMergeRegionArea = 20;
+ navMaxSimplifyError = 1.3f;
+ navMaxEdgeLength = 12.0f;
+ navPartitionMethod = NavPartitionMethod.Watershed;
+ navDetailSampleDist = 6.0f;
+ navDetailMaxError = 0.025f;
+ navMaxPlaneError = 0.15f;
+ }
+
+ public void ApplyTerrainPreset()
+ {
+ navCellSize = 0.08f;
+ navCellHeight = 0.04f;
+ navMinRegionArea = 4;
+ navMergeRegionArea = 6;
+ navMaxSimplifyError = 0.4f;
+ navMaxEdgeLength = 5.0f;
+ navPartitionMethod = NavPartitionMethod.Monotone;
+ navDetailSampleDist = 4.0f;
+ navDetailMaxError = 0.02f;
+ navMaxPlaneError = 0.1f;
+ }
+
+ public void ApplyMultiLevelPreset()
+ {
+ navCellSize = 0.05f;
+ navCellHeight = 0.02f;
+ navMinRegionArea = 6;
+ navMergeRegionArea = 12;
+ navMaxSimplifyError = 0.8f;
+ navMaxEdgeLength = 8.0f;
+ navPartitionMethod = NavPartitionMethod.Layer;
+ navDetailSampleDist = 5.0f;
+ navDetailMaxError = 0.02f;
+ navMaxPlaneError = 0.12f;
+ }
+ }
+}
diff --git a/Runtime/PSXNavigationSettings.cs.meta b/Runtime/PSXNavigationSettings.cs.meta
new file mode 100644
index 0000000..5bff56e
--- /dev/null
+++ b/Runtime/PSXNavigationSettings.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e7ee1563eea737e46af0c78394ef931d
\ No newline at end of file
diff --git a/Runtime/PSXPlayer.cs b/Runtime/PSXPlayer.cs
index 1452a6a..48a3ab5 100644
--- a/Runtime/PSXPlayer.cs
+++ b/Runtime/PSXPlayer.cs
@@ -51,7 +51,7 @@ public class PSXPlayer : MonoBehaviour
[Tooltip("Voxel height (smaller = more accurate vertical resolution)")]
[SerializeField] private float navCellHeight = 0.025f;
- [Header("Navigation — Advanced")]
+ [Header("Navigation - Advanced")]
[Tooltip("Regions smaller than this (in voxels) are removed entirely. Raise to eliminate tiny slivers.")]
[SerializeField] private int navMinRegionArea = 8;
@@ -91,21 +91,21 @@ public class PSXPlayer : MonoBehaviour
[Tooltip("Downward acceleration in world units per second squared (positive value)")]
[SerializeField] private float gravity = 20.0f;
- // Public accessors — Player dimensions
+ // Public accessors - Player dimensions
public float PlayerHeight => playerHeight;
public float PlayerRadius => playerRadius;
- // Public accessors — Movement
+ // Public accessors - Movement
public float MoveSpeed => moveSpeed;
public float SprintSpeed => sprintSpeed;
- // Public accessors — Navigation core
+ // Public accessors - Navigation core
public float MaxStepHeight => maxStepHeight;
public float WalkableSlopeAngle => walkableSlopeAngle;
public float NavCellSize => navCellSize;
public float NavCellHeight => navCellHeight;
- // Public accessors — Navigation advanced
+ // Public accessors - Navigation advanced
public int NavMinRegionArea => navMinRegionArea;
public int NavMergeRegionArea => navMergeRegionArea;
public float NavMaxSimplifyError => navMaxSimplifyError;
@@ -115,7 +115,7 @@ public class PSXPlayer : MonoBehaviour
public float NavDetailMaxError => navDetailMaxError;
public float NavMaxPlaneError => navMaxPlaneError;
- // Public accessors — Physics
+ // Public accessors - Physics
public float JumpHeight => jumpHeight;
public float Gravity => gravity;
public Vector3 CamPoint { get; protected set; }
diff --git a/Runtime/PSXRoom.cs b/Runtime/PSXRoom.cs
index 12dbe4c..efa33a8 100644
--- a/Runtime/PSXRoom.cs
+++ b/Runtime/PSXRoom.cs
@@ -99,7 +99,7 @@ public struct PSXPortal
///
public class PSXRoomBuilder
{
- private const int CELLS_PER_AXIS = 2; // 2×2×2 = 8 cells per room
+ private const int CELLS_PER_AXIS = 2; // 2x2x2 = 8 cells per room
private PSXRoom[] _rooms;
private List _portals = new List();
@@ -263,7 +263,7 @@ public void Build(PSXRoom[] rooms, PSXPortalLink[] portalLinks,
}
///
- /// Subdivide each room's triangle list into a coarse 3D grid (CELLS_PER_AXIS³).
+ /// Subdivide each room's triangle list into a coarse 3D grid (CELLS_PER_AXIS^3).
/// Each cell gets its own tight AABB and contiguous tri-ref sublist.
/// The room's tri-ref list is rewritten so cells point into it.
///
@@ -532,7 +532,7 @@ private void BuildPortals(PSXPortalLink[] portalLinks)
{
if (portalLinks == null) return;
- // Build a fast lookup: PSXRoom instance → index.
+ // Build a fast lookup: PSXRoom instance -> index.
var roomIndex = new Dictionary();
for (int i = 0; i < _rooms.Length; i++)
roomIndex[_rooms[i]] = i;
@@ -542,28 +542,28 @@ private void BuildPortals(PSXPortalLink[] portalLinks)
if (link == null) continue;
if (link.RoomA == null || link.RoomB == null)
{
- Debug.LogWarning($"PSXPortalLink '{link.name}' has unassigned room references — skipped.");
+ Debug.LogWarning($"PSXPortalLink '{link.name}' has unassigned room references - skipped.");
continue;
}
if (link.RoomA == link.RoomB)
{
- Debug.LogWarning($"PSXPortalLink '{link.name}' references the same room twice — skipped.");
+ Debug.LogWarning($"PSXPortalLink '{link.name}' references the same room twice - skipped.");
continue;
}
if (!roomIndex.TryGetValue(link.RoomA, out int idxA))
{
- Debug.LogWarning($"PSXPortalLink '{link.name}': RoomA '{link.RoomA.name}' is not a known PSXRoom — skipped.");
+ Debug.LogWarning($"PSXPortalLink '{link.name}': RoomA '{link.RoomA.name}' is not a known PSXRoom - skipped.");
continue;
}
if (!roomIndex.TryGetValue(link.RoomB, out int idxB))
{
- Debug.LogWarning($"PSXPortalLink '{link.name}': RoomB '{link.RoomB.name}' is not a known PSXRoom — skipped.");
+ Debug.LogWarning($"PSXPortalLink '{link.name}': RoomB '{link.RoomB.name}' is not a known PSXRoom - skipped.");
continue;
}
// Auto-correct normal direction: ensure it points from roomA toward roomB.
// The user may place the PSXPortalLink facing either way; the runtime
- // backface cull assumes the normal always points A→B.
+ // backface cull assumes the normal always points A->B.
Vector3 portalNormal = link.transform.forward;
Vector3 portalRight = link.transform.right;
Vector3 portalUp = link.transform.up;
@@ -572,12 +572,12 @@ private void BuildPortals(PSXPortalLink[] portalLinks)
Vector3 aToB = (roomBCenter - roomACenter).normalized;
if (Vector3.Dot(portalNormal, aToB) < 0)
{
- // Normal faces toward A instead of B — flip it.
+ // Normal faces toward A instead of B - flip it.
// Also flip the right axis to keep the coordinate system consistent
- // (normal × right = up must stay right-handed).
+ // (normal x right = up must stay right-handed).
portalNormal = -portalNormal;
portalRight = -portalRight;
- Debug.Log($"PSXPortalLink '{link.name}': normal auto-corrected to point from RoomA→RoomB.");
+ Debug.Log($"PSXPortalLink '{link.name}': normal auto-corrected to point from RoomA->RoomB.");
}
_portals.Add(new PSXPortal
@@ -595,7 +595,7 @@ private void BuildPortals(PSXPortalLink[] portalLinks)
///
/// Write room/portal data to the splashpack binary.
- /// Layout: [RoomData × (N+1)] [PortalData × P] [TriangleRef × T] [RoomCell × C]
+ /// Layout: [RoomData x (N+1)] [PortalData x P] [TriangleRef x T] [RoomCell x C]
///
public void WriteToBinary(System.IO.BinaryWriter writer, float gteScaling)
{
@@ -636,7 +636,7 @@ public void WriteToBinary(System.IO.BinaryWriter writer, float gteScaling)
runningTriRefOffset += _roomTriRefs[i].Count;
}
- // Catch-all room (always rendered) — written as an extra "room" entry
+ // Catch-all room (always rendered) - written as an extra "room" entry
{
int catchAllRoomIdx = _rooms.Length; // index of catch-all in cell arrays
writer.Write(PSXTrig.ConvertWorldToFixed12(-1000f / gteScaling));
@@ -689,7 +689,7 @@ public void WriteToBinary(System.IO.BinaryWriter writer, float gteScaling)
writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(portal.up.z * 4096f), -32768, 32767));
}
- // Triangle refs (4 bytes each) — rooms in order, then catch-all.
+ // Triangle refs (4 bytes each) - rooms in order, then catch-all.
// After GenerateCells, tri-refs are already ordered by cell within each room.
for (int i = 0; i < _rooms.Length; i++)
{
diff --git a/Runtime/PSXSceneExporter.cs b/Runtime/PSXSceneExporter.cs
index 13dfbba..d35c8c5 100644
--- a/Runtime/PSXSceneExporter.cs
+++ b/Runtime/PSXSceneExporter.cs
@@ -24,7 +24,8 @@ public class PSXSceneExporter : MonoBehaviour
/// audio without directly referencing the Editor assembly.
/// Signature: (AudioClip clip, int sampleRate, bool loop) => byte[] adpcm
///
- public static Func AudioConvertDelegate;
+ // (clip, sampleRate, loop, trimLeadingSilence) -> raw SPU ADPCM
+ public static Func AudioConvertDelegate;
public float GTEScaling = 100.0f;
@@ -43,6 +44,13 @@ public class PSXSceneExporter : MonoBehaviour
[Tooltip("Exterior uses BVH frustum culling. Interior uses room/portal occlusion.")]
public PSXSceneType SceneType = PSXSceneType.Exterior;
+ [Header("Networking")]
+ [Tooltip("Stable id for this scene on the network. Consoles must agree on it to share " +
+ "a room, so pick a name and DO NOT change it once players have the disc " +
+ "(e.g. \"mygame/lobby\"). Leave empty to fall back to the derived hash, which " +
+ "changes whenever you add an object to the scene.")]
+ public string SceneNetworkId = "";
+
[Header("Cutscenes")]
[Tooltip("Cutscene clips to include in this scene's splashpack. Only these will be exported.")]
public PSXCutsceneClip[] Cutscenes = new PSXCutsceneClip[0];
@@ -71,8 +79,9 @@ public class PSXSceneExporter : MonoBehaviour
private PSXInteractable[] _interactables;
private PSXAudioClip[] _audioSources;
private PSXTriggerBox[] _triggerBoxes;
+ private PSXAgent[] _agents;
- // ── Post-export data for memory analysis ──
+ // -- Post-export data for memory analysis --
/// Texture atlases from the last export (null before first export).
public TextureAtlas[] LastExportAtlases => _atlases;
/// Custom font data from the last export.
@@ -102,6 +111,7 @@ public int LastExportTriangleCount
// Phase 6: UI canvases
private PSXCanvasData[] _canvases;
private PSXFontData[] _fonts;
+ private List _spriteSheets;
private PSXData _psxData;
@@ -170,7 +180,7 @@ public void ExportToPath(string outputPath)
// FindObjectsByType, others don't. We filter them out, then add back
// exactly once at the end.
// Also exclude any PSXObjectExporter that lives on or under a
- // PSXSkinnedObjectExporter — only the proxy should represent those meshes.
+ // PSXSkinnedObjectExporter - only the proxy should represent those meshes.
var proxySet = new System.Collections.Generic.HashSet();
foreach (var skinExp in _skinnedExporters)
{
@@ -196,13 +206,13 @@ public void ExportToPath(string outputPath)
}
if (underSkinned)
{
- Debug.Log($"[Export] Skipping PSXObjectExporter on '{exp.name}' — covered by PSXSkinnedObjectExporter proxy");
+ Debug.Log($"[Export] Skipping PSXObjectExporter on '{exp.name}' - covered by PSXSkinnedObjectExporter proxy");
continue;
}
exportersList.Add(exp);
}
- // Append proxies at the end — guaranteed exactly once
+ // Append proxies at the end - guaranteed exactly once
exportersList.AddRange(proxySet);
_exporters = exportersList.ToArray();
try
@@ -219,6 +229,7 @@ public void ExportToPath(string outputPath)
_interactables = FindObjectsByType(FindObjectsSortMode.None);
_audioSources = FindObjectsByType(FindObjectsSortMode.None);
_triggerBoxes = FindObjectsByType(FindObjectsSortMode.None);
+ _agents = FindObjectsByType(FindObjectsSortMode.None);
// Collect UI image textures for VRAM packing alongside 3D textures
PSXUIImage[] uiImages = FindObjectsByType(FindObjectsSortMode.None);
@@ -235,6 +246,31 @@ public void ExportToPath(string outputPath)
}
}
+ // Sprite sheets ride the SAME atlas as UI images and 3D textures, so
+ // they go into the same packing list. CollectSheets throws on bad
+ // authoring (a grid that does not divide the texture, a duplicate
+ // name, an animation running off the end) - better here, naming the
+ // asset, than as an assert on the console.
+ _spriteSheets = PSXSpriteExporter.CollectSheets();
+ // The tilemap's tileset must live in the SAME sheet table, so the
+ // engine can resolve it by index. Pull it in here if no PSXSprite
+ // already referenced it - otherwise the map would export with a
+ // dangling tileset index. Distinctness keeps it from packing twice.
+ PSXSpriteSheet tilesetSheet = PSXTilemapExporter.CollectTileset();
+ if (tilesetSheet != null && !_spriteSheets.Contains(tilesetSheet))
+ _spriteSheets.Add(tilesetSheet);
+ foreach (PSXSpriteSheet sheet in _spriteSheets)
+ {
+ Utils.SetTextureImporterFormat(sheet.SourceTexture, true);
+ // cutout: a sprite is a character, not a rectangle. Without this
+ // the quantizer throws the alpha away and every sprite ships
+ // inside an opaque box.
+ PSXTexture2D tex = PSXTexture2D.CreateFromTexture2D(sheet.SourceTexture, sheet.BitDepth, cutout: true);
+ tex.OriginalTexture = sheet.SourceTexture;
+ sheet.PackedTexture = tex;
+ uiTextures.Add(tex);
+ }
+
EditorUtility.ClearProgressBar();
PackTextures(uiTextures);
@@ -243,6 +279,7 @@ public void ExportToPath(string outputPath)
_canvases = PSXUIExporter.CollectCanvases(selectedResolution, out _fonts);
PSXPlayer player = FindObjectsByType(FindObjectsSortMode.None).FirstOrDefault();
+ PSXNavigationSettings navSettings = FindObjectsByType(FindObjectsSortMode.None).FirstOrDefault();
if (player != null)
{
player.FindNavmesh();
@@ -255,6 +292,17 @@ public void ExportToPath(string outputPath)
_gravity = player.Gravity;
_playerRot = player.transform.rotation;
}
+ else
+ {
+ _playerPos = navSettings != null ? navSettings.SpawnPoint : Vector3.zero;
+ _playerRot = Quaternion.identity;
+ _playerHeight = navSettings != null ? navSettings.AgentHeight : 1.8f;
+ _playerRadius = navSettings != null ? navSettings.AgentRadius : 0.5f;
+ _moveSpeed = 3.0f;
+ _sprintSpeed = 8.0f;
+ _jumpHeight = 2.0f;
+ _gravity = 20.0f;
+ }
_bvh = new BVH(_exporters.ToList());
_bvh.Build();
@@ -274,7 +322,24 @@ public void ExportToPath(string outputPath)
_navRegionBuilder = new PSXNavRegionBuilder();
_navRegionBuilder.AgentRadius = _playerRadius;
_navRegionBuilder.AgentHeight = _playerHeight;
- if (player != null)
+ if (navSettings != null)
+ {
+ _navRegionBuilder.AgentRadius = navSettings.AgentRadius;
+ _navRegionBuilder.AgentHeight = navSettings.AgentHeight;
+ _navRegionBuilder.MaxStepHeight = navSettings.MaxStepHeight;
+ _navRegionBuilder.WalkableSlopeAngle = navSettings.WalkableSlopeAngle;
+ _navRegionBuilder.CellSize = navSettings.NavCellSize;
+ _navRegionBuilder.CellHeight = navSettings.NavCellHeight;
+ _navRegionBuilder.MinRegionArea = navSettings.NavMinRegionArea;
+ _navRegionBuilder.MergeRegionArea = navSettings.NavMergeRegionArea;
+ _navRegionBuilder.MaxSimplifyError = navSettings.NavMaxSimplifyError;
+ _navRegionBuilder.MaxEdgeLength = navSettings.NavMaxEdgeLength;
+ _navRegionBuilder.PartitionMethod = navSettings.NavPartitionMethod;
+ _navRegionBuilder.DetailSampleDist = navSettings.NavDetailSampleDist;
+ _navRegionBuilder.DetailMaxError = navSettings.NavDetailMaxError;
+ _navRegionBuilder.MaxPlaneError = navSettings.NavMaxPlaneError;
+ }
+ else if (player != null)
{
_navRegionBuilder.MaxStepHeight = player.MaxStepHeight;
_navRegionBuilder.WalkableSlopeAngle = player.WalkableSlopeAngle;
@@ -309,7 +374,8 @@ public void ExportToPath(string outputPath)
if (walkoffZones != null && walkoffZones.Length > 0)
_navRegionBuilder.WalkoffZones = walkoffZones;
- _navRegionBuilder.Build(_exporters, _playerPos);
+ Vector3 navSpawn = player != null ? _playerPos : (navSettings != null ? navSettings.SpawnPoint : _playerPos);
+ _navRegionBuilder.Build(_exporters, navSpawn);
if (_navRegionBuilder.RegionCount == 0)
Debug.LogWarning("No nav regions! Enable 'Generate Navigation' on your floor meshes.");
@@ -414,7 +480,8 @@ void ExportFile(string outputPath = null)
{
if (AudioConvertDelegate == null)
throw new InvalidOperationException("AudioConvertDelegate not set. Ensure PSXAudioConverter registers it.");
- byte[] adpcm = AudioConvertDelegate(src.Clip, src.SampleRate, src.Loop);
+ byte[] adpcm = AudioConvertDelegate(src.Clip, src.SampleRate, src.Loop,
+ src.TrimLeadingSilence);
list.Add(new AudioClipExport { adpcmData = adpcm, sampleRate = src.SampleRate, loop = src.Loop, clipName = src.ClipName });
}
else
@@ -467,6 +534,7 @@ void ExportFile(string outputPath = null)
canvases = _canvases,
fonts = _fonts,
triggerBoxes = _triggerBoxes,
+ agents = _agents,
bakedSkinData = _bakedSkinData,
skinnedExporters = _skinnedExporters,
memCardEnabled = _psxData != null && _psxData.MemCardEnabled,
@@ -474,6 +542,12 @@ void ExportFile(string outputPath = null)
memCardProduct = _psxData != null ? _psxData.MemCardProduct : "SLUS-00000",
memCardTitle = _psxData != null ? _psxData.MemCardTitle : "PSXSPLASH SAVE",
memCardIcons = _psxData != null ? _psxData.MemCardIcons : null,
+ spriteSheets = _spriteSheets != null ? _spriteSheets.ToArray() : new PSXSpriteSheet[0],
+ sceneNetworkId = SceneNetworkId,
+ // Flattened against the FINAL sheet list (tileset already added
+ // above), so the tileset index the map stores is the one the
+ // writer emits. Null when the scene has no tilemap.
+ tilemap = PSXTilemapExporter.Flatten(_spriteSheets),
};
PSXSceneWriter.Write(path, in scene, (msg, type) =>
diff --git a/Runtime/PSXSceneWriter.cs b/Runtime/PSXSceneWriter.cs
index 63afd42..d8b9dc4 100644
--- a/Runtime/PSXSceneWriter.cs
+++ b/Runtime/PSXSceneWriter.cs
@@ -50,6 +50,7 @@ public struct SceneData
// Trigger boxes (v16)
public PSXTriggerBox[] triggerBoxes;
+ public PSXAgent[] agents;
// Skinned mesh data (v18)
public PSXSkinnedMeshExporter.BakedSkinData[] bakedSkinData;
@@ -77,9 +78,21 @@ public struct SceneData
public string memCardProduct; // up to 10 chars, e.g. "SLUS-00000"
public string memCardTitle; // ASCII title shown in the BIOS
public Texture2D[] memCardIcons; // 1..3 frames, each exactly 16x16
+
+ // Sprites (v22)
+ public PSXSpriteSheet[] spriteSheets;
+
+ // Authored network scene id (v22). Hashed into the pack; consoles
+ // must agree on it to share a room. Empty means "not authored" and
+ // the runtime falls back to its derived hash.
+ public string sceneNetworkId;
+
+ // Tilemap (v23). Null when the scene has no tilemap, which writes the
+ // header offset as 0 - exactly what a v22 pack left there.
+ public PSXTilemapData tilemap;
}
- // ─── Offset bookkeeping ───
+ // --- Offset bookkeeping ---
private sealed class OffsetData
{
@@ -87,15 +100,35 @@ private sealed class OffsetData
public readonly List DataOffsets = new List();
}
- // ═══════════════════════════════════════════════════════════════
+ ///
+ /// Write a null-terminated name at the current position and backfill the
+ /// placeholder that should point at it. Names are capped at 24 chars to
+ /// match every other name in the format.
+ ///
+ private static void WriteNameAndBackfill(BinaryWriter writer, string name, long placeholderPos)
+ {
+ string s = name ?? "";
+ if (s.Length > 24) s = s.Substring(0, 24);
+
+ long namePos = writer.BaseStream.Position;
+ writer.Write(Encoding.UTF8.GetBytes(s));
+ writer.Write((byte)0);
+
+ long curPos = writer.BaseStream.Position;
+ writer.Seek((int)placeholderPos, SeekOrigin.Begin);
+ writer.Write((uint)namePos);
+ writer.Seek((int)curPos, SeekOrigin.Begin);
+ }
+
+ // ---------------------------------------------------------------
// Public API
- // ═══════════════════════════════════════════════════════════════
+ // ---------------------------------------------------------------
///
/// Serialize the scene to splashpack v20 format: three separate files.
- /// - path → .splashpack (live data only)
- /// - path.Replace(…).vram → VRAM bulk data (atlas pixels + CLUTs + font pixels)
- /// - path.Replace(…).spu → SPU bulk data (audio ADPCM)
+ /// - path -> .splashpack (live data only)
+ /// - path.Replace(...).vram -> VRAM bulk data (atlas pixels + CLUTs + font pixels)
+ /// - path.Replace(...).spu -> SPU bulk data (audio ADPCM)
///
/// Absolute file path to write (splashpack).
/// Pre-built scene data.
@@ -160,12 +193,22 @@ public static void Write(string path, in SceneData scene, Action spriteSheetData = new List();
+ List spriteAnimData = new List();
+ if (scene.spriteSheets != null && scene.spriteSheets.Length > 0)
+ {
+ PSXSpriteExporter.Flatten(new List(scene.spriteSheets),
+ out spriteSheetData, out spriteAnimData);
+ }
+
+ // ------------------------------------------------------
+ // Header (144 bytes - splashpack v23)
+ // ------------------------------------------------------
writer.Write('S');
writer.Write('P');
- writer.Write((ushort)21);
+ writer.Write((ushort)23);
writer.Write((ushort)luaFiles.Count);
writer.Write((ushort)scene.exporters.Length);
writer.Write((ushort)scene.atlases.Length);
@@ -289,7 +332,7 @@ public static void Write(string path, in SceneData scene, Action skinnedProxySet = new HashSet();
if (scene.skinnedExporters != null)
@@ -340,7 +398,7 @@ public static void Write(string path, in SceneData scene, Action() : null;
+ int goIndex = (exp != null && exporterIndex.TryGetValue(exp, out int idx)) ? idx : -1;
+ // A skinned object's own PSXObjectExporter is skipped in favour of
+ // the skinned proxy, so look the proxy up instead.
+ if (goIndex < 0 && agent != null)
+ {
+ var skin = agent.GetComponent();
+ if (skin != null && skin.ProxyExporter != null &&
+ exporterIndex.TryGetValue(skin.ProxyExporter, out int proxyIdx))
+ goIndex = proxyIdx;
+ }
+ // The runtime maps gameObjectIndex + 1 to an actor id, so 0xFFFF
+ // would wrap to actor 0, the player.
+ if (goIndex < 0)
+ throw new System.InvalidOperationException(
+ $"[Agent] '{(agent != null ? agent.name : "null")}' is not on an exported object.");
+
+ int waypointCount = 0;
+ byte flags = 0;
+ if (agent != null)
+ {
+ if (agent.StartEnabled) flags |= 0x01;
+ if (agent.HasVision && agent.VisionRange > 0f) flags |= 0x02;
+ if (agent.HasHearing && agent.HearingRange > 0f) flags |= 0x04;
+ waypointCount = Mathf.Min(agent.PatrolWaypoints?.Count ?? 0, 8);
+ if (waypointCount > 0) flags |= 0x08;
+ }
+
+ // Resolve per-state clip indices (0xFF = none)
+ byte[] clipIndices = new byte[8];
+ for (int s = 0; s < 8; s++) clipIndices[s] = 0xFF;
+ if (agent != null && agent.StateAnims != null && scene.animations != null)
+ {
+ for (int s = 0; s < 8 && s < agent.StateAnims.Length; s++)
+ {
+ var clip = agent.StateAnims[s].clip;
+ if (clip == null) continue;
+ for (int c = 0; c < scene.animations.Length; c++)
+ {
+ if (scene.animations[c] == clip)
+ {
+ clipIndices[s] = (byte)(c < 255 ? c : 0xFE);
+ break;
+ }
+ }
+ }
+ }
+
+ float movePerFrame = agent != null ? agent.MoveSpeed / 30f / gte : 0f;
+ float stopDistNorm = agent != null ? agent.StopDistance / gte : 0f;
+ float visionNorm = agent != null ? agent.VisionRange / gte : 0f;
+ float hearingNorm = agent != null ? agent.HearingRange / gte : 0f;
+ short cosAngle = agent != null ? agent.VisionCosAngleFp12 : (short)0;
+ ushort alertFrames = (ushort)(agent != null ? Mathf.Min(agent.AlertTimeoutFrames, 65535) : 0);
+ byte regionDepth = (byte)(agent != null ? Mathf.Min(agent.VisionRegionDepth, 8) : 0);
+
+ // --- struct fields (28 bytes total) ---
+ writer.Write((ushort)goIndex); // +0
+ writer.Write(flags); // +2
+ writer.Write((byte)waypointCount); // +3
+ writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(movePerFrame * 4096f), 0, 65535)); // +4
+ writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(stopDistNorm * 4096f), 0, 65535)); // +6
+ writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(visionNorm * 4096f), 0, 65535)); // +8
+ writer.Write(cosAngle); // +10
+ writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(hearingNorm * 4096f), 0, 65535)); // +12
+ writer.Write(alertFrames); // +14
+ writer.Write(clipIndices[0]); writer.Write(clipIndices[1]); // +16
+ writer.Write(clipIndices[2]); writer.Write(clipIndices[3]); // +18
+ writer.Write(clipIndices[4]); writer.Write(clipIndices[5]); // +20
+ writer.Write(clipIndices[6]); writer.Write(clipIndices[7]); // +22
+ writer.Write(regionDepth); // +24
+ writer.Write((byte)0); writer.Write((byte)0); writer.Write((byte)0); // +25 reserved
+ // total = 28
+ }
+
+ // Pass 2 - write waypoint payload for all agents (packed int32 XYZ fp12)
+ foreach (PSXAgent agent in scene.agents)
+ {
+ if (agent == null) continue;
+ var wps = agent.PatrolWaypoints;
+ if (wps == null) continue;
+ int count = Mathf.Min(wps.Count, 8);
+ for (int w = 0; w < count; w++)
+ {
+ Vector3 worldPos = wps[w];
+ writer.Write((int)Mathf.RoundToInt(worldPos.x / gte * 4096f));
+ writer.Write((int)Mathf.RoundToInt(worldPos.y / gte * 4096f));
+ writer.Write((int)Mathf.RoundToInt(worldPos.z / gte * 4096f));
+ }
+ }
+ }
+
+ // ------------------------------------------------------
// Nav region data (version 7+)
- // ──────────────────────────────────────────────────────
+ // ------------------------------------------------------
if (scene.navRegionBuilder.RegionCount > 0)
{
AlignToFourBytes(writer);
scene.navRegionBuilder.WriteToBinary(writer, gte);
}
- // ──────────────────────────────────────────────────────
+ // ------------------------------------------------------
// Room/portal data (version 11, interior scenes)
// Must be in the sequential cursor section (after nav regions,
// before atlas metadata) so the C++ reader can find it.
- // ──────────────────────────────────────────────────────
+ // ------------------------------------------------------
if (scene.roomBuilder != null && scene.roomBuilder.RoomCount > 0)
{
AlignToFourBytes(writer);
@@ -479,9 +637,9 @@ public static void Write(string path, in SceneData scene, Action audioDataOffsetPositions = new List();
if (audioClipCount > 0 && scene.audioClips != null)
{
@@ -683,9 +841,9 @@ public static void Write(string path, in SceneData scene, Action 0)
{
PSXCutsceneExporter.ExportCutscenes(
@@ -708,9 +866,9 @@ public static void Write(string path, in SceneData scene, Action 0)
{
PSXAnimationExporter.ExportAnimations(
@@ -731,7 +889,7 @@ public static void Write(string path, in SceneData scene, Action 0 && scene.bakedSkinData != null)
{
PSXSkinnedMeshExporter.ExportSkinData(
@@ -758,21 +916,21 @@ public static void Write(string path, in SceneData scene, Action fontDataOffsetPositions = new List();
if ((uiCanvasCount > 0 && scene.canvases != null) || uiFontCount > 0)
{
AlignToFourBytes(writer);
long uiTableStart = writer.BaseStream.Position;
- // ── Font descriptors (112 bytes each) ──
+ // -- Font descriptors (112 bytes each) --
// Layout: glyphW(1) glyphH(1) vramX(2) vramY(2) textureH(2)
// dataOffset(4) dataSize(4)
if (scene.fonts != null)
@@ -799,14 +957,14 @@ public static void Write(string path, in SceneData scene, Action canvasDataOffsetPos = new List();
List canvasNameOffsetPos = new List();
for (int ci = 0; ci < uiCanvasCount; ci++)
@@ -883,7 +1041,17 @@ public static void Write(string path, in SceneData scene, Action 0)
+ {
+ // The engine reads the 20-byte sheet / 12-byte anim records as
+ // packed structs whose first field is a uint32 (nameOffset), so
+ // the table MUST start 4-byte aligned - an unaligned `lw` is a
+ // hardware LoadAddressError, not a silent misread. The offset is
+ // otherwise whatever the preceding variable-length data (name
+ // strings, etc.) leaves it at, which is even only by luck: a
+ // scene that grew from 0x7B14 to 0xBFF5 crashed exactly here.
+ // Record sizes are multiples of 4, so aligning the start aligns
+ // every record; the trailing name strings are read byte-wise.
+ AlignToFourBytes(writer);
+ long spriteTableStart = writer.BaseStream.Position;
+ List sheetNameOffsetPos = new List();
+ List animNameOffsetPos = new List();
+
+ foreach (var s in spriteSheetData)
+ {
+ sheetNameOffsetPos.Add(writer.BaseStream.Position);
+ writer.Write((uint)0); // nameOffset placeholder
+ writer.Write(s.TexpageX);
+ writer.Write(s.TexpageY);
+ writer.Write(s.U0);
+ writer.Write(s.V0);
+ writer.Write(s.ClutX);
+ writer.Write(s.ClutY);
+ writer.Write(s.CellW);
+ writer.Write(s.CellH);
+ writer.Write(s.Cols);
+ writer.Write(s.Rows);
+ writer.Write(s.BitDepthIndex);
+ writer.Write((byte)0); // pad0
+ writer.Write((ushort)0); // pad1
+ }
+
+ foreach (var a in spriteAnimData)
+ {
+ animNameOffsetPos.Add(writer.BaseStream.Position);
+ writer.Write((uint)0); // nameOffset placeholder
+ writer.Write(a.Sheet);
+ writer.Write(a.FirstFrame);
+ writer.Write(a.FrameCount);
+ writer.Write(a.FrameDuration);
+ writer.Write((byte)(a.Loop ? 1 : 0));
+ writer.Write((byte)0); // pad0
+ writer.Write((ushort)0); // pad1
+ }
+
+ // Name strings, then backfill every offset that points at one.
+ for (int i = 0; i < spriteSheetData.Count; i++)
+ WriteNameAndBackfill(writer, spriteSheetData[i].Name, sheetNameOffsetPos[i]);
+ for (int i = 0; i < spriteAnimData.Count; i++)
+ WriteNameAndBackfill(writer, spriteAnimData[i].Name, animNameOffsetPos[i]);
+
+ {
+ long curPos = writer.BaseStream.Position;
+ writer.Seek((int)spriteTableOffsetPos, SeekOrigin.Begin);
+ writer.Write((uint)spriteTableStart);
+ writer.Seek((int)curPos, SeekOrigin.Begin);
+ }
+
+ log?.Invoke($"{spriteSheetData.Count} sprite sheets " +
+ $"({spriteAnimData.Count} animations) written.", LogType.Log);
+ }
+
+ // ------------------------------------------------------
+ // Tilemap (v23)
+ // ------------------------------------------------------
+ // Layout must match tilesystem.cpp's SPLASHPACKTilemap (20 B),
+ // TileCell (2 B) and TileObject (6 B), which static_assert their
+ // sizes. The header's first field is a uint16, and the object
+ // records read uint16 coords with `lh`, so the whole chunk must
+ // start 4-byte aligned - the same LoadAddressError trap the sprite
+ // table has. Cells and objects are read straight from here, so the
+ // order below IS the runtime's memory layout.
+ if (scene.tilemap != null)
+ {
+ PSXTilemapData tm = scene.tilemap;
+ AlignToFourBytes(writer);
+ long tilemapStart = writer.BaseStream.Position;
+
+ long cellsOffsetPos, objectsOffsetPos;
+
+ // SPLASHPACKTilemap header (20 bytes).
+ writer.Write(tm.Width);
+ writer.Write(tm.Height);
+ writer.Write(tm.TileW);
+ writer.Write(tm.TileH);
+ writer.Write(tm.TilesetSheet);
+ writer.Write((byte)0); // pad0
+ writer.Write((ushort)tm.Objects.Count);
+ writer.Write((ushort)0); // pad1
+ cellsOffsetPos = writer.BaseStream.Position;
+ writer.Write((uint)0); // cellsOffset placeholder
+ objectsOffsetPos = writer.BaseStream.Position;
+ writer.Write((uint)0); // objectsOffset placeholder
+
+ // Cells: 2 bytes each (tile, flags), row-major. Byte-addressed,
+ // so no alignment needed, but the header above left us 4-aligned.
+ long cellsStart = writer.BaseStream.Position;
+ writer.Write(tm.Cells);
+
+ // Objects: 6 bytes each (kind, id, tileX u16, tileY u16). The
+ // u16 fields want 2-byte alignment; cells are an even count of
+ // bytes (2 per cell) so we are still even here, and each record
+ // is 6 bytes, keeping every tileX/tileY 2-aligned.
+ long objectsStart = writer.BaseStream.Position;
+ foreach (var o in tm.Objects)
+ {
+ writer.Write(o.Kind);
+ writer.Write(o.Id);
+ writer.Write(o.TileX);
+ writer.Write(o.TileY);
+ }
+
+ long curPos = writer.BaseStream.Position;
+ writer.Seek((int)cellsOffsetPos, SeekOrigin.Begin);
+ writer.Write((uint)cellsStart);
+ writer.Seek((int)objectsOffsetPos, SeekOrigin.Begin);
+ writer.Write((uint)(tm.Objects.Count > 0 ? objectsStart : 0));
+ writer.Seek((int)tilemapTableOffsetPos, SeekOrigin.Begin);
+ writer.Write((uint)tilemapStart);
+ writer.Seek((int)curPos, SeekOrigin.Begin);
+
+ log?.Invoke($"Tilemap {tm.Width}x{tm.Height} tile={tm.TileW}x{tm.TileH} " +
+ $"sheet={tm.TilesetSheet} objects={tm.Objects.Count} written.", LogType.Log);
+ }
+
+ // ------------------------------------------------------
+ // NO MORE DEAD ZONE - pixel/audio data goes into separate files.
// pixelDataOffset is written as 0 to signal v20 format.
- // ══════════════════════════════════════════════════════
+ // ------------------------------------------------------
// Backfill pixelDataOffset as 0 (signals: no dead zone in this file)
{
@@ -1014,7 +1318,7 @@ public static void Write(string path, in SceneData scene, Action 0 (data is in .spu file)
foreach (var pos in audioDataOffsetPositions)
{
long curPos = writer.BaseStream.Position;
@@ -1023,7 +1327,7 @@ public static void Write(string path, in SceneData scene, Action 0 (data is in .vram file)
foreach (var pos in fontDataOffsetPositions)
{
long curPos = writer.BaseStream.Position;
@@ -1047,16 +1351,16 @@ public static void Write(string path, in SceneData scene, Action Clips = new List();
}
- // ════════════════════════════════════════════════════════════
+ // ------------------------------------------------------------
// Proxy creation
- // ════════════════════════════════════════════════════════════
+ // ------------------------------------------------------------
///
/// Creates a proxy GameObject with MeshFilter + MeshRenderer + PSXObjectExporter
@@ -169,9 +169,9 @@ private static void SetPrivateField(object obj, string fieldName, object value)
if (field != null) field.SetValue(obj, value);
}
- // ════════════════════════════════════════════════════════════
+ // ------------------------------------------------------------
// Humanoid import auto-detection
- // ════════════════════════════════════════════════════════════
+ // ------------------------------------------------------------
///
/// Checks whether any assigned animation clip on this skinned exporter is a
@@ -256,9 +256,9 @@ public static void EnsureHumanoidImportIfNeeded(PSXSkinnedObjectExporter skinExp
#endif
}
- // ════════════════════════════════════════════════════════════
+ // ------------------------------------------------------------
// Bone baking
- // ════════════════════════════════════════════════════════════
+ // ------------------------------------------------------------
///
/// Bake all animation clips for a PSXSkinnedObjectExporter.
@@ -313,10 +313,10 @@ public static BakedSkinData BakeSkinData(
BoneCount = boneCount,
};
- // ── Compute per-vertex bone indices (hard skinning: highest weight wins) ──
+ // -- Compute per-vertex bone indices (hard skinning: highest weight wins) --
result.BoneIndices = ComputePerTriangleBoneIndices(mesh, smr);
- // ── Bake animation clips ──
+ // -- Bake animation clips --
// Capture objectInverse and uniformScale ONCE before sampling.
// This must be fixed so that bone world-space movement (root motion)
// is captured relative to the original object position.
@@ -326,7 +326,7 @@ public static BakedSkinData BakeSkinData(
Debug.Log($"[SkinBake] gteScaling={gteScaling}");
Debug.Log($"[SkinBake] AnimationClips count={skinExp.AnimationClips?.Length ?? 0}, TargetFPS={skinExp.TargetFPS}");
- // ── Find or create an Animator on the correct bone hierarchy root ──
+ // -- Find or create an Animator on the correct bone hierarchy root --
// The Avatar's bone paths (e.g. "Armature/hips") are relative to the
// FBX root transform. skinExp may be a child/sibling of that root,
// NOT the root itself. We probe each ancestor of rootBone with a
@@ -780,9 +780,9 @@ private static BakedBoneMatrix ConvertToPSXBoneMatrix(Matrix4x4 m, float gteScal
};
}
- // ════════════════════════════════════════════════════════════
+ // ------------------------------------------------------------
// Binary serialization
- // ════════════════════════════════════════════════════════════
+ // ------------------------------------------------------------
///
/// Write the skin data section to the splashpack binary.
@@ -803,7 +803,7 @@ public static void ExportSkinData(
AlignToFourBytes(writer);
skinTableStart = writer.BaseStream.Position;
- // ── Skin table: 12 bytes per entry ──
+ // -- Skin table: 12 bytes per entry --
long[] entryPositions = new long[count];
for (int i = 0; i < count; i++)
{
@@ -816,7 +816,7 @@ public static void ExportSkinData(
writer.Write((uint)0); // nameOffset placeholder
}
- // ── Per-mesh skin data blocks ──
+ // -- Per-mesh skin data blocks --
for (int si = 0; si < count; si++)
{
var skin = skinData[si];
diff --git a/Runtime/PSXSprite.cs b/Runtime/PSXSprite.cs
new file mode 100644
index 0000000..23af2aa
--- /dev/null
+++ b/Runtime/PSXSprite.cs
@@ -0,0 +1,28 @@
+using UnityEngine;
+
+namespace SplashEdit.RuntimeCode
+{
+ ///
+ /// Marks a sheet as used by this scene, so the exporter packs it into VRAM
+ /// and lists it in the splashpack.
+ ///
+ ///
+ /// Sprite INSTANCES are created at runtime from Lua (Sprite.Create), not
+ /// authored here. That is the opposite of the UI, and deliberately so: a game
+ /// spawns and destroys sprites as players join, bullets fire and props die,
+ /// and none of that is known at export time. What must be authored is the
+ /// data - the sheets and their animations - because that is what has to be
+ /// resident in VRAM.
+ ///
+ /// Put one of these anywhere in the scene for each sheet the scene uses.
+ ///
+ [DisallowMultipleComponent]
+ [AddComponentMenu("PSX/PSX Sprite Sheet Ref")]
+ public class PSXSprite : MonoBehaviour
+ {
+ [Tooltip("The sheet this scene needs in VRAM. Referenced from Lua by its sheet name.")]
+ [SerializeField] private PSXSpriteSheet sheet;
+
+ public PSXSpriteSheet Sheet => sheet;
+ }
+}
diff --git a/Runtime/PSXSprite.cs.meta b/Runtime/PSXSprite.cs.meta
new file mode 100644
index 0000000..c45d225
--- /dev/null
+++ b/Runtime/PSXSprite.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d2af3656393d8a64d9e21a246e89b9f5
\ No newline at end of file
diff --git a/Runtime/PSXSpriteExporter.cs b/Runtime/PSXSpriteExporter.cs
new file mode 100644
index 0000000..be77219
--- /dev/null
+++ b/Runtime/PSXSpriteExporter.cs
@@ -0,0 +1,181 @@
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+
+namespace SplashEdit.RuntimeCode
+{
+ /// Flattened sheet record, ready for the binary writer.
+ public class PSXSpriteSheetData
+ {
+ public string Name;
+ public byte TexpageX, TexpageY;
+ public ushort ClutX, ClutY;
+ public byte U0, V0;
+ public byte CellW, CellH;
+ public byte Cols, Rows;
+ public byte BitDepthIndex; // 0 = 4bpp, 1 = 8bpp, 2 = 16bpp
+ }
+
+ /// Flattened animation record, ready for the binary writer.
+ public class PSXSpriteAnimData
+ {
+ public string Name;
+ public byte Sheet;
+ public byte FirstFrame;
+ public byte FrameCount;
+ public byte FrameDuration;
+ public bool Loop;
+ }
+
+ ///
+ /// Collects the scene's sprite sheets and flattens them for export.
+ ///
+ public static class PSXSpriteExporter
+ {
+ // Must match SPRITE_MAX_SHEETS / SPRITE_MAX_ANIMS in the engine's
+ // spritesystem.hh, which asserts on overflow at load. Failing here, with
+ // the sheet's name in hand, beats an assert on the console.
+ public const int MaxSheets = 16;
+ public const int MaxAnims = 64;
+
+ ///
+ /// Find every distinct sheet the scene references. Called BEFORE VRAM
+ /// packing so the textures can join the atlas.
+ ///
+ public static List CollectSheets()
+ {
+ var refs = Object.FindObjectsByType(FindObjectsSortMode.None);
+
+ // A PSXUISprite references a sheet exactly as a PSXSprite does, and a
+ // canvas built out of them may be the ONLY thing in the scene that
+ // uses that sheet - a task panel with no world sprites at all. Left
+ // out here the sheet never joins the atlas, and every element drawn
+ // from it exports with a null PackedTexture: a screen of garbage
+ // texels, with nothing in the log to say why.
+ //
+ // Inactive ones count. PSXUIExporter walks canvases with
+ // GetComponentsInChildren(true), so a deactivated element is still
+ // exported; if the search here skipped it the element would ship with
+ // UVs into an unpacked sheet.
+ var uiRefs = Object.FindObjectsByType(
+ FindObjectsInactive.Include, FindObjectsSortMode.None);
+
+ // Distinct: two objects referencing one sheet must not pack it twice.
+ var sheets = refs.Select(r => r.Sheet)
+ .Concat(uiRefs.Select(r => r.Sheet))
+ .Where(s => s != null)
+ .Distinct()
+ .ToList();
+
+ var problems = new List();
+ var names = new HashSet();
+ foreach (var sheet in sheets)
+ {
+ foreach (var p in sheet.Validate())
+ problems.Add($"[SpriteSheet '{sheet.name}'] {p}");
+
+ if (!names.Add(sheet.SheetName))
+ problems.Add($"[SpriteSheet] Two sheets are both named '{sheet.SheetName}'; " +
+ "Sprite.SheetIndex would only ever find the first.");
+ }
+
+ if (sheets.Count > MaxSheets)
+ problems.Add($"[SpriteSheet] Scene uses {sheets.Count} sheets; the runtime pool holds {MaxSheets}.");
+
+ int animTotal = sheets.Sum(s => s.Animations.Count);
+ if (animTotal > MaxAnims)
+ problems.Add($"[SpriteSheet] Scene defines {animTotal} animations; the runtime pool holds {MaxAnims}.");
+
+ if (problems.Count > 0)
+ throw new System.InvalidOperationException(
+ "Sprite sheet export failed:\n " + string.Join("\n ", problems));
+
+ return sheets;
+ }
+
+ ///
+ /// Flatten packed sheets into writer records. Called AFTER VRAM packing,
+ /// once PackedTexture has real coordinates.
+ ///
+ public static void Flatten(List sheets,
+ out List sheetData,
+ out List animData)
+ {
+ sheetData = new List();
+ animData = new List();
+
+ for (int i = 0; i < sheets.Count; i++)
+ {
+ PSXSpriteSheet sheet = sheets[i];
+ PSXTexture2D tex = sheet.PackedTexture;
+ // Anims and the tilemap refer to sheets by their index in this
+ // list, so skipping one would shift every sheet after it.
+ if (tex == null)
+ throw new System.InvalidOperationException(
+ $"[SpriteSheet] '{sheet.SheetName}' was not packed into VRAM.");
+
+ // A 4bpp texture stores four texels per VRAM hword, so the
+ // packer's X is in hwords and the U axis needs expanding - the
+ // same conversion PSXUIExporter does for UI images.
+ int expander = 16 / (int)tex.BitDepth;
+
+ sheetData.Add(new PSXSpriteSheetData
+ {
+ Name = sheet.SheetName,
+ TexpageX = tex.TexpageX,
+ TexpageY = tex.TexpageY,
+ ClutX = (ushort)tex.ClutPackingX,
+ ClutY = (ushort)tex.ClutPackingY,
+ U0 = (byte)(tex.PackingX * expander),
+ V0 = (byte)tex.PackingY,
+ CellW = (byte)sheet.CellWidth,
+ CellH = (byte)sheet.CellHeight,
+ Cols = (byte)sheet.Columns,
+ Rows = (byte)sheet.Rows,
+ BitDepthIndex = tex.BitDepth switch
+ {
+ PSXBPP.TEX_4BIT => (byte)0,
+ PSXBPP.TEX_8BIT => (byte)1,
+ PSXBPP.TEX_16BIT => (byte)2,
+ _ => (byte)2
+ },
+ });
+
+ foreach (var anim in sheet.Animations)
+ {
+ animData.Add(new PSXSpriteAnimData
+ {
+ Name = anim.animationName,
+ Sheet = (byte)i,
+ FirstFrame = (byte)Mathf.Clamp(anim.firstFrame, 0, 255),
+ FrameCount = (byte)Mathf.Clamp(anim.frameCount, 0, 255),
+ FrameDuration = (byte)Mathf.Clamp(anim.frameDuration, 1, 255),
+ Loop = anim.loop,
+ });
+ }
+ }
+ }
+
+ ///
+ /// FNV-1a 32, the authored scene id's hash. Matches the runtime's
+ /// expectation that the id is opaque and stable - the same string always
+ /// produces the same number, on any machine, in any Unity version.
+ ///
+ public static uint HashSceneId(string id)
+ {
+ if (string.IsNullOrEmpty(id)) return 0;
+ const uint offsetBasis = 2166136261;
+ const uint prime = 16777619;
+ uint hash = offsetBasis;
+ foreach (char c in id)
+ {
+ hash ^= (byte)c;
+ hash *= prime;
+ }
+ // 0 is the sentinel for "not authored", so never hand it back as a
+ // real hash; a scene id that happens to collide with it would
+ // silently fall back to the derived hash.
+ return hash == 0 ? 1u : hash;
+ }
+ }
+}
diff --git a/Runtime/PSXSpriteExporter.cs.meta b/Runtime/PSXSpriteExporter.cs.meta
new file mode 100644
index 0000000..585a425
--- /dev/null
+++ b/Runtime/PSXSpriteExporter.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: bb5e9ab74714f094a9e7b869b59de0dc
\ No newline at end of file
diff --git a/Runtime/PSXSpriteSheet.cs b/Runtime/PSXSpriteSheet.cs
new file mode 100644
index 0000000..484d267
--- /dev/null
+++ b/Runtime/PSXSpriteSheet.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace SplashEdit.RuntimeCode
+{
+ ///
+ /// A named run of consecutive cells on a sheet.
+ ///
+ [Serializable]
+ public class PSXSpriteAnimation
+ {
+ [Tooltip("Name used to reference this animation from Lua (max 24 chars).")]
+ public string animationName = "idle";
+
+ [Tooltip("First cell of the run, counting left-to-right then top-to-bottom from 0.")]
+ public int firstFrame = 0;
+
+ [Tooltip("How many consecutive cells the animation covers.")]
+ public int frameCount = 1;
+
+ [Tooltip("How many vsync frames each cell is held. At 60Hz, 6 gives 10 fps.")]
+ public int frameDuration = 6;
+
+ [Tooltip("Restart at the first frame when the run ends; otherwise hold the last.")]
+ public bool loop = true;
+ }
+
+ ///
+ /// A sprite sheet: one texture cut into a uniform grid of cells, plus the
+ /// animations defined over that grid.
+ ///
+ ///
+ /// The texture is packed into the same VRAM atlas as UI images and 3D
+ /// textures, so a sheet costs exactly what its pixels cost and nothing more.
+ ///
+ /// Cells are addressed by index, counting left-to-right then top-to-bottom,
+ /// which is the order every sprite tool on earth exports in.
+ ///
+ [CreateAssetMenu(fileName = "SpriteSheet", menuName = "PSXSplash/Sprite Sheet")]
+ public class PSXSpriteSheet : ScriptableObject
+ {
+ [Tooltip("Name used to reference this sheet from Lua (max 24 chars).")]
+ [SerializeField] private string sheetName = "sheet";
+
+ [Tooltip("Source texture. Quantized and packed into VRAM alongside UI and 3D textures.")]
+ [SerializeField] private Texture2D sourceTexture;
+
+ [Tooltip("Bit depth for VRAM storage. 4-bit is the cheapest and suits flat 2D art.")]
+ [SerializeField] private PSXBPP bitDepth = PSXBPP.TEX_4BIT;
+
+ [Tooltip("Cell width in pixels. The texture width should be a whole multiple of this.")]
+ [SerializeField] private int cellWidth = 16;
+
+ [Tooltip("Cell height in pixels. The texture height should be a whole multiple of this.")]
+ [SerializeField] private int cellHeight = 16;
+
+ [Tooltip("Animations defined over this sheet's cells.")]
+ [SerializeField] private List animations = new List();
+
+ public string SheetName => sheetName;
+ public Texture2D SourceTexture => sourceTexture;
+ public PSXBPP BitDepth => bitDepth;
+ public int CellWidth => Mathf.Max(1, cellWidth);
+ public int CellHeight => Mathf.Max(1, cellHeight);
+ public List Animations => animations;
+
+ /// Cells per row, derived from the source texture.
+ public int Columns => sourceTexture != null ? Mathf.Max(1, sourceTexture.width / CellWidth) : 1;
+
+ /// Rows of cells, derived from the source texture.
+ public int Rows => sourceTexture != null ? Mathf.Max(1, sourceTexture.height / CellHeight) : 1;
+
+ /// Total addressable cells.
+ public int CellCount => Columns * Rows;
+
+ ///
+ /// Filled in by the exporter after VRAM packing, so the writer can emit
+ /// tpage/clut/UV coordinates.
+ ///
+ [NonSerialized] public PSXTexture2D PackedTexture;
+
+ ///
+ /// Everything wrong with this sheet, in the words a person can act on.
+ /// The inspector shows it and the exporter refuses to ship it.
+ ///
+ public List Validate()
+ {
+ var problems = new List();
+
+ if (string.IsNullOrWhiteSpace(sheetName))
+ problems.Add("Sheet name is empty - Lua has no way to reference this sheet.");
+ else if (sheetName.Length > 24)
+ problems.Add($"Sheet name '{sheetName}' is {sheetName.Length} chars; the limit is 24.");
+
+ if (sourceTexture == null)
+ {
+ problems.Add("No source texture.");
+ return problems; // the rest is unknowable without it
+ }
+
+ if (cellWidth <= 0 || cellHeight <= 0)
+ {
+ problems.Add("Cell size must be positive.");
+ return problems;
+ }
+
+ // A cell grid that does not divide the texture silently shifts every
+ // frame after the first, which reads as "my animation is subtly
+ // wrong" rather than as an error. Say it plainly instead.
+ if (sourceTexture.width % CellWidth != 0)
+ problems.Add($"Texture width {sourceTexture.width} is not a multiple of cell width {CellWidth}; " +
+ $"the rightmost {sourceTexture.width % CellWidth}px would be unreachable.");
+ if (sourceTexture.height % CellHeight != 0)
+ problems.Add($"Texture height {sourceTexture.height} is not a multiple of cell height {CellHeight}; " +
+ $"the bottom {sourceTexture.height % CellHeight}px would be unreachable.");
+
+ // The runtime addresses cells and UVs with bytes.
+ if (CellCount > 255)
+ problems.Add($"{CellCount} cells exceeds the 255 the runtime can address.");
+ if (CellWidth > 255 || CellHeight > 255)
+ problems.Add("Cell size must fit in a byte (max 255).");
+
+ var seen = new HashSet();
+ foreach (var anim in animations)
+ {
+ if (string.IsNullOrWhiteSpace(anim.animationName))
+ {
+ problems.Add("An animation has no name.");
+ continue;
+ }
+ if (anim.animationName.Length > 24)
+ problems.Add($"Animation name '{anim.animationName}' is {anim.animationName.Length} chars; the limit is 24.");
+ if (!seen.Add(anim.animationName))
+ problems.Add($"Duplicate animation name '{anim.animationName}' - lookups return the first only.");
+ if (anim.frameCount < 1)
+ problems.Add($"Animation '{anim.animationName}' has no frames.");
+ if (anim.firstFrame < 0 || anim.firstFrame + anim.frameCount > CellCount)
+ problems.Add($"Animation '{anim.animationName}' covers cells " +
+ $"{anim.firstFrame}..{anim.firstFrame + anim.frameCount - 1}, " +
+ $"but the sheet only has {CellCount} (0..{CellCount - 1}).");
+ if (anim.frameDuration < 1)
+ problems.Add($"Animation '{anim.animationName}' has a frame duration below 1; " +
+ "the runtime clamps this to 1 (advance every frame).");
+ }
+
+ return problems;
+ }
+ }
+}
diff --git a/Runtime/PSXSpriteSheet.cs.meta b/Runtime/PSXSpriteSheet.cs.meta
new file mode 100644
index 0000000..ca16342
--- /dev/null
+++ b/Runtime/PSXSpriteSheet.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 59d9300751bf4164d845973ba2459642
\ No newline at end of file
diff --git a/Runtime/PSXTexture2D.cs b/Runtime/PSXTexture2D.cs
index da2152a..eb6bf57 100644
--- a/Runtime/PSXTexture2D.cs
+++ b/Runtime/PSXTexture2D.cs
@@ -112,6 +112,9 @@ public class PSXTexture2D
public ushort ClutPackingX;
public ushort ClutPackingY;
+ /// Whether this was quantized with cutout transparency.
+ public bool Cutout { get; private set; }
+
private int _maxColors;
public VRAMPixel[,] ImageData { get; set; }
@@ -122,7 +125,19 @@ public class PSXTexture2D
/// The input Texture2D.
/// The desired bit depth for the PSX texture.
/// The created PSXTexture2D.
- public static PSXTexture2D CreateFromTexture2D(Texture2D inputTexture, PSXBPP bitDepth)
+ ///
+ /// Keep the source's transparency. Paletted textures otherwise DISCARD
+ /// alpha entirely - the quantizer only ever looked at RGB, and any
+ /// palette entry that landed on pure black was deliberately bumped to
+ /// opaque near-black. Fine for a UI panel, which is a rectangle; fatal
+ /// for a sprite, which is a character in a box.
+ ///
+ /// With this on, palette entry 0 is the PS1's transparent colour
+ /// (0x0000) and every pixel below the alpha threshold maps to it. Costs
+ /// one palette entry. At 16bpp those pixels are written as 0x0000
+ /// directly.
+ ///
+ public static PSXTexture2D CreateFromTexture2D(Texture2D inputTexture, PSXBPP bitDepth, bool cutout = false)
{
PSXTexture2D psxTex = new PSXTexture2D();
Utils.SetTextureImporterFormat(inputTexture, true);
@@ -134,6 +149,7 @@ public static PSXTexture2D CreateFromTexture2D(Texture2D inputTexture, PSXBPP bi
psxTex.Height = inputTexture.height;
psxTex.BitDepth = bitDepth;
+ psxTex.Cutout = cutout;
if (bitDepth == PSXBPP.TEX_16BIT)
@@ -148,6 +164,16 @@ public static PSXTexture2D CreateFromTexture2D(Texture2D inputTexture, PSXBPP bi
for (int x = 0; x < width; x++) // Start from right column, move leftward
{
Color pixel = inputTexture.GetPixel(x, height - y - 1);
+
+ // Cutout at 16bpp: anything below the threshold becomes the
+ // PS1's transparent colour, the same rule the paletted path
+ // applies through palette entry 0.
+ if (cutout && pixel.a < TextureQuantizer.CutoutAlphaThreshold)
+ {
+ psxTex.ImageData[x, y] = new VRAMPixel();
+ continue;
+ }
+
VRAMPixel vramPixel = new VRAMPixel
{
R = (ushort)(pixel.r * 31),
@@ -175,7 +201,7 @@ public static PSXTexture2D CreateFromTexture2D(Texture2D inputTexture, PSXBPP bi
psxTex._maxColors = (int)Mathf.Pow(2, (int)bitDepth);
- TextureQuantizer.QuantizedResult result = TextureQuantizer.Quantize(inputTexture, psxTex._maxColors);
+ TextureQuantizer.QuantizedResult result = TextureQuantizer.Quantize(inputTexture, psxTex._maxColors, cutout);
int targetCount = 4 - (result.Palette.Count % 4);
if (targetCount != 0)
{
@@ -183,16 +209,22 @@ public static PSXTexture2D CreateFromTexture2D(Texture2D inputTexture, PSXBPP bi
int prevCount = result.Palette.Count;
result.Palette.AddRange(new Vector3[targetCount]);
}
-
- foreach (Vector3 color in result.Palette)
+
+ for (int i = 0; i < result.Palette.Count; i++)
{
+ Vector3 color = result.Palette[i];
Color pixel = new Color(color.x, color.y, color.z);
VRAMPixel vramPixel = new VRAMPixel { R = (ushort)(pixel.r * 31), G = (ushort)(pixel.g * 31), B = (ushort)(pixel.b * 31) };
+ // On a cutout, entry 0 is MEANT to be 0x0000 - that is the whole
+ // mechanism. Bumping it here would make the sprite's background
+ // opaque and undo the work.
+ bool transparentEntry = cutout && i == 0;
+
// PS1: palette entry 0x0000 is transparent. Any non-transparent palette
// color that quantizes to pure black must be bumped to near-black (1,1,1)
// with bit15 set to avoid the hardware treating it as see-through.
- if (vramPixel.Pack() == 0x0000)
+ if (!transparentEntry && vramPixel.Pack() == 0x0000)
{
vramPixel.R = 1;
vramPixel.G = 1;
diff --git a/Runtime/PSXTilemap.cs b/Runtime/PSXTilemap.cs
new file mode 100644
index 0000000..8ae15db
--- /dev/null
+++ b/Runtime/PSXTilemap.cs
@@ -0,0 +1,194 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace SplashEdit.RuntimeCode
+{
+ ///
+ /// One paintable tile "type": the graphic to draw, whether you can stand on
+ /// it, and an optional opaque gameplay tag. The whole point of a palette is
+ /// that you decide what a "wall" or a "vent" is ONCE, then paint it everywhere
+ /// - no per-cell property editing, which is the thing that makes tile editors
+ /// miserable.
+ ///
+ [Serializable]
+ public class PSXTileBrush
+ {
+ [Tooltip("Shown in the palette. Purely for your own sanity while painting.")]
+ public string name = "floor";
+
+ [Tooltip("Which cell of the tileset to draw, counting left-to-right then " +
+ "top-to-bottom from 0. -1 draws nothing (a hole in the floor).")]
+ public int tilesetCell = 0;
+
+ [Tooltip("Can a player stand here? Off = solid wall.")]
+ public bool walkable = true;
+
+ [Tooltip("Opaque gameplay tag. 0 = a plain floor/wall (not an object). Any " +
+ "other value makes this an addressable object the game can find by " +
+ "kind+id. The ENGINE attaches no meaning to it - your game does. " +
+ "Name the values in the Tilemap's Object Kind Names list.")]
+ public int objectKind = 0;
+
+ [Tooltip("Object id for kinds that need a specific one. Leave -1 and the " +
+ "exporter numbers each kind for you in reading order (1, 2, 3...).")]
+ public int objectId = -1;
+
+ [Tooltip("Editor tint so this brush is easy to tell apart on the palette " +
+ "and (faintly) on the map. Not exported.")]
+ public Color editorColor = Color.white;
+ }
+
+ ///
+ /// A hand-painted tile map: one tileset, a grid of cells, and the palette of
+ /// brushes the cells refer to. Each cell stores a brush INDEX (or -1 for
+ /// empty), so changing a brush's walkability or graphic updates every cell
+ /// that uses it at once.
+ ///
+ ///
+ /// Exported as splashpack v23. The tileset is a ,
+ /// so its pixels ride the very same VRAM atlas as UI, 3D textures and sprites
+ /// - a tilemap costs exactly its cells (2 bytes each) plus its objects.
+ ///
+ [CreateAssetMenu(fileName = "Tilemap", menuName = "PSXSplash/Tilemap")]
+ public class PSXTilemap : ScriptableObject
+ {
+ [Tooltip("The tileset. Its cell grid IS the tile grid - tile size comes " +
+ "from the sheet's Cell Width/Height.")]
+ [SerializeField] private PSXSpriteSheet tileset;
+
+ [Tooltip("Map width in tiles.")]
+ [SerializeField] private int width = 32;
+
+ [Tooltip("Map height in tiles.")]
+ [SerializeField] private int height = 32;
+
+ [Tooltip("The paintable tile types.")]
+ [SerializeField] private List brushes = new List();
+
+ [Tooltip("Friendly names for object-kind values, purely for authoring. " +
+ "Index = the kind value: element 0 is 'none', element 1 is what " +
+ "kind 1 means in YOUR game, and so on. The engine never sees these " +
+ "- they only label the dropdown in the painter.")]
+ [SerializeField] private List objectKindNames = new List { "(none)" };
+
+ /// Flat row-major grid; each entry is a brush index or -1 (empty).
+ [SerializeField] private int[] cells = new int[0];
+
+ public PSXSpriteSheet Tileset => tileset;
+ public int Width => Mathf.Max(1, width);
+ public int Height => Mathf.Max(1, height);
+ public List Brushes => brushes;
+
+ public int TileWidth => tileset != null ? tileset.CellWidth : 16;
+ public int TileHeight => tileset != null ? tileset.CellHeight : 16;
+
+ public List ObjectKindNames => objectKindNames;
+
+ /// Friendly label for a kind value, or the number if unnamed.
+ public string KindLabel(int kind)
+ {
+ if (objectKindNames != null && kind >= 0 && kind < objectKindNames.Count)
+ return objectKindNames[kind];
+ return kind == 0 ? "(none)" : "kind " + kind;
+ }
+
+ /// Brush index at (x, y), or -1 for an empty or out-of-range cell.
+ public int GetCell(int x, int y)
+ {
+ EnsureCells();
+ if (x < 0 || y < 0 || x >= Width || y >= Height) return -1;
+ return cells[y * Width + x];
+ }
+
+ /// Paint (x, y) with a brush index (or -1 to erase).
+ public void SetCell(int x, int y, int brushIndex)
+ {
+ EnsureCells();
+ if (x < 0 || y < 0 || x >= Width || y >= Height) return;
+ cells[y * Width + x] = brushIndex;
+ }
+
+ public PSXTileBrush BrushAt(int x, int y)
+ {
+ int b = GetCell(x, y);
+ return (b >= 0 && b < brushes.Count) ? brushes[b] : null;
+ }
+
+ /// Resize the grid, preserving the overlapping region.
+ public void Resize(int newWidth, int newHeight)
+ {
+ newWidth = Mathf.Clamp(newWidth, 1, 256);
+ newHeight = Mathf.Clamp(newHeight, 1, 256);
+ EnsureCells();
+
+ var next = new int[newWidth * newHeight];
+ for (int i = 0; i < next.Length; i++) next[i] = -1;
+ int copyW = Mathf.Min(Width, newWidth);
+ int copyH = Mathf.Min(Height, newHeight);
+ for (int y = 0; y < copyH; y++)
+ for (int x = 0; x < copyW; x++)
+ next[y * newWidth + x] = cells[y * Width + x];
+
+ cells = next;
+ width = newWidth;
+ height = newHeight;
+ }
+
+ /// Make sure matches width*height; fill new cells empty.
+ public void EnsureCells()
+ {
+ int need = Width * Height;
+ if (cells != null && cells.Length == need) return;
+
+ var next = new int[need];
+ for (int i = 0; i < need; i++) next[i] = -1;
+ if (cells != null)
+ {
+ int n = Mathf.Min(cells.Length, need);
+ Array.Copy(cells, next, n);
+ }
+ cells = next;
+ }
+
+ ///
+ /// Everything wrong with this map, in words a person can act on. The
+ /// inspector shows it and the exporter refuses to ship it.
+ ///
+ public List Validate()
+ {
+ var problems = new List();
+
+ if (tileset == null)
+ {
+ problems.Add("No tileset assigned - there is nothing to draw the tiles from.");
+ }
+ else
+ {
+ int cellCount = tileset.CellCount;
+ for (int i = 0; i < brushes.Count; i++)
+ {
+ var b = brushes[i];
+ if (b.tilesetCell >= cellCount)
+ problems.Add($"Brush '{b.name}' points at tileset cell {b.tilesetCell}, " +
+ $"but the tileset only has {cellCount} (0..{cellCount - 1}).");
+ if (b.tilesetCell < -1)
+ problems.Add($"Brush '{b.name}' has a negative tileset cell that isn't -1.");
+ }
+ }
+
+ if (Width > 256 || Height > 256)
+ problems.Add($"Map is {Width}x{Height}; each side must be 256 or less (the runtime uses 16-bit coords, but this cap keeps a map's cells under 128 KB).");
+
+ // Every painted cell must reference a real brush.
+ EnsureCells();
+ int badRefs = 0;
+ foreach (int c in cells)
+ if (c >= brushes.Count) badRefs++;
+ if (badRefs > 0)
+ problems.Add($"{badRefs} cell(s) reference a brush that no longer exists - repaint or remove them.");
+
+ return problems;
+ }
+ }
+}
diff --git a/Runtime/PSXTilemap.cs.meta b/Runtime/PSXTilemap.cs.meta
new file mode 100644
index 0000000..cfc69ed
--- /dev/null
+++ b/Runtime/PSXTilemap.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8521dc98f544aad489f0b7fc7ba8e3b5
\ No newline at end of file
diff --git a/Runtime/PSXTilemapExporter.cs b/Runtime/PSXTilemapExporter.cs
new file mode 100644
index 0000000..326e120
--- /dev/null
+++ b/Runtime/PSXTilemapExporter.cs
@@ -0,0 +1,155 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace SplashEdit.RuntimeCode
+{
+ /// One addressable object lifted out of the map, ready for the writer.
+ public class PSXTileObjectData
+ {
+ public byte Kind;
+ public byte Id;
+ public ushort TileX;
+ public ushort TileY;
+ }
+
+ /// Flattened tilemap, ready for the binary writer (splashpack v23).
+ public class PSXTilemapData
+ {
+ public ushort Width;
+ public ushort Height;
+ public byte TileW;
+ public byte TileH;
+ public byte TilesetSheet; // index into the sprite sheet table
+ public byte[] Cells; // 2 bytes per cell: tile, flags (row-major)
+ public List Objects = new List();
+ }
+
+ ///
+ /// Collects the scene's tilemap and flattens it for export. Kept separate from
+ /// the sprite exporter, but its tileset joins the sprite sheet table so the
+ /// engine can resolve it by index - see .
+ ///
+ public static class PSXTilemapExporter
+ {
+ public const int MaxObjects = 128; // must match TILE_MAX_OBJECTS in tilesystem.hh
+ private const byte TileEmpty = 0xFF;
+ private const byte FlagWalkable = 0x01;
+
+ /// The scene's tilemap renderer, or null. Errors if there are two.
+ public static PSXTilemapRenderer FindRenderer()
+ {
+ var renderers = Object.FindObjectsByType(FindObjectsSortMode.None);
+ if (renderers.Length == 0) return null;
+ if (renderers.Length > 1)
+ throw new System.InvalidOperationException(
+ "Tilemap export failed: a scene may have at most one PSXTilemapRenderer, " +
+ $"but this scene has {renderers.Length}.");
+ return renderers[0];
+ }
+
+ ///
+ /// The tileset the scene's tilemap needs packed into the VRAM atlas, or
+ /// null if the scene has no tilemap. The sprite collector calls this so
+ /// the tileset is packed and indexed like any other sheet.
+ ///
+ public static PSXSpriteSheet CollectTileset()
+ {
+ var r = FindRenderer();
+ return (r != null && r.tilemap != null) ? r.tilemap.Tileset : null;
+ }
+
+ ///
+ /// Flatten the scene's tilemap. Returns null when there is no tilemap.
+ /// is the already-collected sheet list, in the
+ /// exact order they are written, so the tileset's index is stable.
+ ///
+ public static PSXTilemapData Flatten(List sheetList)
+ {
+ var r = FindRenderer();
+ if (r == null || r.tilemap == null) return null;
+
+ PSXTilemap map = r.tilemap;
+
+ var problems = map.Validate();
+ if (problems.Count > 0)
+ throw new System.InvalidOperationException(
+ "Tilemap export failed:\n " + string.Join("\n ", problems));
+
+ int sheetIndex = sheetList != null ? sheetList.IndexOf(map.Tileset) : -1;
+ if (sheetIndex < 0)
+ throw new System.InvalidOperationException(
+ $"Tilemap export failed: tileset '{(map.Tileset != null ? map.Tileset.SheetName : "")}' " +
+ "was not packed into the sprite sheet table. This is an exporter wiring bug - " +
+ "PSXTilemapExporter.CollectTileset() must run before sheet packing.");
+
+ map.EnsureCells();
+ int w = map.Width, h = map.Height;
+
+ var data = new PSXTilemapData
+ {
+ Width = (ushort)w,
+ Height = (ushort)h,
+ TileW = (byte)Mathf.Clamp(map.TileWidth, 1, 255),
+ TileH = (byte)Mathf.Clamp(map.TileHeight, 1, 255),
+ TilesetSheet = (byte)sheetIndex,
+ Cells = new byte[w * h * 2],
+ };
+
+ // Auto-numbering per kind, in reading order, so the first tile of a
+ // kind is id 1 - predictable and needing no manual ids. Keyed by the
+ // opaque kind byte; the exporter never interprets what it means.
+ var nextId = new Dictionary();
+
+ for (int y = 0; y < h; y++)
+ {
+ for (int x = 0; x < w; x++)
+ {
+ PSXTileBrush b = map.BrushAt(x, y);
+ int ci = (y * w + x) * 2;
+
+ if (b == null || b.tilesetCell < 0)
+ {
+ data.Cells[ci] = TileEmpty;
+ data.Cells[ci + 1] = 0;
+ continue;
+ }
+
+ data.Cells[ci] = (byte)Mathf.Clamp(b.tilesetCell, 0, 254);
+ data.Cells[ci + 1] = (byte)(b.walkable ? FlagWalkable : 0);
+
+ // Kind 0 is a plain tile; anything else is an addressable object.
+ if (b.objectKind != 0)
+ {
+ int kind = Mathf.Clamp(b.objectKind, 0, 255);
+ int id;
+ if (b.objectId >= 0)
+ {
+ id = b.objectId;
+ }
+ else
+ {
+ if (!nextId.TryGetValue(kind, out int n)) n = 1;
+ id = n;
+ nextId[kind] = n + 1;
+ }
+
+ data.Objects.Add(new PSXTileObjectData
+ {
+ Kind = (byte)kind,
+ Id = (byte)Mathf.Clamp(id, 0, 255),
+ TileX = (ushort)x,
+ TileY = (ushort)y,
+ });
+ }
+ }
+ }
+
+ if (data.Objects.Count > MaxObjects)
+ throw new System.InvalidOperationException(
+ $"Tilemap export failed: {data.Objects.Count} addressable object tiles, " +
+ $"but the runtime pool holds {MaxObjects}. Reduce task/vent/etc. tiles.");
+
+ return data;
+ }
+ }
+}
diff --git a/Runtime/PSXTilemapExporter.cs.meta b/Runtime/PSXTilemapExporter.cs.meta
new file mode 100644
index 0000000..57ffcb9
--- /dev/null
+++ b/Runtime/PSXTilemapExporter.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0801f7dcc93e8a94384cc9bbe28a9042
\ No newline at end of file
diff --git a/Runtime/PSXTilemapRenderer.cs b/Runtime/PSXTilemapRenderer.cs
new file mode 100644
index 0000000..3c42233
--- /dev/null
+++ b/Runtime/PSXTilemapRenderer.cs
@@ -0,0 +1,23 @@
+using UnityEngine;
+
+namespace SplashEdit.RuntimeCode
+{
+ ///
+ /// Drop one of these on a GameObject in a scene to say "this scene has a
+ /// tilemap." The exporter finds it, packs the tileset into the VRAM atlas
+ /// alongside the sprites, and writes the map into the splashpack (v23).
+ ///
+ ///
+ /// A scene may have at most one. Position is ignored: the map's origin is
+ /// tile (0,0) at world (0,0), which is the same space the sprite system and
+ /// the actors move in, so a bound avatar and the floor it stands on line up
+ /// for free.
+ ///
+ [AddComponentMenu("PSXSplash/PSX Tilemap Renderer")]
+ public class PSXTilemapRenderer : MonoBehaviour
+ {
+ [Tooltip("The tile map asset this scene draws. Author it with " +
+ "PlayStation 1 > Tile Painter.")]
+ public PSXTilemap tilemap;
+ }
+}
diff --git a/Runtime/PSXTilemapRenderer.cs.meta b/Runtime/PSXTilemapRenderer.cs.meta
new file mode 100644
index 0000000..af9cb51
--- /dev/null
+++ b/Runtime/PSXTilemapRenderer.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 1079404698ca1494d8e7ceb3a7054aa2
\ No newline at end of file
diff --git a/Runtime/PSXUIBudget.cs b/Runtime/PSXUIBudget.cs
new file mode 100644
index 0000000..b19d185
--- /dev/null
+++ b/Runtime/PSXUIBudget.cs
@@ -0,0 +1,35 @@
+namespace SplashEdit.RuntimeCode
+{
+ ///
+ /// The runtime's UI pool sizes, mirrored here so authoring can be checked
+ /// before a disc is built.
+ ///
+ ///
+ /// These MUST match UI_MAX_ELEMENTS / UI_MAX_CANVASES /
+ /// UI_TEXT_BUF in psxsplash/src/uisystem.hh.
+ ///
+ /// The overflow behaviour is what makes a mirrored constant worth keeping:
+ /// loadFromSplashpack CLAMPS a canvas's element count against the
+ /// remaining pool and carries on. Nothing is logged, nothing asserts - the
+ /// elements past the cap simply do not exist, and from the Lua side
+ /// UI.FindElement returns -1 for them. Half a panel goes missing and
+ /// the only clue is which half.
+ ///
+ public static class PSXUIBudget
+ {
+ /// Total elements across every canvas in one scene.
+ public const int MaxElements = 256;
+
+ /// Canvases in one scene.
+ public const int MaxCanvases = 24;
+
+ /// Characters of an element's text buffer, null terminator included.
+ public const int TextBuffer = 64;
+
+ /// Longest string UI.SetText can hold.
+ public const int MaxTextLength = TextBuffer - 1;
+
+ /// Longest canvas or element name the splashpack stores.
+ public const int MaxNameLength = 24;
+ }
+}
diff --git a/Runtime/PSXUIBudget.cs.meta b/Runtime/PSXUIBudget.cs.meta
new file mode 100644
index 0000000..ad7c657
--- /dev/null
+++ b/Runtime/PSXUIBudget.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f69012f15d6be5f258d451340031f161
\ No newline at end of file
diff --git a/Runtime/PSXUIExporter.cs b/Runtime/PSXUIExporter.cs
index 996d67f..2772999 100644
--- a/Runtime/PSXUIExporter.cs
+++ b/Runtime/PSXUIExporter.cs
@@ -18,7 +18,7 @@ public static class PSXUIExporter
/// converting RectTransform coordinates to PS1 pixel space.
/// Also collects and deduplicates custom fonts.
///
- /// Target PS1 resolution (e.g. 320×240).
+ /// Target PS1 resolution (e.g. 320x240).
/// Output: collected custom font data (max 3).
/// Array of canvas data ready for binary writing.
public static PSXCanvasData[] CollectCanvases(Vector2 resolution, out PSXFontData[] fonts)
@@ -181,15 +181,48 @@ private static PSXCanvasData[] CollectCanvasesInternal(PSXCanvas[] canvases, Vec
});
}
+ WarnOnBudget(result);
return result.ToArray();
}
- // ─── Coordinate baking helpers ───
+ ///
+ /// Say plainly when a scene has authored more UI than the runtime pool
+ /// holds. The loader clamps and carries on silently, so without this the
+ /// symptom is "the last canvas I added does not exist" with nothing in any
+ /// log to connect it to a limit.
+ ///
+ private static void WarnOnBudget(List canvases)
+ {
+ int total = 0;
+ foreach (var cv in canvases) total += cv.Elements?.Length ?? 0;
+
+ if (total > PSXUIBudget.MaxElements)
+ Debug.LogError($"[UIExporter] {total} UI elements across {canvases.Count} canvases, but the " +
+ $"runtime pool holds {PSXUIBudget.MaxElements}. The last " +
+ $"{total - PSXUIBudget.MaxElements} will be DROPPED at load with no error.");
+
+ if (canvases.Count > PSXUIBudget.MaxCanvases)
+ Debug.LogError($"[UIExporter] {canvases.Count} canvases, but the runtime pool holds " +
+ $"{PSXUIBudget.MaxCanvases}. The rest will be dropped at load.");
+
+ foreach (var cv in canvases)
+ {
+ if ((cv.Elements?.Length ?? 0) <= 255) continue;
+ Debug.LogError($"[UIExporter] Canvas '{cv.Name}' has {cv.Elements.Length} elements; the " +
+ "splashpack stores that count in one byte, so it must be 255 or fewer.");
+ }
+ }
+
+ // --- Coordinate baking helpers ---
///
/// Convert a RectTransform into PS1 pixel-space layout values.
- /// Handles anchor-based positioning and Y inversion.
///
+ ///
+ /// The arithmetic lives in so the scene-view
+ /// preview computes the identical rect. This wrapper only unpacks it into
+ /// the out-parameters the collectors below were written against.
+ ///
private static void BakeLayout(
RectTransform rt, RectTransform canvasRect,
float scaleX, float scaleY, Vector2 resolution,
@@ -197,47 +230,10 @@ private static void BakeLayout(
out byte anchorMinX, out byte anchorMinY,
out byte anchorMaxX, out byte anchorMaxY)
{
- // Anchor values in 8.8 fixed point (0-255 maps to 0.0-~1.0)
- anchorMinX = (byte)Mathf.Clamp(Mathf.RoundToInt(rt.anchorMin.x * 255f), 0, 255);
- anchorMinY = (byte)Mathf.Clamp(Mathf.RoundToInt((1f - rt.anchorMax.y) * 255f), 0, 255); // Y invert
- anchorMaxX = (byte)Mathf.Clamp(Mathf.RoundToInt(rt.anchorMax.x * 255f), 0, 255);
- anchorMaxY = (byte)Mathf.Clamp(Mathf.RoundToInt((1f - rt.anchorMin.y) * 255f), 0, 255); // Y invert
-
- if (Mathf.Approximately(rt.anchorMin.x, rt.anchorMax.x) &&
- Mathf.Approximately(rt.anchorMin.y, rt.anchorMax.y))
- {
- // Fixed-size element with single anchor point
- // anchoredPosition is the offset from the anchor in canvas pixels
- float px = rt.anchoredPosition.x * scaleX;
- float py = -rt.anchoredPosition.y * scaleY; // Y invert
- float pw = rt.rect.width * scaleX;
- float ph = rt.rect.height * scaleY;
-
- // Adjust for pivot (anchoredPosition is at the pivot point)
- px -= rt.pivot.x * pw;
- py -= (1f - rt.pivot.y) * ph; // pivot Y inverted
-
- x = (short)Mathf.RoundToInt(px);
- y = (short)Mathf.RoundToInt(py);
- w = (short)Mathf.Max(1, Mathf.RoundToInt(pw));
- h = (short)Mathf.Max(1, Mathf.RoundToInt(ph));
- }
- else
- {
- // Stretched element: offsets from anchored edges
- // offsetMin = distance from anchorMin corner, offsetMax = distance from anchorMax corner
- float leftOff = rt.offsetMin.x * scaleX;
- float rightOff = rt.offsetMax.x * scaleX;
- float topOff = -rt.offsetMax.y * scaleY; // Y invert
- float bottomOff = -rt.offsetMin.y * scaleY; // Y invert
-
- // For stretched elements, x/y store the offset from the anchor start,
- // and w/h store the combined inset (negative = shrink)
- x = (short)Mathf.RoundToInt(leftOff);
- y = (short)Mathf.RoundToInt(topOff);
- w = (short)Mathf.RoundToInt(rightOff - leftOff);
- h = (short)Mathf.RoundToInt(bottomOff - topOff);
- }
+ PSXUILayout.Baked b = PSXUILayout.Bake(rt, scaleX, scaleY);
+ x = b.X; y = b.Y; w = b.W; h = b.H;
+ anchorMinX = b.AnchorMinX; anchorMinY = b.AnchorMinY;
+ anchorMaxX = b.AnchorMaxX; anchorMaxY = b.AnchorMaxY;
}
private static string TruncateName(string name, int maxLen = 24)
@@ -246,7 +242,7 @@ private static string TruncateName(string name, int maxLen = 24)
return name.Length > maxLen ? name.Substring(0, maxLen) : name;
}
- // ─── Collectors ───
+ // --- Collectors ---
///
/// Walk the hierarchy depth-first in sibling order, collecting every
@@ -259,7 +255,7 @@ private static void CollectAllElementsInHierarchyOrder(
List elements,
List uniqueFonts)
{
- // GetComponentsInChildren iterates depth-first in sibling order —
+ // GetComponentsInChildren iterates depth-first in sibling order -
// exactly the hierarchy ordering we want.
Transform[] allTransforms = root.GetComponentsInChildren(true);
foreach (Transform t in allTransforms)
@@ -269,6 +265,13 @@ private static void CollectAllElementsInHierarchyOrder(
// Check each supported component type on this transform.
// A single GameObject should only have one PSX UI component,
// but we check all to be safe.
+ PSXUISprite spr = t.GetComponent();
+ if (spr != null)
+ {
+ CollectSingleSprite(spr, canvasRect, scaleX, scaleY, resolution, elements);
+ continue;
+ }
+
PSXUIImage img = t.GetComponent();
if (img != null)
{
@@ -365,6 +368,94 @@ private static void CollectSingleImage(
elements.Add(data);
}
+ ///
+ /// One cell of a sprite sheet, exported as an Image element plus the
+ /// sheet's cell grid so the runtime can re-point it with UI.SetFrame.
+ ///
+ ///
+ /// The sheet's pixels are already in the atlas - PSXSpriteExporter packed
+ /// them - so this costs no VRAM beyond the sheet itself, however many
+ /// elements draw from it. That is the whole reason a panel can be built
+ /// out of forty of these when forty PSXUIImages would be forty textures.
+ ///
+ private static void CollectSingleSprite(
+ PSXUISprite spr, RectTransform canvasRect,
+ float scaleX, float scaleY, Vector2 resolution,
+ List elements)
+ {
+ RectTransform rt = spr.GetComponent();
+ if (rt == null) return;
+
+ BakeLayout(rt, canvasRect, scaleX, scaleY, resolution,
+ out short x, out short y, out short w, out short h,
+ out byte amin_x, out byte amin_y, out byte amax_x, out byte amax_y);
+
+ var data = new PSXUIElementData
+ {
+ Type = PSXUIElementType.Image,
+ StartVisible = spr.StartVisible,
+ Name = TruncateName(spr.ElementName),
+ X = x, Y = y, W = w, H = h,
+ AnchorMinX = amin_x, AnchorMinY = amin_y,
+ AnchorMaxX = amax_x, AnchorMaxY = amax_y,
+ ColorR = (byte)Mathf.Clamp(Mathf.RoundToInt(spr.Tint.r * 255f), 0, 255),
+ ColorG = (byte)Mathf.Clamp(Mathf.RoundToInt(spr.Tint.g * 255f), 0, 255),
+ ColorB = (byte)Mathf.Clamp(Mathf.RoundToInt(spr.Tint.b * 255f), 0, 255),
+ };
+
+ PSXSpriteSheet sheet = spr.Sheet;
+ PSXTexture2D tex = sheet != null ? sheet.PackedTexture : null;
+ if (sheet == null || tex == null)
+ {
+ // Not fatal: the element still exists, still has its name, and
+ // Lua's UI.FindElement still resolves it. It just draws nothing
+ // recognisable - which is a far better handover than an export
+ // that refuses to run because one icon is unassigned.
+ Debug.LogWarning($"[UISprite] '{spr.ElementName}' has " +
+ (sheet == null ? "no sheet assigned." : "a sheet that was not packed - " +
+ "is it referenced by a PSXSprite or another PSXUISprite in this scene?"));
+ elements.Add(data);
+ return;
+ }
+
+ // A 4bpp texture stores four texels per VRAM halfword, so the packer's
+ // X is in halfwords and the U axis needs expanding - the same
+ // conversion PSXSpriteExporter.Flatten does for the sheet table.
+ int expander = 16 / (int)tex.BitDepth;
+ int baseU = tex.PackingX * expander;
+ int baseV = tex.PackingY;
+
+ int cols = Mathf.Max(1, sheet.Columns);
+ int cell = spr.ResolvedCell;
+ int u0 = baseU + (cell % cols) * sheet.CellWidth;
+ int v0 = baseV + (cell / cols) * sheet.CellHeight;
+
+ data.TexpageX = tex.TexpageX;
+ data.TexpageY = tex.TexpageY;
+ data.ClutX = (ushort)tex.ClutPackingX;
+ data.ClutY = (ushort)tex.ClutPackingY;
+ data.U0 = (byte)u0;
+ data.V0 = (byte)v0;
+ // U1/V1 are the LAST texel, inclusive - without the -1 a 256-wide
+ // sheet overflows the byte to 0 and the element samples a sliver.
+ data.U1 = (byte)(u0 + sheet.CellWidth - 1);
+ data.V1 = (byte)(v0 + sheet.CellHeight - 1);
+ data.BitDepthIndex = tex.BitDepth switch
+ {
+ PSXBPP.TEX_4BIT => 0,
+ PSXBPP.TEX_8BIT => 1,
+ PSXBPP.TEX_16BIT => 2,
+ _ => 2
+ };
+ data.CellW = (byte)sheet.CellWidth;
+ data.CellH = (byte)sheet.CellHeight;
+ data.SheetCols = (byte)Mathf.Clamp(cols, 1, 255);
+ data.BaseU = (byte)baseU;
+ data.BaseV = (byte)baseV;
+
+ elements.Add(data);
+ }
+
private static void CollectSingleBox(
PSXUIBox box, RectTransform canvasRect,
float scaleX, float scaleY, Vector2 resolution,
@@ -489,7 +580,7 @@ private static void CollectSingleProgressBar(
});
}
- // ─── Legacy per-type collectors (kept for reference, no longer called) ───
+ // --- Legacy per-type collectors (kept for reference, no longer called) ---
private static void CollectImages(
Transform root, RectTransform canvasRect,
@@ -520,7 +611,7 @@ private static void CollectImages(
};
// Image texture data is filled in after VRAM packing by
- // FillImageTextureData() — see PSXSceneExporter integration
+ // FillImageTextureData() - see PSXSceneExporter integration
if (img.PackedTexture != null)
{
PSXTexture2D tex = img.PackedTexture;
diff --git a/Runtime/PSXUILayout.cs b/Runtime/PSXUILayout.cs
new file mode 100644
index 0000000..957e4d1
--- /dev/null
+++ b/Runtime/PSXUILayout.cs
@@ -0,0 +1,128 @@
+using UnityEngine;
+
+namespace SplashEdit.RuntimeCode
+{
+ ///
+ /// The one place a RectTransform becomes PS1 screen pixels.
+ ///
+ ///
+ /// Both the exporter and the scene-view preview go through here, and that is
+ /// the point: a preview that computes positions its own way is a preview that
+ /// can agree with itself and disagree with the console. The layout bugs this
+ /// project has actually shipped - rows overlapping by three pixels, a panel
+ /// starting six pixels outside the plate it belonged in - were all "the
+ /// numbers were worked out twice".
+ ///
+ /// mirrors what the exporter writes into the splashpack.
+ /// then mirrors uisystem.cpp's resolveLayout,
+ /// which is what the hardware actually draws - anchors applied, clamped to
+ /// the framebuffer. Preview with Resolve; export with Bake.
+ ///
+ public static class PSXUILayout
+ {
+ /// An element's baked record, in the units the splashpack stores.
+ public struct Baked
+ {
+ /// Offset from the anchor, in PS1 pixels, Y already inverted.
+ public short X, Y;
+ /// Size in PS1 pixels, or the anchor-span inset when stretched.
+ public short W, H;
+ /// Anchors in 8.8 fixed point (0 = 0.0, 128 = 0.5, 255 ~ 1.0).
+ public byte AnchorMinX, AnchorMinY, AnchorMaxX, AnchorMaxY;
+ }
+
+ ///
+ /// Convert a RectTransform into the values the exporter writes.
+ /// Handles anchor-based positioning and the Y flip (Unity counts up from
+ /// the bottom, the PS1 counts down from the top).
+ ///
+ public static Baked Bake(RectTransform rt, float scaleX, float scaleY)
+ {
+ var b = new Baked();
+
+ b.AnchorMinX = (byte)Mathf.Clamp(Mathf.RoundToInt(rt.anchorMin.x * 255f), 0, 255);
+ b.AnchorMinY = (byte)Mathf.Clamp(Mathf.RoundToInt((1f - rt.anchorMax.y) * 255f), 0, 255); // Y invert
+ b.AnchorMaxX = (byte)Mathf.Clamp(Mathf.RoundToInt(rt.anchorMax.x * 255f), 0, 255);
+ b.AnchorMaxY = (byte)Mathf.Clamp(Mathf.RoundToInt((1f - rt.anchorMin.y) * 255f), 0, 255); // Y invert
+
+ if (Mathf.Approximately(rt.anchorMin.x, rt.anchorMax.x) &&
+ Mathf.Approximately(rt.anchorMin.y, rt.anchorMax.y))
+ {
+ // Fixed-size element with a single anchor point. anchoredPosition
+ // is the offset from that anchor, measured at the pivot.
+ float px = rt.anchoredPosition.x * scaleX;
+ float py = -rt.anchoredPosition.y * scaleY; // Y invert
+ float pw = rt.rect.width * scaleX;
+ float ph = rt.rect.height * scaleY;
+
+ px -= rt.pivot.x * pw;
+ py -= (1f - rt.pivot.y) * ph; // pivot Y inverted
+
+ b.X = (short)Mathf.RoundToInt(px);
+ b.Y = (short)Mathf.RoundToInt(py);
+ b.W = (short)Mathf.Max(1, Mathf.RoundToInt(pw));
+ b.H = (short)Mathf.Max(1, Mathf.RoundToInt(ph));
+ }
+ else
+ {
+ // Stretched: x/y are the offset from the anchor start and w/h the
+ // combined inset (negative shrinks).
+ float leftOff = rt.offsetMin.x * scaleX;
+ float rightOff = rt.offsetMax.x * scaleX;
+ float topOff = -rt.offsetMax.y * scaleY; // Y invert
+ float bottomOff = -rt.offsetMin.y * scaleY; // Y invert
+
+ b.X = (short)Mathf.RoundToInt(leftOff);
+ b.Y = (short)Mathf.RoundToInt(topOff);
+ b.W = (short)Mathf.RoundToInt(rightOff - leftOff);
+ b.H = (short)Mathf.RoundToInt(bottomOff - topOff);
+ }
+
+ return b;
+ }
+
+ ///
+ /// The rect the hardware draws, in PS1 pixels from the top-left.
+ /// A line-for-line mirror of UISystem::resolveLayout, clamp
+ /// included: an element pushed off the edge is drawn CLIPPED on a PS1,
+ /// not moved, and a preview that quietly moves it hides the bug.
+ ///
+ public static RectInt Resolve(Baked b, Vector2 resolution)
+ {
+ int resW = Mathf.RoundToInt(resolution.x);
+ int resH = Mathf.RoundToInt(resolution.y);
+
+ int ax = (b.AnchorMinX * resW) >> 8;
+ int ay = (b.AnchorMinY * resH) >> 8;
+ int x = ax + b.X;
+ int y = ay + b.Y;
+
+ int w = b.W;
+ int h = b.H;
+ if (b.AnchorMaxX != b.AnchorMinX) w = ((b.AnchorMaxX * resW) >> 8) - ax + b.W;
+ if (b.AnchorMaxY != b.AnchorMinY) h = ((b.AnchorMaxY * resH) >> 8) - ay + b.H;
+
+ if (x < 0) { w += x; x = 0; }
+ if (y < 0) { h += y; y = 0; }
+ if (w <= 0) w = 1;
+ if (h <= 0) h = 1;
+ if (x + w > resW) w = resW - x;
+ if (y + h > resH) h = resH - y;
+
+ return new RectInt(x, y, w, h);
+ }
+
+ ///
+ /// Bake and resolve in one step, for a RectTransform under a canvas of
+ /// the given size. This is what a preview wants.
+ ///
+ public static RectInt ScreenRect(RectTransform rt, RectTransform canvasRect, Vector2 resolution)
+ {
+ float canvasW = canvasRect != null ? canvasRect.rect.width : resolution.x;
+ float canvasH = canvasRect != null ? canvasRect.rect.height : resolution.y;
+ if (canvasW <= 0) canvasW = resolution.x;
+ if (canvasH <= 0) canvasH = resolution.y;
+ return Resolve(Bake(rt, resolution.x / canvasW, resolution.y / canvasH), resolution);
+ }
+ }
+}
diff --git a/Runtime/PSXUILayout.cs.meta b/Runtime/PSXUILayout.cs.meta
new file mode 100644
index 0000000..f3036be
--- /dev/null
+++ b/Runtime/PSXUILayout.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 38955ca71ceb96ba510544eaa5fa9681
\ No newline at end of file
diff --git a/Runtime/PSXUISprite.cs b/Runtime/PSXUISprite.cs
new file mode 100644
index 0000000..27c4b9e
--- /dev/null
+++ b/Runtime/PSXUISprite.cs
@@ -0,0 +1,123 @@
+using UnityEngine;
+
+namespace SplashEdit.RuntimeCode
+{
+ ///
+ /// One cell of a sprite sheet, drawn as a UI element.
+ ///
+ ///
+ /// This is the piece that lets a game's SCREEN FURNITURE be authored instead
+ /// of assembled by script. A owns a texture, so a
+ /// panel built from forty of them is forty textures in the atlas; a
+ /// PSXUISprite points at a cell of a sheet that is already resident, so a
+ /// whole panel costs the sheet it was drawn from and nothing else.
+ ///
+ /// It exports as a plain Image element - the engine draws it with the same
+ /// two triangles - plus the sheet's cell grid, which is what makes
+ /// UI.SetFrame(handle, cell) work: a digit, a chevron or a lamp
+ /// changes cell at run time without the Lua ever creating anything.
+ ///
+ /// Put one on a child of a . The RectTransform is the
+ /// rect it draws into, in PS1 pixels.
+ ///
+ [RequireComponent(typeof(RectTransform))]
+ [DisallowMultipleComponent]
+ [AddComponentMenu("PSX/UI/PSX UI Sprite")]
+ public class PSXUISprite : MonoBehaviour
+ {
+ ///
+ /// The PS1's tint identity. Hardware computes texel * colour / 128,
+ /// so 128 (0.5 in Unity's 0-1 colour) leaves the art exactly as drawn and
+ /// 255 doubles it. Unity's white is therefore NOT "no tint", which is the
+ /// single easiest thing to get wrong here.
+ ///
+ public static readonly Color TintIdentity = new Color(0.5f, 0.5f, 0.5f, 1f);
+
+ [Tooltip("Name used to reference this element from Lua: " +
+ "UI.FindElement(canvas, \"name\"). Max 24 chars.")]
+ [SerializeField] private string elementName = "sprite";
+
+ [Tooltip("The sheet this cell comes from. Its texture is packed into the " +
+ "VRAM atlas once, however many elements draw from it.")]
+ [SerializeField] private PSXSpriteSheet sheet;
+
+ [Tooltip("Which cell to draw, counting left-to-right then top-to-bottom " +
+ "from 0. Change it at run time with UI.SetFrame().")]
+ [SerializeField] private int cell = 0;
+
+ [Tooltip("Tint. 128 grey is NO tint - the PS1 computes texel * colour / 128, " +
+ "so white is double brightness. Use the Reset button in the inspector.")]
+ [SerializeField] private Color tint = new Color(0.5f, 0.5f, 0.5f, 1f);
+
+ [Tooltip("Whether this element is visible when the scene first loads.")]
+ [SerializeField] private bool startVisible = true;
+
+ /// Element name for Lua access.
+ public string ElementName => elementName;
+
+ /// The sheet this element draws a cell of.
+ public PSXSpriteSheet Sheet => sheet;
+
+ /// Cell index, clamped to the sheet on export.
+ public int Cell => cell;
+
+ /// Tint colour (RGB; 128 grey = the art's own colours).
+ public Color Tint => tint;
+
+ /// Initial visibility flag.
+ public bool StartVisible => startVisible;
+
+ ///
+ /// Cell index actually exported: clamped into the sheet so a stale index
+ /// draws the sheet's last cell rather than sampling whatever is packed
+ /// beside it in VRAM.
+ ///
+ public int ResolvedCell
+ {
+ get
+ {
+ if (sheet == null) return 0;
+ return Mathf.Clamp(cell, 0, Mathf.Max(0, sheet.CellCount - 1));
+ }
+ }
+
+ ///
+ /// The cell's rect in the sheet's source texture, in pixels from the
+ /// TOP-LEFT. Used by the scene-view preview and by the exporter.
+ ///
+ public RectInt CellRect
+ {
+ get
+ {
+ if (sheet == null) return new RectInt(0, 0, 16, 16);
+ int c = ResolvedCell;
+ int cols = Mathf.Max(1, sheet.Columns);
+ return new RectInt((c % cols) * sheet.CellWidth,
+ (c / cols) * sheet.CellHeight,
+ sheet.CellWidth, sheet.CellHeight);
+ }
+ }
+
+ /// Resize the RectTransform to the sheet's natural cell size.
+ public void SnapToCellSize()
+ {
+ if (sheet == null) return;
+ var rt = GetComponent();
+ if (rt == null) return;
+ rt.sizeDelta = new Vector2(sheet.CellWidth, sheet.CellHeight);
+ }
+
+ private void Reset()
+ {
+ // A UI element is authored in PS1 pixels from the top-left of the
+ // screen; that only works if the pivot is the top-left corner, which
+ // is also what PSXUIExporter's coordinate bake assumes.
+ var rt = GetComponent();
+ if (rt != null)
+ {
+ rt.pivot = new Vector2(0f, 1f);
+ rt.sizeDelta = new Vector2(16, 16);
+ }
+ }
+ }
+}
diff --git a/Runtime/PSXUISprite.cs.meta b/Runtime/PSXUISprite.cs.meta
new file mode 100644
index 0000000..f5dbed4
--- /dev/null
+++ b/Runtime/PSXUISprite.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 29eca857313c556a0adc1c0d4e4556e8
\ No newline at end of file
diff --git a/Runtime/PSXUIText.cs b/Runtime/PSXUIText.cs
index 972dfe3..4dc47ae 100644
--- a/Runtime/PSXUIText.cs
+++ b/Runtime/PSXUIText.cs
@@ -48,7 +48,7 @@ public class PSXUIText : MonoBehaviour
///
/// Resolve the effective font for this text element.
- /// Checks: fontOverride → parent PSXCanvas.DefaultFont → null (system font).
+ /// Checks: fontOverride -> parent PSXCanvas.DefaultFont -> null (system font).
///
public PSXFontAsset GetEffectiveFont()
{
diff --git a/Runtime/TexturePacker.cs b/Runtime/TexturePacker.cs
index d639342..6079a4a 100644
--- a/Runtime/TexturePacker.cs
+++ b/Runtime/TexturePacker.cs
@@ -54,7 +54,7 @@ public VRAMPacker(List framebuffers, List reservedAreas)
_reservedAreas.Add(framebuffers[0]);
_reservedAreas.Add(framebuffers[1]);
- // Reserve the font column (x=960-1023) — custom fonts and the system font
+ // Reserve the font column (x=960-1023) - custom fonts and the system font
// are placed here by PSXUIExporter, outside the packer's control.
_reservedAreas.Add(new Rect(960, 0, 64, VramHeight));
@@ -64,7 +64,7 @@ public VRAMPacker(List framebuffers, List reservedAreas)
///
/// Packs the textures from the provided PSXObjectExporter array into VRAM.
/// Each exporter now holds a list of textures.
- /// Duplicates (textures with the same underlying OriginalTexture and BitDepth) across all exporters are merged.
+ /// Duplicates (textures with the same underlying OriginalTexture, BitDepth and cutout) across all exporters are merged.
/// Returns the processed objects and the final VRAM pixel array.
///
/// Array of PSXObjectExporter objects to process.
@@ -85,7 +85,9 @@ public VRAMPacker(List framebuffers, List reservedAreas)
// List to track unique textures and their indices
List uniqueTextures = new List();
- Dictionary<(int, PSXBPP), int> textureToIndexMap = new Dictionary<(int, PSXBPP), int>();
+ // Cutout is part of the key: the same source quantized with and without it
+ // gives different pixels, so the two must not be merged.
+ Dictionary<(int, PSXBPP, bool), int> textureToIndexMap = new Dictionary<(int, PSXBPP, bool), int>();
// Track duplicates so we can propagate packing data after placement
List<(PSXTexture2D duplicate, int uniqueIndex)> duplicates = new List<(PSXTexture2D, int)>();
@@ -113,7 +115,7 @@ public VRAMPacker(List framebuffers, List reservedAreas)
// Process each texture in descending order of area.
foreach (var texture in group.OrderByDescending(tex => tex.QuantizedWidth * tex.Height))
{
- var textureKey = (texture.OriginalTexture.GetInstanceID(), texture.BitDepth);
+ var textureKey = (texture.OriginalTexture.GetInstanceID(), texture.BitDepth, texture.Cutout);
// Check if we've already processed this texture
if (textureToIndexMap.TryGetValue(textureKey, out int existingIndex))
@@ -152,7 +154,7 @@ public VRAMPacker(List framebuffers, List reservedAreas)
for (int i = 0; i < obj.Textures.Count; i++)
{
- var textureKey = (obj.Textures[i].OriginalTexture.GetInstanceID(), obj.Textures[i].BitDepth);
+ var textureKey = (obj.Textures[i].OriginalTexture.GetInstanceID(), obj.Textures[i].BitDepth, obj.Textures[i].Cutout);
if (textureToIndexMap.TryGetValue(textureKey, out int newIndex))
{
oldToNewIndexMap[i] = newIndex;
diff --git a/Runtime/Utils.cs b/Runtime/Utils.cs
index c13e694..95c9589 100644
--- a/Runtime/Utils.cs
+++ b/Runtime/Utils.cs
@@ -402,6 +402,44 @@ public static void SetTextureImporterFormat(Texture2D texture, bool isReadable)
tImporter.isReadable = isReadable;
needReimport = true;
}
+
+ // Pixel art must reach the quantizer EXACTLY as authored.
+ //
+ // npotScale is the one that silently ruins sprite sheets: Unity
+ // defaults to scaling a non-power-of-two texture to the nearest
+ // POT, so a 64x96 sheet is resampled to 64x128 and every 16px cell
+ // row slides out of the grid - which reads as "the bottom of my
+ // sprite is cut off" rather than as an import setting. Compression
+ // and mipmaps are just as fatal at this size: both invent colours
+ // the 4-bit palette then has to spend entries on.
+ if (tImporter.npotScale != TextureImporterNPOTScale.None)
+ {
+ tImporter.npotScale = TextureImporterNPOTScale.None;
+ needReimport = true;
+ }
+ if (tImporter.textureCompression != TextureImporterCompression.Uncompressed)
+ {
+ tImporter.textureCompression = TextureImporterCompression.Uncompressed;
+ needReimport = true;
+ }
+ if (tImporter.mipmapEnabled)
+ {
+ tImporter.mipmapEnabled = false;
+ needReimport = true;
+ }
+ if (tImporter.filterMode != FilterMode.Point)
+ {
+ tImporter.filterMode = FilterMode.Point;
+ needReimport = true;
+ }
+ // Cutout sprites rely on alpha 0 becoming the transparent palette
+ // entry; without this Unity can hand us premultiplied edges.
+ if (!tImporter.alphaIsTransparency)
+ {
+ tImporter.alphaIsTransparency = true;
+ needReimport = true;
+ }
+
if (needReimport)
{
AssetDatabase.ImportAsset(assetPath);