From d5be174247b6a5bbdbe53dfc25096b0de1751592 Mon Sep 17 00:00:00 2001 From: Jan Racek Date: Thu, 26 Mar 2026 19:14:15 +0100 Subject: [PATCH] Broken UI and Loading screens --- Editor/Core/SplashBuildPaths.cs | 9 + Editor/Core/SplashControlPanel.cs | 89 +++++++ Runtime/PSXLoaderPackWriter.cs | 399 ++++++++++++++++++++++++++++ Runtime/PSXLoaderPackWriter.cs.meta | 2 + Runtime/PSXSceneExporter.cs | 6 + Runtime/PSXUIExporter.cs | 38 ++- 6 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 Runtime/PSXLoaderPackWriter.cs create mode 100644 Runtime/PSXLoaderPackWriter.cs.meta 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) {