Files
GhostEngine/docs/specs/asset_registry_design.md
Misaki abd5ad74d5 Refactor asset pipeline: new registry, loader, and runtime
Major overhaul of asset system:
- Split assets into source, .gmeta (JSON), and cooked .imported binaries
- Replaced Asset base class; added TextureAsset, TextureLoader
- AssetManager now uses job-based, dependency-aware loading
- Unified IAssetHandler API; removed legacy handler interfaces
- Updated D3D12 allocator and graphics code for new resource model
- Improved error handling, memory management, and GPU upload logic
- Updated docs and removed obsolete code/interfaces
2026-04-18 01:46:37 +09:00

1626 lines
62 KiB
Markdown
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.
# GhostEngine Asset Registry — Detailed Design Document
> **Audience:** Anyone working on this system alongside me.
> **Scope:** Full rewrite of the asset pipeline — sidecar metadata, SQLite catalog, import queue, cooked cache, and updated handler API.
> **Existing code references** are linked throughout so you can trace every decision back to the current implementation.
---
## Table of Contents
1. [Design Overview](#1-design-overview)
2. [Project Folder Layout](#2-project-folder-layout)
3. [The `.gmeta` Sidecar File](#3-the-gmeta-sidecar-file)
4. [SQLite Catalog (`AssetDB`)](#4-sqlite-catalog-assetdb)
5. [Handler Registration & TypeCache Integration](#5-handler-registration--typecache-integration)
6. [Import Pipeline](#6-import-pipeline)
7. [Asset Loading (Runtime Path)](#7-asset-loading-runtime-path)
8. [FileSystemWatcher & Change Detection](#8-filesystemwatcher--change-detection)
9. [Dependency Graph](#9-dependency-graph)
10. [Cooked Binary Format](#10-cooked-binary-format)
11. [AssetRegistry Class Redesign](#11-assetregistry-class-redesign)
12. [Handler API Redesign](#12-handler-api-redesign)
13. [Migration Path from Current Code](#13-migration-path-from-current-code)
14. [Thread Safety Model](#14-thread-safety-model)
15. [Error Handling Strategy](#15-error-handling-strategy)
16. [Open Decisions & Trade-offs](#16-open-decisions--trade-offs)
---
## 1. Design Overview
### Core Principle: Separate source truth from derived data
```
┌─────────────────────────────────────────────────────────────────┐
│ USER'S PROJECT │
│ │
│ Assets/ ← Source files + sidecar .gmeta (in git) │
│ Library/ ← Derived cache (NOT in git, regenerable) │
│ AssetDB.sqlite ← Fast GUID↔path index + dep graph │
│ Imports/ ← Cooked binary blobs │
│ Sources/ ← C# user scripts │
│ Config/ ← Project config │
└─────────────────────────────────────────────────────────────────┘
```
**Three files per asset, three distinct roles:**
| File | Location | In VCS? | Role |
|------|----------|---------|------|
| `hero.png` | `Assets/Textures/` | ✅ | Source of truth — never modified by the engine |
| `hero.png.gmeta` | `Assets/Textures/` | ✅ | Identity (GUID) + import settings (JSON) |
| `<guid>.imported` | `Library/Imports/` | ❌ | Cooked/processed binary — the runtime loads this |
The current design ([AssetRegistry.cs](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs)) merges all three concerns into a single `.gasset` file. The rewrite splits them.
---
## 2. Project Folder Layout
The existing `EditorApplication` ([EditorApplication.cs](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/EditorApplication.cs)) already defines the project folder constants. We add one:
```csharp
public const string LIBRARY_FOLDER_NAME = "Library";
public static string LibraryFolderPath => Path.Combine(ProjectPath, LIBRARY_FOLDER_NAME);
```
Full project tree at runtime:
```
MyProject/
├── Assets/ ← EditorApplication.AssetsFolderPath
│ ├── Textures/
│ │ ├── hero_diffuse.png ← source file
│ │ ├── hero_diffuse.png.gmeta ← sidecar metadata
│ │ ├── hero_normal.png
│ │ └── hero_normal.png.gmeta
│ ├── Models/
│ │ ├── character.fbx
│ │ └── character.fbx.gmeta
│ └── Materials/
│ ├── hero_material.gasset ← engine-native assets (no source file)
│ └── hero_material.gasset.gmeta
├── Library/ ← EditorApplication.LibraryFolderPath
│ ├── AssetDB.sqlite ← catalog
│ ├── Imports/
│ │ ├── 0906f4eb-c3f0431b-bcea132c88ab0c3f.imported
│ │ └── ...
│ └── Thumbnails/
│ └── ...
├── Sources/ ← EditorApplication.SourcesFolderPath
├── Config/ ← EditorApplication.ConfigFolderPath
└── .gitignore ← must include Library/ and Caches/
```
> [!IMPORTANT]
> **Engine-native assets** (materials, prefabs, scenes — things that have no external source file) still need a `.gmeta`. For these, the `.gasset` file IS the source, and the `.gmeta` sits beside it. The `Library/Imports/` entry is either a copy or not needed (the `.gasset` is already in a loadable format).
---
## 3. The `.gmeta` Sidecar File
### 3.1 Why JSON over YAML/binary
- `System.Text.Json` ships with .NET — zero new dependencies
- JSON diffs are readable in any git client
- Add/remove fields without breaking existing files (missing keys → defaults)
- `JsonSerializerOptions.WriteIndented` makes it human-readable
- We can use source generators (`System.Text.Json.SourceGeneration`) for AOT-safe serialization in the future
### 3.2 File naming convention
The `.gmeta` file name is always `<source_file_name_including_extension>.gmeta`:
```
hero_diffuse.png → hero_diffuse.png.gmeta
character.fbx → character.fbx.gmeta
hero_material.gasset → hero_material.gasset.gmeta
```
This is the Unity convention. It guarantees a 1:1 mapping and lets `FileSystemWatcher` easily pair source ↔ meta.
### 3.3 Data model
```csharp
namespace Ghost.Editor.Core.AssetHandler;
/// <summary>
/// Persisted as a JSON sidecar (.gmeta) next to every source asset.
/// This is the single source of truth for asset identity and import settings.
/// </summary>
public sealed class AssetMeta
{
/// <summary>
/// Globally unique identifier for this asset. Generated once, never changes.
/// Even if the file is moved/renamed, this GUID follows it via the .gmeta.
/// </summary>
public required Guid Guid { get; init; }
/// <summary>
/// The Guid that identifies which IAssetHandler processes this asset.
/// Maps to CustomAssetHandlerAttribute.ID.
/// Null for auto-detection from file extension.
/// </summary>
public Guid? HandlerTypeId { get; set; }
/// <summary>
/// Version of the handler that last imported this asset.
/// Used to detect when a handler upgrade requires re-import.
/// </summary>
public int HandlerVersion { get; set; }
/// <summary>
/// xxHash64 of the source file content at last successful import.
/// Stored as hex string for JSON readability.
/// Used to skip re-import when source hasn't changed.
/// </summary>
public string? ContentHash { get; set; }
/// <summary>
/// xxHash64 of the serialized import settings at last successful import.
/// If settings change, we know to re-import even if source didn't change.
/// </summary>
public string? SettingsHash { get; set; }
/// <summary>
/// UTC timestamp of last successful import.
/// </summary>
public DateTime? LastImportedUtc { get; set; }
/// <summary>
/// GUIDs of other assets this asset depends on.
/// For example, a material might reference texture assets.
/// </summary>
public Guid[] Dependencies { get; set; } = [];
/// <summary>
/// Optional user-facing labels for search/filtering in the editor.
/// </summary>
public string[] Labels { get; set; } = [];
/// <summary>
/// Handler-specific import settings. Stored as a polymorphic JSON object.
/// The concrete type is determined by HandlerTypeId at deserialization time.
/// </summary>
public IAssetSettings? Settings { get; set; }
}
```
### 3.4 Example on disk
```json
{
"guid": "0906f4eb-c3f0-431b-bcea-132c88ab0c3f",
"handlerTypeId": "0906f4eb-c3f0-431b-bcea-132c88ab0c3f",
"handlerVersion": 1,
"contentHash": "A1B2C3D4E5F67890",
"settingsHash": "1234567890ABCDEF",
"lastImportedUtc": "2026-04-14T07:00:00Z",
"dependencies": [],
"labels": ["environment", "hero"],
"settings": {
"$type": "TextureAssetSettings",
"basic": {
"textureType": "Default",
"textureShape": "Texture2D",
"isSRGB": true
},
"advanced": {
"generateMipmaps": true,
"compressionLevel": "Normal",
"mipmapFilter": "Kaiser"
},
"sampler": {
"maxSize": 2048,
"filterMode": "Anisotropic",
"wrapMode": "Repeat"
}
}
}
```
### 3.5 Settings serialization — the polymorphism problem
The `IAssetSettings` property is polymorphic — each handler has its own settings type (`TextureAssetSettings`, future `MeshAssetSettings`, etc.). We solve this with `System.Text.Json`'s built-in polymorphism:
```csharp
// Mark IAssetSettings for polymorphic serialization
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(TextureAssetSettings), "TextureAssetSettings")]
// [JsonDerivedType(typeof(MeshAssetSettings), "MeshAssetSettings")] ← add as you create new types
public interface IAssetSettings;
```
> [!NOTE]
> This replaces the current approach of `Unsafe.WriteUnaligned` raw struct bytes
> ([TextureAsset.cs:229-253](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/AssetHandler/TextureAsset.cs#L229-L253)).
> The old way breaks whenever you add a field to `TextureAssetSettings.BasicSettings`.
> JSON handles field addition/removal gracefully — missing fields get default values.
### 3.6 Reading/writing `.gmeta` files
```csharp
namespace Ghost.Editor.Core.AssetHandler;
internal static class AssetMetaIO
{
private static readonly JsonSerializerOptions s_options = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
/// <summary>
/// Read a .gmeta sidecar file. Returns null if the file doesn't exist.
/// </summary>
public static async ValueTask<AssetMeta?> ReadAsync(string metaPath, CancellationToken token = default)
{
if (!File.Exists(metaPath))
{
return null;
}
await using var stream = new FileStream(metaPath, FileMode.Open, FileAccess.Read, FileShare.Read);
return await JsonSerializer.DeserializeAsync<AssetMeta>(stream, s_options, token).ConfigureAwait(false);
}
/// <summary>
/// Write a .gmeta sidecar file atomically (write to .tmp, then rename).
/// </summary>
public static async ValueTask WriteAsync(string metaPath, AssetMeta meta, CancellationToken token = default)
{
var tempPath = metaPath + ".tmp";
await using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
await JsonSerializer.SerializeAsync(stream, meta, s_options, token).ConfigureAwait(false);
}
File.Move(tempPath, metaPath, overwrite: true);
}
/// <summary>
/// Given a source file path, returns the expected .gmeta path.
/// </summary>
public static string GetMetaPath(string sourceFilePath)
{
return sourceFilePath + ".gmeta";
}
/// <summary>
/// Given a .gmeta path, returns the source file path.
/// </summary>
public static string GetSourcePath(string metaPath)
{
// "hero.png.gmeta" → "hero.png"
return metaPath[..^".gmeta".Length];
}
}
```
> [!TIP]
> The atomic write (write to `.tmp` + `File.Move`) prevents corruption if the editor crashes mid-write. `File.Move` with `overwrite: true` is atomic on NTFS.
---
## 4. SQLite Catalog (`AssetDB`)
This replaces the current in-memory `ConcurrentDictionary<string, Guid>` + `ConcurrentDictionary<Guid, string>` ([AssetRegistry.cs:42-43](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L42-L43)) and the current `Dictionary<Guid, HashSet<Guid>>` dependency graph ([AssetRegistry.cs:48-49](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L48-L49)).
### 4.1 Why SQLite
- `Microsoft.Data.Sqlite` is already in [Ghost.Editor.Core.csproj](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Ghost.Editor.Core.csproj#L18) — **zero additional dependencies**
- Startup is O(1) — open file, tables are already indexed
- Survives editor crashes (WAL mode + transactions)
- Queryable — "find all textures with label X" becomes a SQL query
- Eliminates the current full disk scan on startup ([AssetRegistry.cs:99-134](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L99-L134))
### 4.2 Schema
```sql
-- Database: Library/AssetDB.sqlite
-- Journal mode: WAL (concurrent readers, single writer)
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- Schema version for migrations
PRAGMA user_version = 1;
-- ─── Core tables ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS assets (
-- Asset GUID stored as 16-byte BLOB (matches sizeof(Guid))
guid BLOB(16) PRIMARY KEY NOT NULL,
-- Relative path from Assets/ root to the SOURCE file (not .gmeta)
-- e.g. "Textures/hero_diffuse.png"
source_path TEXT NOT NULL,
-- Handler type GUID (matches CustomAssetHandlerAttribute.ID)
handler_type_id BLOB(16),
-- Handler version that last imported this asset
handler_version INTEGER NOT NULL DEFAULT 0,
-- xxHash64 of source file content (hex string)
content_hash TEXT,
-- xxHash64 of import settings (hex string)
settings_hash TEXT,
-- Unix timestamp (ms) of last successful import
imported_at_ms INTEGER,
-- Asset state: 0 = needs import, 1 = imported, 2 = import failed
state INTEGER NOT NULL DEFAULT 0,
-- Error message if state = 2
error_message TEXT,
UNIQUE(source_path)
);
-- Fast path→guid lookup (most common query during FSW events)
CREATE INDEX IF NOT EXISTS idx_assets_path ON assets(source_path);
-- ─── Dependency edges ──────────────────────────────────────────
CREATE TABLE IF NOT EXISTS dependencies (
-- The asset that depends on another
from_guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE,
-- The asset being depended upon
to_guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE,
PRIMARY KEY (from_guid, to_guid)
);
-- Reverse lookup: "what depends on asset X?" (for invalidation cascades)
CREATE INDEX IF NOT EXISTS idx_dep_reverse ON dependencies(to_guid);
-- ─── Labels ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS labels (
guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE,
label TEXT NOT NULL,
PRIMARY KEY (guid, label)
);
CREATE INDEX IF NOT EXISTS idx_labels_label ON labels(label);
```
### 4.3 The `AssetCatalog` class
This is a thin C# wrapper. It lives in `AssetRegistry.Backend.cs` (replacing the current empty placeholder):
```csharp
namespace Ghost.Editor.Core.Services;
using Microsoft.Data.Sqlite;
/// <summary>
/// Thread-safe SQLite-backed asset catalog.
/// All public methods synchronize internally — callers need no locks.
///
/// Replaces the in-memory ConcurrentDictionary approach with persistent storage
/// that survives editor restarts.
/// </summary>
internal sealed class AssetCatalog : IDisposable
{
private readonly SqliteConnection _connection;
private readonly Lock _writeLock = new();
// ─── Prepared statement cache ──────────────────────────────
// Prepared once, reused on every call — avoids SQL parsing overhead.
private readonly SqliteCommand _cmdGetGuid;
private readonly SqliteCommand _cmdGetPath;
private readonly SqliteCommand _cmdUpsert;
private readonly SqliteCommand _cmdDelete;
private readonly SqliteCommand _cmdSetState;
private readonly SqliteCommand _cmdGetReferencers;
private readonly SqliteCommand _cmdGetDependencies;
private readonly SqliteCommand _cmdInsertDep;
private readonly SqliteCommand _cmdClearDeps;
public AssetCatalog(string dbPath)
{
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
var connString = new SqliteConnectionStringBuilder
{
DataSource = dbPath,
Mode = SqliteOpenMode.ReadWriteCreate,
Cache = SqliteCacheMode.Shared,
}.ToString();
_connection = new SqliteConnection(connString);
_connection.Open();
// Enable WAL for concurrent reads
using var pragma = _connection.CreateCommand();
pragma.CommandText = "PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;";
pragma.ExecuteNonQuery();
CreateSchema();
PrepareStatements();
}
// ─── Lookup ────────────────────────────────────────────────
/// <summary>
/// Get the asset GUID for a source path. Returns Guid.Empty if not found.
/// Path is relative to Assets/ root.
/// </summary>
public Guid GetGuid(string sourcePath) { /* bind + ExecuteScalar */ }
/// <summary>
/// Get the source path for an asset GUID. Returns null if not found.
/// </summary>
public string? GetSourcePath(Guid guid) { /* bind + ExecuteScalar */ }
// ─── Mutation ──────────────────────────────────────────────
/// <summary>
/// Insert or update an asset entry. Called after reading/creating a .gmeta file.
/// </summary>
public void Upsert(AssetMeta meta, string sourcePath) { /* INSERT OR REPLACE */ }
/// <summary>
/// Remove an asset entry. Called when a source file is deleted.
/// CASCADE deletes dependencies and labels automatically.
/// </summary>
public bool Remove(string sourcePath) { /* DELETE, return rows affected > 0 */ }
public bool Remove(Guid guid) { /* DELETE, return rows affected > 0 */ }
/// <summary>
/// Mark an asset as needing re-import (state = 0).
/// </summary>
public void MarkDirty(Guid guid) { /* UPDATE state = 0 */ }
/// <summary>
/// Mark an asset as successfully imported.
/// Updates content_hash, settings_hash, imported_at_ms, state = 1.
/// </summary>
public void MarkImported(Guid guid, string contentHash, string settingsHash) { /* UPDATE */ }
/// <summary>
/// Mark an asset import as failed (state = 2) with error message.
/// </summary>
public void MarkFailed(Guid guid, string error) { /* UPDATE */ }
// ─── Dependencies ──────────────────────────────────────────
/// <summary>
/// Replace all forward dependencies for an asset (transaction).
/// </summary>
public void SetDependencies(Guid assetId, ReadOnlySpan<Guid> dependencies)
{
lock (_writeLock)
{
using var tx = _connection.BeginTransaction();
_cmdClearDeps.Parameters[0].Value = assetId.ToByteArray();
_cmdClearDeps.Transaction = tx;
_cmdClearDeps.ExecuteNonQuery();
foreach (var dep in dependencies)
{
_cmdInsertDep.Parameters[0].Value = assetId.ToByteArray();
_cmdInsertDep.Parameters[1].Value = dep.ToByteArray();
_cmdInsertDep.Transaction = tx;
_cmdInsertDep.ExecuteNonQuery();
}
tx.Commit();
}
}
/// <summary>
/// Get all assets that depend on the given asset (reverse lookup).
/// Used for invalidation cascade — when asset X changes, all its referencers are dirty.
/// </summary>
public List<Guid> GetReferencers(Guid guid) { /* SELECT from_guid WHERE to_guid = ? */ }
/// <summary>
/// Get all assets that the given asset depends on (forward lookup).
/// </summary>
public List<Guid> GetDependencies(Guid guid) { /* SELECT to_guid WHERE from_guid = ? */ }
// ─── Queries ───────────────────────────────────────────────
/// <summary>
/// Get all assets in state 0 (needs import).
/// Used on startup to queue pending imports.
/// </summary>
public List<(Guid guid, string sourcePath)> GetDirtyAssets() { /* SELECT WHERE state = 0 */ }
/// <summary>
/// Enumerate all known assets. Used for full consistency checks.
/// </summary>
public IEnumerable<(Guid guid, string sourcePath)> EnumerateAll() { /* SELECT */ }
public void Dispose()
{
_cmdGetGuid.Dispose();
_cmdGetPath.Dispose();
// ... dispose all prepared commands ...
_connection.Dispose();
}
}
```
### 4.4 Guid storage in SQLite
SQLite doesn't have a native GUID type. We store them as 16-byte `BLOB`:
```csharp
// Writing:
parameter.Value = guid.ToByteArray(); // Guid → byte[16]
// Reading:
var bytes = (byte[])reader["guid"];
var guid = new Guid(bytes); // byte[16] → Guid
```
This keeps GUIDs compact (16 bytes vs 36+ chars for a string) and equality checks fast (BLOB comparison).
---
## 5. Handler Registration & TypeCache Integration
### 5.1 The current problem
The current code scans **all assemblies × all types** on every handler lookup ([AssetRegistry.cs:326-338](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L326-L338) and [AssetRegistry.cs:342-354](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L342-L354)). This is O(N×M) and runs from `FileSystemWatcher` callbacks.
### 5.2 The fix: `AssetHandlerRegistry` (build once at startup)
We already have `TypeCache` ([TypeCache.cs](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Utilities/TypeCache.cs)) that scans assemblies marked with `[EngineAssembly]` at startup. But `TypeCache` currently only caches method-level attributes, not class-level ones. Two options:
**Option A** — Extend `TypeCache` to also cache class-level `DiscoverableAttributeBase` subclasses. This is a broader change that benefits the whole editor.
**Option B** — Build a dedicated `AssetHandlerRegistry` that does its own scan once. Simpler, self-contained.
I recommend **Option B** for now (less blast radius), with Option A as a future cleanup:
```csharp
namespace Ghost.Editor.Core.AssetHandler;
/// <summary>
/// One-time scan at editor startup → two dictionaries.
/// All lookups are O(1) after construction.
/// </summary>
internal sealed class AssetHandlerRegistry
{
// ".png" → handler instance
private readonly Dictionary<string, IAssetHandler> _byExtension;
// TypeId GUID → handler instance
private readonly Dictionary<Guid, IAssetHandler> _byTypeId;
// TypeId GUID → handler version (from attribute or const on the handler)
private readonly Dictionary<Guid, int> _versionByTypeId;
public AssetHandlerRegistry()
{
_byExtension = new Dictionary<string, IAssetHandler>(StringComparer.OrdinalIgnoreCase);
_byTypeId = new Dictionary<Guid, IAssetHandler>();
_versionByTypeId = new Dictionary<Guid, int>();
// Scan once using TypeCache's already-loaded types
foreach (var typeInfo in TypeCache.GetTypes())
{
if (typeInfo.IsAbstract || typeInfo.IsInterface)
continue;
if (!typeof(IAssetHandler).IsAssignableFrom(typeInfo))
continue;
var attr = typeInfo.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
if (attr is null)
continue;
var handler = (IAssetHandler)Activator.CreateInstance(typeInfo)!;
var typeId = new Guid(attr.ID);
_byTypeId[typeId] = handler;
foreach (var ext in attr.SupportedExtensions)
{
_byExtension[ext] = handler;
}
}
}
public IAssetHandler? GetByExtension(string extension)
{
_byExtension.TryGetValue(extension, out var handler);
return handler;
}
public IAssetHandler? GetByTypeId(Guid typeId)
{
_byTypeId.TryGetValue(typeId, out var handler);
return handler;
}
public IEnumerable<string> GetSupportedExtensions() => _byExtension.Keys;
}
```
This replaces the `_cachedHander` dictionary and the two `GetAssetHandlerFor*` methods in the current `AssetRegistry`.
> [!NOTE]
> We also have `AssetImporterAttribute` in [Attributes.cs:24-34](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Attributes.cs#L24-L34) which serves a similar purpose. As part of this rewrite, we should unify it with `CustomAssetHandlerAttribute` to avoid two parallel discovery systems.
---
## 6. Import Pipeline
This is the biggest change from the current design. Currently, import happens inline in `OnFileSystemOp` ([AssetRegistry.cs:189-255](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L189-L255)) and `ImportAssetAsync`. The rewrite separates detection from execution.
### 6.1 Architecture
```
FileSystemWatcher Manual "Reimport" button
│ │
▼ ▼
┌─────────────────────────────────────────────────────┐
│ ImportCoordinator │
│ (channels FSW events → determines what to import) │
└────────────────────────┬────────────────────────────┘
│ writes ImportJob to
Channel<ImportJob>
(bounded, backpressure)
┌───────────────┼──────────────┐
▼ ▼ ▼
ImportWorker ImportWorker ImportWorker
(thread pool) (thread pool) (thread pool)
┌─────────────────┐
│ IAssetHandler │
│ .ImportAsync() │
└────────┬────────┘
│ writes
Library/Imports/<guid>.imported ← cooked binary
AssetCatalog.MarkImported() ← update DB
AssetMeta contentHash/settingsHash ← update .gmeta
OnAssetChanged event (marshalled to UI thread via DispatcherQueue)
```
### 6.2 ImportJob and ImportCoordinator
```csharp
namespace Ghost.Editor.Core.Services;
internal enum ImportReason
{
NewAsset, // .gmeta just created
SourceChanged, // source file content changed
SettingsChanged, // user edited import settings in inspector
HandlerUpgraded, // handler version bumped
ManualReimport, // user clicked "Reimport" in context menu
Startup, // found dirty assets on editor startup
}
internal readonly record struct ImportJob(
Guid AssetGuid,
string SourcePath, // relative to Assets/ root
string MetaPath, // absolute path to .gmeta
ImportReason Reason
);
```
```csharp
internal sealed class ImportCoordinator : IDisposable
{
private readonly Channel<ImportJob> _importChannel;
private readonly AssetCatalog _catalog;
private readonly AssetHandlerRegistry _handlers;
private readonly string _assetsRoot;
private readonly CancellationTokenSource _cts;
private readonly Task[] _workers;
public event EventHandler<IAssetRegistry, AssetChangedEventArgs>? OnAssetChanged;
public ImportCoordinator(AssetCatalog catalog, AssetHandlerRegistry handlers, string assetsRoot, int workerCount = 2)
{
_catalog = catalog;
_handlers = handlers;
_assetsRoot = assetsRoot;
_cts = new CancellationTokenSource();
// Bounded channel provides backpressure if imports pile up
_importChannel = Channel.CreateBounded<ImportJob>(new BoundedChannelOptions(256)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = false,
SingleWriter = false,
});
// Start N workers reading from the channel
_workers = new Task[workerCount];
for (var i = 0; i < workerCount; i++)
{
_workers[i] = Task.Run(() => WorkerLoop(_cts.Token));
}
}
/// <summary>
/// Enqueue an import job. Non-blocking (unless channel is full).
/// </summary>
public ValueTask EnqueueAsync(ImportJob job, CancellationToken token = default)
{
return _importChannel.Writer.WriteAsync(job, token);
}
/// <summary>
/// Queue all assets in state=0 (dirty) from the catalog.
/// Called once at startup after catalog sync.
/// </summary>
public async ValueTask EnqueueDirtyAssetsAsync(CancellationToken token = default)
{
foreach (var (guid, sourcePath) in _catalog.GetDirtyAssets())
{
var metaPath = AssetMetaIO.GetMetaPath(Path.Combine(_assetsRoot, sourcePath));
await EnqueueAsync(new ImportJob(guid, sourcePath, metaPath, ImportReason.Startup), token);
}
}
private async Task WorkerLoop(CancellationToken token)
{
await foreach (var job in _importChannel.Reader.ReadAllAsync(token))
{
try
{
await ProcessImportAsync(job, token);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
Logger.Error($"Import failed for {job.SourcePath}: {ex.Message}");
_catalog.MarkFailed(job.AssetGuid, ex.Message);
}
}
}
private async ValueTask ProcessImportAsync(ImportJob job, CancellationToken token)
{
var fullSourcePath = Path.Combine(_assetsRoot, job.SourcePath);
// 1. Read .gmeta
var meta = await AssetMetaIO.ReadAsync(job.MetaPath, token);
if (meta is null)
{
Logger.Warning($"Missing .gmeta for {job.SourcePath}, skipping import");
return;
}
// 2. Resolve handler
var ext = Path.GetExtension(job.SourcePath);
var handler = meta.HandlerTypeId.HasValue
? _handlers.GetByTypeId(meta.HandlerTypeId.Value)
: _handlers.GetByExtension(ext);
if (handler is not IImportableAssetHandler importable)
{
// Not importable (engine-native asset). Nothing to do.
_catalog.MarkImported(job.AssetGuid, "", "");
return;
}
// 3. Check if import is actually needed (content + settings hash)
var contentHash = await ComputeFileHashAsync(fullSourcePath, token);
var settingsHash = ComputeSettingsHash(meta.Settings);
if (job.Reason != ImportReason.ManualReimport
&& contentHash == meta.ContentHash
&& settingsHash == meta.SettingsHash)
{
// Nothing changed — skip
_catalog.MarkImported(job.AssetGuid, contentHash, settingsHash);
return;
}
// 4. Do the actual import
var importedPath = GetImportedPath(job.AssetGuid);
Directory.CreateDirectory(Path.GetDirectoryName(importedPath)!);
await using var sourceStream = new FileStream(fullSourcePath, FileMode.Open, FileAccess.Read, FileShare.Read);
await using var targetStream = new FileStream(importedPath, FileMode.Create, FileAccess.Write, FileShare.None);
var result = await importable.ImportAsync(sourceStream, targetStream, job.AssetGuid, meta.Settings, token);
if (result.IsFailure)
{
_catalog.MarkFailed(job.AssetGuid, result.Message ?? "Unknown error");
return;
}
// 5. Update .gmeta with new hashes
meta.ContentHash = contentHash;
meta.SettingsHash = settingsHash;
meta.LastImportedUtc = DateTime.UtcNow;
await AssetMetaIO.WriteAsync(job.MetaPath, meta, token);
// 6. Update catalog
_catalog.MarkImported(job.AssetGuid, contentHash, settingsHash);
// 7. Fire change event (marshal to UI thread)
var args = new AssetChangedEventArgs(job.SourcePath, null, AssetChangeType.Modified);
EditorApplication.DispatcherQueue.TryEnqueue(() =>
{
OnAssetChanged?.Invoke(/* registry */, args);
});
}
private string GetImportedPath(Guid guid)
{
return Path.Combine(EditorApplication.LibraryFolderPath, "Imports", $"{guid:N}.imported");
}
private static async ValueTask<string> ComputeFileHashAsync(string filePath, CancellationToken token)
{
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var hash = new XxHash64();
var buffer = ArrayPool<byte>.Shared.Rent(81920);
try
{
int bytesRead;
while ((bytesRead = await stream.ReadAsync(buffer, token)) > 0)
{
hash.Append(buffer.AsSpan(0, bytesRead));
}
return hash.GetCurrentHashAsUInt64().ToString("X16");
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static string ComputeSettingsHash(IAssetSettings? settings)
{
if (settings is null) return "";
var json = JsonSerializer.SerializeToUtf8Bytes(settings);
return XxHash64.HashToUInt64(json).ToString("X16");
}
public void Dispose()
{
_importChannel.Writer.TryComplete();
_cts.Cancel();
Task.WaitAll(_workers, TimeSpan.FromSeconds(5));
_cts.Dispose();
}
}
```
### 6.3 How this fixes the current bugs
| Current Bug | How the rewrite fixes it |
|---|---|
| `async void` FileSystemWatcher callback ([line 189](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L189)) | FSW handler is synchronous — just writes to `Channel<ImportJob>`. All async work happens in worker tasks with proper error handling. |
| Source file deleted after import ([line 224](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L224)) | Source file is never touched. Cooked data goes to `Library/Imports/`. |
| No content hash check ([TextureProcessor.cs](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/AssetHandler/TextureProcessor.cs#L172-L179)) | `ComputeFileHashAsync` hashes source content. Both content + settings hashes must match to skip import. |
| `_ignoreFileChanges` race ([line 51](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L51)) | We watch **only** the source files and `.gmeta` files; `Library/` writes don't need ignore logic because `FileSystemWatcher` is rooted at `Assets/`. |
---
## 7. Asset Loading (Runtime Path)
Loading is the hot path — this is what happens when the game/editor needs an asset at runtime.
### 7.1 Load flow
```
LoadAssetAsync(guid)
├── Check _loadedAssets WeakReference cache (keep from current design)
│ └── if alive → return immediately
├── Check Library/Imports/<guid>.imported exists
│ └── if not → return Result.Failure("Asset not imported")
├── Resolve handler via AssetHandlerRegistry.GetByTypeId()
│ └── O(1) lookup, no assembly scan
├── handler.LoadAsync(importedStream, registry, token)
│ └── reads the cooked binary format (Section 10)
└── Store in _loadedAssets with WeakReference
```
> [!IMPORTANT]
> The `WeakReference<Asset>` cache ([AssetRegistry.cs:46](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L46)) and the double-check locking pattern ([AssetRegistry.cs:414-483](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L414-L483)) are good designs — we keep them. The only change is *where* we read from (`.imported` file instead of `.gasset`).
### 7.2 Handler signature change for LoadAsync
The handler no longer needs to skip past the metadata header — the registry does that. The handler receives a stream pre-positioned at the content section:
```csharp
// Current signature (unchanged)
ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetRegistry, CancellationToken token = default);
```
But now `sourceStream` is an `FileStream` opened on `Library/Imports/<guid>.imported`, not a `.gasset`. The cooked binary format (Section 10) is simpler — it's pure content, no metadata header.
---
## 8. FileSystemWatcher & Change Detection
### 8.1 What we watch
Only watch `Assets/` — never `Library/` (we write there, watching ourselves would cause feedback loops).
```csharp
_watcher = new FileSystemWatcher(EditorApplication.AssetsFolderPath)
{
IncludeSubdirectories = true,
EnableRaisingEvents = true,
NotifyFilter = NotifyFilters.FileName
| NotifyFilters.LastWrite
| NotifyFilters.DirectoryName,
};
```
### 8.2 Event handling decision tree
```csharp
private void OnFileSystemEvent(object sender, FileSystemEventArgs e)
{
var ext = Path.GetExtension(e.FullPath);
var relativePath = Path.GetRelativePath(_assetsRoot, e.FullPath);
// Ignore .gmeta changes triggered by our own writes
if (_ignoreMetaWrites.TryRemove(e.FullPath, out _))
return;
// Ignore temp files
if (ext is ".tmp" or ".gtemp")
return;
// ─── A .gmeta file changed ───────────────────────────
if (ext == ".gmeta")
{
if (e.ChangeType == WatcherChangeTypes.Changed)
{
// User edited import settings externally → re-import the source
var sourcePath = AssetMetaIO.GetSourcePath(relativePath);
var meta = AssetMetaIO.ReadAsync(e.FullPath).AsTask().Result; // sync read is OK here, .gmeta is tiny
if (meta is not null)
{
_importCoordinator.EnqueueAsync(new ImportJob(
meta.Guid, sourcePath, e.FullPath, ImportReason.SettingsChanged
)).AsTask();
}
}
// .gmeta Created/Deleted are handled by the source file events below
return;
}
// ─── A source file changed ───────────────────────────
switch (e.ChangeType)
{
case WatcherChangeTypes.Created:
// New source file dropped in → generate .gmeta + queue import
HandleNewSourceFile(e.FullPath, relativePath);
break;
case WatcherChangeTypes.Changed:
// Source file modified → queue re-import
HandleModifiedSourceFile(e.FullPath, relativePath);
break;
case WatcherChangeTypes.Deleted:
// Source file deleted → remove from catalog, optionally clean Library/
HandleDeletedSourceFile(e.FullPath, relativePath);
break;
}
}
```
### 8.3 Auto-generating `.gmeta` for new files
When an artist drops a `hero.png` into `Assets/Textures/`:
```csharp
private void HandleNewSourceFile(string fullPath, string relativePath)
{
var ext = Path.GetExtension(relativePath);
var handler = _handlerRegistry.GetByExtension(ext);
if (handler is null)
return; // Unknown file type — ignore
var metaPath = AssetMetaIO.GetMetaPath(fullPath);
if (File.Exists(metaPath))
return; // Already has .gmeta (maybe copied together)
var attr = handler.GetType().GetCustomAttribute<CustomAssetHandlerAttribute>()!;
var meta = new AssetMeta
{
Guid = Guid.NewGuid(),
HandlerTypeId = new Guid(attr.ID),
HandlerVersion = 0,
Settings = handler.CreateDefaultSettings(), // new method on IAssetHandler
};
// Write .gmeta (suppress FSW for this write)
_ignoreMetaWrites[metaPath] = true;
AssetMetaIO.WriteAsync(metaPath, meta).AsTask().Wait();
// Register in catalog
_catalog.Upsert(meta, relativePath);
// Queue import
_importCoordinator.EnqueueAsync(new ImportJob(
meta.Guid, relativePath, metaPath, ImportReason.NewAsset
)).AsTask();
}
```
---
## 9. Dependency Graph
### 9.1 Storage
Dependencies are stored in three places (all kept in sync):
1. **`.gmeta`** — `Dependencies: Guid[]` — source of truth, checked into git
2. **SQLite `dependencies` table** — fast queries, regenerable from `.gmeta`
3. **In-memory** — not needed anymore (SQLite is fast enough for editor use)
The current in-memory `_referencerGraph` / `_dependencyCache` dictionaries ([AssetRegistry.cs:48-49](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L48-L49)) are replaced by SQLite queries. SQLite with WAL mode handles concurrent reads efficiently.
### 9.2 Invalidation cascade
When asset A changes, all assets that depend on A may need re-import:
```csharp
public void InvalidateTransitive(Guid changedAssetGuid)
{
var visited = new HashSet<Guid>();
var queue = new Queue<Guid>();
queue.Enqueue(changedAssetGuid);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (!visited.Add(current))
continue;
_catalog.MarkDirty(current);
foreach (var referencer in _catalog.GetReferencers(current))
{
queue.Enqueue(referencer);
}
}
// Re-queue all dirty assets (except the original, which is already being imported)
foreach (var guid in visited)
{
if (guid != changedAssetGuid)
{
var sourcePath = _catalog.GetSourcePath(guid);
if (sourcePath is not null)
{
var metaPath = AssetMetaIO.GetMetaPath(Path.Combine(_assetsRoot, sourcePath));
_importCoordinator.EnqueueAsync(new ImportJob(guid, sourcePath, metaPath, ImportReason.SourceChanged)).AsTask();
}
}
}
}
```
---
## 10. Cooked Binary Format
The `.imported` files in `Library/Imports/` are the cooked binary format. This is **the only place we use binary serialization** — settings are in `.gmeta` (JSON), only the processed content blob is binary.
### 10.1 File structure
We keep the `AssetMetadata` header concept ([Asset.cs:43-117](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs#L43-L117)) but simplify it — settings and dependencies are no longer embedded:
```csharp
/// <summary>
/// Header for .imported files in Library/Imports/.
/// Simplified from the original AssetMetadata — no settings/dependencies
/// (those live in .gmeta and SQLite).
/// </summary>
[StructLayout(LayoutKind.Sequential, Size = SIZE)]
internal struct ImportedHeader
{
public const int CURRENT_FORMAT_VERSION = 1;
public const int SIZE = 64; // enough room for future fields
public int FormatVersion { get; set; }
public Guid AssetGuid { get; set; }
public Guid HandlerTypeId { get; set; }
public int HandlerVersion { get; set; }
public long ContentSize { get; set; }
// Reserved padding (fills to SIZE bytes)
}
```
After the header, the handler writes whatever binary content it needs. For textures, this might be:
```
Bytes 0-63: ImportedHeader
Bytes 64+: ImageContentHeader (width, height, depth, channels)
+ raw pixel data (or DDS reference path)
```
### 10.2 Why keep the binary header at all?
Validation on load:
- `FormatVersion` → detect old/corrupt files, trigger re-import
- `HandlerTypeId` → resolve the correct `IAssetHandler` without touching the catalog
- `HandlerVersion` → detect when handler has been upgraded, trigger re-import
If the catalog is out of sync (e.g., user copied Library/ from another machine), the header acts as a sanity check.
---
## 11. AssetRegistry Class Redesign
### 11.1 New structure (partial class split)
```
Services/
├── AssetRegistry.cs ← public API surface (IAssetRegistry impl)
├── AssetRegistry.Startup.cs ← initialization, catalog sync, first-time setup
├── AssetRegistry.Watcher.cs ← FileSystemWatcher event handling
└── AssetRegistry.Backend.cs ← AssetCatalog class (SQLite wrapper)
```
### 11.2 Top-level class
```csharp
namespace Ghost.Editor.Core.Services;
/// <summary>
/// Central asset registry for the GhostEngine editor.
///
/// Owns the lifecycle of:
/// - AssetCatalog (SQLite GUID↔path mapping + dependency graph)
/// - AssetHandlerRegistry (O(1) handler lookup by extension/typeId)
/// - ImportCoordinator (background import workers + Channel-based queue)
/// - FileSystemWatcher (Assets/ directory monitoring)
///
/// Thread safety: see each partial file for details.
/// The public API is safe to call from any thread — the registry
/// marshals events to the UI thread via DispatcherQueue.
/// </summary>
[EditorInjection(EditorInjectionAttribute.ServiceLifetime.Singleton,
ImplementationType = typeof(AssetRegistry))]
internal sealed partial class AssetRegistry : IAssetRegistry
{
private readonly string _assetsRoot;
private readonly AssetCatalog _catalog;
private readonly AssetHandlerRegistry _handlerRegistry;
private readonly ImportCoordinator _importCoordinator;
private readonly FileSystemWatcher _watcher;
// WeakReference cache — kept from current design, it works well
private readonly ConcurrentDictionary<Guid, WeakReference<Asset>> _loadedAssets;
private readonly SemaphoreSlim _loadLock;
public event EventHandler<IAssetRegistry, AssetChangedEventArgs>? OnAssetChanged;
// ─── IAssetRegistry implementation ─────────────────────
public string? GetAssetPath(Guid id)
=> _catalog.GetSourcePath(id);
public Guid GetAssetGuid(string path)
=> _catalog.GetGuid(path);
public async ValueTask<Result<Guid>> ImportAssetAsync(
string sourceFilePath, string targetAssetPath, CancellationToken token = default)
{
// 1. Copy source file to Assets/ if not already there
// 2. Generate .gmeta with new GUID
// 3. Upsert to catalog
// 4. Enqueue import job
// 5. Return the new GUID immediately (import happens in background)
}
public ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default)
{
// Enqueue a ManualReimport job
}
public async ValueTask<Result<Asset>> LoadAssetAsync(Guid id, CancellationToken token = default)
{
// Same double-check WeakReference pattern as current code
// But reads from Library/Imports/<guid>.imported
}
public async ValueTask<Result> SaveAssetAsync(Asset asset, CancellationToken token = default)
{
// For engine-native assets: write to .gasset + update .gmeta
}
public void Dispose()
{
_watcher.Dispose();
_importCoordinator.Dispose();
_catalog.Dispose();
_loadLock.Dispose();
}
}
```
### 11.3 Startup sequence (`AssetRegistry.Startup.cs`)
```csharp
internal sealed partial class AssetRegistry
{
public AssetRegistry(string assetsRoot)
{
_assetsRoot = assetsRoot;
// 1. Open or create catalog
var dbPath = Path.Combine(EditorApplication.LibraryFolderPath, "AssetDB.sqlite");
_catalog = new AssetCatalog(dbPath);
// 2. Build handler registry (one-time scan)
_handlerRegistry = new AssetHandlerRegistry();
// 3. Sync catalog with filesystem
SyncCatalogWithDisk();
// 4. Start import coordinator
_importCoordinator = new ImportCoordinator(_catalog, _handlerRegistry, _assetsRoot);
_importCoordinator.OnAssetChanged += (_, args) => OnAssetChanged?.Invoke(this, args);
// 5. Queue pending imports
_importCoordinator.EnqueueDirtyAssetsAsync().AsTask().Wait();
// 6. Start watching for changes
_loadedAssets = new ConcurrentDictionary<Guid, WeakReference<Asset>>();
_loadLock = new SemaphoreSlim(1, 1);
SetupWatcher();
}
/// <summary>
/// Walk Assets/ directory, diff against SQLite catalog, and reconcile.
///
/// For each .gmeta file found on disk:
/// - If not in catalog → insert (new asset)
/// - If in catalog with different path → update (file was moved)
///
/// For each entry in catalog:
/// - If .gmeta no longer on disk → delete (asset was removed externally)
///
/// This is O(N) where N = number of assets. Runs once at startup.
/// </summary>
private void SyncCatalogWithDisk()
{
var onDisk = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// 1. Scan all .gmeta files
foreach (var metaFile in Directory.EnumerateFiles(_assetsRoot, "*.gmeta", SearchOption.AllDirectories))
{
var metaRelative = Path.GetRelativePath(_assetsRoot, metaFile);
var sourceRelative = AssetMetaIO.GetSourcePath(metaRelative);
onDisk.Add(sourceRelative);
var meta = AssetMetaIO.ReadAsync(metaFile).AsTask().Result;
if (meta is null)
continue;
var existingPath = _catalog.GetSourcePath(meta.Guid);
if (existingPath is null)
{
// New asset — insert
_catalog.Upsert(meta, sourceRelative);
}
else if (!string.Equals(existingPath, sourceRelative, StringComparison.OrdinalIgnoreCase))
{
// Asset was moved/renamed — update path
_catalog.Upsert(meta, sourceRelative);
}
}
// 2. Remove catalog entries whose .gmeta no longer exists
foreach (var (guid, catalogPath) in _catalog.EnumerateAll())
{
if (!onDisk.Contains(catalogPath))
{
_catalog.Remove(guid);
}
}
}
}
```
> [!IMPORTANT]
> **Startup is now O(N) filesystem enumeration + O(N) SQLite upserts**, which is much faster than the current approach of opening every `.gasset` file and reading 20 bytes from each ([AssetRegistry.cs:99-134](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L99-L134)). For a project with 10,000 assets, this goes from 10,000 file opens → ~10,000 tiny `.gmeta` reads (or even better — just stat + compare timestamps).
---
## 12. Handler API Redesign
### 12.1 Updated interfaces
```csharp
namespace Ghost.Editor.Core.AssetHandler;
public interface IAssetHandler
{
/// <summary>
/// Load a cooked asset from a Library/Imports/ stream.
/// The stream is positioned at the start of handler-specific content
/// (past the ImportedHeader).
/// </summary>
ValueTask<Result<Asset>> LoadAsync(Stream importedStream, IAssetRegistry registry, CancellationToken token = default);
/// <summary>
/// Save an engine-native asset (materials, prefabs, etc).
/// For imported assets (textures, meshes), this is typically not called.
/// </summary>
ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry registry, CancellationToken token = default);
/// <summary>
/// Create default import settings for a new asset of this type.
/// Called when auto-generating .gmeta for a newly dropped file.
/// Return null if this handler has no configurable settings.
/// </summary>
IAssetSettings? CreateDefaultSettings() => null;
}
public interface IImportableAssetHandler : IAssetHandler
{
/// <summary>
/// Import a source file into a cooked binary format.
///
/// sourceStream: the raw source file (PNG, FBX, etc.)
/// targetStream: where to write the cooked binary (Library/Imports/<guid>.imported)
/// id: the asset GUID
/// settings: import settings from .gmeta (previously deserialized from JSON)
/// </summary>
ValueTask<Result> ImportAsync(Stream sourceStream, Stream targetStream, Guid id,
IAssetSettings? settings, CancellationToken token = default);
ValueTask<Result> ExportAsync(Stream importedStream, Stream targetStream,
IAssetExportOptions? options, CancellationToken token = default);
}
```
Note the key change: `ImportAsync` now receives `IAssetSettings? settings` as a parameter. The handler no longer reads/writes settings from the binary stream — settings live in `.gmeta`.
### 12.2 TextureAssetHandler changes
```csharp
[CustomAssetHandler(ID = TextureAsset._TYPE_ID,
SupportedExtensions = [".png", ".jpg", ".jpeg", ".tga", ".bmp", ".hdr"])]
internal class TextureAssetHandler : IImportableAssetHandler
{
private const int _CURRENT_VERSION = 1;
public IAssetSettings? CreateDefaultSettings() => new TextureAssetSettings();
public async ValueTask<Result> ImportAsync(
Stream sourceStream, Stream targetStream, Guid id,
IAssetSettings? settings, CancellationToken token = default)
{
var texSettings = settings as TextureAssetSettings ?? new TextureAssetSettings();
using var image = new MagickImage(sourceStream);
var bytes = image.ToByteArray();
// Kick off NVTT compression in background (this part stays the same)
await TextureProcessor.CompressToCacheAsync(
EditorApplication.CachesFolderPath, id, bytes,
image.Width, image.Height, image.Depth, texSettings, token);
// Write ImportedHeader
var header = new ImportedHeader
{
FormatVersion = ImportedHeader.CURRENT_FORMAT_VERSION,
AssetGuid = id,
HandlerTypeId = TextureAsset.s_typeGuid,
HandlerVersion = _CURRENT_VERSION,
};
// Write content (ImageContentHeader + raw pixels)
var contentHeader = new ImageContentHeader { ... };
// ... (similar to current code but without settings in the binary)
}
public async ValueTask<Result<Asset>> LoadAsync(
Stream importedStream, IAssetRegistry registry, CancellationToken token = default)
{
// Read ImportedHeader (validation)
var header = ImportedHeader.ReadFromStream(importedStream);
// Read image content
var contentHeader = /* read ImageContentHeader */;
// Create GPU texture handle
// ...
return new TextureAsset(header.AssetGuid, [], null, texture);
}
}
```
### 12.3 TextureAssetSettings no longer needs struct layout
Since settings are now serialized as JSON (not raw bytes), we can make `TextureAssetSettings` a normal class with proper properties:
```csharp
// Before (fragile — binary struct layout)
public struct BasicSettings()
{
public TextureType TextureType { get; set; } = TextureType.Default;
// ... adding a field here breaks all existing .gasset files
}
// After (resilient — JSON serialization)
public sealed class BasicSettings
{
public TextureType TextureType { get; set; } = TextureType.Default;
// ... adding a field is safe — old .gmeta files without it get the default
}
```
The `TextureProcessor` still needs `Unsafe.SizeOf` for hashing — but that's only for the cache key hash, not for persistence. We can replace that hash computation with JSON-based hashing (or keep the struct hash as an internal optimization detail).
---
## 13. Migration Path from Current Code
If you have existing `.gasset` files, we need a migration tool. This runs once:
```csharp
internal static class AssetMigration
{
/// <summary>
/// Read old .gasset files and produce .gmeta + Library/Imports/ equivalents.
/// </summary>
public static async Task MigrateFromGassetAsync(string assetsRoot, AssetCatalog catalog)
{
foreach (var gassetPath in Directory.EnumerateFiles(assetsRoot, "*.gasset", SearchOption.AllDirectories))
{
await using var fs = new FileStream(gassetPath, FileMode.Open, FileAccess.Read);
var oldMeta = AssetMetadata.ReadFromStream(fs);
// Create .gmeta
var meta = new AssetMeta
{
Guid = oldMeta.ID,
HandlerTypeId = oldMeta.TypeID,
HandlerVersion = oldMeta.HandlerVersion,
Dependencies = ReadDependencies(fs, oldMeta),
Settings = ReadSettings(fs, oldMeta), // deserialize from binary
};
var metaPath = gassetPath + ".gmeta";
await AssetMetaIO.WriteAsync(metaPath, meta);
// Copy content blob to Library/Imports/
var importedPath = Path.Combine(
EditorApplication.LibraryFolderPath, "Imports", $"{oldMeta.ID:N}.imported");
// ... extract content section from .gasset and write to .imported
// Register in catalog
var relativePath = Path.GetRelativePath(assetsRoot, gassetPath);
catalog.Upsert(meta, relativePath);
}
}
}
```
---
## 14. Thread Safety Model
| Component | Thread Model | Mechanism |
|---|---|---|
| `AssetCatalog` (SQLite) | Single writer, multiple readers | `Lock` on writes; SQLite WAL handles concurrent reads |
| `AssetHandlerRegistry` | Read-only after construction | Immutable dictionaries, no sync needed |
| `ImportCoordinator` | Multiple worker tasks read from `Channel<T>` | Channel provides thread-safe MPSC semantics |
| `_loadedAssets` cache | Concurrent reads, rare writes | `ConcurrentDictionary` + `SemaphoreSlim` for double-check (same as current) |
| `FileSystemWatcher` callbacks | Runs on thread pool threads | Non-blocking — just writes to channel |
| `OnAssetChanged` events | Marshalled to UI thread | `DispatcherQueue.TryEnqueue` |
| `.gmeta` file I/O | One writer at a time per file | Atomic write via temp-file + rename |
---
## 15. Error Handling Strategy
Following GhostEngine conventions ([AGENTS.md](file:///f:/csharp/GhostEngine/AGENTS.md)):
| Situation | Approach |
|---|---|
| Source file not found | `Result.Failure(Error.NotFound)` |
| No handler for extension | `Result.Failure("No handler for .xyz")` |
| Import fails (NVTT crash, corrupt file) | `Logger.Error(...)` + `catalog.MarkFailed(guid, msg)` — asset shows red in editor browser |
| `.gmeta` parse error | `Logger.Warning(...)` + regenerate with defaults |
| SQLite error | Throw — programming error, should not happen |
| `.imported` file corrupt/missing | Mark dirty in catalog → re-import on next startup |
---
## 16. Open Decisions & Trade-offs
### 16.1 What to do with engine-native assets (materials, scenes)?
Engine-native assets like materials don't have a "source file" separate from the engine format. Options:
- **Option A**: The `.gasset` file IS the source. `.gmeta` sits beside it. No `.imported` file needed.
- **Option B**: Store as JSON (like `.gmeta` but with content), drop binary entirely for these types.
I lean toward **Option A** — keep `.gasset` for engine-native assets only, use `.gmeta` + `.imported` for imported assets.
### 16.2 Should `ImportAssetAsync` return immediately or wait for import?
Current design returns after import completes. New design enqueues and returns GUID immediately. Which do you prefer?
We could offer both:
```csharp
// Fire-and-forget (for drag-and-drop, batch imports)
ValueTask<Result<Guid>> ImportAssetAsync(...);
// Wait for completion (for scripted imports, tests)
ValueTask<Result<Guid>> ImportAssetAndWaitAsync(...);
```
### 16.3 Hot-reload after re-import
When an asset is re-imported, should live `Asset` objects auto-refresh? Your current code already has `Asset.RefreshAsync` ([Asset.cs:36-39](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs#L36-L39)) and the `WeakReference` lookup in `ReimportAssetAsync` ([AssetRegistry.cs:406-409](file:///f:/csharp/GhostEngine/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs#L406-L409)). We should keep this behavior — after import completes, check `_loadedAssets` for a live reference and call `RefreshAsync`.
### 16.4 Library/ on first clone
When someone clones the repo, `Library/` doesn't exist. Options:
1. **Full re-import on first open** — slowest but simplest
2. **Pre-built Library in git-lfs** — fast startup but large repo
3. **Asset server / CDN** — best UX but complex to build
I suggest starting with **(1)** — it's correct by construction. Optimize later with (3) if import times become painful.
---
## File Summary: What Gets Created / Modified / Deleted
### New Files
| File | Purpose |
|---|---|
| `AssetHandler/AssetMeta.cs` | `AssetMeta` data model + `AssetMetaIO` read/write |
| `AssetHandler/AssetHandlerRegistry.cs` | One-time handler scan, O(1) lookups |
| `Services/AssetCatalog.cs` | SQLite wrapper (replaces `AssetRegistry.Backend.cs` placeholder) |
| `Services/ImportCoordinator.cs` | Channel-based import queue + worker pool |
### Modified Files
| File | Changes |
|---|---|
| `Services/AssetRegistry.cs` | Rewritten — delegates to AssetCatalog + ImportCoordinator |
| `Services/AssetRegistry.Backend.cs` | Deleted or merged into `AssetCatalog.cs` |
| `AssetHandler/Asset.cs` | `AssetMetadata` simplified → `ImportedHeader` (no settings/deps in binary) |
| `AssetHandler/AssetHandler.cs` | `IImportableAssetHandler.ImportAsync` gains `IAssetSettings?` param |
| `AssetHandler/TextureAsset.cs` | Handler updated for new API; settings → class instead of struct |
| `Contracts/IAssetRegistry.cs` | Minor — add `IAssetSettings? GetSettings(Guid)` if needed by inspector |
| `EditorApplication.cs` | Add `LIBRARY_FOLDER_NAME` and `LibraryFolderPath` |
| `Attributes.cs` | Unify `AssetImporterAttribute` with `CustomAssetHandlerAttribute` |
### Deleted Files
| File | Reason |
|---|---|
| `Utilities/AssetHandlerUtility.cs` | Superseded by handler API changes |