Files
secretsplash/Runtime/PSXSceneWriter.cs
2026-03-25 17:14:22 +01:00

985 lines
52 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEngine;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Pure binary serializer for the splashpack v8 format.
/// All I/O extracted from PSXSceneExporter so the MonoBehaviour stays thin.
/// </summary>
public static class PSXSceneWriter
{
/// <summary>
/// All scene data needed to produce a .bin file.
/// Populated by PSXSceneExporter before calling <see cref="Write"/>.
/// </summary>
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;
// 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 int sceneType; // 0=exterior, 1=interior
public bool fogEnabled;
public Color fogColor;
public int fogDensity; // 1-10
}
// ─── Offset bookkeeping ───
private sealed class OffsetData
{
public readonly List<long> PlaceholderPositions = new List<long>();
public readonly List<long> DataOffsets = new List<long>();
}
// ═══════════════════════════════════════════════════════════════
// Public API
// ═══════════════════════════════════════════════════════════════
/// <summary>
/// Serialize the scene to a splashpack v8 binary file.
/// </summary>
/// <param name="path">Absolute file path to write.</param>
/// <param name="scene">Pre-built scene data.</param>
/// <param name="log">Optional callback for progress messages.</param>
public static void Write(string path, in SceneData scene, Action<string, LogType> 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<LuaFile> luaFiles = new List<LuaFile>();
// 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);
using (BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)))
{
int colliderCount = 0;
foreach (var e in scene.exporters)
{
if (e.CollisionType != PSXCollisionType.None)
colliderCount++;
}
// Build exporter index lookup for components
Dictionary<PSXObjectExporter, int> exporterIndex = new Dictionary<PSXObjectExporter, int>();
for (int i = 0; i < scene.exporters.Length; i++)
exporterIndex[scene.exporters[i]] = i;
// ──────────────────────────────────────────────────────
// Header (72 bytes total — splashpack v8)
// ──────────────────────────────────────────────────────
writer.Write('S');
writer.Write('P');
writer.Write((ushort)13); // version
writer.Write((ushort)luaFiles.Count);
writer.Write((ushort)scene.exporters.Length);
writer.Write((ushort)0); // navmeshCount (legacy)
writer.Write((ushort)scene.atlases.Length);
writer.Write((ushort)clutCount);
writer.Write((ushort)colliderCount);
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));
// Scene Lua index
if (scene.sceneLuaFile != null)
writer.Write((short)luaFiles.IndexOf(scene.sceneLuaFile));
else
writer.Write((short)-1);
// BVH info
writer.Write((ushort)scene.bvh.NodeCount);
writer.Write((ushort)scene.bvh.TriangleRefCount);
// Component counts (version 4)
writer.Write((ushort)scene.interactables.Length);
writer.Write((ushort)0); // healthCount (removed)
writer.Write((ushort)0); // timerCount (removed)
writer.Write((ushort)0); // spawnerCount (removed)
// NavGrid (version 5, legacy)
writer.Write((ushort)0);
writer.Write((ushort)0);
// Scene type (version 6)
writer.Write((ushort)scene.sceneType); // 0=exterior, 1=interior
writer.Write((ushort)0);
// World collision + nav regions (version 7)
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 (version 8, 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); // padding
}
// Name table offset placeholder (version 9, 4 bytes)
long nameTableOffsetPos = writer.BaseStream.Position;
writer.Write((uint)0); // placeholder for name table offset
// Audio clip info (version 10, 8 bytes)
int audioClipCount = scene.audioClips?.Length ?? 0;
writer.Write((ushort)audioClipCount);
writer.Write((ushort)0); // padding
long audioTableOffsetPos = writer.BaseStream.Position;
writer.Write((uint)0); // placeholder for audio table offset
// Fog + room/portal header (version 11, 12 bytes)
{
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); // reserved
int roomCount = scene.roomBuilder?.RoomCount ?? 0;
int portalCount = scene.roomBuilder?.PortalCount ?? 0;
int roomTriRefCount = scene.roomBuilder?.TotalTriRefCount ?? 0;
// roomCount is the room count NOT including catch-all; the binary adds +1 for it
writer.Write((ushort)(roomCount > 0 ? roomCount + 1 : 0));
writer.Write((ushort)portalCount);
writer.Write((ushort)roomTriRefCount);
}
// Cutscene header (version 12, 8 bytes)
int cutsceneCount = scene.cutscenes?.Length ?? 0;
writer.Write((ushort)cutsceneCount);
writer.Write((ushort)0); // reserved_cs
long cutsceneTableOffsetPos = writer.BaseStream.Position;
writer.Write((uint)0); // cutsceneTableOffset placeholder
// UI canvas header (version 13, 8 bytes)
int uiCanvasCount = scene.canvases?.Length ?? 0;
int uiFontCount = scene.fonts?.Length ?? 0;
writer.Write((ushort)uiCanvasCount);
writer.Write((byte)uiFontCount); // was uiReserved low byte
writer.Write((byte)0); // was uiReserved high byte
long uiTableOffsetPos = writer.BaseStream.Position;
writer.Write((uint)0); // uiTableOffset 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<PSXObjectExporter, int> interactableIndices = new Dictionary<PSXObjectExporter, int>();
for (int i = 0; i < scene.interactables.Length; i++)
{
var exp = scene.interactables[i].GetComponent<PSXObjectExporter>();
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)
// ──────────────────────────────────────────────────────
for (int exporterIdx = 0; exporterIdx < scene.exporters.Length; exporterIdx++)
{
PSXObjectExporter exporter = scene.exporters[exporterIdx];
if (exporter.CollisionType == PSXCollisionType.None)
continue;
MeshFilter meshFilter = exporter.GetComponent<MeshFilter>();
Mesh collisionMesh = exporter.CustomCollisionMesh != null
? exporter.CustomCollisionMesh
: meshFilter?.sharedMesh;
if (collisionMesh == null)
continue;
WriteWorldAABB(writer, exporter, collisionMesh.bounds, gte);
// Collision metadata (8 bytes)
writer.Write((byte)exporter.CollisionType);
writer.Write((byte)(1 << (exporter.CollisionLayer - 1)));
writer.Write((ushort)exporterIdx);
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<PSXObjectExporter>();
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
}
}
}
// 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());
}
}
}
// ──────────────────────────────────────────────────────
// 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)
// ──────────────────────────────────────────────────────
if (audioClipCount > 0 && scene.audioClips != null)
{
// Write audio table: per clip metadata (12 bytes each)
AlignToFourBytes(writer);
long audioTableStart = writer.BaseStream.Position;
// First pass: write metadata placeholders (16 bytes each)
List<long> audioDataOffsetPositions = new List<long>();
List<long> audioNameOffsetPositions = new List<long>();
for (int i = 0; i < audioClipCount; i++)
{
var clip = scene.audioClips[i];
audioDataOffsetPositions.Add(writer.BaseStream.Position);
writer.Write((uint)0); // dataOffset placeholder
writer.Write((uint)(clip.adpcmData?.Length ?? 0)); // sizeBytes
writer.Write((ushort)clip.sampleRate);
string name = clip.clipName ?? "";
writer.Write((byte)(clip.loop ? 1 : 0));
writer.Write((byte)System.Math.Min(name.Length, 255));
audioNameOffsetPositions.Add(writer.BaseStream.Position);
writer.Write((uint)0); // nameOffset placeholder
}
// Second pass: write ADPCM data and backfill offsets
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);
// Backfill data offset
long curPos = writer.BaseStream.Position;
writer.Seek((int)audioDataOffsetPositions[i], SeekOrigin.Begin);
writer.Write((uint)dataPos);
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);
}
int totalAudioBytes = 0;
foreach (var clip in scene.audioClips)
if (clip.adpcmData != null) totalAudioBytes += clip.adpcmData.Length;
// Third pass: write audio clip names and backfill name offsets
for (int i = 0; i < audioClipCount; i++)
{
string name = scene.audioClips[i].clipName ?? "";
if (name.Length > 255) name = name.Substring(0, 255);
long namePos = writer.BaseStream.Position;
byte[] nameBytes = System.Text.Encoding.ASCII.GetBytes(name);
writer.Write(nameBytes);
writer.Write((byte)0); // null terminator
long curPos = writer.BaseStream.Position;
writer.Seek((int)audioNameOffsetPositions[i], SeekOrigin.Begin);
writer.Write((uint)namePos);
writer.Seek((int)curPos, SeekOrigin.Begin);
}
log?.Invoke($"{audioClipCount} audio clips ({totalAudioBytes / 1024}KB ADPCM) written.", LogType.Log);
}
// ──────────────────────────────────────────────────────
// 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)
// Font pixel data: raw 4bpp (after font descriptors)
// Canvas descriptor table: 12 bytes per canvas
// Element records: 48 bytes each
// Name and text strings follow with offset backfill
// ──────────────────────────────────────────────────────
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)
List<long> fontDataOffsetPositions = new List<long>();
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 (raw 4bpp) ──
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);
// Backfill data offset
long curPos = writer.BaseStream.Position;
writer.Seek((int)fontDataOffsetPositions[fi], SeekOrigin.Begin);
writer.Write((uint)dataPos);
writer.Seek((int)curPos, SeekOrigin.Begin);
}
if (scene.fonts.Length > 0)
{
int totalFontBytes = 0;
foreach (var f in scene.fonts) totalFontBytes += f.PixelData?.Length ?? 0;
log?.Invoke($"{scene.fonts.Length} custom font(s) written ({totalFontBytes} bytes 4bpp data).", LogType.Log);
}
}
// ── 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<long> canvasDataOffsetPos = new List<long>();
List<long> canvasNameOffsetPos = new List<long>();
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<long> textOffsetPositions = new List<long>();
List<string> textContents = new List<string>();
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);
}
// 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<MeshFilter>();
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<string, LogType> 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]);
}
}
}
}