Files
secretsplash/Runtime/PSXSceneWriter.cs
2026-03-24 15:50:35 +01:00

729 lines
37 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;
// 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)12); // 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
// ──────────────────────────────────────────────────────
// 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);
}
}
// 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]);
}
}
}
}