From 6615fe794e223252cfc1e87832e893b81d36ce07 Mon Sep 17 00:00:00 2001 From: Misaki Date: Tue, 14 Apr 2026 20:18:38 +0900 Subject: [PATCH] feat(asset): modern asset system with SQLite catalog Refactored asset management to use a persistent, thread-safe SQLite-backed AssetCatalog, replacing in-memory dictionaries. Added AssetHandlerRegistry for O(1) handler lookup, ImportCoordinator for async background importing, and robust AssetMeta/AssetMetaIO for JSON-based metadata and settings. Refactored AssetRegistry to integrate these components and support auto-import via file system watcher. Updated IImportableAssetHandler for handler-specific settings and polymorphic serialization. Added comprehensive unit tests for all new systems. Removed obsolete code and legacy integration tests. BREAKING CHANGE: Asset system APIs and storage format have changed; migration required for existing projects. --- .../Ghost.Editor.Core/AssetHandler/Asset.cs | 2 - .../AssetHandler/AssetHandler.cs | 7 +- .../AssetHandler/AssetHandlerRegistry.cs | 92 +++ .../AssetHandler/AssetMeta.cs | 114 ++++ .../AssetHandler/TextureAsset.cs | 8 +- src/Editor/Ghost.Editor.Core/Attributes.cs | 15 +- .../Contracts/IAssetRegistry.cs | 2 + .../Ghost.Editor.Core/EditorApplication.cs | 15 +- .../Services/AssetCatalog.cs | 264 +++++++++ .../Services/AssetRegistry.Backend.cs | 6 - .../Services/AssetRegistry.cs | 553 +++++------------- .../Services/ImportCoordinator.cs | 179 ++++++ src/Editor/Ghost.Editor/ActivationHandler.cs | 1 - src/Editor/Ghost.Editor/App.xaml.cs | 29 +- src/Test/Ghost.Entities.Test/SystemTest.cs | 2 +- .../RenderPipeline/TestRenderPipeline.cs | 3 + .../TestRenderPipelineSettings.cs | 4 + .../Systems/RenderExtractionSystem.cs | 2 + .../Utilities/MeshUtility.cs | 6 +- .../Windows/GraphicsTestWindow.xaml.cs | 4 + src/Test/Ghost.MicroTest/MeshoptBenchmark.cs | 256 -------- src/Test/Ghost.MicroTest/Program.cs | 3 +- .../AssetDatabaseIntegrationTest.cs | 436 -------------- .../AssetSystem/AssertRegistryTest.cs | 47 ++ .../AssetSystem/AssetCatalogTests.cs | 92 +++ .../AssetSystem/AssetHandlerRegistryTests.cs | 39 ++ .../AssetSystem/AssetMetaTests.cs | 59 ++ .../AssetSystem/ImportCoordinatorTests.cs | 78 +++ 28 files changed, 1188 insertions(+), 1130 deletions(-) create mode 100644 src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandlerRegistry.cs create mode 100644 src/Editor/Ghost.Editor.Core/AssetHandler/AssetMeta.cs create mode 100644 src/Editor/Ghost.Editor.Core/Services/AssetCatalog.cs delete mode 100644 src/Editor/Ghost.Editor.Core/Services/AssetRegistry.Backend.cs create mode 100644 src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs delete mode 100644 src/Test/Ghost.MicroTest/MeshoptBenchmark.cs delete mode 100644 src/Test/Ghost.UnitTest/AssetDatabaseIntegrationTest.cs create mode 100644 src/Test/Ghost.UnitTest/AssetSystem/AssertRegistryTest.cs create mode 100644 src/Test/Ghost.UnitTest/AssetSystem/AssetCatalogTests.cs create mode 100644 src/Test/Ghost.UnitTest/AssetSystem/AssetHandlerRegistryTests.cs create mode 100644 src/Test/Ghost.UnitTest/AssetSystem/AssetMetaTests.cs create mode 100644 src/Test/Ghost.UnitTest/AssetSystem/ImportCoordinatorTests.cs diff --git a/src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs b/src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs index d180522..623ff61 100644 --- a/src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs +++ b/src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs @@ -175,5 +175,3 @@ public readonly struct AssetReference : IEquatable return !(left == right); } } - -public interface IAssetSettings; diff --git a/src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandler.cs b/src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandler.cs index 0d1a9c9..4885429 100644 --- a/src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandler.cs +++ b/src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandler.cs @@ -32,17 +32,18 @@ public interface IAssetHandler public interface IImportableAssetHandler : IAssetHandler { - ValueTask ImportAsync(Stream sourceStream, Stream targetStream, Guid id, CancellationToken token = default); + IAssetSettings? CreateDefaultSettings() => null; + ValueTask ImportAsync(Stream sourceStream, Stream targetStream, Guid id, IAssetSettings? settings, CancellationToken token = default); ValueTask ExportAsync(Stream assetStream, Stream targetStream, IAssetExportOptions? options, CancellationToken token = default); } public static class AssetHandlerExtensions { - public static async ValueTask ImportAsync(this IImportableAssetHandler handler, string sourceFilePath, string targetFilePath, Guid id, CancellationToken token = default) + public static async ValueTask ImportAsync(this IImportableAssetHandler handler, string sourceFilePath, string targetFilePath, Guid id, IAssetSettings? settings = null, CancellationToken token = default) { await using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); await using var targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.None); - return await handler.ImportAsync(sourceStream, targetStream, id, token); + return await handler.ImportAsync(sourceStream, targetStream, id, settings, token); } public static async ValueTask ExportAsync(this IImportableAssetHandler handler, string assetFilePath, string targetFilePath, IAssetExportOptions? options, CancellationToken token = default) diff --git a/src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandlerRegistry.cs b/src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandlerRegistry.cs new file mode 100644 index 0000000..ef62488 --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandlerRegistry.cs @@ -0,0 +1,92 @@ +using System.Reflection; +using Ghost.Editor.Core.Utilities; + +namespace Ghost.Editor.Core.AssetHandler; + +/// +/// One-time scan at editor startup → two dictionaries. +/// All lookups are O(1) after construction. +/// +internal sealed class AssetHandlerRegistry +{ + private readonly Dictionary _byExtension; + private readonly Dictionary _byTypeId; + private readonly Dictionary _versionByTypeId; + + public AssetHandlerRegistry() + { + _byExtension = new Dictionary(StringComparer.OrdinalIgnoreCase); + _byTypeId = new Dictionary(); + _versionByTypeId = new Dictionary(); + + foreach (var typeInfo in TypeCache.GetTypes()) + { + if (typeInfo.IsAbstract || typeInfo.IsInterface) + { + continue; + } + + if (!typeof(IAssetHandler).IsAssignableFrom(typeInfo)) + { + continue; + } + + var attr = typeInfo.GetCustomAttribute(); + if (attr == null) + { + continue; + } + + if (!Guid.TryParse(attr.ID, out var typeId)) + { + continue; + } + + try + { + if (Activator.CreateInstance(typeInfo) is IAssetHandler handler) + { + _byTypeId[typeId] = handler; + // Note: Versioning could be expanded, but for now we assume version 1 or look for a constant + _versionByTypeId[typeId] = 1; + + foreach (var ext in attr.SupportedExtensions) + { + var normalizedExt = ext.StartsWith('.') ? ext : "." + ext; + _byExtension[normalizedExt] = handler; + } + } + } + catch + { + // Log failure to instantiate handler in real app + } + } + } + + public IAssetHandler? GetByExtension(string extension) + { + if (string.IsNullOrEmpty(extension)) + { + return null; + } + + var normalized = extension.StartsWith('.') ? extension : "." + extension; + _byExtension.TryGetValue(normalized, out var handler); + return handler; + } + + public IAssetHandler? GetByTypeId(Guid typeId) + { + _byTypeId.TryGetValue(typeId, out var handler); + return handler; + } + + public int GetVersionByTypeId(Guid typeId) + { + _versionByTypeId.TryGetValue(typeId, out var version); + return version; + } + + public IEnumerable GetSupportedExtensions() => _byExtension.Keys; +} diff --git a/src/Editor/Ghost.Editor.Core/AssetHandler/AssetMeta.cs b/src/Editor/Ghost.Editor.Core/AssetHandler/AssetMeta.cs new file mode 100644 index 0000000..ad8107a --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/AssetHandler/AssetMeta.cs @@ -0,0 +1,114 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Ghost.Editor.Core.AssetHandler; + +/// +/// Mark IAssetSettings for polymorphic serialization. +/// Each handler type will register its own derived type. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(DefaultAssetSettings), "Default")] +public interface IAssetSettings; + +public sealed class DefaultAssetSettings : IAssetSettings; + +/// +/// Persisted as a JSON sidecar (.gmeta) next to every source asset. +/// This is the single source of truth for asset identity and import settings. +/// +public sealed class AssetMeta +{ + /// + /// Globally unique identifier for this asset. Generated once, never changes. + /// + public required Guid Guid { get; init; } + + /// + /// The Guid that identifies which IAssetHandler processes this asset. + /// + public Guid? HandlerTypeId { get; set; } + + /// + /// Version of the handler that last imported this asset. + /// + public int HandlerVersion { get; set; } + + /// + /// xxHash64 of the source file content at last successful import. + /// + public string? ContentHash { get; set; } + + /// + /// xxHash64 of the serialized import settings at last successful import. + /// + public string? SettingsHash { get; set; } + + /// + /// UTC timestamp of last successful import. + /// + public DateTime? LastImportedUtc { get; set; } + + /// + /// GUIDs of other assets this asset depends on. + /// + public Guid[] Dependencies { get; set; } = []; + + /// + /// Optional user-facing labels for search/filtering in the editor. + /// + public string[] Labels { get; set; } = []; + + /// + /// Handler-specific import settings. + /// + public IAssetSettings? Settings { get; set; } +} + +internal static class AssetMetaIO +{ + private static readonly JsonSerializerOptions s_options = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + + public static async ValueTask ReadAsync(string metaPath, CancellationToken token = default) + { + if (!File.Exists(metaPath)) + { + return null; + } + + try + { + await using var stream = new FileStream(metaPath, FileMode.Open, FileAccess.Read, FileShare.Read); + return await JsonSerializer.DeserializeAsync(stream, s_options, token).ConfigureAwait(false); + } + catch + { + return null; + } + } + + 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); + } + + if (File.Exists(metaPath)) + { + File.Delete(metaPath); + } + File.Move(tempPath, metaPath); + } + + public static string GetMetaPath(string sourceFilePath) => sourceFilePath + ".gmeta"; + + public static string GetSourcePath(string metaPath) => metaPath[..^".gmeta".Length]; +} diff --git a/src/Editor/Ghost.Editor.Core/AssetHandler/TextureAsset.cs b/src/Editor/Ghost.Editor.Core/AssetHandler/TextureAsset.cs index 6aae3af..e7aefd6 100644 --- a/src/Editor/Ghost.Editor.Core/AssetHandler/TextureAsset.cs +++ b/src/Editor/Ghost.Editor.Core/AssetHandler/TextureAsset.cs @@ -290,13 +290,13 @@ internal class TextureAssetHandler : IImportableAssetHandler throw new NotImplementedException(); } - public async ValueTask ImportAsync(Stream sourceStream, Stream targetStream, Guid id, CancellationToken token = default) + public async ValueTask ImportAsync(Stream sourceStream, Stream targetStream, Guid id, IAssetSettings? settings, CancellationToken token = default) { + var textureSettings = settings as TextureAssetSettings ?? new TextureAssetSettings(); using var image = new MagickImage(sourceStream); var bytes = image.ToByteArray(); - var settings = new TextureAssetSettings(); - await TextureProcessor.CompressToCacheAsync(EditorApplication.LibraryFolderPath, id, bytes, image.Width, image.Height, image.Depth, settings, token).ConfigureAwait(false); + await TextureProcessor.CompressToCacheAsync(EditorApplication.LibraryFolderPath, id, bytes, image.Width, image.Height, image.Depth, textureSettings, token).ConfigureAwait(false); var header = new AssetMetadata(id, TextureAsset.s_typeGuid) { @@ -305,7 +305,7 @@ internal class TextureAssetHandler : IImportableAssetHandler }; targetStream.Seek(header.SettingsOffset, SeekOrigin.Begin); - var sizeResult = await WriteSettingsToStreamAsync(settings, targetStream, token).ConfigureAwait(false); + var sizeResult = await WriteSettingsToStreamAsync(textureSettings, targetStream, token).ConfigureAwait(false); if (sizeResult.IsFailure) { return Result.Failure($"Failed to write texture asset settings: {sizeResult.Message}"); diff --git a/src/Editor/Ghost.Editor.Core/Attributes.cs b/src/Editor/Ghost.Editor.Core/Attributes.cs index f381aec..34b940b 100644 --- a/src/Editor/Ghost.Editor.Core/Attributes.cs +++ b/src/Editor/Ghost.Editor.Core/Attributes.cs @@ -55,23 +55,10 @@ public class EditorInjectionAttribute : DiscoverableAttributeBase { Singleton, Transient, - Scoped } - public ServiceLifetime Lifetime + public EditorInjectionAttribute(ServiceLifetime lifetime, Type implementationType) { - get; - } - - public Type? ImplementationType - { - get; - } - - public EditorInjectionAttribute(ServiceLifetime lifetime, Type? implementationType = null) - { - Lifetime = lifetime; - ImplementationType = implementationType; } } diff --git a/src/Editor/Ghost.Editor.Core/Contracts/IAssetRegistry.cs b/src/Editor/Ghost.Editor.Core/Contracts/IAssetRegistry.cs index 311c646..3f90c2b 100644 --- a/src/Editor/Ghost.Editor.Core/Contracts/IAssetRegistry.cs +++ b/src/Editor/Ghost.Editor.Core/Contracts/IAssetRegistry.cs @@ -1,5 +1,6 @@ using Ghost.Core; using Ghost.Editor.Core.AssetHandler; +using Ghost.Editor.Core.Services; namespace Ghost.Editor.Core.Contracts; @@ -37,6 +38,7 @@ public sealed class AssetChangedEventArgs : EventArgs } } +[EditorInjection(EditorInjectionAttribute.ServiceLifetime.Singleton, typeof(AssetRegistry))] public interface IAssetRegistry : IDisposable { string? GetAssetPath(Guid id); diff --git a/src/Editor/Ghost.Editor.Core/EditorApplication.cs b/src/Editor/Ghost.Editor.Core/EditorApplication.cs index 1a042ee..b88cc97 100644 --- a/src/Editor/Ghost.Editor.Core/EditorApplication.cs +++ b/src/Editor/Ghost.Editor.Core/EditorApplication.cs @@ -1,3 +1,4 @@ +using Ghost.Editor.Core.Utilities; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; @@ -11,6 +12,8 @@ public static class EditorApplication public const string LIBRARY_FOLDER_NAME = "Library"; public const string CONFIG_FOLDER_NAME = "Config"; + public const string IMPORTS_FOLDER_NAME = "Imports"; + private static IServiceProvider? s_serviceProvider; private static string s_currentProjectPath = string.Empty; private static string s_currentProjectName = string.Empty; @@ -22,11 +25,13 @@ public static class EditorApplication public static string ProjectPath => s_currentProjectPath; public static string ProjectName => s_currentProjectName; - public static string AssetsFolderPath => Path.Combine(ProjectPath, ASSETS_FOLDER_NAME); - public static string SourcesFolderPath => Path.Combine(ProjectPath, SOURCES_FOLDER_NAME); - public static string PackagesFolderPath => Path.Combine(ProjectPath, PACKAGES_FOLDER_NAME); - public static string LibraryFolderPath => Path.Combine(ProjectPath, LIBRARY_FOLDER_NAME); - public static string ConfigFolderPath => Path.Combine(ProjectPath, CONFIG_FOLDER_NAME); + public static readonly string AssetsFolderPath = Path.Combine(ProjectPath, ASSETS_FOLDER_NAME); + public static readonly string SourcesFolderPath = Path.Combine(ProjectPath, SOURCES_FOLDER_NAME); + public static readonly string PackagesFolderPath = Path.Combine(ProjectPath, PACKAGES_FOLDER_NAME); + public static readonly string LibraryFolderPath = Path.Combine(ProjectPath, LIBRARY_FOLDER_NAME); + public static readonly string ConfigFolderPath = Path.Combine(ProjectPath, CONFIG_FOLDER_NAME); + + public static readonly string ImportsFolderPath = Path.Combine(LibraryFolderPath, IMPORTS_FOLDER_NAME); public static DispatcherQueue DispatcherQueue { diff --git a/src/Editor/Ghost.Editor.Core/Services/AssetCatalog.cs b/src/Editor/Ghost.Editor.Core/Services/AssetCatalog.cs new file mode 100644 index 0000000..a89baee --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/Services/AssetCatalog.cs @@ -0,0 +1,264 @@ +using Ghost.Editor.Core.AssetHandler; +using Microsoft.Data.Sqlite; + +namespace Ghost.Editor.Core.Services; + +/// +/// Thread-safe SQLite-backed asset catalog. +/// Replaces the in-memory dictionary approach with persistent storage. +/// +internal sealed class AssetCatalog : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly object _writeLock = new(); + + // Prepared statements + private readonly SqliteCommand _cmdGetGuid; + private readonly SqliteCommand _cmdGetPath; + private readonly SqliteCommand _cmdUpsert; + private readonly SqliteCommand _cmdDelete; + private readonly SqliteCommand _cmdMarkDirty; + private readonly SqliteCommand _cmdMarkImported; + private readonly SqliteCommand _cmdMarkFailed; + private readonly SqliteCommand _cmdGetReferencers; + private readonly SqliteCommand _cmdGetDependencies; + private readonly SqliteCommand _cmdInsertDep; + private readonly SqliteCommand _cmdClearDeps; + private readonly SqliteCommand _cmdGetDirty; + private readonly SqliteCommand _cmdEnumerate; + + public AssetCatalog(string dbPath) + { + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + + var connString = new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Cache = SqliteCacheMode.Shared, + }.ToString(); + + _connection = new SqliteConnection(connString); + _connection.Open(); + + using (var pragma = _connection.CreateCommand()) + { + pragma.CommandText = "PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;"; + pragma.ExecuteNonQuery(); + } + + CreateSchema(); + + _cmdGetGuid = CreateCommand("SELECT guid FROM assets WHERE source_path = @path"); + _cmdGetPath = CreateCommand("SELECT source_path FROM assets WHERE guid = @guid"); + _cmdUpsert = CreateCommand(@" + INSERT INTO assets (guid, source_path, handler_type_id, handler_version, state) + VALUES (@guid, @path, @handler_id, @version, 0) + ON CONFLICT(guid) DO UPDATE SET + source_path = excluded.source_path, + handler_type_id = excluded.handler_type_id, + handler_version = excluded.handler_version, + state = 0;"); + _cmdDelete = CreateCommand("DELETE FROM assets WHERE guid = @guid"); + _cmdMarkDirty = CreateCommand("UPDATE assets SET state = 0 WHERE guid = @guid"); + _cmdMarkImported = CreateCommand(@" + UPDATE assets SET + content_hash = @content_hash, + settings_hash = @settings_hash, + imported_at_ms = @time, + state = 1, + error_message = NULL + WHERE guid = @guid"); + _cmdMarkFailed = CreateCommand("UPDATE assets SET state = 2, error_message = @msg WHERE guid = @guid"); + _cmdGetReferencers = CreateCommand("SELECT from_guid FROM dependencies WHERE to_guid = @guid"); + _cmdGetDependencies = CreateCommand("SELECT to_guid FROM dependencies WHERE from_guid = @guid"); + _cmdInsertDep = CreateCommand("INSERT INTO dependencies (from_guid, to_guid) VALUES (@from, @to)"); + _cmdClearDeps = CreateCommand("DELETE FROM dependencies WHERE from_guid = @guid"); + _cmdGetDirty = CreateCommand("SELECT guid, source_path FROM assets WHERE state = 0"); + _cmdEnumerate = CreateCommand("SELECT guid, source_path FROM assets"); + } + + private SqliteCommand CreateCommand(string sql) + { + var cmd = _connection.CreateCommand(); + cmd.CommandText = sql; + return cmd; + } + + private void CreateSchema() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = @" + CREATE TABLE IF NOT EXISTS assets ( + guid BLOB(16) PRIMARY KEY NOT NULL, + source_path TEXT NOT NULL, + handler_type_id BLOB(16), + handler_version INTEGER NOT NULL DEFAULT 0, + content_hash TEXT, + settings_hash TEXT, + imported_at_ms INTEGER, + state INTEGER NOT NULL DEFAULT 0, + error_message TEXT + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_path ON assets(source_path); + + CREATE TABLE IF NOT EXISTS dependencies ( + from_guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE, + to_guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE, + PRIMARY KEY (from_guid, to_guid) + ); + CREATE INDEX IF NOT EXISTS idx_dep_reverse ON dependencies(to_guid); + + 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);"; + cmd.ExecuteNonQuery(); + } + + public Guid GetGuid(string sourcePath) + { + _cmdGetGuid.Parameters.Clear(); + _cmdGetGuid.Parameters.AddWithValue("@path", sourcePath); + var result = _cmdGetGuid.ExecuteScalar(); + return result is byte[] bytes ? new Guid(bytes) : Guid.Empty; + } + + public string? GetSourcePath(Guid guid) + { + _cmdGetPath.Parameters.Clear(); + _cmdGetPath.Parameters.AddWithValue("@guid", guid.ToByteArray()); + return _cmdGetPath.ExecuteScalar() as string; + } + + public void Upsert(AssetMeta meta, string sourcePath) + { + lock (_writeLock) + { + _cmdUpsert.Parameters.Clear(); + _cmdUpsert.Parameters.AddWithValue("@guid", meta.Guid.ToByteArray()); + _cmdUpsert.Parameters.AddWithValue("@path", sourcePath); + _cmdUpsert.Parameters.AddWithValue("@handler_id", meta.HandlerTypeId?.ToByteArray() ?? (object)DBNull.Value); + _cmdUpsert.Parameters.AddWithValue("@version", meta.HandlerVersion); + _cmdUpsert.ExecuteNonQuery(); + } + } + + public bool Remove(Guid guid) + { + lock (_writeLock) + { + _cmdDelete.Parameters.Clear(); + _cmdDelete.Parameters.AddWithValue("@guid", guid.ToByteArray()); + return _cmdDelete.ExecuteNonQuery() > 0; + } + } + + public void MarkDirty(Guid guid) + { + lock (_writeLock) + { + _cmdMarkDirty.Parameters.Clear(); + _cmdMarkDirty.Parameters.AddWithValue("@guid", guid.ToByteArray()); + _cmdMarkDirty.ExecuteNonQuery(); + } + } + + public void MarkImported(Guid guid, string contentHash, string settingsHash) + { + lock (_writeLock) + { + _cmdMarkImported.Parameters.Clear(); + _cmdMarkImported.Parameters.AddWithValue("@guid", guid.ToByteArray()); + _cmdMarkImported.Parameters.AddWithValue("@content_hash", contentHash); + _cmdMarkImported.Parameters.AddWithValue("@settings_hash", settingsHash); + _cmdMarkImported.Parameters.AddWithValue("@time", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + _cmdMarkImported.ExecuteNonQuery(); + } + } + + public void MarkFailed(Guid guid, string error) + { + lock (_writeLock) + { + _cmdMarkFailed.Parameters.Clear(); + _cmdMarkFailed.Parameters.AddWithValue("@guid", guid.ToByteArray()); + _cmdMarkFailed.Parameters.AddWithValue("@msg", error); + _cmdMarkFailed.ExecuteNonQuery(); + } + } + + public void SetDependencies(Guid assetId, ReadOnlySpan dependencies) + { + lock (_writeLock) + { + using var tx = _connection.BeginTransaction(); + _cmdClearDeps.Transaction = tx; + _cmdClearDeps.Parameters.Clear(); + _cmdClearDeps.Parameters.AddWithValue("@guid", assetId.ToByteArray()); + _cmdClearDeps.ExecuteNonQuery(); + + _cmdInsertDep.Transaction = tx; + foreach (var dep in dependencies) + { + _cmdInsertDep.Parameters.Clear(); + _cmdInsertDep.Parameters.AddWithValue("@from", assetId.ToByteArray()); + _cmdInsertDep.Parameters.AddWithValue("@to", dep.ToByteArray()); + _cmdInsertDep.ExecuteNonQuery(); + } + tx.Commit(); + } + } + + public List GetReferencers(Guid guid) + { + _cmdGetReferencers.Parameters.Clear(); + _cmdGetReferencers.Parameters.AddWithValue("@guid", guid.ToByteArray()); + using var reader = _cmdGetReferencers.ExecuteReader(); + var list = new List(); + while (reader.Read()) + { + list.Add(new Guid((byte[])reader[0])); + } + return list; + } + + public List<(Guid guid, string sourcePath)> GetDirtyAssets() + { + using var reader = _cmdGetDirty.ExecuteReader(); + var list = new List<(Guid guid, string sourcePath)>(); + while (reader.Read()) + { + list.Add((new Guid((byte[])reader[0]), reader.GetString(1))); + } + return list; + } + + public IEnumerable<(Guid guid, string sourcePath)> EnumerateAll() + { + using var reader = _cmdEnumerate.ExecuteReader(); + while (reader.Read()) + { + yield return (new Guid((byte[])reader[0]), reader.GetString(1)); + } + } + + public void Dispose() + { + _cmdGetGuid.Dispose(); + _cmdGetPath.Dispose(); + _cmdUpsert.Dispose(); + _cmdDelete.Dispose(); + _cmdMarkDirty.Dispose(); + _cmdMarkImported.Dispose(); + _cmdMarkFailed.Dispose(); + _cmdGetReferencers.Dispose(); + _cmdGetDependencies.Dispose(); + _cmdInsertDep.Dispose(); + _cmdClearDeps.Dispose(); + _cmdGetDirty.Dispose(); + _cmdEnumerate.Dispose(); + _connection.Dispose(); + } +} diff --git a/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.Backend.cs b/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.Backend.cs deleted file mode 100644 index c3f961f..0000000 --- a/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.Backend.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace TestProject.AssetDB; - -internal partial class AssetRegistry -{ - // TODO: Sqlite backend implementation -} diff --git a/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs b/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs index 86395d5..0248dec 100644 --- a/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs +++ b/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs @@ -1,510 +1,269 @@ +using System.Collections.Concurrent; +using System.Reflection; using Ghost.Core; using Ghost.Editor.Core.AssetHandler; using Ghost.Editor.Core.Contracts; -using System.Collections.Concurrent; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -namespace TestProject.AssetDB; +namespace Ghost.Editor.Core.Services; -internal class PathComparer : IEqualityComparer +/// +/// Central asset registry for the GhostEngine editor. +/// +internal sealed class AssetRegistry : IAssetRegistry, IDisposable { - private static string ToCanonicalPath(string? path) - { - return path?.Replace('\\', '/').TrimEnd('/') ?? string.Empty; - } - - public bool Equals(string? x, string? y) - { - return string.Equals( - ToCanonicalPath(x), - ToCanonicalPath(y), - StringComparison.Ordinal); - } - - public int GetHashCode(string str) - { - return ToCanonicalPath(str).GetHashCode(StringComparison.Ordinal); - } -} - -// TODO: Path based locking for multi-threaded access? -// Is it actually necessary since this is mostly used in editor environment where single-threaded access is common (99.999%)? -internal partial class AssetRegistry : IAssetRegistry -{ - public const string ASSET_EXTENSION = ".gasset"; - public const string TEMP_EXTENSION = ".gtemp"; - - private readonly string _rootDirectory; + private readonly string _assetsRoot; + private readonly string _libraryRoot; + private readonly AssetCatalog _catalog; + private readonly AssetHandlerRegistry _handlerRegistry; + private readonly ImportCoordinator _importCoordinator; private readonly FileSystemWatcher _watcher; - private readonly ConcurrentDictionary _pathToGuid; - private readonly ConcurrentDictionary _guidToPath; - - private readonly ConcurrentDictionary _cachedHander; private readonly ConcurrentDictionary> _loadedAssets; - - private readonly Dictionary> _referencerGraph; - private readonly Dictionary> _dependencyCache; - - private readonly ConcurrentDictionary _ignoreFileChanges; - - private readonly SemaphoreSlim _cacheSlim; - private readonly Lock _pathLock; + private readonly SemaphoreSlim _loadLock = new(1, 1); + private readonly ConcurrentDictionary _ignoreMetaWrites = new(StringComparer.OrdinalIgnoreCase); public event EventHandler? OnAssetChanged; - public AssetRegistry(string rootDirectory) + public AssetRegistry(string assetsRoot) { - if (!Directory.Exists(rootDirectory)) - { - throw new DirectoryNotFoundException("The specified root directory does not exist."); - } + _assetsRoot = Path.GetFullPath(assetsRoot); + _libraryRoot = Path.Combine(Path.GetDirectoryName(_assetsRoot)!, EditorApplication.LIBRARY_FOLDER_NAME); - if (!Path.IsPathFullyQualified(rootDirectory)) - { - throw new InvalidOperationException("The specified root directory must be an absolute path."); - } + // TODO: This should be handled by EditorApplication. + Directory.CreateDirectory(_assetsRoot); + Directory.CreateDirectory(_libraryRoot); - _rootDirectory = rootDirectory; - _watcher = new FileSystemWatcher(rootDirectory) + var dbPath = Path.Combine(_libraryRoot, "AssetDB.sqlite"); + + _catalog = new AssetCatalog(dbPath); + _handlerRegistry = new AssetHandlerRegistry(); + _importCoordinator = new ImportCoordinator(_catalog, _handlerRegistry, _assetsRoot, _libraryRoot); + + _loadedAssets = new ConcurrentDictionary>(); + + SyncCatalogWithDisk(); + + _watcher = new FileSystemWatcher(_assetsRoot) { IncludeSubdirectories = true, EnableRaisingEvents = true, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.DirectoryName }; - _pathToGuid = new ConcurrentDictionary(4, 512, new PathComparer()); - _guidToPath = new ConcurrentDictionary(4, 512); - _cachedHander = new ConcurrentDictionary(4, 16); - _loadedAssets = new ConcurrentDictionary>(4, 512); + _watcher.Created += OnFileSystemEvent; + _watcher.Deleted += OnFileSystemEvent; + _watcher.Changed += OnFileSystemEvent; + _watcher.Renamed += OnFileSystemRenameEvent; - _referencerGraph = new Dictionary>(); - _dependencyCache = new Dictionary>(); - - _ignoreFileChanges = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - _cacheSlim = new SemaphoreSlim(1, 1); - _pathLock = new Lock(); - - LoadExistingAssets(); - - _watcher.Created += OnFileSystemOp; - _watcher.Deleted += OnFileSystemOp; - _watcher.Changed += OnFileSystemOp; - _watcher.Renamed += OnFileSystemRenameOp; + _importCoordinator.EnqueueDirtyAssetsAsync().AsTask().Wait(); } - // TODO: DB Cache - private unsafe void LoadExistingAssets() + private void SyncCatalogWithDisk() { - Span guidBuffer = stackalloc byte[sizeof(Guid)]; - foreach (var filePath in Directory.EnumerateFiles(_rootDirectory, $"*{ASSET_EXTENSION}", SearchOption.AllDirectories)) - { - var relativePath = Path.GetRelativePath(_rootDirectory, filePath); - - try - { - var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - try - { - fs.Seek(4, SeekOrigin.Begin); // Skip format version - fs.ReadExactly(guidBuffer); - - var guid = Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(guidBuffer)); - UpdatePathMapping(relativePath, guid); - } - finally - { - fs.Dispose(); - } - } - catch (Exception -#if DEBUG - ex -#endif - ) - { -#if DEBUG - System.Diagnostics.Debugger.BreakForUserUnhandledException(ex); -#endif - continue; - } - } - } - - private void UpdateGraph(Guid assetId, IEnumerable newDependencies) - { - // 1. Clean up old references (reverse) - if (_dependencyCache.TryGetValue(assetId, out var oldDeps)) - { - foreach (var dep in oldDeps) - { - if (_referencerGraph.TryGetValue(dep, out var refs)) - { - refs.Remove(assetId); - } - } - } - - // 2. Set new forward dependencies - var newDepSet = new HashSet(newDependencies); - _dependencyCache[assetId] = newDepSet; - - // 3. Add new references (reverse) - foreach (var dep in newDepSet) - { - ref var referencers = ref CollectionsMarshal.GetValueRefOrAddDefault(_referencerGraph, dep, out var exists); - if (!exists || referencers is null) - { - referencers = new HashSet(); - } - - referencers.Add(assetId); - } - } - - private void UpdatePathMapping(string relativePath, Guid guid) - { - lock (_pathLock) - { - _pathToGuid[relativePath] = guid; - _guidToPath[guid] = relativePath; - } - } - - private bool RemovePathMappingByPath(string relativePath) - { - lock (_pathLock) - { - if (_pathToGuid.Remove(relativePath, out var guid)) - { - return _guidToPath.TryRemove(guid, out _); - } - } - - return false; - } - - private async void OnFileSystemOp(object sender, FileSystemEventArgs e) - { - if (_ignoreFileChanges.TryRemove(e.FullPath, out _)) + if (!Directory.Exists(_assetsRoot)) { return; } - var relativePath = Path.GetRelativePath(_rootDirectory, e.FullPath); - var ext = Path.GetExtension(relativePath); + var metaFiles = Directory.EnumerateFiles(_assetsRoot, "*.gmeta", SearchOption.AllDirectories); + var foundGuids = new HashSet(); - var changeType = AssetChangeType.None; - var fireEvent = false; - var isAsset = ext.Equals(ASSET_EXTENSION, StringComparison.Ordinal); - var isTemp = ext.Equals(TEMP_EXTENSION, StringComparison.Ordinal); - - switch (e.ChangeType) + foreach (var metaPath in metaFiles) { - case WatcherChangeTypes.Created: - changeType = AssetChangeType.Created; - if (!isAsset && !isTemp) - { - var handler = GetAssetHandlerForExtension(ext); - if (handler is IImportableAssetHandler importableHandler) - { - var assetPath = string.Create(e.FullPath.Length - ext.Length + ASSET_EXTENSION.Length, e.FullPath, (destSpan, source) => - { - source.AsSpan(0, source.Length - ext.Length).CopyTo(destSpan); - ASSET_EXTENSION.AsSpan().CopyTo(destSpan.Slice(source.Length - ext.Length)); - }); - - var newGuid = Guid.NewGuid(); - await using var sourceStream = new FileStream(e.FullPath, FileMode.Open, FileAccess.Read); - await using var targetStream = new FileStream(assetPath, FileMode.Create, FileAccess.Write); - await importableHandler.ImportAsync(sourceStream, targetStream, newGuid); - - File.Delete(assetPath); - UpdatePathMapping(relativePath, newGuid); - - fireEvent = true; - } - } - break; - - case WatcherChangeTypes.Deleted: - changeType = AssetChangeType.Deleted; - if (isAsset) - { - fireEvent = RemovePathMappingByPath(relativePath); - } - break; - - case WatcherChangeTypes.Changed: - changeType = AssetChangeType.Modified; - fireEvent = isAsset; - break; - case WatcherChangeTypes.All: - // Can this even happen? - break; - default: - break; + var meta = AssetMetaIO.ReadAsync(metaPath).AsTask().Result; + if (meta != null) + { + var sourceRelative = AssetMetaIO.GetSourcePath(Path.GetRelativePath(_assetsRoot, metaPath)); + _catalog.Upsert(meta, sourceRelative.Replace('\\', '/')); + foundGuids.Add(meta.Guid); + } } - if (fireEvent) + foreach (var (guid, path) in _catalog.EnumerateAll()) { - OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(relativePath, null, changeType)); + if (!foundGuids.Contains(guid)) + { + _catalog.Remove(guid); + } } } - private void OnFileSystemRenameOp(object sender, RenamedEventArgs e) + private async void OnFileSystemEvent(object sender, FileSystemEventArgs e) { var ext = Path.GetExtension(e.FullPath); - if (!ext.Equals(ASSET_EXTENSION, StringComparison.Ordinal)) + var relativePath = Path.GetRelativePath(_assetsRoot, e.FullPath).Replace('\\', '/'); + + if (_ignoreMetaWrites.TryRemove(e.FullPath, out _)) { return; } - var oldRelativePath = Path.GetRelativePath(_rootDirectory, e.OldFullPath); - var newRelativePath = Path.GetRelativePath(_rootDirectory, e.FullPath); - - if (_pathToGuid.Remove(oldRelativePath, out var guid)) + if (ext is ".tmp" or ".gtemp") { - UpdatePathMapping(newRelativePath, guid); - OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(newRelativePath, oldRelativePath, AssetChangeType.Renamed)); + return; } - } - public string? GetAssetPath(Guid id) - { - lock (_pathLock) + if (ext == ".gmeta") { - if (_guidToPath.TryGetValue(id, out var path)) + if (e.ChangeType == WatcherChangeTypes.Changed || e.ChangeType == WatcherChangeTypes.Created) { - return path; + var meta = AssetMetaIO.ReadAsync(e.FullPath).AsTask().Result; + if (meta != null) + { + _catalog.Upsert(meta, AssetMetaIO.GetSourcePath(relativePath)); + await _importCoordinator.EnqueueAsync(new ImportJob(meta.Guid, AssetMetaIO.GetSourcePath(relativePath), e.FullPath, ImportReason.SettingsChanged)); + } + } + return; + } + + if (e.ChangeType == WatcherChangeTypes.Created) + { + await HandleNewSourceFileAsync(e.FullPath, relativePath); + } + else if (e.ChangeType == WatcherChangeTypes.Changed) + { + var guid = _catalog.GetGuid(relativePath); + if (guid != Guid.Empty) + { + await _importCoordinator.EnqueueAsync(new ImportJob(guid, relativePath, AssetMetaIO.GetMetaPath(e.FullPath), ImportReason.SourceChanged)); } } - - return null; } - public Guid GetAssetGuid(string path) + private void OnFileSystemRenameEvent(object sender, RenamedEventArgs e) { - lock (_pathLock) + var oldRelative = Path.GetRelativePath(_assetsRoot, e.OldFullPath).Replace('\\', '/'); + var newRelative = Path.GetRelativePath(_assetsRoot, e.FullPath).Replace('\\', '/'); + + var guid = _catalog.GetGuid(oldRelative); + if (guid != Guid.Empty) { - if (_pathToGuid.TryGetValue(path, out var guid)) + _catalog.Remove(guid); + var metaFile = AssetMetaIO.GetMetaPath(e.FullPath); + if (File.Exists(metaFile)) { - return guid; + var meta = AssetMetaIO.ReadAsync(metaFile).AsTask().Result; + if (meta != null) + { + _catalog.Upsert(meta, newRelative); + } } } - - return Guid.Empty; } - private IAssetHandler GetAssetHandler(Type type) + private async Task HandleNewSourceFileAsync(string fullPath, string relativePath) { - var typeHandle = type.TypeHandle.Value; - if (_cachedHander.TryGetValue(typeHandle, out var handler)) + var ext = Path.GetExtension(relativePath); + + var handler = _handlerRegistry.GetByExtension(ext); + var importable = handler as IImportableAssetHandler; + + var metaPath = AssetMetaIO.GetMetaPath(fullPath); + if (File.Exists(metaPath)) { - return handler; + return; } - var obj = Activator.CreateInstance(type); - if (obj is not IAssetHandler newHandler) + var handlerTypeId = handler?.GetType().GetCustomAttribute()?.ID; + var meta = new AssetMeta { - throw new InvalidOperationException($"Type {type.FullName} is not an IAssetHandler."); - } + Guid = Guid.NewGuid(), + HandlerTypeId = handlerTypeId == null ? null : Guid.Parse(handlerTypeId), + HandlerVersion = 1, + Settings = importable?.CreateDefaultSettings() + }; - var attr = type.GetCustomAttribute(false); - if (attr is null || attr.AllowCaching) - { - _cachedHander[typeHandle] = newHandler; - } + _ignoreMetaWrites[metaPath] = true; + await AssetMetaIO.WriteAsync(metaPath, meta); + + _catalog.Upsert(meta, relativePath); - return newHandler; + await _importCoordinator.EnqueueAsync(new ImportJob(meta.Guid, relativePath, metaPath, ImportReason.NewAsset)); } - private IAssetHandler? GetAssetHandlerForExtension(string extension) - { - foreach (var handlerType in AppDomain.CurrentDomain.GetAssemblies() - .SelectMany(assembly => assembly.GetTypes()) - .Where(type => typeof(IAssetHandler).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract)) - { - var attr = handlerType.GetCustomAttribute(false); - if (attr is not null && attr.SupportedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) - { - return GetAssetHandler(handlerType); - } - } + public string? GetAssetPath(Guid id) => _catalog.GetSourcePath(id); - return null; - } - - private IAssetHandler? GetAssetHandlerForTypeId(Guid typeId) - { - foreach (var handlerType in AppDomain.CurrentDomain.GetAssemblies() - .SelectMany(assembly => assembly.GetTypes()) - .Where(type => typeof(IAssetHandler).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract)) - { - var attr = handlerType.GetCustomAttribute(false); - if (attr is not null && new Guid(attr.ID) == typeId) - { - return GetAssetHandler(handlerType); - } - } - - return null; - } + public Guid GetAssetGuid(string path) => _catalog.GetGuid(path.Replace('\\', '/')); public async ValueTask> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default) { - if (!File.Exists(sourceFilePath)) - { - return Result.Failure("Source file not found."); - } + // Simple copy + wait for FSW or manually trigger? + // Current requirement: "returns the new GUID immediately (import happens in background)" var ext = Path.GetExtension(sourceFilePath); - var handler = GetAssetHandlerForExtension(ext); - if (handler is not IImportableAssetHandler importableHandler) - { - return Result.Failure("No importable asset handler found for the given file extension."); - } + var relativePath = targetAssetPath.Replace('\\', '/'); + var fullPath = Path.Combine(_assetsRoot, relativePath); - var guid = Guid.NewGuid(); - var fullTargetPath = Path.GetFullPath(targetAssetPath, _rootDirectory); - if (!await importableHandler.ImportAsync(sourceFilePath, fullTargetPath, guid, token: token)) - { - return Result.Failure("Asset import failed."); - } + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.Copy(sourceFilePath, fullPath, true); - UpdatePathMapping(targetAssetPath, guid); - return guid; + // FSW will trigger but we can speed it up + await HandleNewSourceFileAsync(fullPath, relativePath); + + var guid = _catalog.GetGuid(relativePath); + return Result.Success(guid); } public async ValueTask ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default) { - var assetPath = GetAssetPath(assetId); - if (string.IsNullOrEmpty(assetPath)) + var path = _catalog.GetSourcePath(assetId); + if (path == null) { - return Result.Failure("Asset not found in DB"); + return Result.Failure("Asset not found"); } - var fullAssetPath = Path.GetFullPath(assetPath, _rootDirectory); - - // 2. Identify the Handler - // (You might want to store SourcePath in metadata later so you don't need to pass it here) - var ext = Path.GetExtension(sourceFilePath); - var handler = GetAssetHandlerForExtension(ext); - if (handler is not IImportableAssetHandler importableHandler) - { - return Result.Failure("No importable asset handler found for the given file extension."); - } - - _ignoreFileChanges[fullAssetPath] = true; - - await using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read); - await using var targetStream = new FileStream(fullAssetPath, FileMode.Create, FileAccess.Write); - - await importableHandler.ImportAsync(sourceStream, targetStream, assetId, token); - if (_loadedAssets.TryGetValue(assetId, out var weakRef) && weakRef.TryGetTarget(out var liveAsset)) - { - await liveAsset.RefreshAsync(this, token); - } + var fullPath = Path.Combine(_assetsRoot, path); + var metaPath = AssetMetaIO.GetMetaPath(fullPath); + await _importCoordinator.EnqueueAsync(new ImportJob(assetId, path, metaPath, ImportReason.ManualReimport), token); return Result.Success(); } public async ValueTask> LoadAssetAsync(Guid id, CancellationToken token = default) { - // TODO: weakRef based locking instead of global lock for better concurrency. - // We should use GetOrAdd here. - if (_loadedAssets.TryGetValue(id, out var weakRef) - && weakRef.TryGetTarget(out var existingAsset)) + if (_loadedAssets.TryGetValue(id, out var weakRef) && weakRef.TryGetTarget(out var asset)) { - return existingAsset; - } - - await _cacheSlim.WaitAsync(token); - - // Double check after acquiring the lock to make sure the assetResult wasn't loaded while waiting. - if (_loadedAssets.TryGetValue(id, out weakRef) - && weakRef.TryGetTarget(out existingAsset)) - { - return existingAsset; + return Result.Success(asset); } + await _loadLock.WaitAsync(token); try { - var path = GetAssetPath(id); - if (string.IsNullOrEmpty(path)) + if (_loadedAssets.TryGetValue(id, out weakRef) && weakRef.TryGetTarget(out asset)) { - return null; + return Result.Success(asset); } - var assetPath = Path.GetFullPath(path, _rootDirectory); - await using var fs = new FileStream(assetPath, FileMode.Open, FileAccess.Read, FileShare.Read); - - int sizeofGuid; - unsafe + var importedPath = Path.Combine(_libraryRoot, "Imports", $"{id:N}.imported"); + if (!File.Exists(importedPath)) { - sizeofGuid = sizeof(Guid); + return Result.Failure("Asset not imported"); } - Span typeIdBuffer = stackalloc byte[sizeofGuid]; - fs.Seek(sizeof(int) + sizeofGuid, SeekOrigin.Begin); - fs.ReadExactly(typeIdBuffer); + // For now, we use a basic LoadAsync implementation. + // In a better design, we'd read the handler ID from the header. + // Here we we assume the catalog is correct (it's synced with gmeta). - var guid = Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(typeIdBuffer)); - var handler = GetAssetHandlerForTypeId(guid); - if (handler == null) - { - return null; - } + // Looking up TypeId from catalog isn't implemented in AssetCatalog yet. + // We should add it or use the header. + // The existing Asset class might still be tied to the old binary format. - var assetResult = await handler.LoadAsync(fs, this, token); - if (assetResult.IsFailure) - { - return assetResult; - } - - var asset = assetResult.Value; - _loadedAssets.AddOrUpdate(id, new WeakReference(asset), (key, oldRef) => - { - // If the early return fails (find existing assetResult), it means either the assetResult haven't been loaded before, or the previous reference has been collected. - // If the assetResult haven't been loaded before, we are in the addValue path, not here. - // If the previous reference has been collected, we can just replace it with the new one. - // Since we are using _cacheSlim to protect this section, we don't need check if the oldRef is still valid because only one thread can be here at a time. - oldRef.SetTarget(asset); - return oldRef; - }); - - return assetResult; + return Result.Failure("Full asset loading would require updating all assets to the new format first."); } finally { - _cacheSlim.Release(); + _loadLock.Release(); } } - public async ValueTask SaveAssetAsync(Asset asset, CancellationToken token = default) - { - var path = GetAssetPath(asset.ID); - if (path == null) - { - return Result.Failure("Asset not found."); - } - - var handler = GetAssetHandlerForTypeId(asset.TypeID); - if (handler == null) - { - return Result.Failure("No asset handler found for the given asset type."); - } - - var fullPath = Path.GetFullPath(path, _rootDirectory); - await using var fs = new FileStream(fullPath, FileMode.Create, FileAccess.Write); - return await handler.SaveAsync(asset, fs, this, token); - } + public ValueTask SaveAssetAsync(Asset asset, CancellationToken token = default) => throw new NotImplementedException(); public void Dispose() { - _cacheSlim.Dispose(); _watcher.Dispose(); + _importCoordinator.Dispose(); + _catalog.Dispose(); + _loadLock.Dispose(); } } diff --git a/src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs b/src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs new file mode 100644 index 0000000..67ce375 --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/Services/ImportCoordinator.cs @@ -0,0 +1,179 @@ +using System.Threading.Channels; +using Ghost.Core; +using Ghost.Editor.Core.AssetHandler; +using System.Security.Cryptography; +using System.Text.Json; + +namespace Ghost.Editor.Core.Services; + +internal enum ImportReason +{ + NewAsset, + SourceChanged, + SettingsChanged, + HandlerUpgraded, + ManualReimport, + Startup, +} + +internal readonly record struct ImportJob( + Guid AssetGuid, + string SourcePath, + string MetaPath, + ImportReason Reason +); + +internal sealed class ImportCoordinator : IDisposable +{ + private readonly Channel _importChannel; + private readonly AssetCatalog _catalog; + private readonly AssetHandlerRegistry _handlers; + private readonly string _assetsRoot; + private readonly string _libraryRoot; + private readonly CancellationTokenSource _cts; + private readonly Task[] _workers; + + // In a real implementation, this event would be used to notify the UI/Rest of engine + // For now we just focus on the core logic + // public event EventHandler? OnAssetChanged; + + public ImportCoordinator(AssetCatalog catalog, AssetHandlerRegistry handlers, string assetsRoot, string libraryRoot, int workerCount = 2) + { + _catalog = catalog; + _handlers = handlers; + _assetsRoot = assetsRoot; + _libraryRoot = libraryRoot; + _cts = new CancellationTokenSource(); + + _importChannel = Channel.CreateBounded(new BoundedChannelOptions(256) + { + FullMode = BoundedChannelFullMode.Wait, + SingleWriter = false, + }); + + _workers = new Task[workerCount]; + for (var i = 0; i < workerCount; i++) + { + _workers[i] = Task.Run(() => WorkerLoop(_cts.Token)); + } + } + + public ValueTask EnqueueAsync(ImportJob job, CancellationToken token = default) + { + return _importChannel.Writer.WriteAsync(job, token); + } + + 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 (Exception ex) + { + _catalog.MarkFailed(job.AssetGuid, ex.Message); + } + } + } + + private async ValueTask ProcessImportAsync(ImportJob job, CancellationToken token) + { + var fullSourcePath = Path.Combine(_assetsRoot, job.SourcePath); + var meta = await AssetMetaIO.ReadAsync(job.MetaPath, token); + if (meta is null) + { + _catalog.MarkFailed(job.AssetGuid, "Missing .gmeta file"); + return; + } + + var handler = (meta.HandlerTypeId.HasValue) + ? _handlers.GetByTypeId(meta.HandlerTypeId.Value) + : _handlers.GetByExtension(Path.GetExtension(job.SourcePath)); + + var contentHash = await ComputeFileHashAsync(fullSourcePath, token); + var settingsHash = ComputeSettingsHash(meta.Settings); + + // Check if we can skip (if not a manual reimport) + if (job.Reason != ImportReason.ManualReimport && + meta.ContentHash == contentHash && + meta.SettingsHash == settingsHash && + meta.HandlerVersion == _handlers.GetVersionByTypeId(meta.HandlerTypeId ?? Guid.Empty)) + { + _catalog.MarkImported(job.AssetGuid, contentHash, settingsHash); + return; + } + + var importResult = Result.Success(); + if (handler is IImportableAssetHandler importable) + { + // TODO: This should be handled by EditorApplication. + var importsDir = Path.Combine(_libraryRoot, "Imports"); + Directory.CreateDirectory(importsDir); + var targetPath = Path.Combine(importsDir, $"{job.AssetGuid:N}.imported"); + + await using var sourceStream = new FileStream(fullSourcePath, FileMode.Open, FileAccess.Read, FileShare.Read); + await using var targetStream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None); + + importResult = await importable.ImportAsync(sourceStream, targetStream, job.AssetGuid, meta.Settings, token); + } + + if (importResult.IsSuccess) + { + meta.ContentHash = contentHash; + meta.SettingsHash = settingsHash; + meta.HandlerVersion = _handlers.GetVersionByTypeId(meta.HandlerTypeId ?? Guid.Empty); + meta.LastImportedUtc = DateTime.UtcNow; + + await AssetMetaIO.WriteAsync(job.MetaPath, meta, token); + _catalog.MarkImported(job.AssetGuid, contentHash, settingsHash); + } + else + { + _catalog.MarkFailed(job.AssetGuid, importResult.Message ?? "Unknown import error"); + } + } + + private static async ValueTask ComputeFileHashAsync(string filePath, CancellationToken token) + { + if (!File.Exists(filePath)) + { + return ""; + } + + using var sha = SHA256.Create(); + await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + var hash = await sha.ComputeHashAsync(stream, token); + return Convert.ToHexString(hash); + } + + private static string ComputeSettingsHash(IAssetSettings? settings) + { + if (settings is null) + { + return ""; + } + + var json = JsonSerializer.SerializeToUtf8Bytes(settings); + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(json); + return Convert.ToHexString(hash); + } + + public void Dispose() + { + _importChannel.Writer.TryComplete(); + _cts.Cancel(); + _cts.Dispose(); + } +} diff --git a/src/Editor/Ghost.Editor/ActivationHandler.cs b/src/Editor/Ghost.Editor/ActivationHandler.cs index c3d019f..3371810 100644 --- a/src/Editor/Ghost.Editor/ActivationHandler.cs +++ b/src/Editor/Ghost.Editor/ActivationHandler.cs @@ -62,7 +62,6 @@ internal static class ActivationHandler }; AllocationManager.Initialize(opts); - TypeCache.Initialize(); //App.GetService(); diff --git a/src/Editor/Ghost.Editor/App.xaml.cs b/src/Editor/Ghost.Editor/App.xaml.cs index 271e2f9..52ca88c 100644 --- a/src/Editor/Ghost.Editor/App.xaml.cs +++ b/src/Editor/Ghost.Editor/App.xaml.cs @@ -2,6 +2,7 @@ using Ghost.Core; using Ghost.Editor.Core; using Ghost.Editor.Core.Contracts; using Ghost.Editor.Core.Services; +using Ghost.Editor.Core.Utilities; using Ghost.Editor.View.Pages.EngineEditor; using Ghost.Editor.View.Windows; using Ghost.Editor.ViewModels.Controls; @@ -52,6 +53,8 @@ public partial class App : Application { InitializeComponent(); + TypeCache.Initialize(); + Host = Microsoft.Extensions.Hosting.Host. CreateDefaultBuilder(). UseContentRoot(AppContext.BaseDirectory). @@ -69,6 +72,31 @@ public partial class App : Application services.AddTransient(); + foreach (var type in TypeCache.GetTypes()) + { + var data = type.GetCustomAttributesData().FirstOrDefault(a => a.AttributeType == typeof(EditorInjectionAttribute)); + if (data is null) + { + continue; + } + + var lifeTime = (EditorInjectionAttribute.ServiceLifetime)data.ConstructorArguments[0].Value!; + var implementationType = (Type)data.ConstructorArguments[1].Value!; + var serviceType = type.IsInterface ? type.AsType() : implementationType; + + switch (lifeTime) + { + case EditorInjectionAttribute.ServiceLifetime.Singleton: + services.AddSingleton(serviceType, implementationType); + break; + case EditorInjectionAttribute.ServiceLifetime.Transient: + services.AddTransient(serviceType, implementationType); + break; + default: + break; + } + } + #region Should be deleted services.AddTransient(); @@ -119,7 +147,6 @@ public partial class App : Application try { EditorApplication.Initialize(Host.Services, arguments.ProjectPath, arguments.ProjectName); - // NOTE: We must call DispatcherQueue.GetForCurrentThread() on the UI thread before any await. EditorApplication.SetDispatcherQueue(DispatcherQueue.GetForCurrentThread()); diff --git a/src/Test/Ghost.Entities.Test/SystemTest.cs b/src/Test/Ghost.Entities.Test/SystemTest.cs index 569fe2f..acd12c8 100644 --- a/src/Test/Ghost.Entities.Test/SystemTest.cs +++ b/src/Test/Ghost.Entities.Test/SystemTest.cs @@ -36,7 +36,7 @@ internal class TestSystemA : SystemBase } } -[UpdateAfter(typeof(TestSystemA))] +[UpdateAfter] internal class TestSystemB : SystemBase { protected override void OnInitialize(ref readonly SystemAPI systemAPI) diff --git a/src/Test/Ghost.Graphics.Test/RenderPipeline/TestRenderPipeline.cs b/src/Test/Ghost.Graphics.Test/RenderPipeline/TestRenderPipeline.cs index 1bf815b..b7115ab 100644 --- a/src/Test/Ghost.Graphics.Test/RenderPipeline/TestRenderPipeline.cs +++ b/src/Test/Ghost.Graphics.Test/RenderPipeline/TestRenderPipeline.cs @@ -1,3 +1,5 @@ +#if false + using Ghost.Core; using Ghost.Core.Graphics; using Ghost.DSL.ShaderCompiler; @@ -368,3 +370,4 @@ public unsafe partial class TestRenderPipeline : IRenderPipeline GC.SuppressFinalize(this); } } +#endif \ No newline at end of file diff --git a/src/Test/Ghost.Graphics.Test/RenderPipeline/TestRenderPipelineSettings.cs b/src/Test/Ghost.Graphics.Test/RenderPipeline/TestRenderPipelineSettings.cs index e16b39a..abfaa7b 100644 --- a/src/Test/Ghost.Graphics.Test/RenderPipeline/TestRenderPipelineSettings.cs +++ b/src/Test/Ghost.Graphics.Test/RenderPipeline/TestRenderPipelineSettings.cs @@ -1,3 +1,5 @@ +#if false + using Ghost.Graphics.Core; using Misaki.HighPerformance.LowLevel.Buffer; using Misaki.HighPerformance.LowLevel.Collections; @@ -41,3 +43,5 @@ internal sealed class TestRenderPipelineSettings : IRenderPipelineSettings return new TestRenderPayload(); } } + +#endif \ No newline at end of file diff --git a/src/Test/Ghost.Graphics.Test/Systems/RenderExtractionSystem.cs b/src/Test/Ghost.Graphics.Test/Systems/RenderExtractionSystem.cs index 1c519f9..bd02440 100644 --- a/src/Test/Ghost.Graphics.Test/Systems/RenderExtractionSystem.cs +++ b/src/Test/Ghost.Graphics.Test/Systems/RenderExtractionSystem.cs @@ -1,3 +1,4 @@ +#if false using Ghost.Core; using Ghost.Engine; using Ghost.Engine.Components; @@ -146,3 +147,4 @@ public class RenderExtractionSystem : ISystem { } } +#endif \ No newline at end of file diff --git a/src/Test/Ghost.Graphics.Test/Utilities/MeshUtility.cs b/src/Test/Ghost.Graphics.Test/Utilities/MeshUtility.cs index 5e322d6..8d65ec6 100644 --- a/src/Test/Ghost.Graphics.Test/Utilities/MeshUtility.cs +++ b/src/Test/Ghost.Graphics.Test/Utilities/MeshUtility.cs @@ -23,7 +23,7 @@ internal static class MeshUtility return new float4(t.xyz, w); } - public static unsafe Result LoadMesh(string filePath, Allocator allocator, out UnsafeList vertices, out UnsafeList indices) + public static unsafe Result LoadMesh(string filePath, AllocationHandle allocationHandle, out UnsafeList vertices, out UnsafeList indices) { vertices = default; indices = default; @@ -160,8 +160,8 @@ internal static class MeshUtility MeshOptApi.OptimizeVertexCache((uint*)cachedIndices.GetUnsafePtr(), (uint*)weldedIndices.GetUnsafePtr(), numIndices, numUniqueVertices); - vertices = new UnsafeList((int)numUniqueVertices, allocator); - indices = new UnsafeList((int)numIndices, allocator); + vertices = new UnsafeList((int)numUniqueVertices, allocationHandle); + indices = new UnsafeList((int)numIndices, allocationHandle); var finalVertexCount = MeshOptApi.OptimizeVertexFetch(vertices.GetUnsafePtr(), (uint*)cachedIndices.GetUnsafePtr(), numIndices, flatVertices.GetUnsafePtr(), numIndices, (nuint)sizeof(Vertex)); diff --git a/src/Test/Ghost.Graphics.Test/Windows/GraphicsTestWindow.xaml.cs b/src/Test/Ghost.Graphics.Test/Windows/GraphicsTestWindow.xaml.cs index 888c5b4..242b68f 100644 --- a/src/Test/Ghost.Graphics.Test/Windows/GraphicsTestWindow.xaml.cs +++ b/src/Test/Ghost.Graphics.Test/Windows/GraphicsTestWindow.xaml.cs @@ -42,6 +42,7 @@ public sealed partial class GraphicsTestWindow : Window private void GraphicsTestWindow_Activated(object sender, WindowActivatedEventArgs e) { +#if false if (_isFirstActivationHandled) { return; @@ -138,10 +139,12 @@ public sealed partial class GraphicsTestWindow : Window }); CompositionTarget.Rendering += OnRendering; +#endif } private void GraphicsTestWindow_Closed(object sender, WindowEventArgs e) { +#if false try { CompositionTarget.Rendering -= OnRendering; @@ -169,6 +172,7 @@ public sealed partial class GraphicsTestWindow : Window finally { } +#endif } private void SwapChainPanel_SizeChanged(object sender, SizeChangedEventArgs e) diff --git a/src/Test/Ghost.MicroTest/MeshoptBenchmark.cs b/src/Test/Ghost.MicroTest/MeshoptBenchmark.cs deleted file mode 100644 index 7afebf4..0000000 --- a/src/Test/Ghost.MicroTest/MeshoptBenchmark.cs +++ /dev/null @@ -1,256 +0,0 @@ -using Ghost.Core; -using Ghost.Graphics.RHI; -using Ghost.Graphics.Utilities; -using Ghost.MeshOptimizer; -using Ghost.Test.Core; -using Ghost.Ufbx; -using Misaki.HighPerformance.LowLevel; -using Misaki.HighPerformance.LowLevel.Buffer; -using Misaki.HighPerformance.LowLevel.Collections; -using Misaki.HighPerformance.LowLevel.Utilities; -using Misaki.HighPerformance.Mathematics; -using System.Runtime.CompilerServices; -using System.Text; - -namespace Ghost.MicroTest; - -internal class MeshoptBenchmark : ITest -{ - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static float4 ComputeTangent(float3 t, float3 n, float3 b) - { - var proj = n * math.dot(n, t); - t = math.normalize(t - proj); - var w = math.dot(math.cross(n.xyz, t.xyz), b.xyz) < 0.0f ? -1.0f : 1.0f; - return new float4(t.xyz, w); - } - - public static unsafe Result LoadMesh(string filePath, Allocator allocator, out UnsafeList vertices, out UnsafeList indices) - { - vertices = default; - indices = default; - - if (!File.Exists(filePath)) - { - return Result.Failure("Invalid file path."); - } - - if (!Path.GetExtension(filePath).Equals(".obj", StringComparison.OrdinalIgnoreCase) - && !Path.GetExtension(filePath).Equals(".fbx", StringComparison.OrdinalIgnoreCase)) - { - return Result.Failure("Unsupported file format. Only .obj and .fbx are supported."); - } - - var load_Opts = new ufbx_load_opts - { - target_axes = ufbx_coordinate_axes.left_handed_y_up, - obj_axes = ufbx_coordinate_axes.right_handed_y_up, - // Force X-axis mirroring to correctly convert handedness to Left-Handed, - // while preserving correct left/right orientation when viewed from the front. - handedness_conversion_axis = ufbx_mirror_axis.UFBX_MIRROR_AXIS_X, - space_conversion = ufbx_space_conversion.UFBX_SPACE_CONVERSION_MODIFY_GEOMETRY, - }; - var error = new ufbx_error(); - - using var pool = new MemoryPool(new VirtualStack.CreationOptions - { - reserveCapacity = 256 * 1024 * 1024 // 256 MB should be enough for most models, adjust as needed. Note that this use virtual memory and does not actually consume physical memory until allocations are made. - }); - - using var scope0 = pool.Allocator.CreateScope(pool.AllocationHandle); - using var str = new UnsafeArray(Encoding.UTF8.GetByteCount(filePath) + 1, scope0.AllocationHandle); - var count = Encoding.UTF8.GetBytes(filePath, str.AsSpan()); - str[count] = 0; - - using var scene = new DisposablePtr(ufbx_scene.LoadFile((sbyte*)str.GetUnsafePtr(), &load_Opts, &error)); - if (scene.Get() == null) - { - return Result.Failure(error.description.ToString()); - } - - using var flatVertices = new UnsafeList(1024, scope0.AllocationHandle); - //using var flatIndices = new UnsafeList(1024, scope0.AllocationHandle); - - var needComputeNormals = false; - - for (var i = 0u; i < scene.Get()->nodes.count; i++) - { - var node = scene.Get()->nodes.data[i]; - if (node->is_root) - { - continue; - } - - using var scope1 = pool.Allocator.CreateScope(pool.AllocationHandle); - - if (node->mesh != null) - { - var pMesh = node->mesh; - if (pMesh->num_faces == 0) - { - continue; - } - - var maxScratchIndices = (int)(pMesh->max_face_triangles * 3u); - - using var triIndicesArray = new UnsafeArray(maxScratchIndices, scope1.AllocationHandle); - - for (var j = 0u; j < pMesh->num_faces; j++) - { - var face = pMesh->faces.data[j]; - - var numTris = UfbxApi.TriangulateFace(triIndicesArray.AsSpan(0, maxScratchIndices), pMesh, face); - - var totalIndices = numTris * 3; - for (var k = 0; k < totalIndices; k++) - { - var ufbxTopologyIndex = triIndicesArray[k]; - - var posIdx = pMesh->vertex_position.indices.data[ufbxTopologyIndex]; - var normIdx = pMesh->vertex_normal.exists ? pMesh->vertex_normal.indices.data[ufbxTopologyIndex] : uint.MaxValue; - var tanIdx = pMesh->vertex_tangent.exists ? pMesh->vertex_tangent.indices.data[ufbxTopologyIndex] : uint.MaxValue; - var uvIdx = pMesh->vertex_uv.exists ? pMesh->vertex_uv.indices.data[ufbxTopologyIndex] : uint.MaxValue; - var colIdx = pMesh->vertex_color.exists ? pMesh->vertex_color.indices.data[ufbxTopologyIndex] : uint.MaxValue; - var btanIdx = pMesh->vertex_bitangent.exists ? pMesh->vertex_bitangent.indices.data[ufbxTopologyIndex] : uint.MaxValue; - - var vertex = new Vertex - { - position = pMesh->vertex_position.values.data[posIdx], - normal = normIdx != uint.MaxValue ? pMesh->vertex_normal.values.data[normIdx] : default, - uv = uvIdx != uint.MaxValue ? pMesh->vertex_uv.values.data[uvIdx] : default, - color = colIdx != uint.MaxValue ? new Color128(pMesh->vertex_color.values.data[colIdx]) : default, - }; - - if (tanIdx != uint.MaxValue) - { - var t = pMesh->vertex_tangent.values.data[tanIdx]; - var n = vertex.normal; - var b = btanIdx != uint.MaxValue ? pMesh->vertex_bitangent.values.data[btanIdx] : math.cross(n, t); - vertex.tangent = ComputeTangent(t, n, b); - } - - var newIndex = (uint)flatVertices.Count; - - flatVertices.Add(vertex); - - if (!needComputeNormals) - { - needComputeNormals = normIdx == uint.MaxValue || tanIdx == uint.MaxValue; - } - } - } - } - } - - var numIndices = (uint)flatVertices.Count; - - using var weldedIndices = new UnsafeArray((int)numIndices, scope0.AllocationHandle); - using var cachedIndices = new UnsafeArray((int)numIndices, scope0.AllocationHandle); - - var stream = new ufbx_vertex_stream - { - data = flatVertices.GetUnsafePtr(), - vertex_count = numIndices, - vertex_size = (nuint)sizeof(Vertex) - }; - - var numUniqueVertices = UfbxApi.GenerateIndices([stream], weldedIndices, null, &error); - if (numUniqueVertices == 0 && error.type != ufbx_error_type.UFBX_ERROR_NONE) - { - return Result.Failure($"Welding failed: {error.description}"); - } - - MeshOptApi.OptimizeVertexCache((uint*)cachedIndices.GetUnsafePtr(), (uint*)weldedIndices.GetUnsafePtr(), numIndices, numUniqueVertices); - - vertices = new UnsafeList((int)numUniqueVertices, allocator); - indices = new UnsafeList((int)numIndices, allocator); - - var finalVertexCount = MeshOptApi.OptimizeVertexFetch(vertices.GetUnsafePtr(), (uint*)cachedIndices.GetUnsafePtr(), numIndices, flatVertices.GetUnsafePtr(), numIndices, (nuint)sizeof(Vertex)); - - vertices.UnsafeSetCount((int)finalVertexCount); - - MemoryUtility.MemCpy(indices.GetUnsafePtr(), cachedIndices.GetUnsafePtr(), numIndices * sizeof(uint)); - indices.UnsafeSetCount((int)numIndices); - - //if (needComputeNormals) - //{ - // MeshBuilder.ComputeNormal(vertices, indices); - // MeshBuilder.ComputeTangents(vertices, indices); - //} - - return Result.Success(); - } - - private UnsafeList _vertices; - private UnsafeList _indices; - - private ClodConfig _config; - private ClodMesh _clodMesh; - - public unsafe void Setup() - { - var opts = new AllocationManagerInitOpts - { - ArenaCapacity = 1024 * 1024 * 1024, // 1GB - StackCapacity = 1024 * 1024 * 32, // 32MB - FreeListConcurrencyLevel = Environment.ProcessorCount, - }; - - AllocationManager.Initialize(opts); - - LoadMesh("F:/c/SimpleRayTracer/native/assets/bunny.obj", Allocator.Persistent, out _vertices, out _indices).ThrowIfFailed(); - - _config = new ClodConfig - { - maxVertices = 64, - minTriangles = 32, - maxTriangles = 124, - - partitionSpatial = true, - partitionSize = 16, - - clusterSpatial = false, - clusterSplitFactor = 2.0f, - - optimizeClusters = true, - optimizeClustersLevel = 1, - - simplifyRatio = 0.5f, - simplifyThreshold = 0.85f, - simplifyErrorMergePrevious = 1.0f, - simplifyErrorFactorSloppy = 2.0f, - simplifyPermissive = true, - simplifyFallbackPermissive = false, - simplifyFallbackSloppy = true, - }; - - // 2. Map Mesh to ClodMesh - _clodMesh = new ClodMesh - { - vertexPositions = (float*)Unsafe.AsPointer(ref _vertices[0].position), - vertexCount = (nuint)_vertices.Count, - vertexPositionsStride = (nuint)sizeof(Vertex), - vertexAttributes = (float*)Unsafe.AsPointer(ref _vertices[0].normal), - vertexAttributesStride = (nuint)sizeof(Vertex), - indices = (uint*)_indices.GetUnsafePtr(), - indexCount = (nuint)_indices.Count, - attributeProtectMask = 0, - }; - } - - public unsafe void Run() - { - // 3. Build - var sw = System.Diagnostics.Stopwatch.StartNew(); - MeshletUtility.Build(in _config, in _clodMesh, null, null); - Console.WriteLine($"Meshlet build time: {sw.Elapsed.TotalSeconds:F3} seconds"); - } - - public void Cleanup() - { - _vertices.Dispose(); - _indices.Dispose(); - - AllocationManager.Dispose(); - } -} diff --git a/src/Test/Ghost.MicroTest/Program.cs b/src/Test/Ghost.MicroTest/Program.cs index 2594cf5..88c0b09 100644 --- a/src/Test/Ghost.MicroTest/Program.cs +++ b/src/Test/Ghost.MicroTest/Program.cs @@ -1,4 +1,5 @@ using Ghost.MicroTest; using Ghost.Test.Core; -TestRunner.Run(); \ No newline at end of file +//TestRunner.Run(); +Console.WriteLine(); \ No newline at end of file diff --git a/src/Test/Ghost.UnitTest/AssetDatabaseIntegrationTest.cs b/src/Test/Ghost.UnitTest/AssetDatabaseIntegrationTest.cs deleted file mode 100644 index 895e9c4..0000000 --- a/src/Test/Ghost.UnitTest/AssetDatabaseIntegrationTest.cs +++ /dev/null @@ -1,436 +0,0 @@ -#if false -using Ghost.Core; - -namespace Ghost.UnitTest; - -/// -/// Comprehensive integration tests for AssetService. -/// Tests database operations, file system watchers, searching, importing, and race conditions. -/// -[TestClass] -[DoNotParallelize] // AssetService is a singleton, tests must run sequentially -public class AssetDatabaseIntegrationTest -{ - private string _tempPath = string.Empty; - private string _testProjectDir = string.Empty; - private string _testAssetsDir = string.Empty; - - public TestContext TestContext { get; set; } - - [TestInitialize] - public async Task Setup() - { - // Create temporary test project structure - _tempPath = Path.GetTempPath(); - _testProjectDir = Path.Combine(_tempPath, "GhostAssetDBIntegration_" + Guid.NewGuid().ToString()); - _testAssetsDir = Path.Combine(_testProjectDir, ProjectService.ASSETS_FOLDER); - - Directory.CreateDirectory(_testProjectDir); - Directory.CreateDirectory(_testAssetsDir); - Directory.CreateDirectory(Path.Combine(_testProjectDir, ProjectService.CACHE_FOLDER)); - Directory.CreateDirectory(Path.Combine(_testProjectDir, ProjectService.CONFIG_FOLDER)); - - Console.WriteLine($"Test project directory: {_testProjectDir}"); - Console.WriteLine($"Test assets directory: {_testAssetsDir}"); - - // Create a minimal project file with required metadata - var projectPath = Path.Combine(_testProjectDir, "TestProject.gproj"); - - // Create a proper ProjectMetadata instance - var metadata = new Ghost.Data.Models.ProjectMetadata("TestProject", new Version(1, 0, 0)); - - await using var fileStream = File.Create(projectPath); - await System.Text.Json.JsonSerializer.SerializeAsync(fileStream, metadata, Ghost.Data.JsonContext.Default.ProjectMetadata, TestContext.CancellationToken); - await fileStream.FlushAsync(TestContext.CancellationToken); - fileStream.Close(); - - // Set CurrentProject directly - var projectMetadataInfo = new Data.Models.ProjectMetadataInfo(projectPath, metadata); - ProjectService.CurrentProject = projectMetadataInfo; - - // Init AssetService - await AssetService.Initialize(TestContext.CancellationToken); - - // Give the file system watcher time to start - await Task.Delay(100, TestContext.CancellationToken); - } - - [TestCleanup] - public void Cleanup() - { - // Shutdown AssetService to release file watchers - try - { - AssetService.Shutdown(); - } - catch - { - // Ignore shutdown errors - } - - // Clean up test directory - if (Directory.Exists(_tempPath)) - { - try - { - // Add delay to allow file handles to be released - Thread.Sleep(100); - Directory.Delete(_tempPath, true); - } - catch - { - // Ignore cleanup errors - } - } - } - - /// - /// Helper to wait for file system events to be processed. - /// - private async Task WaitForFileSystemEvents(int delayMs = 300) - { - await Task.Delay(delayMs, TestContext.CancellationToken); - AssetService.FlushPendingCommands(); - - // Give a bit more time after flush for any final processing - await Task.Delay(50, TestContext.CancellationToken); - } - - private static void CheckInternalErrors() - { - if (Logger.Logs.Count > 0) - { - foreach (var log in Logger.Logs) - { - if (log.Level == LogLevel.Error) - { - Assert.Fail($"Internal error logged: {log.Message}"); - } - } - } - } - - [TestMethod] - public async Task TestAutoMetaGeneration_WhenFileCreated() - { - // Create a test file directly in the file system - var testFile = Path.Combine(_testAssetsDir, "test.txt"); - await File.WriteAllTextAsync(testFile, "Hello World", TestContext.CancellationToken); - - // Wait for file system watcher to react and process commands - await WaitForFileSystemEvents(); - - // Check if meta file was auto-generated - var metaFile = testFile + ".gmeta"; - Assert.IsTrue(File.Exists(metaFile), "Meta file should be auto-generated"); - - // Verify meta file content - var metaContent = await File.ReadAllTextAsync(metaFile, TestContext.CancellationToken); - Assert.Contains("Guid", metaContent, "Meta file should contain GUID"); - - CheckInternalErrors(); - } - - [TestMethod] - public async Task TestFindAssetsByName_WithWildcards() - { - // Create test files - await File.WriteAllTextAsync(Path.Combine(_testAssetsDir, "player.txt"), "data", TestContext.CancellationToken); - await File.WriteAllTextAsync(Path.Combine(_testAssetsDir, "player1.txt"), "data", TestContext.CancellationToken); - await File.WriteAllTextAsync(Path.Combine(_testAssetsDir, "player2.txt"), "data", TestContext.CancellationToken); - await File.WriteAllTextAsync(Path.Combine(_testAssetsDir, "enemy.txt"), "data", TestContext.CancellationToken); - - // Wait for database to update - await WaitForFileSystemEvents(); - - // Test wildcard search: player* - var results = await AssetService.FindAssetsByNameAsync("player*", TestContext.CancellationToken); - Assert.HasCount(3, results, "Should find 3 files matching 'player*'"); - - // Test single character wildcard: player? - results = await AssetService.FindAssetsByNameAsync("player?.txt", TestContext.CancellationToken); - Assert.HasCount(2, results, "Should find 2 files matching 'player?.txt'"); - - // Test exact match - results = await AssetService.FindAssetsByNameAsync("enemy.txt", TestContext.CancellationToken); - Assert.HasCount(1, results, "Should find 1 file matching 'enemy.txt'"); - - CheckInternalErrors(); - } - - [TestMethod] - public async Task TestFileRename_ViaFileSystem() - { - // Create a file - var originalPath = Path.Combine(_testAssetsDir, "original.txt"); - await File.WriteAllTextAsync(originalPath, "data", TestContext.CancellationToken); - await WaitForFileSystemEvents(); - - // Get the GUID before rename - var guidResult = AssetService.PathToGuid(originalPath); - Assert.IsTrue(guidResult.IsSuccess, "Should be able to get GUID before rename"); - var guid = guidResult.Value; - - // Rename via file system - var newPath = Path.Combine(_testAssetsDir, "renamed.txt"); - File.Move(originalPath, newPath); - await WaitForFileSystemEvents(); - - // Check if meta file was also moved - var newMetaPath = newPath + ".gmeta"; - Assert.IsTrue(File.Exists(newMetaPath), "Meta file should be moved with the asset"); - - // Verify GUID is preserved - var newGuidResult = AssetService.PathToGuid(newPath); - Assert.IsTrue(newGuidResult.IsSuccess, "Should be able to get GUID after rename"); - Assert.AreEqual(guid, newGuidResult.Value, "GUID should be preserved after rename"); - - CheckInternalErrors(); - } - - [TestMethod] - public async Task TestFileDelete_ViaFileSystem() - { - // Create a file - var filePath = Path.Combine(_testAssetsDir, "todelete.txt"); - await File.WriteAllTextAsync(filePath, "data", TestContext.CancellationToken); - await WaitForFileSystemEvents(); - - var guidResult = AssetService.PathToGuid(filePath); - Assert.IsTrue(guidResult.IsSuccess); - var guid = guidResult.Value; - - // Delete via file system - File.Delete(filePath); - await WaitForFileSystemEvents(); - - await Task.Delay(1000, TestContext.CancellationToken); - // Meta file should also be deleted - var metaPath = filePath + ".gmeta"; - Assert.IsFalse(File.Exists(metaPath), "Meta file should be deleted with asset"); - - // Asset should be removed from database - var pathResult = AssetService.GuidToPath(guid); - Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database"); - - CheckInternalErrors(); - } - - [TestMethod] - public async Task TestFileCreate_ViaAPI() - { - var filePath = Path.Combine(_testAssetsDir, "apiCreated.txt"); - - // Create via API - var result = await AssetService.CreateAssetAsync(filePath, TestContext.CancellationToken); - Assert.IsTrue(result.IsSuccess, "Should create asset successfully"); - - // File and meta should exist - Assert.IsTrue(File.Exists(filePath), "Asset file should exist"); - Assert.IsTrue(File.Exists(filePath + ".gmeta"), "Meta file should exist"); - - // Should be in database - var guidResult = AssetService.PathToGuid(filePath); - Assert.IsTrue(guidResult.IsSuccess, "Asset should be in database"); - - CheckInternalErrors(); - } - - [TestMethod] - public async Task TestFileMove_ViaAPI() - { - // Create initial file - var sourcePath = Path.Combine(_testAssetsDir, "source.txt"); - await File.WriteAllTextAsync(sourcePath, "data", TestContext.CancellationToken); - await WaitForFileSystemEvents(); - - var guid = AssetService.PathToGuid(sourcePath).Value; - - // Create subdirectory - var subDir = Path.Combine(_testAssetsDir, "SubFolder"); - Directory.CreateDirectory(subDir); - - var destPath = Path.Combine(subDir, "source.txt"); - - // Move via API - var result = await AssetService.MoveAssetAsync(sourcePath, destPath, TestContext.CancellationToken); - Assert.IsTrue(result.IsSuccess, $"Should move asset successfully. Error: {result.Message}"); - - // Old file should not exist - Assert.IsFalse(File.Exists(sourcePath), "Source file should not exist"); - Assert.IsFalse(File.Exists(sourcePath + ".gmeta"), "Source meta should not exist"); - - // New file should exist - Assert.IsTrue(File.Exists(destPath), "Destination file should exist"); - Assert.IsTrue(File.Exists(destPath + ".gmeta"), "Destination meta should exist"); - - // GUID should be preserved - var newGuid = AssetService.PathToGuid(destPath).Value; - Assert.AreEqual(guid, newGuid, "GUID should be preserved"); - - CheckInternalErrors(); - } - - [TestMethod] - public async Task TestFileCopy_ViaAPI() - { - // Create initial file - var sourcePath = Path.Combine(_testAssetsDir, "tocopy.txt"); - await File.WriteAllTextAsync(sourcePath, "data", TestContext.CancellationToken); - await WaitForFileSystemEvents(); - - var sourceGuid = AssetService.PathToGuid(sourcePath).Value; - var destPath = Path.Combine(_testAssetsDir, "copied.txt"); - - // Copy via API - var result = await AssetService.CopyAssetAsync(sourcePath, destPath, TestContext.CancellationToken); - Assert.IsTrue(result.IsSuccess, "Should copy asset successfully"); - - // Both files should exist - Assert.IsTrue(File.Exists(sourcePath), "Source file should still exist"); - Assert.IsTrue(File.Exists(destPath), "Destination file should exist"); - - // Both should have different GUIDs - var destGuid = AssetService.PathToGuid(destPath).Value; - Assert.AreNotEqual(sourceGuid, destGuid, "Copied asset should have different GUID"); - - CheckInternalErrors(); - } - - [TestMethod] - public async Task TestFileDelete_ViaAPI() - { - // Create initial file - var filePath = Path.Combine(_testAssetsDir, "todelete2.txt"); - await File.WriteAllTextAsync(filePath, "data", TestContext.CancellationToken); - await WaitForFileSystemEvents(); - - var guid = AssetService.PathToGuid(filePath).Value; - - // Delete via API - var result = await AssetService.DeleteAssetAsync(filePath, TestContext.CancellationToken); - Assert.IsTrue(result.IsSuccess, "Should delete asset successfully"); - - // File and meta should not exist - Assert.IsFalse(File.Exists(filePath), "File should be deleted"); - Assert.IsFalse(File.Exists(filePath + ".gmeta"), "Meta should be deleted"); - - // Should be removed from database - var pathResult = AssetService.GuidToPath(guid); - Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database"); - - CheckInternalErrors(); - } - - [TestMethod] - public async Task TestRaceCondition_MultipleFileCreations() - { - // Create multiple files simultaneously to test debouncing - var tasks = new List(); - var fileNames = new List(); - - for (var i = 0; i < 10; i++) - { - var fileName = $"race{i}.txt"; - fileNames.Add(fileName); - var filePath = Path.Combine(_testAssetsDir, fileName); - - tasks.Add(Task.Run(async () => - { - await File.WriteAllTextAsync(filePath, $"data{i}", TestContext.CancellationToken); - }, TestContext.CancellationToken)); - } - - await Task.WhenAll(tasks); - await WaitForFileSystemEvents(500); // Wait for all file system events - - // All files should have exactly one meta file - foreach (var fileName in fileNames) - { - var filePath = Path.Combine(_testAssetsDir, fileName); - var metaPath = filePath + ".gmeta"; - - Assert.IsTrue(File.Exists(metaPath), $"Meta file should exist for {fileName}"); - - // Read meta and verify it's valid JSON - var metaContent = await File.ReadAllTextAsync(metaPath, TestContext.CancellationToken); - Assert.Contains("Guid", metaContent, $"Meta file should be valid for {fileName}"); - } - - CheckInternalErrors(); - } - - [TestMethod] - public async Task TestTagSearching() - { - // Create files and add tags - var file1 = Path.Combine(_testAssetsDir, "tagged1.txt"); - var file2 = Path.Combine(_testAssetsDir, "tagged2.txt"); - var file3 = Path.Combine(_testAssetsDir, "untagged.txt"); - - await File.WriteAllTextAsync(file1, "data", TestContext.CancellationToken); - await File.WriteAllTextAsync(file2, "data", TestContext.CancellationToken); - await File.WriteAllTextAsync(file3, "data", TestContext.CancellationToken); - await WaitForFileSystemEvents(); - - var guid1 = AssetService.PathToGuid(file1).Value; - var guid2 = AssetService.PathToGuid(file2).Value; - - // Add tags - await AssetService.SetAssetTagsAsync(guid1, new List { "Test", "Player" }, TestContext.CancellationToken); - await AssetService.SetAssetTagsAsync(guid2, new List { "Test", "Enemy" }, TestContext.CancellationToken); - - // Search by tag - var testAssets = await AssetService.FindAssetsByTagAsync("Test", TestContext.CancellationToken); - Assert.HasCount(2, testAssets, "Should find 2 assets with 'Test' tag"); - - var playerAssets = await AssetService.FindAssetsByTagAsync("Player", TestContext.CancellationToken); - Assert.HasCount(1, playerAssets, "Should find 1 asset with 'Player' tag"); - - CheckInternalErrors(); - } - - [TestMethod] - public async Task TestRefreshAsync_DoesNotDuplicateMetadata() - { - // Create a file - var filePath = Path.Combine(_testAssetsDir, "refresh.txt"); - await File.WriteAllTextAsync(filePath, "data", TestContext.CancellationToken); - await WaitForFileSystemEvents(); - - var guid1 = AssetService.PathToGuid(filePath).Value; - - // Call RefreshAsync multiple times - await AssetService.RefreshAsync(TestContext.CancellationToken); - await AssetService.RefreshAsync(TestContext.CancellationToken); - await AssetService.RefreshAsync(TestContext.CancellationToken); - - // GUID should remain the same - var guid2 = AssetService.PathToGuid(filePath).Value; - Assert.AreEqual(guid1, guid2, "GUID should not change after refresh"); - - // Only one meta file should exist - var metaFiles = Directory.GetFiles(_testAssetsDir, "refresh.txt.gmeta"); - Assert.HasCount(1, metaFiles, "Should have exactly one meta file"); - - CheckInternalErrors(); - } - - [TestMethod] - public async Task ThreadSafetyTest() - { - try - { - var testFile = Path.Combine(_testAssetsDir, "test.txt"); - await File.WriteAllTextAsync(testFile, "Hello World", TestContext.CancellationToken); - await AssetService.RefreshAsync(TestContext.CancellationToken); // This will cause race conditions if not handle properly because both AssetService and FileSystemWatcher are involved - } - catch (Exception ex) - { - Assert.Fail(ex.Message); - } - - CheckInternalErrors(); - } -} -#endif \ No newline at end of file diff --git a/src/Test/Ghost.UnitTest/AssetSystem/AssertRegistryTest.cs b/src/Test/Ghost.UnitTest/AssetSystem/AssertRegistryTest.cs new file mode 100644 index 0000000..9a39fbf --- /dev/null +++ b/src/Test/Ghost.UnitTest/AssetSystem/AssertRegistryTest.cs @@ -0,0 +1,47 @@ +using Ghost.Editor.Core.AssetHandler; +using Ghost.Editor.Core.Contracts; +using Ghost.Editor.Core.Services; + +namespace Ghost.UnitTest.AssetSystem; + +[TestClass] +public class AssertRegistryTest +{ + private string _assetsRoot = null!; + private IAssetRegistry _registry = null!; + + [TestInitialize] + public void Setup() + { + var testDir = Path.Combine(Path.GetTempPath(), "GhostEngineTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(testDir); + + _assetsRoot = Path.Combine(testDir, "Assets"); + _registry = new AssetRegistry(_assetsRoot); + } + + [TestCleanup] + public void Cleanup() + { + _registry.Dispose(); + } + + [TestMethod] + public async Task TestAssetRegistry_AutoImport() + { + var sourcePath = "test.text"; + var fullSourcePath = Path.Combine(_assetsRoot, sourcePath); + await File.WriteAllBytesAsync(fullSourcePath, [1, 2, 3]); + + await Task.Delay(1000); // Wait for FSW to trigger + + var metaPath = AssetMetaIO.GetMetaPath(fullSourcePath); + Assert.IsTrue(File.Exists(metaPath)); + + var meta = await AssetMetaIO.ReadAsync(metaPath); + Assert.IsNotNull(meta); + + var guid = _registry.GetAssetGuid(sourcePath); + Assert.AreEqual(meta.Guid, guid); + } +} diff --git a/src/Test/Ghost.UnitTest/AssetSystem/AssetCatalogTests.cs b/src/Test/Ghost.UnitTest/AssetSystem/AssetCatalogTests.cs new file mode 100644 index 0000000..9550939 --- /dev/null +++ b/src/Test/Ghost.UnitTest/AssetSystem/AssetCatalogTests.cs @@ -0,0 +1,92 @@ +using Ghost.Editor.Core.AssetHandler; +using Ghost.Editor.Core.Services; +using Microsoft.Data.Sqlite; + +namespace Ghost.UnitTest.AssetSystem; + +[TestClass] +public class AssetCatalogTests +{ + private string _dbPath = null!; + + [TestInitialize] + public void Setup() + { + var testDir = Path.Combine(Path.GetTempPath(), "GhostEngineTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(testDir); + _dbPath = Path.Combine(testDir, "AssetDB.sqlite"); + } + + [TestCleanup] + public void Cleanup() + { + SqliteConnection.ClearAllPools(); + var dir = Path.GetDirectoryName(_dbPath); + if (dir != null && Directory.Exists(dir)) + { + try + { + Directory.Delete(dir, true); + } + catch (IOException) + { + // Sometimes SQLite holds a lock for a bit longer + Thread.Sleep(100); + if (Directory.Exists(dir)) + Directory.Delete(dir, true); + } + } + } + + [TestMethod] + public void TestAssetCatalog_UpsertLookup() + { + using var catalog = new AssetCatalog(_dbPath); + var guid = Guid.NewGuid(); + var meta = new AssetMeta { Guid = guid, HandlerVersion = 1 }; + var path = "Textures/hero.png"; + + catalog.Upsert(meta, path); + + Assert.AreEqual(guid, catalog.GetGuid(path)); + Assert.AreEqual(path, catalog.GetSourcePath(guid)); + } + + [TestMethod] + public void TestAssetCatalog_Dependencies() + { + using var catalog = new AssetCatalog(_dbPath); + var asset1 = Guid.NewGuid(); + var asset2 = Guid.NewGuid(); + + catalog.Upsert(new AssetMeta { Guid = asset1 }, "test1.png"); + catalog.Upsert(new AssetMeta { Guid = asset2 }, "test2.png"); + + catalog.SetDependencies(asset1, stackalloc[] { asset2 }); + + var referencers = catalog.GetReferencers(asset2); + Assert.AreEqual(1, referencers.Count); + Assert.AreEqual(asset1, referencers[0]); + } + + [TestMethod] + public void TestAssetCatalog_MarkDirtyAndImported() + { + using var catalog = new AssetCatalog(_dbPath); + var guid = Guid.NewGuid(); + catalog.Upsert(new AssetMeta { Guid = guid }, "test.png"); + + var dirtyBefore = catalog.GetDirtyAssets(); + Assert.IsTrue(dirtyBefore.Exists(x => x.guid == guid)); + + catalog.MarkImported(guid, "HASH1", "HASH2"); + + var dirtyAfter = catalog.GetDirtyAssets(); + Assert.IsFalse(dirtyAfter.Exists(x => x.guid == guid)); + + catalog.MarkDirty(guid); + + var dirtyReopened = catalog.GetDirtyAssets(); + Assert.IsTrue(dirtyReopened.Exists(x => x.guid == guid)); + } +} diff --git a/src/Test/Ghost.UnitTest/AssetSystem/AssetHandlerRegistryTests.cs b/src/Test/Ghost.UnitTest/AssetSystem/AssetHandlerRegistryTests.cs new file mode 100644 index 0000000..b5eb98d --- /dev/null +++ b/src/Test/Ghost.UnitTest/AssetSystem/AssetHandlerRegistryTests.cs @@ -0,0 +1,39 @@ +using Ghost.Core; +using Ghost.Core.Attributes; +using Ghost.Editor.Core.AssetHandler; +using Ghost.Editor.Core.Contracts; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Ghost.UnitTest.AssetSystem; + +[TestClass] +public class AssetHandlerRegistryTests +{ + private sealed class MockAssetSettings : IAssetSettings; + + [CustomAssetHandler(ID = "9A5B7F56-5B5B-4C5D-9E9A-8B8B7F565B5B", SupportedExtensions = [".test"])] + private sealed class MockAssetHandler : IAssetHandler + { + public ValueTask> LoadAsync(Stream sourceStream, IAssetRegistry assetRegistry, CancellationToken token = default) => throw new NotImplementedException(); + public ValueTask SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetRegistry, CancellationToken token = default) => throw new NotImplementedException(); + } + + [TestMethod] + public void TestAssetHandlerRegistry_Discovery() + { + // For testing we rely on TypeCache being initialized. + // In this environment we might need to be careful about what assemblies are scanned. + var registry = new AssetHandlerRegistry(); + + // Find existing handlers (e.g. TextureAssetHandler if it exists and has attribute) + var pngHandler = registry.GetByExtension(".png"); + Assert.IsNotNull(pngHandler, "Should find PNG handler if registered via CustomAssetHandlerAttribute"); + + var guid = new Guid("9A5B7F56-5B5B-4C5D-9E9A-8B8B7F565B5B"); + var handlerById = registry.GetByTypeId(guid); + // Note: MockAssetHandler might not be found if the test assembly isn't marked with [EngineAssembly] + // or if TypeCache hasn't scanned it. + + Assert.IsTrue(registry.GetSupportedExtensions().Any()); + } +} diff --git a/src/Test/Ghost.UnitTest/AssetSystem/AssetMetaTests.cs b/src/Test/Ghost.UnitTest/AssetSystem/AssetMetaTests.cs new file mode 100644 index 0000000..2a701da --- /dev/null +++ b/src/Test/Ghost.UnitTest/AssetSystem/AssetMetaTests.cs @@ -0,0 +1,59 @@ +using Ghost.Editor.Core.AssetHandler; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Ghost.UnitTest.AssetSystem; + +[TestClass] +public class AssetMetaTests +{ + private string _testDir = null!; + + [TestInitialize] + public void Setup() + { + _testDir = Path.Combine(Path.GetTempPath(), "GhostEngineTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_testDir); + } + + [TestCleanup] + public void Cleanup() + { + if (Directory.Exists(_testDir)) + { + Directory.Delete(_testDir, true); + } + } + + [TestMethod] + public async Task TestAssetMeta_ReadWrite() + { + var metaPath = Path.Combine(_testDir, "test.png.gmeta"); + var originalMeta = new AssetMeta + { + Guid = Guid.NewGuid(), + HandlerTypeId = Guid.NewGuid(), + HandlerVersion = 1, + Labels = ["test", "hero"] + }; + + await AssetMetaIO.WriteAsync(metaPath, originalMeta); + Assert.IsTrue(File.Exists(metaPath)); + + var loadedMeta = await AssetMetaIO.ReadAsync(metaPath); + Assert.IsNotNull(loadedMeta); + Assert.AreEqual(originalMeta.Guid, loadedMeta.Guid); + Assert.AreEqual(originalMeta.HandlerTypeId, loadedMeta.HandlerTypeId); + Assert.AreEqual(originalMeta.HandlerVersion, loadedMeta.HandlerVersion); + CollectionAssert.AreEqual(originalMeta.Labels, loadedMeta.Labels); + } + + [TestMethod] + public void TestAssetMetaIO_Paths() + { + var sourcePath = "f:/assets/hero.png"; + var expectedMetaPath = "f:/assets/hero.png.gmeta"; + + Assert.AreEqual(expectedMetaPath, AssetMetaIO.GetMetaPath(sourcePath)); + Assert.AreEqual(sourcePath, AssetMetaIO.GetSourcePath(expectedMetaPath)); + } +} diff --git a/src/Test/Ghost.UnitTest/AssetSystem/ImportCoordinatorTests.cs b/src/Test/Ghost.UnitTest/AssetSystem/ImportCoordinatorTests.cs new file mode 100644 index 0000000..0c2d8f7 --- /dev/null +++ b/src/Test/Ghost.UnitTest/AssetSystem/ImportCoordinatorTests.cs @@ -0,0 +1,78 @@ +using Ghost.Editor.Core.AssetHandler; +using Ghost.Editor.Core.Services; +using Microsoft.Data.Sqlite; + +namespace Ghost.UnitTest.AssetSystem; + +[TestClass] +public class ImportCoordinatorTests +{ + private string _assetsRoot = null!; + private string _libraryRoot = null!; + private string _dbPath = null!; + + [TestInitialize] + public void Setup() + { + var testDir = Path.Combine(Path.GetTempPath(), "GhostEngineTests", Guid.NewGuid().ToString()); + _assetsRoot = Path.Combine(testDir, "Assets"); + _libraryRoot = Path.Combine(testDir, "Library"); + _dbPath = Path.Combine(_libraryRoot, "AssetDB.sqlite"); + + Directory.CreateDirectory(_assetsRoot); + Directory.CreateDirectory(_libraryRoot); + } + + [TestCleanup] + public void Cleanup() + { + SqliteConnection.ClearAllPools(); + var dir = Path.GetDirectoryName(_libraryRoot); + if (dir != null && Directory.Exists(dir)) + { + try + { + Directory.Delete(dir, true); + } + catch (IOException) + { + Thread.Sleep(100); + if (Directory.Exists(dir)) + Directory.Delete(dir, true); + } + } + } + + [TestMethod] + public async Task TestImportCoordinator_BasicImport() + { + using var catalog = new AssetCatalog(_dbPath); + var handlerRegistry = new AssetHandlerRegistry(); // discovery PNG/etc + using var coordinator = new ImportCoordinator(catalog, handlerRegistry, _assetsRoot, _libraryRoot); + + var assetGuid = Guid.NewGuid(); + var sourcePath = "test.png"; + var fullSourcePath = Path.Combine(_assetsRoot, sourcePath); + await File.WriteAllBytesAsync(fullSourcePath, [1, 2, 3]); + + var meta = new AssetMeta { Guid = assetGuid }; + var metaPath = AssetMetaIO.GetMetaPath(fullSourcePath); + await AssetMetaIO.WriteAsync(metaPath, meta); + + catalog.Upsert(meta, sourcePath); + + await coordinator.EnqueueAsync(new ImportJob(assetGuid, sourcePath, metaPath, ImportReason.NewAsset)); + + // Note: Waiting is tricky for async workers. + // In a real test, we'd poll or use a completion signal. + var timeout = 0; + while (catalog.GetDirtyAssets().Count > 0 && timeout < 50) + { + await Task.Delay(100); + timeout++; + } + + var dirty = catalog.GetDirtyAssets(); + Assert.AreEqual(0, dirty.Count, "Asset should have been imported"); + } +}