forked from Misaki/GhostEngine
Imporving AssetDatabase
This commit is contained in:
@@ -17,11 +17,8 @@ public static partial class AssetDatabase
|
||||
private const int MAX_CACHED_ASSETS = 1000;
|
||||
|
||||
// Percentage of cache to evict when limit is reached (evict oldest 20%)
|
||||
private const float CACHE_EVICTION_PERCENTAGE = 0.2f;
|
||||
private const float _CACHE_EVICTION_PERCENTAGE = 0.2f;
|
||||
|
||||
/// <summary>
|
||||
/// Get the path to the imported asset data directory.
|
||||
/// </summary>
|
||||
private static Result<string> GetImportedAssetsDirectory()
|
||||
{
|
||||
if (AssetsDirectory == null)
|
||||
@@ -30,7 +27,6 @@ public static partial class AssetDatabase
|
||||
}
|
||||
|
||||
var cacheDir = Path.Combine(AssetsDirectory.Parent!.FullName, ProjectService.CACHE_FOLDER, "ImportedAssets");
|
||||
|
||||
if (!Directory.Exists(cacheDir))
|
||||
{
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
@@ -39,11 +35,6 @@ public static partial class AssetDatabase
|
||||
return cacheDir;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the path where imported asset data is stored for a specific GUID.
|
||||
/// </summary>
|
||||
/// <param name="guid">GUID of the asset.</param>
|
||||
/// <returns>Full path to the imported asset data file.</returns>
|
||||
private static Result<string> GetImportedAssetPath(Guid guid)
|
||||
{
|
||||
var importedDirResult = GetImportedAssetsDirectory();
|
||||
@@ -57,12 +48,6 @@ public static partial class AssetDatabase
|
||||
return assetDataPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load asset by GUID with caching (internal implementation).
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of asset to load.</typeparam>
|
||||
/// <param name="guid">GUID of the asset.</param>
|
||||
/// <returns>The loaded asset.</returns>
|
||||
private static Result<T> LoadAssetInternal<T>(Guid guid) where T : Asset
|
||||
{
|
||||
// Check cache first
|
||||
@@ -89,7 +74,6 @@ public static partial class AssetDatabase
|
||||
}
|
||||
|
||||
var assetDataPath = assetPathResult.Value;
|
||||
|
||||
if (!File.Exists(assetDataPath))
|
||||
{
|
||||
return Result<T>.Failure($"Imported asset data not found at {assetDataPath}. Asset may not have been imported yet.");
|
||||
@@ -100,7 +84,6 @@ public static partial class AssetDatabase
|
||||
// Read and deserialize asset data
|
||||
var json = File.ReadAllText(assetDataPath);
|
||||
var asset = JsonSerializer.Deserialize<T>(json);
|
||||
|
||||
if (asset == null)
|
||||
{
|
||||
return Result<T>.Failure("Failed to deserialize asset data");
|
||||
@@ -108,7 +91,6 @@ public static partial class AssetDatabase
|
||||
|
||||
// Add to cache
|
||||
CacheAsset(guid, asset);
|
||||
|
||||
return asset;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -117,12 +99,6 @@ public static partial class AssetDatabase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load asset by path with caching.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of asset to load.</typeparam>
|
||||
/// <param name="assetPath">Full or relative path to the asset.</param>
|
||||
/// <returns>The loaded asset.</returns>
|
||||
public static Result<T> LoadAssetAtPath<T>(string assetPath) where T : Asset
|
||||
{
|
||||
var guidResult = PathToGuid(assetPath);
|
||||
@@ -134,9 +110,6 @@ public static partial class AssetDatabase
|
||||
return LoadAsset<T>(guidResult.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add an asset to the cache with LRU eviction if needed.
|
||||
/// </summary>
|
||||
private static void CacheAsset(Guid guid, Asset asset)
|
||||
{
|
||||
// Check if we need to evict old assets
|
||||
@@ -149,12 +122,9 @@ public static partial class AssetDatabase
|
||||
s_assetAccessTime[guid] = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evict the oldest assets from cache based on LRU.
|
||||
/// </summary>
|
||||
private static void EvictOldestAssets()
|
||||
{
|
||||
var evictionCount = (int)(MAX_CACHED_ASSETS * CACHE_EVICTION_PERCENTAGE);
|
||||
var evictionCount = (int)(MAX_CACHED_ASSETS * _CACHE_EVICTION_PERCENTAGE);
|
||||
|
||||
// Sort by access time and remove oldest entries
|
||||
var oldestAssets = s_assetAccessTime
|
||||
@@ -216,7 +186,8 @@ public static partial class AssetDatabase
|
||||
/// <param name="guid">GUID of the asset.</param>
|
||||
/// <param name="assetData">Processed asset data to save.</param>
|
||||
/// <returns>Result indicating success or failure.</returns>
|
||||
internal static Result SaveImportedAsset<T>(Guid guid, T assetData) where T : Asset
|
||||
public static Result SaveImportedAsset<T>(Guid guid, T assetData)
|
||||
where T : Asset
|
||||
{
|
||||
var assetPathResult = GetImportedAssetPath(guid);
|
||||
if (assetPathResult.IsFailure)
|
||||
@@ -231,7 +202,6 @@ public static partial class AssetDatabase
|
||||
|
||||
// Invalidate cache for this asset so it gets reloaded next time
|
||||
UnloadAsset(guid);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Ghost.Core;
|
||||
using Ghost.Editor.Core.Utilities;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -27,10 +28,10 @@ public static partial class AssetDatabase
|
||||
}
|
||||
}
|
||||
|
||||
s_watcher.Created += OnAssetCreated;
|
||||
s_watcher.Deleted += OnAssetDeleted;
|
||||
s_watcher.Created += OnFSEvent;
|
||||
s_watcher.Deleted += OnFSEvent;
|
||||
s_watcher.Changed += OnFSEvent;
|
||||
s_watcher.Renamed += OnAssetRenamed;
|
||||
s_watcher.Changed += OnAssetChanged;
|
||||
}
|
||||
|
||||
private static Result<string> GetMetaFilePath(string assetPath)
|
||||
@@ -194,48 +195,38 @@ public static partial class AssetDatabase
|
||||
return r;
|
||||
}
|
||||
|
||||
private static void OnAssetCreated(object sender, FileSystemEventArgs e)
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static bool IsMetaFile(string path)
|
||||
{
|
||||
// Skip meta files
|
||||
if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PostCommand(new AssetCommand(AssetCommandType.FileCreated, e.FullPath, Timestamp: DateTime.UtcNow));
|
||||
return Path.GetExtension(path).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void OnAssetDeleted(object sender, FileSystemEventArgs e)
|
||||
private static async void OnFSEvent(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
// Skip meta files
|
||||
if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
|
||||
if (IsMetaFile(e.FullPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PostCommand(new AssetCommand(AssetCommandType.FileDeleted, e.FullPath, Timestamp: DateTime.UtcNow));
|
||||
var type = e.ChangeType switch
|
||||
{
|
||||
WatcherChangeTypes.Created => AssetCommandType.FileCreated,
|
||||
WatcherChangeTypes.Deleted => AssetCommandType.FileDeleted,
|
||||
WatcherChangeTypes.Changed => AssetCommandType.FileModified,
|
||||
_ => throw new InvalidOperationException("Unsupported file system event type")
|
||||
};
|
||||
|
||||
await PostCommandAsync(new AssetCommand(type, e.FullPath, Timestamp: DateTime.UtcNow));
|
||||
}
|
||||
|
||||
private static void OnAssetRenamed(object sender, RenamedEventArgs e)
|
||||
private static async void OnAssetRenamed(object sender, RenamedEventArgs e)
|
||||
{
|
||||
// Skip meta files
|
||||
if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
|
||||
if (IsMetaFile(e.FullPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PostCommand(new AssetCommand(AssetCommandType.FileRenamed, e.FullPath, e.OldFullPath, DateTime.UtcNow));
|
||||
}
|
||||
|
||||
private static void OnAssetChanged(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
// Skip meta files
|
||||
if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PostCommand(new AssetCommand(AssetCommandType.FileModified, e.FullPath, Timestamp: DateTime.UtcNow));
|
||||
await PostCommandAsync(new AssetCommand(AssetCommandType.FileRenamed, e.FullPath, e.OldFullPath, DateTime.UtcNow));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -243,6 +234,8 @@ public static partial class AssetDatabase
|
||||
/// </summary>
|
||||
private static async Task MarkDependentAssetsDirtyAsync(Guid assetGuid)
|
||||
{
|
||||
// TODO: We should have a reverse dependency lookup in the database to avoid scanning all assets.
|
||||
|
||||
// Query database for all assets and check their dependencies
|
||||
var allAssets = GetAllAssets();
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ public static partial class AssetDatabase
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_path ON Assets(Path);
|
||||
";
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(token);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Ghost.Core;
|
||||
using Ghost.Data.Services;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Channels;
|
||||
@@ -41,23 +42,22 @@ public static partial class AssetDatabase
|
||||
private static readonly Dictionary<string, Guid> s_pathAssetLookup = new();
|
||||
|
||||
// In-memory dirty asset tracking (for runtime modifications only)
|
||||
// TODO: We do not handle the reimporting of dirty assets yet
|
||||
private static readonly HashSet<Guid> s_dirtyAssets = new();
|
||||
|
||||
// Command buffer pattern - Channel for file system event commands
|
||||
private static Channel<AssetCommand>? s_commandChannel;
|
||||
private static Timer? s_commandProcessorTimer;
|
||||
private static readonly HashSet<string> s_pendingCommandPaths = new(); // Track paths with pending commands
|
||||
private static readonly Lock s_commandLock = new();
|
||||
private static readonly ConcurrentQueue<AssetCommand> s_waitingCommands = new(); // Commands waiting for manual refresh
|
||||
private static bool s_autoRefreshEnabled = true;
|
||||
private static readonly Queue<AssetCommand> s_waitingCommands = new(); // Commands waiting for manual refresh
|
||||
|
||||
private static TaskCompletionSource<bool>? s_refreshTcs;
|
||||
|
||||
// Initialization guard
|
||||
private static readonly Lock s_initializationLock = new();
|
||||
private static bool s_initialized = false;
|
||||
|
||||
private static readonly TimeSpan s_debounceDelay = TimeSpan.FromMilliseconds(100);
|
||||
private static ManualResetEventSlim s_resetEventSlim = new(false);
|
||||
|
||||
private static readonly JsonSerializerOptions s_defaultJsonOptions = new()
|
||||
{
|
||||
@@ -79,14 +79,16 @@ public static partial class AssetDatabase
|
||||
/// Initialize the asset database.
|
||||
/// Must be called after project is loaded.
|
||||
/// </summary>
|
||||
|
||||
internal static async Task Initialize(CancellationToken token = default)
|
||||
{
|
||||
lock (s_initializationLock)
|
||||
{
|
||||
if (s_initialized)
|
||||
{
|
||||
return; // Already initialized, skip
|
||||
return;
|
||||
}
|
||||
|
||||
s_initialized = true;
|
||||
}
|
||||
|
||||
@@ -99,8 +101,8 @@ public static partial class AssetDatabase
|
||||
|
||||
s_commandChannel = Channel.CreateUnbounded<AssetCommand>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = false, // Timer callback reads
|
||||
SingleWriter = false // Multiple FS events can write
|
||||
SingleReader = false,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
// Initialize command processor timer (starts disabled, triggered by events)
|
||||
@@ -120,6 +122,7 @@ public static partial class AssetDatabase
|
||||
InitializeAssetHandle();
|
||||
InitializeMetaData();
|
||||
|
||||
// TODO: Timestamp fake instead of full scan.
|
||||
await ValidateAndFixDatabaseAsync(token);
|
||||
}
|
||||
|
||||
@@ -137,7 +140,7 @@ public static partial class AssetDatabase
|
||||
try
|
||||
{
|
||||
// Scan all files in assets directory
|
||||
var allFiles = Directory.GetFiles(AssetsDirectory.FullName, "*.*", SearchOption.AllDirectories)
|
||||
var allFiles = Directory.EnumerateFiles(AssetsDirectory.FullName, "*.*", SearchOption.AllDirectories)
|
||||
.Where(f => !f.EndsWith(Utilities.FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Ensure all files have metadata
|
||||
@@ -183,24 +186,16 @@ public static partial class AssetDatabase
|
||||
public static async Task<Result> RefreshAsync(CancellationToken token = default)
|
||||
{
|
||||
// Flush waiting commands to channel
|
||||
lock (s_commandLock)
|
||||
while (s_waitingCommands.TryDequeue(out var cmd))
|
||||
{
|
||||
while (s_waitingCommands.TryDequeue(out var cmd))
|
||||
{
|
||||
s_commandChannel?.Writer.TryWrite(cmd);
|
||||
}
|
||||
|
||||
s_refreshTcs = new TaskCompletionSource<bool>();
|
||||
s_commandChannel?.Writer.TryWrite(cmd);
|
||||
}
|
||||
|
||||
s_resetEventSlim.Reset();
|
||||
s_commandChannel?.Writer.TryWrite(new AssetCommand(AssetCommandType.ManualRefresh, string.Empty));
|
||||
s_commandProcessorTimer?.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan);
|
||||
|
||||
if (!await s_refreshTcs.Task.WaitAsync(token))
|
||||
{
|
||||
return Result.Failure("Asset database refresh failed");
|
||||
}
|
||||
|
||||
await Task.Run(s_resetEventSlim.Wait, token);
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -269,9 +264,6 @@ public static partial class AssetDatabase
|
||||
s_autoRefreshEnabled = enabled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process all pending commands immediately (synchronous, for testing).
|
||||
/// </summary>
|
||||
internal static void FlushPendingCommands()
|
||||
{
|
||||
// Stop timer temporarily
|
||||
@@ -284,10 +276,7 @@ public static partial class AssetDatabase
|
||||
ProcessPendingCommands(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Post a command to the command channel for processing.
|
||||
/// </summary>
|
||||
private static void PostCommand(AssetCommand command)
|
||||
private static async ValueTask PostCommandAsync(AssetCommand command, CancellationToken token = default)
|
||||
{
|
||||
if (s_commandChannel == null)
|
||||
{
|
||||
@@ -296,53 +285,38 @@ public static partial class AssetDatabase
|
||||
|
||||
if (s_autoRefreshEnabled)
|
||||
{
|
||||
// Add to pending paths for temp file tracking
|
||||
lock (s_commandLock)
|
||||
{
|
||||
s_pendingCommandPaths.Add(command.Path);
|
||||
if (command.OldPath != null)
|
||||
{
|
||||
s_pendingCommandPaths.Add(command.OldPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Write to channel (lock-free, async-safe)
|
||||
s_commandChannel.Writer.TryWrite(command);
|
||||
|
||||
// Start or reset timer (safe even if not initialized yet)
|
||||
await s_commandChannel.Writer.WriteAsync(command, token);
|
||||
s_commandProcessorTimer?.Change(s_debounceDelay, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Queue for manual refresh
|
||||
lock (s_commandLock)
|
||||
{
|
||||
s_waitingCommands.Enqueue(command);
|
||||
}
|
||||
s_waitingCommands.Enqueue(command);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timer callback to process pending commands.
|
||||
/// </summary>
|
||||
private static async void ProcessPendingCommands(object? state)
|
||||
{
|
||||
if (s_commandChannel == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Collect all pending commands
|
||||
var commands = new List<AssetCommand>();
|
||||
// // Collect all pending commands
|
||||
// var commands = new List<AssetCommand>();
|
||||
//
|
||||
// while (s_commandChannel.Reader.TryRead(out var cmd))
|
||||
// {
|
||||
// commands.Add(cmd);
|
||||
// }
|
||||
|
||||
while (s_commandChannel?.Reader.TryRead(out var cmd) == true)
|
||||
{
|
||||
commands.Add(cmd);
|
||||
}
|
||||
|
||||
// Group commands by path (last command wins)
|
||||
var commandsByPath = new Dictionary<string, AssetCommand>();
|
||||
foreach (var cmd in commands)
|
||||
{
|
||||
commandsByPath[cmd.Path] = cmd;
|
||||
}
|
||||
// // Group commands by path (last command wins)
|
||||
// var commandsByPath = new Dictionary<string, AssetCommand>();
|
||||
// foreach (var cmd in commands)
|
||||
// {
|
||||
// commandsByPath[cmd.Path] = cmd;
|
||||
// }
|
||||
|
||||
// NOTE: We handle the temp file filtering in each command handler now
|
||||
// We should able to remove this allocation heavy code
|
||||
@@ -365,24 +339,31 @@ public static partial class AssetDatabase
|
||||
// }
|
||||
|
||||
// Execute commands
|
||||
foreach (var cmd in commandsByPath.Values)
|
||||
// NOTE: We many don't need to collect all commands first, just process as we read.
|
||||
// Channel in c# is thread-safe for multiple readers/writers.
|
||||
//await foreach (var cmd in s_commandChannel.Reader.ReadAllAsync())
|
||||
//{
|
||||
// await ExecuteCommandAsync(cmd);
|
||||
//}
|
||||
|
||||
while (s_commandChannel.Reader.TryRead(out var cmd))
|
||||
{
|
||||
await ExecuteCommandAsync(cmd);
|
||||
}
|
||||
|
||||
s_refreshTcs?.SetResult(true);
|
||||
await ImportDirtyAssetsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Error processing commands: {ex.Message}");
|
||||
s_refreshTcs?.SetResult(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
s_resetEventSlim.Set();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute a single asset command.
|
||||
/// </summary>
|
||||
private static async Task ExecuteCommandAsync(AssetCommand command)
|
||||
private static async ValueTask ExecuteCommandAsync(AssetCommand command)
|
||||
{
|
||||
switch (command.Type)
|
||||
{
|
||||
@@ -411,10 +392,7 @@ public static partial class AssetDatabase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle file created event.
|
||||
/// </summary>
|
||||
private static async Task HandleFileCreatedAsync(string path)
|
||||
private static async ValueTask HandleFileCreatedAsync(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
@@ -424,10 +402,7 @@ public static partial class AssetDatabase
|
||||
await GenerateMetaFileAsync(path, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle file modified event.
|
||||
/// </summary>
|
||||
private static async Task HandleFileModifiedAsync(string path)
|
||||
private static async ValueTask HandleFileModifiedAsync(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
@@ -443,7 +418,6 @@ public static partial class AssetDatabase
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new hash and compare against database
|
||||
var newHash = await CalculateFileHashAsync(path, CancellationToken.None);
|
||||
var oldHash = await GetFileHashAsync(metaResult.Value.Guid, CancellationToken.None);
|
||||
|
||||
@@ -455,16 +429,8 @@ public static partial class AssetDatabase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle file deleted event.
|
||||
/// </summary>
|
||||
private static async Task HandleFileDeletedAsync(string path)
|
||||
private static async ValueTask HandleFileDeletedAsync(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var metaFileResult = GetMetaFilePath(path);
|
||||
if (metaFileResult.IsSuccess && File.Exists(metaFileResult.Value))
|
||||
{
|
||||
@@ -491,22 +457,8 @@ public static partial class AssetDatabase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle file renamed event.
|
||||
/// </summary>
|
||||
private static async Task HandleFileRenamedAsync(string oldPath, string newPath)
|
||||
private static async ValueTask HandleFileRenamedAsync(string oldPath, string newPath)
|
||||
{
|
||||
if (!File.Exists(oldPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (File.Exists(newPath))
|
||||
{
|
||||
Logger.LogWarning($"Cannot rename asset from '{oldPath}' to '{newPath}': target file already exists.");
|
||||
return;
|
||||
}
|
||||
|
||||
var oldMetaPath = oldPath + Utilities.FileExtensions.META_FILE_EXTENSION;
|
||||
var newMetaPath = newPath + Utilities.FileExtensions.META_FILE_EXTENSION;
|
||||
|
||||
@@ -543,22 +495,17 @@ public static partial class AssetDatabase
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shutdown the asset database.
|
||||
/// Disposes resources and closes database connections.
|
||||
/// </summary>
|
||||
internal static void Shutdown()
|
||||
{
|
||||
lock (s_initializationLock)
|
||||
{
|
||||
if (!s_initialized)
|
||||
{
|
||||
return; // Not initialized, nothing to shutdown
|
||||
return;
|
||||
}
|
||||
|
||||
s_watcher?.Dispose();
|
||||
@@ -574,7 +521,6 @@ public static partial class AssetDatabase
|
||||
s_assetPathLookup.Clear();
|
||||
s_pathAssetLookup.Clear();
|
||||
s_dirtyAssets.Clear();
|
||||
s_pendingCommandPaths.Clear();
|
||||
s_waitingCommands.Clear();
|
||||
s_importerInstances.Clear();
|
||||
s_importerTypeLookup.Clear();
|
||||
|
||||
@@ -35,7 +35,7 @@ public abstract class AssetImporter
|
||||
/// </summary>
|
||||
/// <param name="dependencies">List of dependency GUIDs extracted from the asset.</param>
|
||||
/// <returns>Result indicating if all dependencies are valid.</returns>
|
||||
protected virtual ValueTask<Result> ValidateDependenciesAsync(List<Guid> dependencies)
|
||||
protected virtual ValueTask<Result> ValidateDependenciesAsync(List<Guid> dependencies, CancellationToken token = default)
|
||||
{
|
||||
foreach (var dependencyGuid in dependencies)
|
||||
{
|
||||
|
||||
@@ -27,7 +27,7 @@ internal class TextImporterSettings : ImporterSettings
|
||||
[AssetImporter(".txt", ".md")]
|
||||
internal class TextImporter : AssetImporter<TextImporterSettings>
|
||||
{
|
||||
public override async Task<Result> ImportAsync(string assetPath, AssetMeta meta)
|
||||
public override async ValueTask<Result> ImportAsync(string assetPath, AssetMeta meta, CancellationToken token = default)
|
||||
{
|
||||
var settings = GetSettings(meta);
|
||||
|
||||
@@ -45,7 +45,7 @@ internal class TextImporter : AssetImporter<TextImporterSettings>
|
||||
try
|
||||
{
|
||||
// Read the file
|
||||
var content = await File.ReadAllTextAsync(assetPath);
|
||||
var content = await File.ReadAllTextAsync(assetPath, token);
|
||||
|
||||
if (settings.TrimWhitespace)
|
||||
{
|
||||
|
||||
@@ -73,7 +73,7 @@ internal class TextureImporterSettings : ImporterSettings
|
||||
[AssetImporter(".png", ".jpg", ".jpeg", ".dds", ".tga", ".bmp")]
|
||||
internal class TextureImporter : AssetImporter<TextureImporterSettings>
|
||||
{
|
||||
public override async Task<Result> ImportAsync(string assetPath, AssetMeta meta)
|
||||
public override async ValueTask<Result> ImportAsync(string assetPath, AssetMeta meta, CancellationToken token = default)
|
||||
{
|
||||
var settings = GetSettings(meta);
|
||||
|
||||
@@ -82,7 +82,7 @@ internal class TextureImporter : AssetImporter<TextureImporterSettings>
|
||||
var dependencies = new List<Guid>();
|
||||
|
||||
// Validate dependencies
|
||||
var depResult = await ValidateDependenciesAsync(dependencies);
|
||||
var depResult = await ValidateDependenciesAsync(dependencies, token);
|
||||
if (depResult.IsFailure)
|
||||
{
|
||||
return depResult;
|
||||
@@ -97,7 +97,7 @@ internal class TextureImporter : AssetImporter<TextureImporterSettings>
|
||||
}
|
||||
|
||||
// Get image dimensions (simplified - in real implementation would use image library)
|
||||
var (width, height) = await GetImageDimensionsAsync(assetPath);
|
||||
var (width, height) = GetImageDimensions(assetPath);
|
||||
|
||||
if (width == 0 || height == 0)
|
||||
{
|
||||
@@ -160,7 +160,7 @@ internal class TextureImporter : AssetImporter<TextureImporterSettings>
|
||||
/// Get image dimensions from file.
|
||||
/// Simplified implementation - in production, use an image library.
|
||||
/// </summary>
|
||||
private async Task<(uint width, uint height)> GetImageDimensionsAsync(string imagePath)
|
||||
private static (uint width, uint height) GetImageDimensions(string imagePath)
|
||||
{
|
||||
// This is a placeholder implementation
|
||||
// In a real implementation, you would use a library like:
|
||||
@@ -174,24 +174,24 @@ internal class TextureImporter : AssetImporter<TextureImporterSettings>
|
||||
{
|
||||
// For DDS files, read the header
|
||||
// DDS header format: https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-header
|
||||
return await ReadDDSHeaderAsync(imagePath);
|
||||
return ReadDDSHeader(imagePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
// For PNG/JPG/etc, we would use an image library
|
||||
// For now, return placeholder values
|
||||
return await Task.FromResult<(uint, uint)>((1024, 1024));
|
||||
return (1024, 1024);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read DDS file header to get dimensions.
|
||||
/// </summary>
|
||||
private async Task<(uint width, uint height)> ReadDDSHeaderAsync(string ddsPath)
|
||||
private static (uint width, uint height) ReadDDSHeader(string ddsPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(ddsPath);
|
||||
using var stream = File.OpenRead(ddsPath);
|
||||
using var reader = new BinaryReader(stream);
|
||||
|
||||
// Read magic number (should be "DDS ")
|
||||
@@ -226,7 +226,7 @@ internal class TextureImporter : AssetImporter<TextureImporterSettings>
|
||||
/// <summary>
|
||||
/// Export a texture asset from memory to disk.
|
||||
/// </summary>
|
||||
public override async Task<Result> ExportAsync<T>(string assetPath, T assetData, AssetMeta meta)
|
||||
public override async ValueTask<Result> ExportAsync<T>(string assetPath, T assetData, AssetMeta meta, CancellationToken token = default)
|
||||
{
|
||||
if (assetData is not TextureAsset textureAsset)
|
||||
{
|
||||
@@ -246,7 +246,7 @@ internal class TextureImporter : AssetImporter<TextureImporterSettings>
|
||||
WriteIndented = true
|
||||
});
|
||||
|
||||
await File.WriteAllTextAsync(assetPath, json);
|
||||
await File.WriteAllTextAsync(assetPath, json, token);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Ghost.Editor.Core.AssetHandle;
|
||||
using Ghost.Data.Services;
|
||||
using Ghost.Core;
|
||||
|
||||
namespace Ghost.UnitTest;
|
||||
|
||||
@@ -94,6 +95,20 @@ public class AssetDatabaseIntegrationTest
|
||||
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()
|
||||
{
|
||||
@@ -111,6 +126,8 @@ public class AssetDatabaseIntegrationTest
|
||||
// Verify meta file content
|
||||
var metaContent = await File.ReadAllTextAsync(metaFile, TestContext.CancellationToken);
|
||||
Assert.Contains("Guid", metaContent, "Meta file should contain GUID");
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -136,6 +153,8 @@ public class AssetDatabaseIntegrationTest
|
||||
// Test exact match
|
||||
results = await AssetDatabase.FindAssetsByNameAsync("enemy.txt", TestContext.CancellationToken);
|
||||
Assert.HasCount(1, results, "Should find 1 file matching 'enemy.txt'");
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -164,6 +183,8 @@ public class AssetDatabaseIntegrationTest
|
||||
var newGuidResult = AssetDatabase.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]
|
||||
@@ -182,6 +203,7 @@ public class AssetDatabaseIntegrationTest
|
||||
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");
|
||||
@@ -189,6 +211,8 @@ public class AssetDatabaseIntegrationTest
|
||||
// Asset should be removed from database
|
||||
var pathResult = AssetDatabase.GuidToPath(guid);
|
||||
Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database");
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -207,6 +231,8 @@ public class AssetDatabaseIntegrationTest
|
||||
// Should be in database
|
||||
var guidResult = AssetDatabase.PathToGuid(filePath);
|
||||
Assert.IsTrue(guidResult.IsSuccess, "Asset should be in database");
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -240,6 +266,8 @@ public class AssetDatabaseIntegrationTest
|
||||
// GUID should be preserved
|
||||
var newGuid = AssetDatabase.PathToGuid(destPath).Value;
|
||||
Assert.AreEqual(guid, newGuid, "GUID should be preserved");
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -264,6 +292,8 @@ public class AssetDatabaseIntegrationTest
|
||||
// Both should have different GUIDs
|
||||
var destGuid = AssetDatabase.PathToGuid(destPath).Value;
|
||||
Assert.AreNotEqual(sourceGuid, destGuid, "Copied asset should have different GUID");
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -287,6 +317,8 @@ public class AssetDatabaseIntegrationTest
|
||||
// Should be removed from database
|
||||
var pathResult = AssetDatabase.GuidToPath(guid);
|
||||
Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database");
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -323,6 +355,8 @@ public class AssetDatabaseIntegrationTest
|
||||
var metaContent = await File.ReadAllTextAsync(metaPath, TestContext.CancellationToken);
|
||||
Assert.Contains("Guid", metaContent, $"Meta file should be valid for {fileName}");
|
||||
}
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -351,6 +385,8 @@ public class AssetDatabaseIntegrationTest
|
||||
|
||||
var playerAssets = await AssetDatabase.FindAssetsByTagAsync("Player", TestContext.CancellationToken);
|
||||
Assert.HasCount(1, playerAssets, "Should find 1 asset with 'Player' tag");
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -375,13 +411,24 @@ public class AssetDatabaseIntegrationTest
|
||||
// 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()
|
||||
{
|
||||
var testFile = Path.Combine(_testAssetsDir, "test.txt");
|
||||
await File.WriteAllTextAsync(testFile, "Hello World", TestContext.CancellationToken);
|
||||
await AssetDatabase.RefreshAsync(TestContext.CancellationToken); // This will cause race conditions if not handle properly because both AssetDatabase and FileSystemWatcher are involved
|
||||
try
|
||||
{
|
||||
var testFile = Path.Combine(_testAssetsDir, "test.txt");
|
||||
await File.WriteAllTextAsync(testFile, "Hello World", TestContext.CancellationToken);
|
||||
await AssetDatabase.RefreshAsync(TestContext.CancellationToken); // This will cause race conditions if not handle properly because both AssetDatabase and FileSystemWatcher are involved
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Assert.Fail(ex.Message);
|
||||
}
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user