using System; using System.Collections.Generic; using System.IO; using System.Text; using UnityEngine; namespace SplashEdit.RuntimeCode { /// /// Pure binary serializer for the splashpack v8 format. /// All I/O extracted from PSXSceneExporter so the MonoBehaviour stays thin. /// public static class PSXSceneWriter { /// /// All scene data needed to produce a .bin file. /// Populated by PSXSceneExporter before calling . /// public struct SceneData { public PSXObjectExporter[] exporters; public TextureAtlas[] atlases; public PSXInteractable[] interactables; public AudioClipExport[] audioClips; public PSXCollisionExporter collisionExporter; public PSXNavRegionBuilder navRegionBuilder; public PSXRoomBuilder roomBuilder; public BVH bvh; public LuaFile sceneLuaFile; public float gteScaling; // Cutscene data (v12) public PSXCutsceneClip[] cutscenes; public PSXAudioSource[] audioSources; // UI canvases (v13) public PSXCanvasData[] canvases; // Custom fonts (v13, embedded in UI block) public PSXFontData[] fonts; // Trigger boxes (v16) public PSXTriggerBox[] triggerBoxes; // Player public Vector3 playerPos; public Quaternion playerRot; public float playerHeight; public float playerRadius; public float moveSpeed; public float sprintSpeed; public float jumpHeight; public float gravity; // Scene configuration (v11) public PSXSceneType sceneType; public bool fogEnabled; public Color fogColor; public int fogDensity; // 1-10 } // ─── Offset bookkeeping ─── private sealed class OffsetData { public readonly List PlaceholderPositions = new List(); public readonly List DataOffsets = new List(); } // ═══════════════════════════════════════════════════════════════ // Public API // ═══════════════════════════════════════════════════════════════ /// /// Serialize the scene to a splashpack v8 binary file. /// /// Absolute file path to write. /// Pre-built scene data. /// Optional callback for progress messages. public static void Write(string path, in SceneData scene, Action log = null) { float gte = scene.gteScaling; int totalFaces = 0; OffsetData luaOffset = new OffsetData(); OffsetData meshOffset = new OffsetData(); OffsetData atlasOffset = new OffsetData(); OffsetData clutOffset = new OffsetData(); int clutCount = 0; List luaFiles = new List(); // Count CLUTs foreach (TextureAtlas atlas in scene.atlases) { foreach (var texture in atlas.ContainedTextures) { if (texture.ColorPalette != null) clutCount++; } } // Collect unique Lua files foreach (PSXObjectExporter exporter in scene.exporters) { if (exporter.LuaFile != null && !luaFiles.Contains(exporter.LuaFile)) luaFiles.Add(exporter.LuaFile); } if (scene.sceneLuaFile != null && !luaFiles.Contains(scene.sceneLuaFile)) luaFiles.Add(scene.sceneLuaFile); // Trigger box Lua files if (scene.triggerBoxes != null) { foreach (var tb in scene.triggerBoxes) { if (tb.LuaFile != null && !luaFiles.Contains(tb.LuaFile)) luaFiles.Add(tb.LuaFile); } } using (BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create))) { int colliderCount = 0; foreach (var e in scene.exporters) { if (e.CollisionType != PSXCollisionType.Dynamic) continue; MeshFilter mf = e.GetComponent(); if (mf?.sharedMesh != null) colliderCount++; } int triggerBoxCount = scene.triggerBoxes?.Length ?? 0; // Build exporter index lookup for components Dictionary exporterIndex = new Dictionary(); for (int i = 0; i < scene.exporters.Length; i++) exporterIndex[scene.exporters[i]] = i; // ────────────────────────────────────────────────────── // Header (104 bytes — splashpack v16) // ────────────────────────────────────────────────────── writer.Write('S'); writer.Write('P'); writer.Write((ushort)16); writer.Write((ushort)luaFiles.Count); writer.Write((ushort)scene.exporters.Length); writer.Write((ushort)scene.atlases.Length); writer.Write((ushort)clutCount); writer.Write((ushort)colliderCount); writer.Write((ushort)scene.interactables.Length); writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerPos.x, gte)); writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(-scene.playerPos.y, gte)); writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerPos.z, gte)); writer.Write((ushort)PSXTrig.ConvertToFixed12(scene.playerRot.eulerAngles.x * Mathf.Deg2Rad)); writer.Write((ushort)PSXTrig.ConvertToFixed12(scene.playerRot.eulerAngles.y * Mathf.Deg2Rad)); writer.Write((ushort)PSXTrig.ConvertToFixed12(scene.playerRot.eulerAngles.z * Mathf.Deg2Rad)); writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerHeight, gte)); if (scene.sceneLuaFile != null) writer.Write((short)luaFiles.IndexOf(scene.sceneLuaFile)); else writer.Write((short)-1); writer.Write((ushort)scene.bvh.NodeCount); writer.Write((ushort)scene.bvh.TriangleRefCount); writer.Write((ushort)scene.sceneType); writer.Write((ushort)triggerBoxCount); // was pad0 writer.Write((ushort)scene.collisionExporter.MeshCount); writer.Write((ushort)scene.collisionExporter.TriangleCount); writer.Write((ushort)scene.navRegionBuilder.RegionCount); writer.Write((ushort)scene.navRegionBuilder.PortalCount); // Movement parameters (12 bytes) { const float fps = 30f; float movePerFrame = scene.moveSpeed / fps / gte; float sprintPerFrame = scene.sprintSpeed / fps / gte; writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(movePerFrame * 4096f), 0, 65535)); writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(sprintPerFrame * 4096f), 0, 65535)); float jumpVel = Mathf.Sqrt(2f * scene.gravity * scene.jumpHeight) / gte; writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(jumpVel * 4096f), 0, 65535)); float gravPsx = scene.gravity / gte; writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(gravPsx * 4096f), 0, 65535)); writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerRadius, gte)); writer.Write((ushort)0); // pad1 } long nameTableOffsetPos = writer.BaseStream.Position; writer.Write((uint)0); int audioClipCount = scene.audioClips?.Length ?? 0; writer.Write((ushort)audioClipCount); writer.Write((ushort)0); // pad2 long audioTableOffsetPos = writer.BaseStream.Position; writer.Write((uint)0); { writer.Write((byte)(scene.fogEnabled ? 1 : 0)); writer.Write((byte)Mathf.Clamp(Mathf.RoundToInt(scene.fogColor.r * 255f), 0, 255)); writer.Write((byte)Mathf.Clamp(Mathf.RoundToInt(scene.fogColor.g * 255f), 0, 255)); writer.Write((byte)Mathf.Clamp(Mathf.RoundToInt(scene.fogColor.b * 255f), 0, 255)); writer.Write((byte)Mathf.Clamp(scene.fogDensity, 1, 10)); writer.Write((byte)0); // pad3 int roomCount = scene.roomBuilder?.RoomCount ?? 0; int portalCount = scene.roomBuilder?.PortalCount ?? 0; int roomTriRefCount = scene.roomBuilder?.TotalTriRefCount ?? 0; writer.Write((ushort)(roomCount > 0 ? roomCount + 1 : 0)); writer.Write((ushort)portalCount); writer.Write((ushort)roomTriRefCount); } int cutsceneCount = scene.cutscenes?.Length ?? 0; writer.Write((ushort)cutsceneCount); writer.Write((ushort)0); // pad4 long cutsceneTableOffsetPos = writer.BaseStream.Position; writer.Write((uint)0); // cutsceneTableOffset placeholder int uiCanvasCount = scene.canvases?.Length ?? 0; int uiFontCount = scene.fonts?.Length ?? 0; writer.Write((ushort)uiCanvasCount); writer.Write((byte)uiFontCount); writer.Write((byte)0); // uiPad5 long uiTableOffsetPos = writer.BaseStream.Position; writer.Write((uint)0); long pixelDataOffsetPos = writer.BaseStream.Position; writer.Write((uint)0); // pixelDataOffset placeholder // ────────────────────────────────────────────────────── // Lua file metadata // ────────────────────────────────────────────────────── foreach (LuaFile luaFile in luaFiles) { luaOffset.PlaceholderPositions.Add(writer.BaseStream.Position); writer.Write((int)0); // placeholder writer.Write((uint)Encoding.UTF8.GetByteCount(luaFile.LuaScript)); } // ────────────────────────────────────────────────────── // GameObject section // ────────────────────────────────────────────────────── Dictionary interactableIndices = new Dictionary(); for (int i = 0; i < scene.interactables.Length; i++) { var exp = scene.interactables[i].GetComponent(); if (exp != null) interactableIndices[exp] = i; } foreach (PSXObjectExporter exporter in scene.exporters) { meshOffset.PlaceholderPositions.Add(writer.BaseStream.Position); writer.Write((int)0); // placeholder // Transform — position as 20.12 fixed-point Vector3 pos = exporter.transform.localToWorldMatrix.GetPosition(); writer.Write(PSXTrig.ConvertWorldToFixed12(pos.x / gte)); writer.Write(PSXTrig.ConvertWorldToFixed12(-pos.y / gte)); writer.Write(PSXTrig.ConvertWorldToFixed12(pos.z / gte)); int[,] rot = PSXTrig.ConvertRotationToPSXMatrix(exporter.transform.rotation); for (int r = 0; r < 3; r++) for (int c = 0; c < 3; c++) writer.Write((int)rot[r, c]); writer.Write((ushort)exporter.Mesh.Triangles.Count); if (exporter.LuaFile != null) writer.Write((short)luaFiles.IndexOf(exporter.LuaFile)); else writer.Write((short)-1); // Bitfield (LSB = isActive) writer.Write(exporter.IsActive ? 1 : 0); // Component indices (8 bytes) writer.Write(interactableIndices.TryGetValue(exporter, out int interactIdx) ? (ushort)interactIdx : (ushort)0xFFFF); writer.Write((ushort)0xFFFF); // _reserved0 (legacy healthIndex) writer.Write((uint)0); // eventMask (runtime-only, must be zero) // World-space AABB (24 bytes) WriteObjectAABB(writer, exporter, gte); } // ────────────────────────────────────────────────────── // Collider metadata (32 bytes each) — Dynamic objects only // ────────────────────────────────────────────────────── for (int exporterIdx = 0; exporterIdx < scene.exporters.Length; exporterIdx++) { PSXObjectExporter exporter = scene.exporters[exporterIdx]; if (exporter.CollisionType != PSXCollisionType.Dynamic) continue; MeshFilter meshFilter = exporter.GetComponent(); Mesh renderMesh = meshFilter?.sharedMesh; if (renderMesh == null) continue; WriteWorldAABB(writer, exporter, renderMesh.bounds, gte); writer.Write((byte)1); // CollisionType::Solid on C++ side writer.Write((byte)0xFF); // layerMask (all layers) writer.Write((ushort)exporterIdx); writer.Write((uint)0); } // ────────────────────────────────────────────────────── // Trigger box metadata (32 bytes each) // ────────────────────────────────────────────────────── if (scene.triggerBoxes != null) { foreach (var tb in scene.triggerBoxes) { Bounds wb = tb.GetWorldBounds(); Vector3 wMin = wb.min; Vector3 wMax = wb.max; writer.Write(PSXTrig.ConvertWorldToFixed12(wMin.x / gte)); writer.Write(PSXTrig.ConvertWorldToFixed12(-wMax.y / gte)); writer.Write(PSXTrig.ConvertWorldToFixed12(wMin.z / gte)); writer.Write(PSXTrig.ConvertWorldToFixed12(wMax.x / gte)); writer.Write(PSXTrig.ConvertWorldToFixed12(-wMin.y / gte)); writer.Write(PSXTrig.ConvertWorldToFixed12(wMax.z / gte)); if (tb.LuaFile != null) writer.Write((short)luaFiles.IndexOf(tb.LuaFile)); else writer.Write((short)-1); writer.Write((ushort)0); // padding writer.Write((uint)0); // padding } } // ────────────────────────────────────────────────────── // BVH data (inline) // ────────────────────────────────────────────────────── AlignToFourBytes(writer); scene.bvh.WriteToBinary(writer, gte); // ────────────────────────────────────────────────────── // Interactable components (24 bytes each) // ────────────────────────────────────────────────────── AlignToFourBytes(writer); foreach (PSXInteractable interactable in scene.interactables) { var exp = interactable.GetComponent(); int goIndex = exporterIndex.TryGetValue(exp, out int idx) ? idx : 0xFFFF; float radiusSq = interactable.InteractionRadius * interactable.InteractionRadius; writer.Write(PSXTrig.ConvertWorldToFixed12(radiusSq / (gte * gte))); Vector3 offset = interactable.InteractionOffset; writer.Write(PSXTrig.ConvertWorldToFixed12(offset.x / gte)); writer.Write(PSXTrig.ConvertWorldToFixed12(-offset.y / gte)); writer.Write(PSXTrig.ConvertWorldToFixed12(offset.z / gte)); writer.Write((byte)interactable.InteractButton); byte flags = 0; if (interactable.IsRepeatable) flags |= 0x01; if (interactable.ShowPrompt) flags |= 0x02; if (interactable.RequireLineOfSight) flags |= 0x04; writer.Write(flags); writer.Write(interactable.CooldownFrames); writer.Write((ushort)0); // currentCooldown (runtime) writer.Write((ushort)goIndex); } // ────────────────────────────────────────────────────── // World collision soup (version 7+) // ────────────────────────────────────────────────────── if (scene.collisionExporter.MeshCount > 0) { AlignToFourBytes(writer); scene.collisionExporter.WriteToBinary(writer, gte); } // ────────────────────────────────────────────────────── // Nav region data (version 7+) // ────────────────────────────────────────────────────── if (scene.navRegionBuilder.RegionCount > 0) { AlignToFourBytes(writer); scene.navRegionBuilder.WriteToBinary(writer, gte); } // ────────────────────────────────────────────────────── // Room/portal data (version 11, interior scenes) // Must be in the sequential cursor section (after nav regions, // before atlas metadata) so the C++ reader can find it. // ────────────────────────────────────────────────────── if (scene.roomBuilder != null && scene.roomBuilder.RoomCount > 0) { AlignToFourBytes(writer); scene.roomBuilder.WriteToBinary(writer, scene.gteScaling); log?.Invoke($"Room/portal data: {scene.roomBuilder.RoomCount} rooms, {scene.roomBuilder.PortalCount} portals, {scene.roomBuilder.TotalTriRefCount} tri-refs.", LogType.Log); } // ────────────────────────────────────────────────────── // Atlas metadata // ────────────────────────────────────────────────────── foreach (TextureAtlas atlas in scene.atlases) { atlasOffset.PlaceholderPositions.Add(writer.BaseStream.Position); writer.Write((int)0); // placeholder writer.Write((ushort)atlas.Width); writer.Write((ushort)TextureAtlas.Height); writer.Write((ushort)atlas.PositionX); writer.Write((ushort)atlas.PositionY); } // ────────────────────────────────────────────────────── // CLUT metadata // ────────────────────────────────────────────────────── foreach (TextureAtlas atlas in scene.atlases) { foreach (var texture in atlas.ContainedTextures) { if (texture.ColorPalette != null) { clutOffset.PlaceholderPositions.Add(writer.BaseStream.Position); writer.Write((int)0); // placeholder writer.Write((ushort)texture.ClutPackingX); writer.Write((ushort)texture.ClutPackingY); writer.Write((ushort)texture.ColorPalette.Count); writer.Write((ushort)0); } } } // ══════════════════════════════════════════════════════ // Data sections // ══════════════════════════════════════════════════════ // Lua data foreach (LuaFile luaFile in luaFiles) { AlignToFourBytes(writer); luaOffset.DataOffsets.Add(writer.BaseStream.Position); writer.Write(Encoding.UTF8.GetBytes(luaFile.LuaScript)); } // Mesh data foreach (PSXObjectExporter exporter in scene.exporters) { AlignToFourBytes(writer); meshOffset.DataOffsets.Add(writer.BaseStream.Position); totalFaces += exporter.Mesh.Triangles.Count; foreach (Tri tri in exporter.Mesh.Triangles) { // Vertex positions (3 × 6 bytes) WriteVertexPosition(writer, tri.v0); WriteVertexPosition(writer, tri.v1); WriteVertexPosition(writer, tri.v2); // Normal for v0 only WriteVertexNormals(writer, tri.v0); // Vertex colors (3 × 4 bytes) WriteVertexColor(writer, tri.v0); WriteVertexColor(writer, tri.v1); WriteVertexColor(writer, tri.v2); if (tri.IsUntextured) { // Zero UVs writer.Write((byte)0); writer.Write((byte)0); writer.Write((byte)0); writer.Write((byte)0); writer.Write((byte)0); writer.Write((byte)0); writer.Write((ushort)0); // padding // Sentinel tpage = 0xFFFF marks untextured writer.Write((ushort)0xFFFF); writer.Write((ushort)0); writer.Write((ushort)0); writer.Write((ushort)0); } else { PSXTexture2D tex = exporter.GetTexture(tri.TextureIndex); int expander = 16 / (int)tex.BitDepth; WriteVertexUV(writer, tri.v0, tex, expander); WriteVertexUV(writer, tri.v1, tex, expander); WriteVertexUV(writer, tri.v2, tex, expander); writer.Write((ushort)0); // padding TPageAttr tpage = new TPageAttr(); tpage.SetPageX(tex.TexpageX); tpage.SetPageY(tex.TexpageY); tpage.Set(tex.BitDepth.ToColorMode()); tpage.SetDithering(true); writer.Write((ushort)tpage.info); writer.Write((ushort)tex.ClutPackingX); writer.Write((ushort)tex.ClutPackingY); writer.Write((ushort)0); // padding } } } // ────────────────────────────────────────────────────── // Object name table (version 9) // ────────────────────────────────────────────────────── AlignToFourBytes(writer); long nameTableStart = writer.BaseStream.Position; foreach (PSXObjectExporter exporter in scene.exporters) { string objName = exporter.gameObject.name; if (objName.Length > 24) objName = objName.Substring(0, 24); byte[] nameBytes = Encoding.UTF8.GetBytes(objName); writer.Write((byte)nameBytes.Length); writer.Write(nameBytes); writer.Write((byte)0); // null terminator } // Backfill name table offset { long endPos = writer.BaseStream.Position; writer.Seek((int)nameTableOffsetPos, SeekOrigin.Begin); writer.Write((uint)nameTableStart); writer.Seek((int)endPos, SeekOrigin.Begin); } // ────────────────────────────────────────────────────── // Audio clip data (version 10) // Metadata entries are 16 bytes each, written contiguously. // Name strings follow the metadata block with backfilled offsets. // ADPCM blobs deferred to dead zone. // ────────────────────────────────────────────────────── List audioDataOffsetPositions = new List(); if (audioClipCount > 0 && scene.audioClips != null) { AlignToFourBytes(writer); long audioTableStart = writer.BaseStream.Position; List audioNameOffsetPositions = new List(); List audioClipNames = new List(); // Phase 1: Write all 16-byte metadata entries contiguously for (int i = 0; i < audioClipCount; i++) { var clip = scene.audioClips[i]; string name = clip.clipName ?? ""; if (name.Length > 255) name = name.Substring(0, 255); audioDataOffsetPositions.Add(writer.BaseStream.Position); writer.Write((uint)0); // dataOffset placeholder (backfilled in dead zone) writer.Write((uint)(clip.adpcmData?.Length ?? 0)); writer.Write((ushort)clip.sampleRate); writer.Write((byte)(clip.loop ? 1 : 0)); writer.Write((byte)name.Length); audioNameOffsetPositions.Add(writer.BaseStream.Position); writer.Write((uint)0); // nameOffset placeholder audioClipNames.Add(name); } // Phase 2: Write name strings (after all metadata entries) for (int i = 0; i < audioClipCount; i++) { string name = audioClipNames[i]; long namePos = writer.BaseStream.Position; byte[] nameBytes = System.Text.Encoding.ASCII.GetBytes(name); writer.Write(nameBytes); writer.Write((byte)0); long curPos = writer.BaseStream.Position; writer.Seek((int)audioNameOffsetPositions[i], SeekOrigin.Begin); writer.Write((uint)namePos); writer.Seek((int)curPos, SeekOrigin.Begin); } // Backfill audio table offset in header { long curPos = writer.BaseStream.Position; writer.Seek((int)audioTableOffsetPos, SeekOrigin.Begin); writer.Write((uint)audioTableStart); writer.Seek((int)curPos, SeekOrigin.Begin); } } // ────────────────────────────────────────────────────── // Cutscene data (version 12) // ────────────────────────────────────────────────────── if (cutsceneCount > 0) { PSXCutsceneExporter.ExportCutscenes( writer, scene.cutscenes, scene.exporters, scene.audioSources, scene.gteScaling, out long cutsceneTableActual, log); // Backfill cutscene table offset in header if (cutsceneTableActual != 0) { long curPos = writer.BaseStream.Position; writer.Seek((int)cutsceneTableOffsetPos, SeekOrigin.Begin); writer.Write((uint)cutsceneTableActual); writer.Seek((int)curPos, SeekOrigin.Begin); } } // ────────────────────────────────────────────────────── // UI canvas + font data (version 13) // Font descriptors: 112 bytes each (before canvas data) // Canvas descriptor table: 12 bytes per canvas // Element records: 48 bytes each // Name and text strings follow with offset backfill // Font pixel data is deferred to the dead zone. // ────────────────────────────────────────────────────── List fontDataOffsetPositions = new List(); if ((uiCanvasCount > 0 && scene.canvases != null) || uiFontCount > 0) { AlignToFourBytes(writer); long uiTableStart = writer.BaseStream.Position; // ── Font descriptors (112 bytes each) ── // Layout: glyphW(1) glyphH(1) vramX(2) vramY(2) textureH(2) // dataOffset(4) dataSize(4) if (scene.fonts != null) { foreach (var font in scene.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 // [16-111] per-character advance widths for proportional rendering if (font.AdvanceWidths != null && font.AdvanceWidths.Length >= 96) writer.Write(font.AdvanceWidths, 0, 96); else writer.Write(new byte[96]); // zero-fill if missing } } // Font pixel data is deferred to the dead zone (after pixelDataOffset). // The C++ loader reads font pixel data via the dataOffset, uploads to VRAM, // then never accesses it again. // ── Canvas descriptor table (12 bytes each) ── // Layout per descriptor: // uint32 dataOffset — offset to this canvas's element array // uint8 nameLen // uint8 sortOrder // uint8 elementCount // uint8 flags — bit 0 = startVisible // uint32 nameOffset — offset to null-terminated name string List canvasDataOffsetPos = new List(); List canvasNameOffsetPos = new List(); for (int ci = 0; ci < uiCanvasCount; ci++) { var cv = scene.canvases[ci]; string cvName = cv.Name ?? ""; if (cvName.Length > 24) cvName = cvName.Substring(0, 24); canvasDataOffsetPos.Add(writer.BaseStream.Position); writer.Write((uint)0); // dataOffset placeholder writer.Write((byte)cvName.Length); // nameLen writer.Write((byte)cv.SortOrder); // sortOrder writer.Write((byte)(cv.Elements?.Length ?? 0)); // elementCount writer.Write((byte)(cv.StartVisible ? 0x01 : 0x00)); // flags canvasNameOffsetPos.Add(writer.BaseStream.Position); writer.Write((uint)0); // nameOffset placeholder } // Phase 2: Write element records (56 bytes each) per canvas for (int ci = 0; ci < uiCanvasCount; ci++) { var cv = scene.canvases[ci]; if (cv.Elements == null || cv.Elements.Length == 0) continue; AlignToFourBytes(writer); long elemStart = writer.BaseStream.Position; // Track text offset positions for backfill List textOffsetPositions = new List(); List textContents = new List(); foreach (var el in cv.Elements) { // Identity (8 bytes) writer.Write((byte)el.Type); // type byte eFlags = (byte)(el.StartVisible ? 0x01 : 0x00); writer.Write(eFlags); // flags string eName = el.Name ?? ""; if (eName.Length > 24) eName = eName.Substring(0, 24); writer.Write((byte)eName.Length); // nameLen writer.Write((byte)0); // pad0 // nameOffset placeholder (backfilled later) long elemNameOffPos = writer.BaseStream.Position; writer.Write((uint)0); // nameOffset // 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); // [0] writer.Write(el.TexpageY); // [1] writer.Write(el.ClutX); // [2-3] writer.Write(el.ClutY); // [4-5] writer.Write(el.U0); // [6] writer.Write(el.V0); // [7] writer.Write(el.U1); // [8] writer.Write(el.V1); // [9] writer.Write(el.BitDepthIndex); // [10] writer.Write(new byte[5]); // [11-15] padding break; case PSXUIElementType.Progress: writer.Write(el.BgR); // [0] writer.Write(el.BgG); // [1] writer.Write(el.BgB); // [2] writer.Write(el.ProgressValue); // [3] writer.Write(new byte[12]); // [4-15] padding break; case PSXUIElementType.Text: writer.Write(el.FontIndex); // [0] font index (0=system, 1+=custom) writer.Write(new byte[15]); // [1-15] padding break; default: writer.Write(new byte[16]); // zeroed break; } // Text content offset (8 bytes) long textOff = writer.BaseStream.Position; writer.Write((uint)0); // textOffset placeholder writer.Write((uint)0); // pad2 // Remember for backfill textOffsetPositions.Add(textOff); textContents.Add(el.Type == PSXUIElementType.Text ? (el.DefaultText ?? "") : null); // Also remember element name for backfill // We need to write it after all elements textOffsetPositions.Add(elemNameOffPos); textContents.Add("__NAME__" + eName); } // Backfill canvas data offset { long curPos = writer.BaseStream.Position; writer.Seek((int)canvasDataOffsetPos[ci], SeekOrigin.Begin); writer.Write((uint)elemStart); writer.Seek((int)curPos, SeekOrigin.Begin); } // Write strings and backfill offsets for (int si = 0; si < textOffsetPositions.Count; si++) { string s = textContents[si]; if (s == null) continue; bool isName = s.StartsWith("__NAME__"); string content = isName ? s.Substring(8) : s; if (string.IsNullOrEmpty(content) && !isName) continue; long strPos = writer.BaseStream.Position; byte[] strBytes = Encoding.UTF8.GetBytes(content); writer.Write(strBytes); writer.Write((byte)0); // null terminator long curPos = writer.BaseStream.Position; writer.Seek((int)textOffsetPositions[si], SeekOrigin.Begin); writer.Write((uint)strPos); writer.Seek((int)curPos, SeekOrigin.Begin); } } // Write canvas name strings and backfill name offsets for (int ci = 0; ci < uiCanvasCount; ci++) { string cvName = scene.canvases[ci].Name ?? ""; if (cvName.Length > 24) cvName = cvName.Substring(0, 24); long namePos = writer.BaseStream.Position; byte[] nameBytes = Encoding.UTF8.GetBytes(cvName); writer.Write(nameBytes); writer.Write((byte)0); // null terminator long curPos = writer.BaseStream.Position; writer.Seek((int)canvasNameOffsetPos[ci], SeekOrigin.Begin); writer.Write((uint)namePos); writer.Seek((int)curPos, SeekOrigin.Begin); } // Backfill UI table offset in header { long curPos = writer.BaseStream.Position; writer.Seek((int)uiTableOffsetPos, SeekOrigin.Begin); writer.Write((uint)uiTableStart); writer.Seek((int)curPos, SeekOrigin.Begin); } int totalElements = 0; foreach (var cv in scene.canvases) totalElements += cv.Elements?.Length ?? 0; log?.Invoke($"{uiCanvasCount} UI canvases ({totalElements} elements) written.", LogType.Log); } // ══════════════════════════════════════════════════════ // DEAD ZONE — pixel/audio bulk data (freed after VRAM/SPU upload) // Everything written after this point is not needed at runtime. // ══════════════════════════════════════════════════════ AlignToFourBytes(writer); long pixelDataStart = writer.BaseStream.Position; // Atlas pixel data foreach (TextureAtlas atlas in scene.atlases) { AlignToFourBytes(writer); atlasOffset.DataOffsets.Add(writer.BaseStream.Position); for (int y = 0; y < atlas.vramPixels.GetLength(1); y++) for (int x = 0; x < atlas.vramPixels.GetLength(0); x++) writer.Write(atlas.vramPixels[x, y].Pack()); } // CLUT data foreach (TextureAtlas atlas in scene.atlases) { foreach (var texture in atlas.ContainedTextures) { if (texture.ColorPalette != null) { AlignToFourBytes(writer); clutOffset.DataOffsets.Add(writer.BaseStream.Position); foreach (VRAMPixel color in texture.ColorPalette) writer.Write((ushort)color.Pack()); } } } // Audio ADPCM data if (audioClipCount > 0 && scene.audioClips != null) { for (int i = 0; i < audioClipCount; i++) { byte[] data = scene.audioClips[i].adpcmData; if (data != null && data.Length > 0) { AlignToFourBytes(writer); long dataPos = writer.BaseStream.Position; writer.Write(data); long curPos = writer.BaseStream.Position; writer.Seek((int)audioDataOffsetPositions[i], SeekOrigin.Begin); writer.Write((uint)dataPos); writer.Seek((int)curPos, SeekOrigin.Begin); } } int totalAudioBytes = 0; foreach (var clip in scene.audioClips) if (clip.adpcmData != null) totalAudioBytes += clip.adpcmData.Length; log?.Invoke($"{audioClipCount} audio clips ({totalAudioBytes / 1024}KB ADPCM) written.", LogType.Log); } // Font pixel data if (scene.fonts != null) { for (int fi = 0; fi < scene.fonts.Length; fi++) { var font = scene.fonts[fi]; if (font.PixelData == null || font.PixelData.Length == 0) continue; AlignToFourBytes(writer); long dataPos = writer.BaseStream.Position; writer.Write(font.PixelData); long curPos = writer.BaseStream.Position; writer.Seek((int)fontDataOffsetPositions[fi], SeekOrigin.Begin); writer.Write((uint)dataPos); writer.Seek((int)curPos, SeekOrigin.Begin); } } // Backfill pixelDataOffset in header { long curPos = writer.BaseStream.Position; writer.Seek((int)pixelDataOffsetPos, SeekOrigin.Begin); writer.Write((uint)pixelDataStart); writer.Seek((int)curPos, SeekOrigin.Begin); } long totalSize = writer.BaseStream.Position; long deadBytes = totalSize - pixelDataStart; log?.Invoke($"Pixel/audio dead zone: {deadBytes / 1024}KB (freed after VRAM/SPU upload).", LogType.Log); // Backfill offsets BackfillOffsets(writer, luaOffset, "lua", log); BackfillOffsets(writer, meshOffset, "mesh", log); BackfillOffsets(writer, atlasOffset, "atlas", log); BackfillOffsets(writer, clutOffset, "clut", log); } log?.Invoke($"{totalFaces} faces written to {Path.GetFileName(path)}", LogType.Log); } // ═══════════════════════════════════════════════════════════════ // Static helpers // ═══════════════════════════════════════════════════════════════ private static void WriteVertexPosition(BinaryWriter w, PSXVertex v) { w.Write((short)v.vx); w.Write((short)v.vy); w.Write((short)v.vz); } private static void WriteVertexNormals(BinaryWriter w, PSXVertex v) { w.Write((short)v.nx); w.Write((short)v.ny); w.Write((short)v.nz); } private static void WriteVertexColor(BinaryWriter w, PSXVertex v) { w.Write((byte)v.r); w.Write((byte)v.g); w.Write((byte)v.b); w.Write((byte)0); // padding } private static void WriteVertexUV(BinaryWriter w, PSXVertex v, PSXTexture2D t, int expander) { w.Write((byte)(v.u + t.PackingX * expander)); w.Write((byte)(v.v + t.PackingY)); } private static void WriteObjectAABB(BinaryWriter writer, PSXObjectExporter exporter, float gte) { MeshFilter mf = exporter.GetComponent(); Mesh mesh = mf?.sharedMesh; if (mesh != null) { WriteWorldAABB(writer, exporter, mesh.bounds, gte); } else { for (int z = 0; z < 6; z++) writer.Write((int)0); } } private static void WriteWorldAABB(BinaryWriter writer, PSXObjectExporter exporter, Bounds localBounds, float gte) { Vector3 ext = localBounds.extents; Vector3 center = localBounds.center; Vector3 aabbMin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue); Vector3 aabbMax = new Vector3(float.MinValue, float.MinValue, float.MinValue); // Compute world-space AABB from 8 transformed corners for (int i = 0; i < 8; i++) { Vector3 corner = center + new Vector3( (i & 1) != 0 ? ext.x : -ext.x, (i & 2) != 0 ? ext.y : -ext.y, (i & 4) != 0 ? ext.z : -ext.z ); Vector3 world = exporter.transform.TransformPoint(corner); aabbMin = Vector3.Min(aabbMin, world); aabbMax = Vector3.Max(aabbMax, world); } // PS1 coordinate space (negate Y, swap min/max) writer.Write(PSXTrig.ConvertWorldToFixed12(aabbMin.x / gte)); writer.Write(PSXTrig.ConvertWorldToFixed12(-aabbMax.y / gte)); writer.Write(PSXTrig.ConvertWorldToFixed12(aabbMin.z / gte)); writer.Write(PSXTrig.ConvertWorldToFixed12(aabbMax.x / gte)); writer.Write(PSXTrig.ConvertWorldToFixed12(-aabbMin.y / gte)); writer.Write(PSXTrig.ConvertWorldToFixed12(aabbMax.z / gte)); } private static void AlignToFourBytes(BinaryWriter writer) { long pos = writer.BaseStream.Position; int padding = (int)(4 - (pos % 4)) % 4; if (padding > 0) writer.Write(new byte[padding]); } private static void BackfillOffsets(BinaryWriter writer, OffsetData data, string sectionName, Action log) { if (data.PlaceholderPositions.Count != data.DataOffsets.Count) { log?.Invoke($"Offset mismatch in {sectionName}: {data.PlaceholderPositions.Count} placeholders vs {data.DataOffsets.Count} data blocks", LogType.Error); return; } for (int i = 0; i < data.PlaceholderPositions.Count; i++) { writer.Seek((int)data.PlaceholderPositions[i], SeekOrigin.Begin); writer.Write((int)data.DataOffsets[i]); } } } }