using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEngine;
namespace SplashEdit.RuntimeCode
{
///
/// Pure binary serializer for the splashpack v16 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 PSXNavRegionBuilder navRegionBuilder;
public PSXRoomBuilder roomBuilder;
public BVH bvh;
public LuaFile sceneLuaFile;
public float gteScaling;
// Cutscene data (v12)
public PSXCutsceneClip[] cutscenes;
public PSXAudioClip[] 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 v16 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(PSXTrig.ConvertCoordinateToPSX(scene.playerPos.x, gte));
writer.Write(PSXTrig.ConvertCoordinateToPSX(-scene.playerPos.y, gte));
writer.Write(PSXTrig.ConvertCoordinateToPSX(scene.playerPos.z, gte));
writer.Write(PSXTrig.ConvertToFixed12(scene.playerRot.eulerAngles.x * Mathf.Deg2Rad));
writer.Write(PSXTrig.ConvertToFixed12(scene.playerRot.eulerAngles.y * Mathf.Deg2Rad));
writer.Write(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)0); // collisionMeshCount (removed, kept for binary compat)
writer.Write((ushort)0); // collisionTriCount (removed, kept for binary compat)
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 (28 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)));
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);
// Prompt canvas name (16 bytes, null-terminated, zero-padded)
string canvasName = interactable.PromptCanvasName ?? "";
byte[] nameBytes = new byte[16];
int len = System.Math.Min(canvasName.Length, 15);
for (int ci = 0; ci < len; ci++)
nameBytes[ci] = (byte)canvasName[ci];
writer.Write(nameBytes);
}
// ──────────────────────────────────────────────────────
// 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
// haha funny word. Sentinel, sentinel, sentinel. I could keep saying it forever.
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.
// You may be asking why we don't just put pixel/audio data in separate files
// or why don't we put this data at the end of the file to begin with. The answer is
// Very simple and I'm going to tell it to you right now... OH GOD I FORGOT TO TURN OFF THE STOVE (runs away)
// ══════════════════════════════════════════════════════
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]);
}
}
}
}