980 lines
51 KiB
C#
980 lines
51 KiB
C#
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: 16 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 (16 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
|
||
}
|
||
}
|
||
|
||
// ── 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]);
|
||
}
|
||
}
|
||
}
|
||
}
|