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
62 KiB
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
- Design Overview
- Project Folder Layout
- The
.gmetaSidecar File - SQLite Catalog (
AssetDB) - Handler Registration & TypeCache Integration
- Import Pipeline
- Asset Loading (Runtime Path)
- FileSystemWatcher & Change Detection
- Dependency Graph
- Cooked Binary Format
- AssetRegistry Class Redesign
- Handler API Redesign
- Migration Path from Current Code
- Thread Safety Model
- Error Handling Strategy
- 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) merges all three concerns into a single .gasset file. The rewrite splits them.
2. Project Folder Layout
The existing EditorApplication (EditorApplication.cs) already defines the project folder constants. We add one:
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.gassetfile IS the source, and the.gmetasits beside it. TheLibrary/Imports/entry is either a copy or not needed (the.gassetis already in a loadable format).
3. The .gmeta Sidecar File
3.1 Why JSON over YAML/binary
System.Text.Jsonships with .NET — zero new dependencies- JSON diffs are readable in any git client
- Add/remove fields without breaking existing files (missing keys → defaults)
JsonSerializerOptions.WriteIndentedmakes 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
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
{
"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:
// 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.WriteUnalignedraw struct bytes (TextureAsset.cs:229-253). The old way breaks whenever you add a field toTextureAssetSettings.BasicSettings. JSON handles field addition/removal gracefully — missing fields get default values.
3.6 Reading/writing .gmeta files
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.Movewithoverwrite: trueis atomic on NTFS.
4. SQLite Catalog (AssetDB)
This replaces the current in-memory ConcurrentDictionary<string, Guid> + ConcurrentDictionary<Guid, string> (AssetRegistry.cs:42-43) and the current Dictionary<Guid, HashSet<Guid>> dependency graph (AssetRegistry.cs:48-49).
4.1 Why SQLite
Microsoft.Data.Sqliteis already in Ghost.Editor.Core.csproj — 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)
4.2 Schema
-- 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):
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:
// 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 and AssetRegistry.cs:342-354). 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) 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:
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
AssetImporterAttributein Attributes.cs:24-34 which serves a similar purpose. As part of this rewrite, we should unify it withCustomAssetHandlerAttributeto 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) 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
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
);
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) |
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) | Source file is never touched. Cooked data goes to Library/Imports/. |
| No content hash check (TextureProcessor.cs) | ComputeFileHashAsync hashes source content. Both content + settings hashes must match to skip import. |
_ignoreFileChanges race (line 51) |
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) and the double-check locking pattern (AssetRegistry.cs:414-483) are good designs — we keep them. The only change is where we read from (.importedfile 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:
// 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).
_watcher = new FileSystemWatcher(EditorApplication.AssetsFolderPath)
{
IncludeSubdirectories = true,
EnableRaisingEvents = true,
NotifyFilter = NotifyFilters.FileName
| NotifyFilters.LastWrite
| NotifyFilters.DirectoryName,
};
8.2 Event handling decision tree
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/:
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):
.gmeta—Dependencies: Guid[]— source of truth, checked into git- SQLite
dependenciestable — fast queries, regenerable from.gmeta - In-memory — not needed anymore (SQLite is fast enough for editor use)
The current in-memory _referencerGraph / _dependencyCache dictionaries (AssetRegistry.cs:48-49) 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:
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) but simplify it — settings and dependencies are no longer embedded:
/// <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-importHandlerTypeId→ resolve the correctIAssetHandlerwithout touching the catalogHandlerVersion→ 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
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)
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
.gassetfile and reading 20 bytes from each (AssetRegistry.cs:99-134). For a project with 10,000 assets, this goes from 10,000 file opens → ~10,000 tiny.gmetareads (or even better — just stat + compare timestamps).
12. Handler API Redesign
12.1 Updated interfaces
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
[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:
// 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:
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):
| 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
.gassetfile IS the source..gmetasits beside it. No.importedfile needed. - Option B: Store as JSON (like
.gmetabut 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:
// 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) and the WeakReference lookup in ReimportAssetAsync (AssetRegistry.cs:406-409). 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:
- Full re-import on first open — slowest but simplest
- Pre-built Library in git-lfs — fast startup but large repo
- 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 |