diff --git a/Editor/Core/SceneMemoryReport.cs b/Editor/Core/SceneMemoryReport.cs
new file mode 100644
index 0000000..81ab27e
--- /dev/null
+++ b/Editor/Core/SceneMemoryReport.cs
@@ -0,0 +1,183 @@
+using System;
+using System.IO;
+using UnityEngine;
+
+namespace SplashEdit.EditorCode
+{
+ ///
+ /// Memory analysis report for a single exported scene.
+ /// All values are in bytes unless noted otherwise.
+ ///
+ [Serializable]
+ public class SceneMemoryReport
+ {
+ public string sceneName;
+
+ // ─── Main RAM ───
+ public long splashpackFileSize; // Total file on disc
+ public long splashpackLiveSize; // Bytes kept in RAM at runtime (before bulk data freed)
+ public int triangleCount;
+ public int gameObjectCount;
+
+ // ─── VRAM (1024 x 512 x 2 = 1,048,576 bytes) ───
+ public long framebufferSize; // 2 x W x H x 2
+ public long textureAtlasSize; // Sum of atlas pixel data
+ public long clutSize; // Sum of CLUT entries x 2
+ public long fontVramSize; // Custom font textures
+ public int atlasCount;
+ public int clutCount;
+
+ // ─── SPU RAM (512KB, 0x1010 reserved) ───
+ public long audioDataSize;
+ public int audioClipCount;
+
+ // ─── CD Storage ───
+ public long loaderPackSize;
+
+ // ─── Constants ───
+ public const long TOTAL_RAM = 2 * 1024 * 1024;
+ public const long KERNEL_RESERVED = 0x10000; // 64KB kernel area
+ public const long USABLE_RAM = TOTAL_RAM - KERNEL_RESERVED;
+ public const long TOTAL_VRAM = 1024 * 512 * 2; // 1MB
+ public const long TOTAL_SPU = 512 * 1024;
+ public const long SPU_RESERVED = 0x1010;
+ public const long USABLE_SPU = TOTAL_SPU - SPU_RESERVED;
+
+ // Fixed runtime overhead from C++ (renderer.hh constants)
+ public const long BUMP_ALLOC_TOTAL = 2L * 8096 * 24; // ~380KB
+ public const long OT_TOTAL = 2L * 16384 * 4; // ~128KB
+ public const long VIS_REFS = 4096 * 4; // 16KB
+ public const long STACK_ESTIMATE = 32 * 1024; // 32KB
+ public const long LUA_OVERHEAD = 16 * 1024; // 16KB approximate
+ public const long SYSTEM_FONT_VRAM = 4 * 1024; // ~4KB
+
+ public long FixedOverhead => BUMP_ALLOC_TOTAL + OT_TOTAL + VIS_REFS + STACK_ESTIMATE + LUA_OVERHEAD;
+
+ /// RAM used by scene data (live portion of splashpack).
+ public long SceneRamUsage => splashpackLiveSize > 0 ? splashpackLiveSize : splashpackFileSize;
+
+ /// Total estimated RAM: fixed overhead + scene data. Does NOT include code/BSS.
+ public long TotalRamUsage => FixedOverhead + SceneRamUsage;
+
+ public long TotalVramUsed => framebufferSize + textureAtlasSize + clutSize + fontVramSize + SYSTEM_FONT_VRAM;
+ public long TotalSpuUsed => audioDataSize;
+ public long TotalDiscSize => splashpackFileSize + loaderPackSize;
+
+ public float RamPercent => Mathf.Clamp01((float)TotalRamUsage / USABLE_RAM) * 100f;
+ public float VramPercent => Mathf.Clamp01((float)TotalVramUsed / TOTAL_VRAM) * 100f;
+ public float SpuPercent => USABLE_SPU > 0 ? Mathf.Clamp01((float)TotalSpuUsed / USABLE_SPU) * 100f : 0f;
+
+ public long RamFree => USABLE_RAM - TotalRamUsage;
+ public long VramFree => TOTAL_VRAM - TotalVramUsed;
+ public long SpuFree => USABLE_SPU - TotalSpuUsed;
+ }
+
+ ///
+ /// Builds a SceneMemoryReport by reading the exported splashpack binary header
+ /// and the scene's VRAM/audio data.
+ ///
+ public static class SceneMemoryAnalyzer
+ {
+ ///
+ /// Analyze an exported scene. Call after ExportToPath().
+ ///
+ /// Display name for the scene.
+ /// Path to the exported .splashpack file.
+ /// Path to the loading screen file (may be null).
+ /// Texture atlases from the export pipeline.
+ /// Array of ADPCM byte sizes per audio clip.
+ /// Custom font descriptors.
+ public static SceneMemoryReport Analyze(
+ string sceneName,
+ string splashpackPath,
+ string loaderPackPath,
+ SplashEdit.RuntimeCode.TextureAtlas[] atlases,
+ long[] audioExportSizes,
+ SplashEdit.RuntimeCode.PSXFontData[] fonts,
+ int triangleCount = 0)
+ {
+ var r = new SceneMemoryReport { sceneName = sceneName };
+
+ // ── File sizes ──
+ if (File.Exists(splashpackPath))
+ r.splashpackFileSize = new FileInfo(splashpackPath).Length;
+ if (!string.IsNullOrEmpty(loaderPackPath) && File.Exists(loaderPackPath))
+ r.loaderPackSize = new FileInfo(loaderPackPath).Length;
+
+ r.triangleCount = triangleCount;
+
+ // ── Parse splashpack header for counts and pixelDataOffset ──
+ if (File.Exists(splashpackPath))
+ {
+ try { ReadHeader(splashpackPath, r); }
+ catch (Exception e) { Debug.LogWarning($"Memory report: failed to read header: {e.Message}"); }
+ }
+
+ // ── Framebuffers ──
+ int fbW = SplashSettings.ResolutionWidth;
+ int fbH = SplashSettings.ResolutionHeight;
+ int fbCount = SplashSettings.DualBuffering ? 2 : 1;
+ r.framebufferSize = fbW * fbH * 2L * fbCount;
+
+ // ── VRAM: Texture atlases + CLUTs ──
+ if (atlases != null)
+ {
+ r.atlasCount = atlases.Length;
+ foreach (var atlas in atlases)
+ {
+ r.textureAtlasSize += atlas.Width * SplashEdit.RuntimeCode.TextureAtlas.Height * 2L;
+ foreach (var tex in atlas.ContainedTextures)
+ {
+ if (tex.ColorPalette != null)
+ {
+ r.clutCount++;
+ r.clutSize += tex.ColorPalette.Count * 2L;
+ }
+ }
+ }
+ }
+
+ // ── VRAM: Custom fonts ──
+ if (fonts != null)
+ {
+ foreach (var font in fonts)
+ {
+ if (font.TextureHeight > 0)
+ r.fontVramSize += 64L * font.TextureHeight * 2; // 4bpp = 64 hwords wide
+ }
+ }
+
+ // ── SPU: Audio ──
+ if (audioExportSizes != null)
+ {
+ r.audioClipCount = audioExportSizes.Length;
+ foreach (long sz in audioExportSizes)
+ r.audioDataSize += sz;
+ }
+
+ return r;
+ }
+
+ private static void ReadHeader(string path, SceneMemoryReport r)
+ {
+ using (var reader = new BinaryReader(File.OpenRead(path)))
+ {
+ if (reader.BaseStream.Length < 104) return;
+
+ // Magic + version (4 bytes)
+ reader.ReadBytes(4);
+
+ // luaFileCount(2) + gameObjectCount(2) + textureAtlasCount(2) + clutCount(2)
+ reader.ReadUInt16(); // luaFileCount
+ r.gameObjectCount = reader.ReadUInt16();
+ reader.ReadUInt16(); // textureAtlasCount
+ reader.ReadUInt16(); // clutCount
+
+ // Skip to pixelDataOffset at byte 100
+ reader.BaseStream.Seek(100, SeekOrigin.Begin);
+ uint pixelDataOffset = reader.ReadUInt32();
+ r.splashpackLiveSize = pixelDataOffset > 0 ? pixelDataOffset : r.splashpackFileSize;
+ }
+ }
+ }
+}
diff --git a/Editor/Core/SceneMemoryReport.cs.meta b/Editor/Core/SceneMemoryReport.cs.meta
new file mode 100644
index 0000000..51ba2f8
--- /dev/null
+++ b/Editor/Core/SceneMemoryReport.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f68ba273eb88c3b4796e43f40b226c71
\ No newline at end of file
diff --git a/Editor/Core/SplashControlPanel.cs b/Editor/Core/SplashControlPanel.cs
index 2c7ddfc..a5977f0 100644
--- a/Editor/Core/SplashControlPanel.cs
+++ b/Editor/Core/SplashControlPanel.cs
@@ -42,6 +42,10 @@ namespace SplashEdit.EditorCode
// ───── Scene List ─────
private List _sceneList = new List();
+ // ───── Memory Reports ─────
+ private List _memoryReports = new List();
+ private bool _showMemoryReport = true;
+
// ───── Toolchain Cache ─────
private bool _hasMIPS;
private bool _hasMake;
@@ -585,6 +589,11 @@ namespace SplashEdit.EditorCode
// Clean Build toggle
SplashSettings.CleanBuild = EditorGUILayout.Toggle("Clean Build", SplashSettings.CleanBuild);
+ // Memory Overlay toggle
+ SplashSettings.MemoryOverlay = EditorGUILayout.Toggle(
+ new GUIContent("Memory Overlay", "Show heap/RAM usage bar at top-right during gameplay"),
+ SplashSettings.MemoryOverlay);
+
// Serial port (only for Real Hardware)
if (SplashSettings.Target == BuildTarget.RealHardware)
{
@@ -680,6 +689,145 @@ namespace SplashEdit.EditorCode
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
+
+ // Memory report (shown after export)
+ if (_memoryReports.Count > 0)
+ {
+ EditorGUILayout.Space(8);
+ DrawMemoryReports();
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════════
+ // Memory Reports
+ // ═══════════════════════════════════════════════════════════════
+
+ private void DrawMemoryReports()
+ {
+ _showMemoryReport = DrawSectionFoldout("Memory Report", _showMemoryReport);
+ if (!_showMemoryReport) return;
+
+ foreach (var report in _memoryReports)
+ {
+ EditorGUILayout.BeginVertical(PSXEditorStyles.CardStyle);
+
+ GUILayout.Label($"Scene: {report.sceneName}", PSXEditorStyles.SectionHeader);
+ EditorGUILayout.Space(4);
+
+ // Main RAM bar
+ DrawMemoryBar("Main RAM",
+ report.TotalRamUsage, SceneMemoryReport.USABLE_RAM,
+ report.RamPercent,
+ new Color(0.3f, 0.6f, 1f),
+ $"Scene: {FormatBytes(report.SceneRamUsage)} | " +
+ $"Fixed: {FormatBytes(report.FixedOverhead)} | " +
+ $"Free: {FormatBytes(report.RamFree)}");
+
+ EditorGUILayout.Space(4);
+
+ // VRAM bar
+ DrawMemoryBar("VRAM",
+ report.TotalVramUsed, SceneMemoryReport.TOTAL_VRAM,
+ report.VramPercent,
+ new Color(0.9f, 0.5f, 0.2f),
+ $"FB: {FormatBytes(report.framebufferSize)} | " +
+ $"Tex: {FormatBytes(report.textureAtlasSize)} | " +
+ $"CLUT: {FormatBytes(report.clutSize)} | " +
+ $"Free: {FormatBytes(report.VramFree)}");
+
+ EditorGUILayout.Space(4);
+
+ // SPU RAM bar
+ DrawMemoryBar("SPU RAM",
+ report.TotalSpuUsed, SceneMemoryReport.USABLE_SPU,
+ report.SpuPercent,
+ new Color(0.6f, 0.3f, 0.9f),
+ report.audioClipCount > 0
+ ? $"{report.audioClipCount} clips | {FormatBytes(report.audioDataSize)} | Free: {FormatBytes(report.SpuFree)}"
+ : "No audio clips");
+
+ EditorGUILayout.Space(4);
+
+ // CD Storage (no bar, just info)
+ EditorGUILayout.BeginHorizontal();
+ GUILayout.Label("CD Storage:", EditorStyles.miniLabel, GUILayout.Width(70));
+ GUILayout.Label(
+ $"Scene: {FormatBytes(report.splashpackFileSize)}" +
+ (report.loaderPackSize > 0 ? $" | Loader: {FormatBytes(report.loaderPackSize)}" : "") +
+ $" | Total: {FormatBytes(report.TotalDiscSize)}",
+ EditorStyles.miniLabel);
+ EditorGUILayout.EndHorizontal();
+
+ // Summary stats
+ PSXEditorStyles.DrawSeparator(4, 4);
+ EditorGUILayout.LabelField(
+ $"{report.gameObjectCount} objects | " +
+ $"{report.triangleCount} tris | " +
+ $"{report.atlasCount} atlases | " +
+ $"{report.clutCount} CLUTs",
+ PSXEditorStyles.RichLabel);
+
+ EditorGUILayout.EndVertical();
+ EditorGUILayout.Space(4);
+ }
+ }
+
+ private void DrawMemoryBar(string label, long used, long total, float percent, Color barColor, string details)
+ {
+ // Label row
+ EditorGUILayout.BeginHorizontal();
+ GUILayout.Label(label, EditorStyles.boldLabel, GUILayout.Width(70));
+ GUILayout.Label($"{FormatBytes(used)} / {FormatBytes(total)} ({percent:F1}%)", EditorStyles.miniLabel);
+ GUILayout.FlexibleSpace();
+ EditorGUILayout.EndHorizontal();
+
+ // Progress bar
+ Rect barRect = GUILayoutUtility.GetRect(0, 16, GUILayout.ExpandWidth(true));
+
+ // Background
+ EditorGUI.DrawRect(barRect, new Color(0.15f, 0.15f, 0.17f));
+
+ // Fill
+ float fillFraction = Mathf.Clamp01((float)used / total);
+ Rect fillRect = new Rect(barRect.x, barRect.y, barRect.width * fillFraction, barRect.height);
+
+ // Color shifts toward red when over 80%
+ Color fillColor = barColor;
+ if (percent > 90f)
+ fillColor = Color.Lerp(PSXEditorStyles.Warning, PSXEditorStyles.Error, (percent - 90f) / 10f);
+ else if (percent > 80f)
+ fillColor = Color.Lerp(barColor, PSXEditorStyles.Warning, (percent - 80f) / 10f);
+ EditorGUI.DrawRect(fillRect, fillColor);
+
+ // Border
+ DrawRectOutline(barRect, new Color(0.3f, 0.3f, 0.35f));
+
+ // Percent text overlay
+ var style = new GUIStyle(EditorStyles.miniLabel)
+ {
+ alignment = TextAnchor.MiddleCenter,
+ normal = { textColor = Color.white }
+ };
+ GUI.Label(barRect, $"{percent:F1}%", style);
+
+ // Details row
+ GUILayout.Label(details, EditorStyles.miniLabel);
+ }
+
+ private static void DrawRectOutline(Rect rect, Color color)
+ {
+ EditorGUI.DrawRect(new Rect(rect.x, rect.y, rect.width, 1), color);
+ EditorGUI.DrawRect(new Rect(rect.x, rect.yMax - 1, rect.width, 1), color);
+ EditorGUI.DrawRect(new Rect(rect.x, rect.y, 1, rect.height), color);
+ EditorGUI.DrawRect(new Rect(rect.xMax - 1, rect.y, 1, rect.height), color);
+ }
+
+ private static string FormatBytes(long bytes)
+ {
+ if (bytes < 0) return "N/A";
+ if (bytes < 1024) return $"{bytes} B";
+ if (bytes < 1024 * 1024) return $"{bytes / 1024f:F1} KB";
+ return $"{bytes / (1024f * 1024f):F2} MB";
}
// ═══════════════════════════════════════════════════════════════
@@ -808,6 +956,7 @@ namespace SplashEdit.EditorCode
{
SplashBuildPaths.EnsureDirectories();
_loaderPackCache = new Dictionary();
+ _memoryReports.Clear();
// Save current scene
string currentScenePath = SceneManager.GetActiveScene().path;
@@ -841,15 +990,34 @@ namespace SplashEdit.EditorCode
// Export to the build directory
string outputPath = SplashBuildPaths.GetSceneSplashpackPath(i, scene.name);
+ string loaderPath = null;
exporter.ExportToPath(outputPath);
Log($"Exported '{scene.name}' → {Path.GetFileName(outputPath)}", LogType.Log);
// Export loading screen if assigned
if (exporter.LoadingScreenPrefab != null)
{
- string loaderPath = SplashBuildPaths.GetSceneLoaderPackPath(i, scene.name);
+ loaderPath = SplashBuildPaths.GetSceneLoaderPackPath(i, scene.name);
ExportLoaderPack(exporter.LoadingScreenPrefab, loaderPath, i, scene.name);
}
+
+ // Generate memory report for this scene
+ try
+ {
+ var report = SceneMemoryAnalyzer.Analyze(
+ scene.name,
+ outputPath,
+ loaderPath,
+ exporter.LastExportAtlases,
+ exporter.LastExportAudioSizes,
+ exporter.LastExportFonts,
+ exporter.LastExportTriangleCount);
+ _memoryReports.Add(report);
+ }
+ catch (Exception reportEx)
+ {
+ Log($"Memory report for '{scene.name}' failed: {reportEx.Message}", LogType.Warning);
+ }
}
catch (Exception ex)
{
@@ -1017,6 +1185,9 @@ namespace SplashEdit.EditorCode
if (SplashSettings.Target == BuildTarget.ISO)
buildArg += " LOADER=cdrom";
+ if (SplashSettings.MemoryOverlay)
+ buildArg += " MEMOVERLAY=1";
+
int jobCount = Math.Max(1, SystemInfo.processorCount - 1);
string cleanPrefix = SplashSettings.CleanBuild ? "make clean && " : "";
string makeCmd = $"{cleanPrefix}make all -j{jobCount} {buildArg}".Trim();
diff --git a/Editor/Core/SplashSettings.cs b/Editor/Core/SplashSettings.cs
index 8ecf1c3..821d6d9 100644
--- a/Editor/Core/SplashSettings.cs
+++ b/Editor/Core/SplashSettings.cs
@@ -124,6 +124,18 @@ namespace SplashEdit.EditorCode
set => EditorPrefs.SetBool(Prefix + "CleanBuild", value);
}
+ // --- Memory Overlay ---
+ ///
+ /// When enabled, compiles the runtime with a heap/RAM usage progress bar
+ /// and text overlay at the top-right corner of the screen.
+ /// Passes MEMOVERLAY=1 to the native Makefile.
+ ///
+ public static bool MemoryOverlay
+ {
+ get => EditorPrefs.GetBool(Prefix + "MemoryOverlay", false);
+ set => EditorPrefs.SetBool(Prefix + "MemoryOverlay", value);
+ }
+
// --- Export settings ---
public static float DefaultGTEScaling
{
diff --git a/Runtime/PSXSceneExporter.cs b/Runtime/PSXSceneExporter.cs
index 2245d30..cc3e676 100644
--- a/Runtime/PSXSceneExporter.cs
+++ b/Runtime/PSXSceneExporter.cs
@@ -54,11 +54,32 @@ namespace SplashEdit.RuntimeCode
private PSXObjectExporter[] _exporters;
private TextureAtlas[] _atlases;
-
+
// Component arrays
private PSXInteractable[] _interactables;
private PSXAudioClip[] _audioSources;
private PSXTriggerBox[] _triggerBoxes;
+
+ // ── Post-export data for memory analysis ──
+ /// Texture atlases from the last export (null before first export).
+ public TextureAtlas[] LastExportAtlases => _atlases;
+ /// Custom font data from the last export.
+ public PSXFontData[] LastExportFonts => _fonts;
+ /// Audio clip ADPCM sizes from the last export.
+ public long[] LastExportAudioSizes => _lastAudioSizes;
+ private long[] _lastAudioSizes;
+ /// Total triangle count from the last export.
+ public int LastExportTriangleCount
+ {
+ get
+ {
+ if (_exporters == null) return 0;
+ int count = 0;
+ foreach (var exp in _exporters)
+ if (exp.Mesh != null) count += exp.Mesh.Triangles.Count;
+ return count;
+ }
+ }
// Phase 3+4: World collision and nav regions
private PSXCollisionExporter _collisionExporter;
@@ -271,6 +292,18 @@ namespace SplashEdit.RuntimeCode
audioExports = list.ToArray();
}
+ // Cache audio sizes for memory analysis
+ if (audioExports != null)
+ {
+ _lastAudioSizes = new long[audioExports.Length];
+ for (int i = 0; i < audioExports.Length; i++)
+ _lastAudioSizes[i] = audioExports[i].adpcmData != null ? audioExports[i].adpcmData.Length : 0;
+ }
+ else
+ {
+ _lastAudioSizes = null;
+ }
+
var scene = new PSXSceneWriter.SceneData
{
exporters = _exporters,
diff --git a/Runtime/SceneMemoryAnalyzer.cs b/Runtime/SceneMemoryAnalyzer.cs
new file mode 100644
index 0000000..4532b9b
--- /dev/null
+++ b/Runtime/SceneMemoryAnalyzer.cs
@@ -0,0 +1,255 @@
+using System.Text;
+using UnityEngine;
+
+namespace SplashEdit.RuntimeCode
+{
+ ///
+ /// Computes a SceneMemoryReport from scene export data.
+ /// All sizes match PSXSceneWriter's binary layout exactly.
+ ///
+ public static class SceneMemoryAnalyzer
+ {
+ // Per-triangle binary size in splashpack (matches PSXSceneWriter.Write mesh section)
+ // 3 vertices * 6 bytes + normal 6 bytes + 3 colors * 4 bytes + 3 UVs * 2 bytes + 2 pad + tpage 2 + clutXY 4 + pad 2
+ private const int BytesPerTriangle = 52;
+
+ // Per-GameObject entry in splashpack
+ // offset(4) + position(12) + rotation(36) + polyCount(2) + luaIdx(2) + flags(4) + components(8) + AABB(24) = 92
+ private const int BytesPerGameObject = 92;
+
+ // Per-collider entry
+ private const int BytesPerCollider = 32;
+
+ // Per-interactable entry
+ private const int BytesPerInteractable = 24;
+
+ // Atlas metadata entry
+ private const int BytesPerAtlasMetadata = 12;
+
+ // CLUT metadata entry
+ private const int BytesPerClutMetadata = 12;
+
+ // Audio clip metadata entry (offset + size + sampleRate + flags + nameOffset)
+ private const int BytesPerAudioMetadata = 16;
+
+ // Renderer ordering table size (from renderer.hh)
+ private const int OrderingTableSize = 2048 * 3;
+
+ // Renderer bump allocator size (from renderer.hh)
+ private const int BumpAllocatorSize = 8096 * 24;
+
+ ///
+ /// Analyze scene data and produce a memory report.
+ /// Call this after PSXSceneWriter.Write to get accurate stats,
+ /// or before to get estimates.
+ ///
+ public static SceneMemoryReport Analyze(
+ in PSXSceneWriter.SceneData scene,
+ string sceneName,
+ int resolutionWidth,
+ int resolutionHeight,
+ bool dualBuffering,
+ long compiledExeBytes = 0)
+ {
+ var report = new SceneMemoryReport();
+ report.sceneName = sceneName;
+
+ // ---- Count CLUTs ----
+ int clutCount = 0;
+ foreach (var atlas in scene.atlases)
+ foreach (var tex in atlas.ContainedTextures)
+ if (tex.ColorPalette != null)
+ clutCount++;
+
+ // ---- Count Lua files ----
+ var luaFiles = new System.Collections.Generic.List();
+ foreach (var 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);
+
+ // ---- Count colliders ----
+ int colliderCount = 0;
+ foreach (var e in scene.exporters)
+ if (e.CollisionType != PSXCollisionType.None)
+ colliderCount++;
+
+ // ---- Count triangles ----
+ int totalTriangles = 0;
+ foreach (var e in scene.exporters)
+ totalTriangles += e.Mesh.Triangles.Count;
+
+ // ---- Store counts ----
+ report.objectCount = scene.exporters.Length;
+ report.triangleCount = totalTriangles;
+ report.atlasCount = scene.atlases.Length;
+ report.clutCount = clutCount;
+ report.audioClipCount = scene.audioClips?.Length ?? 0;
+ report.navRegionCount = scene.navRegionBuilder?.RegionCount ?? 0;
+ report.roomCount = scene.roomBuilder?.RoomCount ?? 0;
+ report.portalCount = scene.roomBuilder?.PortalCount ?? 0;
+
+ // ---- Header (v11, ~96 bytes with all extensions) ----
+ report.headerBytes = 96; // approximate header size including all version extensions
+
+ // ---- Lua section ----
+ long luaBytes = 0;
+ foreach (var lua in luaFiles)
+ {
+ luaBytes += 8; // offset placeholder + size
+ luaBytes += Align4(Encoding.UTF8.GetByteCount(lua.LuaScript));
+ }
+ report.luaBytes = luaBytes;
+
+ // ---- GameObject section ----
+ report.gameObjectBytes = (long)scene.exporters.Length * BytesPerGameObject;
+
+ // ---- Collider section ----
+ report.colliderBytes = (long)colliderCount * BytesPerCollider;
+
+ // ---- BVH section ----
+ report.bvhBytes = EstimateBvhSize(scene.bvh);
+
+ // ---- Interactable section ----
+ report.interactableBytes = (long)scene.interactables.Length * BytesPerInteractable;
+
+ // ---- Collision soup ----
+ report.collisionBytes = EstimateCollisionSize(scene.collisionExporter);
+
+ // ---- Nav region data ----
+ report.navRegionBytes = EstimateNavRegionSize(scene.navRegionBuilder);
+
+ // ---- Room/portal data ----
+ report.roomPortalBytes = EstimateRoomPortalSize(scene.roomBuilder);
+
+ // ---- Atlas metadata ----
+ report.atlasMetadataBytes = (long)scene.atlases.Length * BytesPerAtlasMetadata;
+
+ // ---- CLUT metadata ----
+ report.clutMetadataBytes = (long)clutCount * BytesPerClutMetadata;
+
+ // ---- Mesh data ----
+ report.meshDataBytes = (long)totalTriangles * BytesPerTriangle;
+
+ // ---- Atlas pixel data ----
+ long atlasPixelBytes = 0;
+ foreach (var atlas in scene.atlases)
+ atlasPixelBytes += Align4((long)atlas.Width * TextureAtlas.Height * 2);
+ report.atlasPixelBytes = atlasPixelBytes;
+
+ // ---- CLUT data ----
+ long clutDataBytes = 0;
+ foreach (var atlas in scene.atlases)
+ foreach (var tex in atlas.ContainedTextures)
+ if (tex.ColorPalette != null)
+ clutDataBytes += Align4((long)tex.ColorPalette.Count * 2);
+ report.clutDataBytes = clutDataBytes;
+
+ // ---- Name table ----
+ long nameTableBytes = 0;
+ foreach (var e in scene.exporters)
+ {
+ string name = e.gameObject.name;
+ if (name.Length > 24) name = name.Substring(0, 24);
+ nameTableBytes += 1 + Encoding.UTF8.GetByteCount(name) + 1; // length byte + name + null
+ }
+ report.nameTableBytes = nameTableBytes;
+
+ // ---- Audio metadata + data ----
+ int audioClipCount = scene.audioClips?.Length ?? 0;
+ report.audioMetadataBytes = (long)audioClipCount * BytesPerAudioMetadata;
+
+ long audioDataBytes = 0;
+ long spuAudioBytes = 0;
+ if (scene.audioClips != null)
+ {
+ foreach (var clip in scene.audioClips)
+ {
+ if (clip.adpcmData != null)
+ {
+ audioDataBytes += Align4(clip.adpcmData.Length);
+ spuAudioBytes += clip.adpcmData.Length;
+ }
+ // clip name data
+ string name = clip.clipName ?? "";
+ audioDataBytes += name.Length + 1; // name + null
+ }
+ }
+ report.audioDataBytes = audioDataBytes;
+ report.spuAudioBytes = spuAudioBytes;
+
+ // ---- VRAM breakdown ----
+ int fbCount = dualBuffering ? 2 : 1;
+ report.framebufferVramBytes = (long)resolutionWidth * resolutionHeight * 2 * fbCount;
+ report.textureVramBytes = atlasPixelBytes; // same data, uploaded to VRAM
+ report.clutVramBytes = clutDataBytes;
+
+ // ---- Renderer overhead (double-buffered OTs + bump allocators) ----
+ long otBytes = 2L * OrderingTableSize * 4; // two OTs, each entry is a pointer (4 bytes)
+ long bumpBytes = 2L * BumpAllocatorSize;
+ report.rendererOverhead = otBytes + bumpBytes;
+
+ // ---- Executable size ----
+ report.executableBytes = compiledExeBytes > 0 ? compiledExeBytes : 150 * 1024; // estimate if unknown
+
+ return report;
+ }
+
+ ///
+ /// Overload that reads the compiled .ps-exe file size if available.
+ ///
+ public static SceneMemoryReport Analyze(
+ in PSXSceneWriter.SceneData scene,
+ string sceneName,
+ int resolutionWidth,
+ int resolutionHeight,
+ bool dualBuffering,
+ string compiledExePath)
+ {
+ long exeBytes = 0;
+ if (!string.IsNullOrEmpty(compiledExePath) && System.IO.File.Exists(compiledExePath))
+ exeBytes = new System.IO.FileInfo(compiledExePath).Length;
+ return Analyze(in scene, sceneName, resolutionWidth, resolutionHeight, dualBuffering, exeBytes);
+ }
+
+ private static long Align4(long size)
+ {
+ return (size + 3) & ~3L;
+ }
+
+ private static long EstimateBvhSize(BVH bvh)
+ {
+ if (bvh == null) return 0;
+ // BVH nodes: each node has AABB (24 bytes) + child/tri info (8 bytes) = 32 bytes
+ // Triangle refs: 2 bytes each (uint16)
+ return (long)bvh.NodeCount * 32 + (long)bvh.TriangleRefCount * 2;
+ }
+
+ private static long EstimateCollisionSize(PSXCollisionExporter collision)
+ {
+ if (collision == null || collision.MeshCount == 0) return 0;
+ // Each collision mesh header: AABB + triangle count (28 bytes)
+ // Each collision triangle: 3 vertices * 6 bytes = 18 bytes + normal 6 bytes = 24 bytes
+ return (long)collision.MeshCount * 28 + (long)collision.TriangleCount * 24;
+ }
+
+ private static long EstimateNavRegionSize(PSXNavRegionBuilder nav)
+ {
+ if (nav == null || nav.RegionCount == 0) return 0;
+ // Region: 84 bytes each (header + vertex data)
+ // Portal: 20 bytes each
+ return (long)nav.RegionCount * 84 + (long)nav.PortalCount * 20;
+ }
+
+ private static long EstimateRoomPortalSize(PSXRoomBuilder rooms)
+ {
+ if (rooms == null || rooms.RoomCount == 0) return 0;
+ // Room data: AABB (24 bytes) + tri ref range (4 bytes) + portal range (4 bytes) = 32 bytes per room
+ // Portal data: 40 bytes each (with right/up vectors)
+ // Tri refs: 4 bytes each (uint32)
+ int roomCount = rooms.RoomCount + 1; // +1 for catch-all room
+ return (long)roomCount * 32 + (long)rooms.PortalCount * 40 + (long)rooms.TotalTriRefCount * 4;
+ }
+ }
+}
diff --git a/Runtime/SceneMemoryAnalyzer.cs.meta b/Runtime/SceneMemoryAnalyzer.cs.meta
new file mode 100644
index 0000000..5f3e6b1
--- /dev/null
+++ b/Runtime/SceneMemoryAnalyzer.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: edf19eb00f51d7a4dad3f01c6b019432
\ No newline at end of file
diff --git a/Runtime/SceneMemoryReport.cs b/Runtime/SceneMemoryReport.cs
new file mode 100644
index 0000000..474ef8d
--- /dev/null
+++ b/Runtime/SceneMemoryReport.cs
@@ -0,0 +1,161 @@
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+
+namespace SplashEdit.RuntimeCode
+{
+ ///
+ /// Memory usage breakdown for a single exported scene.
+ /// Covers Main RAM, VRAM, SPU RAM, and CD storage.
+ ///
+ public class SceneMemoryReport
+ {
+ // ---- Capacities (PS1 hardware limits) ----
+ public const long MainRamCapacity = 2 * 1024 * 1024; // 2 MB
+ public const long VramCapacity = 1024 * 512 * 2; // 1 MB (1024x512 @ 16bpp)
+ public const long SpuRamCapacity = 512 * 1024; // 512 KB
+ public const long CdRomCapacity = 650L * 1024 * 1024; // 650 MB (standard CD)
+
+ // ---- Scene name ----
+ public string sceneName;
+
+ // ---- Splashpack section sizes (bytes) ----
+ public long headerBytes;
+ public long luaBytes;
+ public long gameObjectBytes;
+ public long colliderBytes;
+ public long bvhBytes;
+ public long interactableBytes;
+ public long collisionBytes;
+ public long navRegionBytes;
+ public long roomPortalBytes;
+ public long atlasMetadataBytes;
+ public long clutMetadataBytes;
+ public long meshDataBytes;
+ public long atlasPixelBytes;
+ public long clutDataBytes;
+ public long nameTableBytes;
+ public long audioMetadataBytes;
+ public long audioDataBytes;
+
+ // ---- Counts ----
+ public int objectCount;
+ public int triangleCount;
+ public int atlasCount;
+ public int clutCount;
+ public int audioClipCount;
+ public int navRegionCount;
+ public int roomCount;
+ public int portalCount;
+
+ // ---- Main RAM estimate ----
+ // Renderer double-buffered overhead (ordering tables + bump allocators)
+ public long rendererOverhead;
+ // PSYQo + psxsplash executable code (estimated from .ps-exe if available)
+ public long executableBytes;
+
+ // ---- VRAM breakdown ----
+ public long framebufferVramBytes;
+ public long textureVramBytes;
+ public long clutVramBytes;
+
+ // ---- SPU RAM ----
+ public const long SpuReservedBytes = 0x1010; // capture buffers + PSYQo dummy sample
+ public long spuAudioBytes;
+
+ // ---- Computed properties ----
+
+ public long TotalSplashpackBytes =>
+ headerBytes + luaBytes + gameObjectBytes + colliderBytes +
+ bvhBytes + interactableBytes + collisionBytes + navRegionBytes +
+ roomPortalBytes + atlasMetadataBytes + clutMetadataBytes +
+ meshDataBytes + atlasPixelBytes + clutDataBytes +
+ nameTableBytes + audioMetadataBytes + audioDataBytes;
+
+ ///
+ /// Total Main RAM usage estimate. Splashpack data is loaded into heap at runtime.
+ ///
+ public long TotalMainRamBytes =>
+ executableBytes + TotalSplashpackBytes + rendererOverhead + 8192 /* stack */ + 16384 /* misc heap */;
+
+ public float MainRamPercent => (float)TotalMainRamBytes / MainRamCapacity;
+ public long MainRamFree => MainRamCapacity - TotalMainRamBytes;
+
+ public long TotalVramBytes => framebufferVramBytes + textureVramBytes + clutVramBytes;
+ public float VramPercent => (float)TotalVramBytes / VramCapacity;
+ public long VramFree => VramCapacity - TotalVramBytes;
+
+ public long TotalSpuBytes => SpuReservedBytes + spuAudioBytes;
+ public float SpuPercent => (float)TotalSpuBytes / SpuRamCapacity;
+ public long SpuFree => SpuRamCapacity - TotalSpuBytes;
+
+ // CD storage includes the splashpack file
+ public long CdSceneBytes => TotalSplashpackBytes;
+
+ // ---- Breakdown lists for UI ----
+
+ public struct BarSegment
+ {
+ public string label;
+ public long bytes;
+ public Color color;
+ }
+
+ public List GetMainRamSegments()
+ {
+ var segments = new List();
+ if (executableBytes > 0)
+ segments.Add(new BarSegment { label = "Executable", bytes = executableBytes, color = new Color(0.4f, 0.6f, 0.9f) });
+ if (meshDataBytes > 0)
+ segments.Add(new BarSegment { label = "Mesh Data", bytes = meshDataBytes, color = new Color(0.3f, 0.85f, 0.45f) });
+ if (atlasPixelBytes > 0)
+ segments.Add(new BarSegment { label = "Texture Data", bytes = atlasPixelBytes, color = new Color(0.95f, 0.75f, 0.2f) });
+ if (audioDataBytes > 0)
+ segments.Add(new BarSegment { label = "Audio Data", bytes = audioDataBytes, color = new Color(0.85f, 0.3f, 0.65f) });
+
+ long otherSplashpack = TotalSplashpackBytes - meshDataBytes - atlasPixelBytes - audioDataBytes;
+ if (otherSplashpack > 0)
+ segments.Add(new BarSegment { label = "Scene Metadata", bytes = otherSplashpack, color = new Color(0.6f, 0.6f, 0.65f) });
+
+ if (rendererOverhead > 0)
+ segments.Add(new BarSegment { label = "Renderer Buffers", bytes = rendererOverhead, color = new Color(0.3f, 0.85f, 0.95f) });
+
+ long misc = 8192 + 16384; // stack + misc heap
+ segments.Add(new BarSegment { label = "Stack + Misc", bytes = misc, color = new Color(0.45f, 0.45f, 0.5f) });
+
+ return segments;
+ }
+
+ public List GetVramSegments()
+ {
+ var segments = new List();
+ if (framebufferVramBytes > 0)
+ segments.Add(new BarSegment { label = "Framebuffers", bytes = framebufferVramBytes, color = new Color(0.9f, 0.3f, 0.35f) });
+ if (textureVramBytes > 0)
+ segments.Add(new BarSegment { label = "Texture Atlases", bytes = textureVramBytes, color = new Color(0.95f, 0.75f, 0.2f) });
+ if (clutVramBytes > 0)
+ segments.Add(new BarSegment { label = "CLUTs", bytes = clutVramBytes, color = new Color(0.85f, 0.5f, 0.2f) });
+ return segments;
+ }
+
+ public List GetSpuSegments()
+ {
+ var segments = new List();
+ segments.Add(new BarSegment { label = "Reserved", bytes = SpuReservedBytes, color = new Color(0.45f, 0.45f, 0.5f) });
+ if (spuAudioBytes > 0)
+ segments.Add(new BarSegment { label = "Audio Clips", bytes = spuAudioBytes, color = new Color(0.85f, 0.3f, 0.65f) });
+ return segments;
+ }
+
+ ///
+ /// Get a severity color for a usage percentage.
+ ///
+ public static Color GetUsageColor(float percent)
+ {
+ if (percent < 0.6f) return new Color(0.35f, 0.85f, 0.45f); // green
+ if (percent < 0.8f) return new Color(0.95f, 0.75f, 0.2f); // yellow
+ if (percent < 0.95f) return new Color(0.95f, 0.5f, 0.2f); // orange
+ return new Color(0.9f, 0.3f, 0.35f); // red
+ }
+ }
+}
diff --git a/Runtime/SceneMemoryReport.cs.meta b/Runtime/SceneMemoryReport.cs.meta
new file mode 100644
index 0000000..ea20688
--- /dev/null
+++ b/Runtime/SceneMemoryReport.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 09443b447544693488a25a9dfbf8714b
\ No newline at end of file