diff --git a/Editor/Core/SplashBuildPaths.cs b/Editor/Core/SplashBuildPaths.cs
index 714a901..7ec302f 100644
--- a/Editor/Core/SplashBuildPaths.cs
+++ b/Editor/Core/SplashBuildPaths.cs
@@ -109,6 +109,15 @@ namespace SplashEdit.EditorCode
return Path.Combine(BuildOutputDir, $"scene_{sceneIndex}.splashpack");
}
+ ///
+ /// Gets the loader pack (loading screen) output path for a scene by index.
+ /// Uses a deterministic naming scheme: scene_0.loading, scene_1.loading, etc.
+ ///
+ public static string GetSceneLoaderPackPath(int sceneIndex, string sceneName)
+ {
+ return Path.Combine(BuildOutputDir, $"scene_{sceneIndex}.loading");
+ }
+
///
/// ISO output path for release builds.
///
diff --git a/Editor/Core/SplashControlPanel.cs b/Editor/Core/SplashControlPanel.cs
index b4e5bce..7b8c31f 100644
--- a/Editor/Core/SplashControlPanel.cs
+++ b/Editor/Core/SplashControlPanel.cs
@@ -877,6 +877,7 @@ namespace SplashEdit.EditorCode
public bool ExportAllScenes()
{
SplashBuildPaths.EnsureDirectories();
+ _loaderPackCache = new Dictionary();
// Save current scene
string currentScenePath = SceneManager.GetActiveScene().path;
@@ -912,6 +913,13 @@ namespace SplashEdit.EditorCode
string outputPath = SplashBuildPaths.GetSceneSplashpackPath(i, scene.name);
exporter.ExportToPath(outputPath);
Log($"Exported '{scene.name}' → {Path.GetFileName(outputPath)}", LogType.Log);
+
+ // Export loading screen if assigned
+ if (exporter.LoadingScreenPrefab != null)
+ {
+ string loaderPath = SplashBuildPaths.GetSceneLoaderPackPath(i, scene.name);
+ ExportLoaderPack(exporter.LoadingScreenPrefab, loaderPath, i, scene.name);
+ }
}
catch (Exception ex)
{
@@ -934,6 +942,87 @@ namespace SplashEdit.EditorCode
return success;
}
+ ///
+ /// Cache of already-exported loader packs for deduplication.
+ /// Key = prefab asset GUID, Value = path of the written file.
+ /// If two scenes reference the same loading screen prefab, we copy the file
+ /// instead of regenerating it.
+ ///
+ private Dictionary _loaderPackCache = new Dictionary();
+
+ private void ExportLoaderPack(GameObject prefab, string outputPath, int sceneIndex, string sceneName)
+ {
+ string prefabPath = AssetDatabase.GetAssetPath(prefab);
+ string guid = AssetDatabase.AssetPathToGUID(prefabPath);
+
+ // Dedup: if we already exported this exact prefab, just copy the file
+ if (!string.IsNullOrEmpty(guid) && _loaderPackCache.TryGetValue(guid, out string cachedPath))
+ {
+ if (File.Exists(cachedPath))
+ {
+ File.Copy(cachedPath, outputPath, true);
+ Log($"Loading screen for '{sceneName}' → {Path.GetFileName(outputPath)} (deduped from {Path.GetFileName(cachedPath)})", LogType.Log);
+ return;
+ }
+ }
+
+ // Need the PSXData resolution to pass to the writer
+ Vector2 resolution;
+ bool db, vl;
+ List pa;
+ DataStorage.LoadData(out resolution, out db, out vl, out pa);
+
+ // Instantiate the prefab temporarily so the components are live
+ // (GetComponentsInChildren needs active hierarchy)
+ GameObject instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
+ try
+ {
+ // Pack UI image textures into VRAM (same flow as PSXSceneExporter)
+ TextureAtlas[] atlases = null;
+ PSXUIImage[] uiImages = instance.GetComponentsInChildren(true);
+ if (uiImages != null && uiImages.Length > 0)
+ {
+ List uiTextures = new List();
+ foreach (PSXUIImage img in uiImages)
+ {
+ if (img.SourceTexture != null)
+ {
+ Utils.SetTextureImporterFormat(img.SourceTexture, true);
+ PSXTexture2D tex = PSXTexture2D.CreateFromTexture2D(img.SourceTexture, img.BitDepth);
+ tex.OriginalTexture = img.SourceTexture;
+ img.PackedTexture = tex;
+ uiTextures.Add(tex);
+ }
+ }
+
+ if (uiTextures.Count > 0)
+ {
+ (Rect buffer1, Rect buffer2) = Utils.BufferForResolution(resolution, vl);
+ List framebuffers = new List { buffer1 };
+ if (db) framebuffers.Add(buffer2);
+
+ VRAMPacker packer = new VRAMPacker(framebuffers, pa);
+ var packed = packer.PackTexturesIntoVRAM(new PSXObjectExporter[0], uiTextures);
+ atlases = packed.atlases;
+ }
+ }
+
+ // CollectCanvasFromPrefab reads PackedTexture VRAM coords (set by packer above)
+ bool ok = PSXLoaderPackWriter.Write(outputPath, instance, resolution, atlases,
+ (msg, type) => Log(msg, type));
+ if (ok)
+ {
+ Log($"Loading screen for '{sceneName}' → {Path.GetFileName(outputPath)}", LogType.Log);
+ if (!string.IsNullOrEmpty(guid))
+ _loaderPackCache[guid] = outputPath;
+ }
+ }
+ finally
+ {
+ UnityEngine.Object.DestroyImmediate(instance);
+ }
+ }
+
private void WriteManifest()
{
string manifestPath = SplashBuildPaths.ManifestPath;
diff --git a/Runtime/PSXLoaderPackWriter.cs b/Runtime/PSXLoaderPackWriter.cs
new file mode 100644
index 0000000..2594898
--- /dev/null
+++ b/Runtime/PSXLoaderPackWriter.cs
@@ -0,0 +1,399 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using UnityEngine;
+
+namespace SplashEdit.RuntimeCode
+{
+ ///
+ /// Writes a standalone "loader pack" binary (.loading) for a loading screen canvas.
+ ///
+ /// Format v2:
+ /// Header (16 bytes):
+ /// char[2] magic = "LP"
+ /// 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)
+ ///
+ /// After header (at offset 16):
+ /// Atlas headers (12 bytes each × atlasCount)
+ /// CLUT headers (12 bytes each × clutCount)
+ /// Atlas pixel data (referenced by offsets in atlas headers)
+ /// CLUT pixel data (referenced by offsets in CLUT headers)
+ ///
+ /// At tableOffset:
+ /// Same layout as the splashpack UI section:
+ /// - Font descriptors (112 bytes each)
+ /// - Font pixel data
+ /// - Canvas descriptors (12 bytes each)
+ /// - Element data (48 bytes per element)
+ /// - String data (names + text content)
+ ///
+ /// This reuses the same binary layout that UISystem::loadFromSplashpack() parses,
+ /// so the C++ side can reuse the same parsing code.
+ ///
+ public static class PSXLoaderPackWriter
+ {
+ public const ushort LOADER_PACK_VERSION = 2;
+
+ ///
+ /// Write a loader pack file for a loading screen canvas prefab.
+ ///
+ /// Output file path.
+ /// The loading screen prefab (must contain PSXCanvas).
+ /// Target PS1 resolution.
+ /// Texture atlases from VRAM packing (may be null if no images).
+ /// Optional log callback.
+ /// True on success.
+ public static bool Write(string path, GameObject prefab, Vector2 resolution,
+ TextureAtlas[] atlases = null,
+ System.Action log = null)
+ {
+ if (prefab == null)
+ {
+ log?.Invoke("LoaderPackWriter: No prefab specified.", LogType.Error);
+ return false;
+ }
+
+ // Collect canvas data from the prefab
+ PSXFontData[] fonts;
+ PSXCanvasData[] canvases = PSXUIExporter.CollectCanvasFromPrefab(prefab, resolution, out fonts);
+
+ if (canvases == null || canvases.Length == 0)
+ {
+ log?.Invoke($"LoaderPackWriter: No PSXCanvas found in prefab '{prefab.name}'.", LogType.Error);
+ return false;
+ }
+
+ // Only export the first canvas (loading screen = single canvas)
+ PSXCanvasData canvas = canvases[0];
+
+ using (FileStream fs = new FileStream(path, FileMode.Create, FileAccess.Write))
+ using (BinaryWriter writer = new BinaryWriter(fs))
+ {
+ // Count CLUTs across all atlases
+ int clutCount = 0;
+ if (atlases != null)
+ {
+ foreach (var atlas in atlases)
+ foreach (var tex in atlas.ContainedTextures)
+ if (tex.ColorPalette != null)
+ clutCount++;
+ }
+
+ // ── Header (16 bytes) ──
+ writer.Write((byte)'L');
+ writer.Write((byte)'P');
+ writer.Write(LOADER_PACK_VERSION);
+ writer.Write((byte)(fonts?.Length ?? 0));
+ writer.Write((byte)1); // canvasCount = 1
+ writer.Write((ushort)resolution.x);
+ writer.Write((ushort)resolution.y);
+ writer.Write((byte)(atlases?.Length ?? 0)); // atlasCount
+ writer.Write((byte)clutCount); // clutCount
+ long tableOffsetPos = writer.BaseStream.Position;
+ writer.Write((uint)0); // tableOffset placeholder
+
+ // ── Atlas headers (12 bytes each) ──
+ List atlasOffsetPlaceholders = new List();
+ if (atlases != null)
+ {
+ foreach (var atlas in atlases)
+ {
+ atlasOffsetPlaceholders.Add(writer.BaseStream.Position);
+ writer.Write((uint)0); // pixelDataOffset placeholder
+ writer.Write((ushort)atlas.Width);
+ writer.Write((ushort)TextureAtlas.Height);
+ writer.Write((ushort)atlas.PositionX);
+ writer.Write((ushort)atlas.PositionY);
+ }
+ }
+
+ // ── CLUT headers (12 bytes each) ──
+ List clutOffsetPlaceholders = new List();
+ if (atlases != null)
+ {
+ foreach (var atlas in atlases)
+ {
+ foreach (var tex in atlas.ContainedTextures)
+ {
+ if (tex.ColorPalette != null)
+ {
+ clutOffsetPlaceholders.Add(writer.BaseStream.Position);
+ writer.Write((uint)0); // clutDataOffset placeholder
+ writer.Write((ushort)tex.ClutPackingX);
+ writer.Write((ushort)tex.ClutPackingY);
+ writer.Write((ushort)tex.ColorPalette.Count);
+ writer.Write((ushort)0); // pad
+ }
+ }
+ }
+ }
+
+ // ── Atlas pixel data ──
+ int atlasIdx = 0;
+ if (atlases != null)
+ {
+ foreach (var atlas in atlases)
+ {
+ AlignToFourBytes(writer);
+ long dataPos = writer.BaseStream.Position;
+
+ // Backfill this atlas header's pixelDataOffset
+ long cur = writer.BaseStream.Position;
+ writer.Seek((int)atlasOffsetPlaceholders[atlasIdx], SeekOrigin.Begin);
+ writer.Write((uint)dataPos);
+ writer.Seek((int)cur, SeekOrigin.Begin);
+
+ // Write pixel data in row-major order (same as PSXSceneWriter)
+ for (int y = 0; y < atlas.vramPixels.GetLength(1); y++)
+ for (int x = 0; x < atlas.vramPixels.GetLength(0); x++)
+ writer.Write(atlas.vramPixels[x, y].Pack());
+
+ atlasIdx++;
+ }
+ }
+
+ // ── CLUT pixel data ──
+ int clutIdx = 0;
+ if (atlases != null)
+ {
+ foreach (var atlas in atlases)
+ {
+ foreach (var tex in atlas.ContainedTextures)
+ {
+ if (tex.ColorPalette != null)
+ {
+ AlignToFourBytes(writer);
+ long dataPos = writer.BaseStream.Position;
+
+ // Backfill this CLUT header's clutDataOffset
+ long cur = writer.BaseStream.Position;
+ writer.Seek((int)clutOffsetPlaceholders[clutIdx], SeekOrigin.Begin);
+ writer.Write((uint)dataPos);
+ writer.Seek((int)cur, SeekOrigin.Begin);
+
+ foreach (VRAMPixel color in tex.ColorPalette)
+ writer.Write((ushort)color.Pack());
+
+ clutIdx++;
+ }
+ }
+ }
+ }
+
+ // ── UI table (same format as splashpack UI section) ──
+ AlignToFourBytes(writer);
+ long uiTableStart = writer.BaseStream.Position;
+
+ // ── Font descriptors (112 bytes each) ──
+ List fontDataOffsetPositions = new List();
+ if (fonts != null)
+ {
+ foreach (var font in fonts)
+ {
+ writer.Write(font.GlyphWidth); // [0]
+ writer.Write(font.GlyphHeight); // [1]
+ writer.Write(font.VramX); // [2-3]
+ writer.Write(font.VramY); // [4-5]
+ writer.Write(font.TextureHeight); // [6-7]
+ fontDataOffsetPositions.Add(writer.BaseStream.Position);
+ writer.Write((uint)0); // [8-11] dataOffset placeholder
+ writer.Write((uint)(font.PixelData?.Length ?? 0)); // [12-15] dataSize
+ if (font.AdvanceWidths != null && font.AdvanceWidths.Length >= 96)
+ writer.Write(font.AdvanceWidths, 0, 96);
+ else
+ writer.Write(new byte[96]);
+ }
+ }
+
+ // ── Font pixel data ──
+ if (fonts != null)
+ {
+ for (int fi = 0; fi < fonts.Length; fi++)
+ {
+ var font = fonts[fi];
+ if (font.PixelData == null || font.PixelData.Length == 0) continue;
+
+ AlignToFourBytes(writer);
+ long dataPos = writer.BaseStream.Position;
+ writer.Write(font.PixelData);
+
+ long curPos = writer.BaseStream.Position;
+ writer.Seek((int)fontDataOffsetPositions[fi], SeekOrigin.Begin);
+ writer.Write((uint)dataPos);
+ writer.Seek((int)curPos, SeekOrigin.Begin);
+ }
+ }
+
+ // ── Canvas descriptor (12 bytes) ──
+ var elements = canvas.Elements ?? new PSXUIElementData[0];
+ string cvName = canvas.Name ?? "loading";
+ if (cvName.Length > 24) cvName = cvName.Substring(0, 24);
+
+ long canvasDataOffsetPos = writer.BaseStream.Position;
+ writer.Write((uint)0); // dataOffset placeholder
+ writer.Write((byte)cvName.Length);
+ writer.Write(canvas.SortOrder);
+ writer.Write((byte)elements.Length);
+ byte flags = 0;
+ if (canvas.StartVisible) flags |= 0x01;
+ writer.Write(flags);
+ long canvasNameOffsetPos = writer.BaseStream.Position;
+ writer.Write((uint)0); // nameOffset placeholder
+
+ // ── Element data (48 bytes per element) ──
+ AlignToFourBytes(writer);
+ long elemDataStart = writer.BaseStream.Position;
+
+ // Backfill canvas data offset
+ {
+ long cur = writer.BaseStream.Position;
+ writer.Seek((int)canvasDataOffsetPos, SeekOrigin.Begin);
+ writer.Write((uint)elemDataStart);
+ writer.Seek((int)cur, SeekOrigin.Begin);
+ }
+
+ List textOffsetPositions = new List();
+ List textContents = new List();
+
+ for (int ei = 0; ei < elements.Length; ei++)
+ {
+ var el = elements[ei];
+
+ // Identity (8 bytes)
+ writer.Write((byte)el.Type);
+ byte eFlags = 0;
+ if (el.StartVisible) eFlags |= 0x01;
+ writer.Write(eFlags);
+ string eName = el.Name ?? "";
+ if (eName.Length > 24) eName = eName.Substring(0, 24);
+ writer.Write((byte)eName.Length);
+ writer.Write((byte)0); // pad0
+ long elemNameOffPos = writer.BaseStream.Position;
+ writer.Write((uint)0); // nameOffset placeholder
+
+ // Layout (8 bytes)
+ writer.Write(el.X);
+ writer.Write(el.Y);
+ writer.Write(el.W);
+ writer.Write(el.H);
+
+ // Anchors (4 bytes)
+ writer.Write(el.AnchorMinX);
+ writer.Write(el.AnchorMinY);
+ writer.Write(el.AnchorMaxX);
+ writer.Write(el.AnchorMaxY);
+
+ // Primary color (4 bytes)
+ writer.Write(el.ColorR);
+ writer.Write(el.ColorG);
+ writer.Write(el.ColorB);
+ writer.Write((byte)0); // pad1
+
+ // Type-specific data (16 bytes)
+ switch (el.Type)
+ {
+ case PSXUIElementType.Image:
+ writer.Write(el.TexpageX);
+ writer.Write(el.TexpageY);
+ writer.Write(el.ClutX);
+ writer.Write(el.ClutY);
+ writer.Write(el.U0);
+ writer.Write(el.V0);
+ writer.Write(el.U1);
+ writer.Write(el.V1);
+ writer.Write(el.BitDepthIndex);
+ writer.Write(new byte[5]);
+ break;
+ case PSXUIElementType.Progress:
+ writer.Write(el.BgR);
+ writer.Write(el.BgG);
+ writer.Write(el.BgB);
+ writer.Write(el.ProgressValue);
+ writer.Write(new byte[12]);
+ break;
+ case PSXUIElementType.Text:
+ writer.Write(el.FontIndex);
+ writer.Write(new byte[15]);
+ break;
+ default:
+ writer.Write(new byte[16]);
+ break;
+ }
+
+ // Text content offset (8 bytes)
+ long textOff = writer.BaseStream.Position;
+ writer.Write((uint)0); // textOffset placeholder
+ writer.Write((uint)0); // pad2
+
+ textOffsetPositions.Add(textOff);
+ textContents.Add(el.Type == PSXUIElementType.Text ? (el.DefaultText ?? "") : null);
+
+ textOffsetPositions.Add(elemNameOffPos);
+ textContents.Add("__NAME__" + eName);
+ }
+
+ // ── String data (text content + element names) ──
+ for (int si = 0; si < textOffsetPositions.Count; si++)
+ {
+ string content = textContents[si];
+ if (content == null) continue;
+
+ bool isName = content.StartsWith("__NAME__");
+ string str = isName ? content.Substring(8) : content;
+ if (string.IsNullOrEmpty(str)) continue;
+
+ AlignToFourBytes(writer);
+ long strPos = writer.BaseStream.Position;
+ byte[] strBytes = Encoding.UTF8.GetBytes(str);
+ writer.Write(strBytes);
+ writer.Write((byte)0); // null terminator
+
+ long cur = writer.BaseStream.Position;
+ writer.Seek((int)textOffsetPositions[si], SeekOrigin.Begin);
+ writer.Write((uint)strPos);
+ writer.Seek((int)cur, SeekOrigin.Begin);
+ }
+
+ // ── Canvas name ──
+ {
+ AlignToFourBytes(writer);
+ long namePos = writer.BaseStream.Position;
+ byte[] nameBytes = Encoding.UTF8.GetBytes(cvName);
+ writer.Write(nameBytes);
+ writer.Write((byte)0);
+
+ long cur = writer.BaseStream.Position;
+ writer.Seek((int)canvasNameOffsetPos, SeekOrigin.Begin);
+ writer.Write((uint)namePos);
+ writer.Seek((int)cur, SeekOrigin.Begin);
+ }
+
+ // ── Backfill header table offset ──
+ {
+ long cur = writer.BaseStream.Position;
+ writer.Seek((int)tableOffsetPos, SeekOrigin.Begin);
+ writer.Write((uint)uiTableStart);
+ writer.Seek((int)cur, SeekOrigin.Begin);
+ }
+ }
+
+ log?.Invoke($"LoaderPackWriter: Wrote loading screen '{canvas.Name}' to {Path.GetFileName(path)}", LogType.Log);
+ return true;
+ }
+
+ private static void AlignToFourBytes(BinaryWriter writer)
+ {
+ long pos = writer.BaseStream.Position;
+ int pad = (int)((4 - (pos % 4)) % 4);
+ for (int i = 0; i < pad; i++)
+ writer.Write((byte)0);
+ }
+ }
+}
diff --git a/Runtime/PSXLoaderPackWriter.cs.meta b/Runtime/PSXLoaderPackWriter.cs.meta
new file mode 100644
index 0000000..a71f737
--- /dev/null
+++ b/Runtime/PSXLoaderPackWriter.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d2745d7a7b19ea046a7d10c7ec902333
\ No newline at end of file
diff --git a/Runtime/PSXSceneExporter.cs b/Runtime/PSXSceneExporter.cs
index 8f04489..125f7c8 100644
--- a/Runtime/PSXSceneExporter.cs
+++ b/Runtime/PSXSceneExporter.cs
@@ -41,6 +41,12 @@ namespace SplashEdit.RuntimeCode
[Tooltip("Cutscene clips to include in this scene's splashpack. Only these will be exported.")]
public PSXCutsceneClip[] Cutscenes = new PSXCutsceneClip[0];
+ [Header("Loading Screen")]
+ [Tooltip("Optional prefab containing a PSXCanvas to use as a loading screen when loading this scene.\n" +
+ "The canvas may contain a PSXUIProgressBar named 'loading' which will be automatically\n" +
+ "updated during scene load. If null, no loading screen is shown.")]
+ public GameObject LoadingScreenPrefab;
+
private PSXObjectExporter[] _exporters;
private TextureAtlas[] _atlases;
diff --git a/Runtime/PSXUIExporter.cs b/Runtime/PSXUIExporter.cs
index d4e2de8..b5eec89 100644
--- a/Runtime/PSXUIExporter.cs
+++ b/Runtime/PSXUIExporter.cs
@@ -13,7 +13,7 @@ namespace SplashEdit.RuntimeCode
///
public static class PSXUIExporter
{
- ///
+ ///
/// Collect all PSXCanvas components and their child UI elements,
/// converting RectTransform coordinates to PS1 pixel space.
/// Also collects and deduplicates custom fonts.
@@ -37,6 +37,42 @@ namespace SplashEdit.RuntimeCode
return new PSXCanvasData[0];
}
+ return CollectCanvasesInternal(canvases, resolution, out fonts);
+ }
+
+ ///
+ /// Collect a single canvas from a prefab instance for loading screen export.
+ /// The prefab must have a PSXCanvas on its root.
+ /// Note: Image elements that reference VRAM textures will NOT work in loading screens
+ /// since the VRAM hasn't been populated yet. Use Box, Text, and ProgressBar only.
+ ///
+ public static PSXCanvasData[] CollectCanvasFromPrefab(GameObject prefab, Vector2 resolution, out PSXFontData[] fonts)
+ {
+ if (prefab == null)
+ {
+ fonts = new PSXFontData[0];
+ return new PSXCanvasData[0];
+ }
+
+ PSXCanvas canvas = prefab.GetComponentInChildren(true);
+ if (canvas == null)
+ {
+ Debug.LogWarning($"PSXUIExporter: Prefab '{prefab.name}' has no PSXCanvas component.");
+ fonts = new PSXFontData[0];
+ return new PSXCanvasData[0];
+ }
+
+ return CollectCanvasesInternal(new[] { canvas }, resolution, out fonts);
+ }
+
+ ///
+ /// Internal shared implementation for canvas collection.
+ /// Works on an explicit array of PSXCanvas components.
+ ///
+ private static PSXCanvasData[] CollectCanvasesInternal(PSXCanvas[] canvases, Vector2 resolution, out PSXFontData[] fonts)
+ {
+ List uniqueFonts = new List();
+
// First pass: collect unique fonts
foreach (PSXCanvas canvas in canvases)
{