Refactor folder structure

This commit is contained in:
2026-02-18 00:50:46 +09:00
parent 426786397c
commit db8ca971a8
413 changed files with 2885 additions and 3634 deletions

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@
*.user *.user
*.userosscache *.userosscache
*.sln.docstates *.sln.docstates
AGENTS.md
# User-specific files (MonoDevelop/Xamarin Studio) # User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs *.userprefs

297
AGENTS.md
View File

@@ -1,297 +0,0 @@
# GhostEngine - Agent Development Guide
This guide provides essential information for AI coding agents working on the GhostEngine codebase.
## Project Overview
- **Type**: Game Engine
- **Language**: C#
- **Target Framework**: .NET 10.0
- **Special Features**: ECS architecture, D3D12 rendering, AOT compilation, WinUI 3 editor
- **Platform**: Windows (net10.0-windows10.0.22621.0 for editor projects)
## Build Commands
### Build Entire Solution
```bash
dotnet build GhostEngine.slnx
```
### Build Specific Project
```bash
dotnet build Ghost.Entities/Ghost.Entities.csproj
dotnet build Ghost.Editor/Ghost.Editor.csproj
```
### Build with Configuration
```bash
dotnet build GhostEngine.slnx -c Release
dotnet build GhostEngine.slnx -c Debug
```
### Clean Build
```bash
dotnet clean GhostEngine.slnx
dotnet build GhostEngine.slnx
```
## Test Commands
### Run All Tests (Custom Framework)
Tests use a custom test framework (not xUnit/NUnit/MSTest). Each test project is an executable.
```bash
# Run entity tests
dotnet run --project Ghost.Entities.Test/Ghost.Entities.Test.csproj
# Run shader tests
dotnet run --project Ghost.Shader.Test/Ghost.Shader.Test.csproj
```
### Run Single Test
Tests implement `ITest` interface. To run a specific test, modify the test project's `Program.cs`:
```csharp
// In Ghost.Entities.Test/Program.cs
TestRunner.Run<EntityQueryTest>(); // Run specific test
TestRunner.Run<EntityQueryTest>(10); // Run with 10 iterations
```
### Visual Tests (Graphics)
Graphics tests use WinUI 3 and require running as packaged apps:
```bash
dotnet run --project Ghost.Graphics.Test/Ghost.Graphics.Test.csproj
```
## Code Style Guidelines
### Formatting (from .editorconfig)
- **Braces**: Allman style - all opening braces on new lines
- **Line Length**: Max 400 characters (very permissive)
- **Single-line statements**: Preserved (allowed)
- **Single-line blocks**: Preserved (allowed)
```csharp
// Correct brace style
public void Method()
{
if (condition)
{
DoSomething();
}
}
```
### Imports
- **System directives**: NOT sorted first (dotnet_sort_system_directives_first = false)
- **No grouping**: Import directives not separated by blank lines
- **Order**: Organize by project convention, not alphabetically
```csharp
using Ghost.Core;
using Ghost.Entities;
using Misaki.HighPerformance.Collections;
using System.Diagnostics;
using TerraFX.Interop.DirectX;
```
### Types and Nullability
- **Nullable**: Enabled for all projects
- **Implicit usings**: Enabled
- **Unsafe code**: Allowed in most projects (AllowUnsafeBlocks = True)
- **Primary constructors**: NOT preferred (csharp_style_prefer_primary_constructors = false)
### Naming Conventions
- **Classes/Interfaces**: PascalCase (`EntityManager`, `ICommandBuffer`)
- **Methods**: PascalCase (`CreateEntity`, `GetComponent`)
- **Properties**: PascalCase (`IsSuccess`, `Value`)
- **Fields (private)**: Camel case with underscore prefix (`_entityLocations`, `_world`)
- **Fields (public/internal)**: Camel case, no prefix for struct fields (`archetypeID`, `chunkIndex`)
- **Type parameters**: Single letter or PascalCase (`T`, `TComponent`)
- **Constants**: PascalCase (no SCREAMING_SNAKE_CASE)
```csharp
public class EntityManager
{
private readonly World _world; // Private field
private UnsafeSlotMap<EntityLocation> _entityLocations;
public World World => _world; // Property
public Entity CreateEntity() { } // Method
}
internal struct EntityLocation // Struct
{
public int archetypeID; // Public struct field
public int chunkIndex;
}
```
### Error Handling
**Use Result Types** - Railway-oriented programming pattern:
```csharp
// Custom result types defined in Ghost.Core
public ErrorStatus DoOperation()
{
return ErrorStatus.None; // or ErrorStatus.NotFound, etc.
}
public Result<T> GetValue()
{
if (success)
return Result<T>.Success(value);
else
return Result<T>.Failure("Error message");
}
public Result<T, ErrorStatus> GetValueWithStatus()
{
if (success)
return value; // Implicit conversion
else
return ErrorStatus.NotFound; // Implicit conversion
}
// Extension methods for checking results
result.ThrowIfFailed();
var value = result.GetValueOrThrow();
var value = result.GetValueOrDefault(defaultValue);
if (result.TryGetValue(out var value)) { }
```
**Error Status Values**: None, NotFound, InvalidArgument, InvalidState, InternalError, PermissionDenied, NotSupported, OutOfMemory, Timeout, Cancelled, UnknownError
### Memory and Performance
- **Use unsafe code** when needed for performance-critical paths
- **Span<T> and stackalloc**: Prefer for temporary allocations
- **ref returns**: Use for zero-copy access to internal data
- **Allocator patterns**: Use `Allocator.Persistent` for long-lived allocations
- **AllocationManager**: Create stack scopes for temporary allocations
```csharp
// Stack allocation pattern
var entities = (Span<Entity>)stackalloc Entity[1];
// Using allocation scope
using var scope = AllocationManager.CreateStackScope();
var batchDestroy = new UnsafeList<EntityLocation>(entities.Length, scope.AllocationHandle);
// Ref returns for zero-copy access
public ref T GetSingleton<T>() where T : unmanaged, IComponent
{
var ptr = GetSingleton(ComponentTypeID<T>.Value);
return ref *(T*)ptr;
}
```
### Type Safety Patterns
**Strongly-typed identifiers**:
```csharp
Identifier<IComponent> componentID;
Identifier<Archetype> archetypeID;
Handle<T> resourceHandle;
```
**Generic constraints**:
```csharp
public void Method<T>() where T : unmanaged, IComponent
public void Method<T, E>() where E : struct, Enum
```
### Documentation
- **XML comments**: Required for public APIs
- **Summary tags**: Describe what, not how
- **Remarks**: Add for complex behavior, thread-safety warnings, structural changes
```csharp
/// <summary>
/// Create an entity with specified components.
/// </summary>
/// <param name="set">A set of component space IDs to add to the entities.</param>
/// <returns>The created entity.</returns>
/// <remarks>
/// This method causes structural changes and is not thread-safe.
/// Use <see cref="EntityCommandBuffer"/> to defer changes.
/// </remarks>
public Entity CreateEntity(ComponentSet set) { }
```
### Common Patterns
**ECS Component Registration**:
```csharp
// Type-safe component ID
ComponentTypeID<Transform>.Value
// Component sets for archetypes
var set = new ComponentSet(ComponentTypeID<Transform>.Value, ComponentTypeID<Velocity>.Value);
```
**Disposal Pattern**:
```csharp
private bool _disposed;
~MyClass()
{
Dispose();
}
public void Dispose()
{
if (_disposed) return;
// Cleanup code
_disposed = true;
GC.SuppressFinalize(this);
}
```
**Debug-only validation**:
```csharp
#if DEBUG || GHOST_EDITOR
if (!_isSuccess)
{
throw new InvalidOperationException($"Error: {_message}");
}
#endif
```
## Architecture Notes
### Entity Component System (ECS)
- Archetype-based storage (similar to Unity DOTS)
- Component data stored in chunks
- Queries use bitset signatures for fast matching
- Structural changes move entities between archetypes
### Graphics (D3D12)
- Hardware abstraction via `ICommandBuffer`
- Resource lifetime managed via handles
- Pipeline state objects (PSO) cached in library
- Native interop via TerraFX.Interop
### Custom Dependencies
- `Misaki.HighPerformance.*`: High-performance collections and utilities
- `TerraFX.Interop.*`: Native Windows/DirectX interop
- Custom source generators in `Ghost.Generator`
## Important Rules
1. **Never disable nullable warnings** - fix the root cause
2. **Use Result types** instead of throwing exceptions for expected failures
3. **Document thread-safety** in XML comments for public APIs
4. **AllowUnsafeBlocks** is enabled - use unsafe code when it improves performance
5. **Avoid collection expressions/initializers** (disabled in .editorconfig)
6. **Prefer explicit over implicit** - clarity over brevity
7. **Test changes** by running the appropriate test project executable

View File

@@ -1,134 +0,0 @@
# Asset Database Plan
AssetDB is a core component of the Ghost Editor that manages the storage, retrieval, and organization of various assets used within the editor.
This document outlines the plan for implementing the AssetDB, including its structure, functionality, and integration with other components of the Ghost Editor.
## Data Structure
- Asset Metadata: Each asset will have associated metadata, including:
- Unique Identifier (GUID)
- Version (Version of the asset pipeline, not the asset. This is primarily for migration when we redesign the asset pipeline in the future)
- Tags
- Importer Settings
An simplified example of metadata file (filename.png.gmeta):
```json
{
"Guid": "123e4567-e89b-12d3-a456-426614174000",
"Version": 1,
"Tags": ["Environment", "Texture"],
"ImporterSettings": [
"TextureImporter": {
"Version": 1,
"MaxSize": 2048,
"MipLevels": 1
},
"OtherImporter": {
}
]
}
```
- Asset: The base class for all assets.
- Asset Database: A centralized database that stores and manages all assets. It will handle:
- Asset registration and deregistration
- Asset lookup by GUID
- Asset lookup by path
- Automatic handle file creation, remove, rename, move, etc.
- Asset dependency management
- Automatic asset re-importing when source files change.
- Asset tagging.
- Add type specific default importer settings for new asset.
- SQLite (`Microsoft.Data.Sqlite`) for persistent storage and efficient querying.
An simplified data model example in SQLite:
```sql
CREATE TABLE Assets (
Guid TEXT PRIMARY KEY,
Path TEXT NOT NULL,
Type INTEGER,
Version INTEGER,
Tags TEXT,
DependencyGuids TEXT
);
```
## Simplified Workflow
### New Asset Addition
1. A file is added to the project directory.
2. Generates the metadata for the asset with name filename.etx + ".gmeta" (You can get the extension from Ghost.Editor.Core.Utilities.FileExtensions.META_FILE_EXTENSION)
3. Add the asset to the database.
### File Removal
1. A file is removed from the project directory.
2. Deletes the corresponding asset metadata.
3. Remove the asset from the database.
4. Mark dependent assets as dirty for re-importing.
### File Renaming/Moving
1. A file is renamed or moved within the project directory.
2. Check if the new path has an existing metadata file (just in case user move the file with the metadata file together).
- If exists, validate the data and update the database accordingly.
- if not, regenerate the metadata file for the new path and update the database.
3. Delete the old metadata file if exists.
### File Modification
1. A file is modified in the project directory.
2. Check the file hash to see if it has changed.
- If changed, mark the asset as dirty for re-importing.
- If not, do nothing.
### Asset Importing (You don't need to write any assets importer right now, just write the framework and a simple test importer if it's needed for unit test)
1. An asset is marked as dirty.
2. The asset importer for that type processes the asset based on its importer settings.
3. Validate the references and dependencies, report errors if any (for example, missing dependencies).
## Features Checklist
### In Code (API)
- [ ] Find GUID by path
- [ ] Find path by GUID
- [ ] Load asset by GUID (May need special asset loader, not included in this plan, leave an API and TODO comment)
- [ ] API for adding/removing/moving/copying assets
- [ ] API for opening asset in editor or external program
- [ ] API to set asset dirty and save all assets if dirty
- [ ] Get and set asset tags.
- [ ] Refresh asset database (re-scan project directory for changes)
### In Editor (API Only, I will handle the UI part)
- [ ] Asset Browser window to view and manage assets (automatically refresh when assets change)
- [ ] Skip meta file in asset browser view
- [ ] Search assets by name, tag, type, etc.
### In Background
- [ ] File system watcher to monitor changes in the project directory and update the AssetDB accordingly.
- [ ] Automatic asset re-importing when source files change (detect changes via file system watcher and quick hash comparison).
- [ ] Asset dependency management.
- [ ] Validate and fix AssetDB on project load (check for missing/corrupted assets if user add/delete/rename/move files when the editor is not running, etc.)
- [ ] Asset importer system to handle different asset types and their import settings.
## Testing
Make sure everything builds correctly at first.
Write unit tests and integration tests to ensure the AssetDB functions correctly inside the `Ghost.UnitTests` project.
## Critical Considerations
- Performance: Ensure that the AssetDB operations are efficient, especially for large projects with many assets.
- Stability: The meta data files should be the only source of truth for asset information.
The AssetDB should be able to recover from inconsistencies by re-generating data from the meta files.
We still need to trust the meta data files even if they are corrupted or missing by regenerating them when necessary.
Database is used for caching and quick lookup only.
- Asynchronous patterns: Consider using asynchronous operations for file I/O and database operations to avoid blocking the main thread.
Packages like `Microsoft.Data.Sqlite` support async operations.

View File

@@ -1,355 +0,0 @@
using Ghost.Core;
namespace Ghost.Editor.Core.AssetHandle;
public partial class AssetService
{
/// <summary>
/// Create a new asset at the specified path.
/// Generates metadata and adds it to the database.
/// </summary>
/// <param name="assetPath">Path to create the asset at.</param>
/// <param name="content">Content to write to the asset file.</param>
/// <returns>Result indicating success or failure.</returns>
public async ValueTask<Result> CreateAssetAsync(string assetPath, ReadOnlyMemory<byte> content, CancellationToken token = default)
{
if (AssetsDirectory == null)
{
return Result.Failure("AssetsDirectory not initialized");
}
if (!assetPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase))
{
return Result.Failure("Asset path must be within the Assets directory");
}
if (File.Exists(assetPath))
{
return Result.Failure("Asset already exists");
}
try
{
var directory = Path.GetDirectoryName(assetPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
using var fs = File.Create(assetPath);
await fs.WriteAsync(content, token);
// GenerateMetaFileAsync will be called automatically by the file watcher
// But we'll call it directly to ensure it's created immediately
await GenerateMetaFileAsync(assetPath, token);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to create asset: {ex.Message}");
}
}
/// <summary>
/// Create an empty asset at the specified path.
/// Generates metadata and adds it to the database.
/// </summary>
/// <param name="assetPath">Path to create the asset at.</param>
/// <returns>Result indicating success or failure.</returns>
public ValueTask<Result> CreateAssetAsync(string assetPath, CancellationToken token = default)
{
return CreateAssetAsync(assetPath, ReadOnlyMemory<byte>.Empty, token);
}
/// <summary>
/// Delete an asset and its metadata.
/// </summary>
/// <param name="guid">GUID of the asset to delete.</param>
/// <returns>Result indicating success or failure.</returns>
public async ValueTask<Result> DeleteAssetAsync(Guid guid, CancellationToken token = default)
{
var pathResult = GuidToPath(guid);
if (pathResult.IsFailure)
{
return Result.Failure(pathResult.Message);
}
var fullPathResult = GetFullPath(pathResult.Value);
if (fullPathResult.IsFailure)
{
return Result.Failure(fullPathResult.Message);
}
try
{
var assetPath = fullPathResult.Value;
// Delete the asset file
if (File.Exists(assetPath))
{
File.Delete(assetPath);
}
// Delete the .gmeta file
var metaPath = assetPath + Utilities.FileExtensions.META_FILE_EXTENSION;
if (File.Exists(metaPath))
{
File.Delete(metaPath);
}
// Remove from database
await RemoveAssetFromDatabaseAsync(guid, token);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to delete asset: {ex.Message}");
}
}
/// <summary>
/// Delete an asset and its metadata by path.
/// </summary>
/// <param name="assetPath">Path to the asset to delete.</param>
/// <returns>Result indicating success or failure.</returns>
public ValueTask<Result> DeleteAssetAsync(string assetPath, CancellationToken token = default)
{
var guidResult = PathToGuid(assetPath);
if (guidResult.IsFailure)
{
return new ValueTask<Result>(Task.FromResult(Result.Failure(guidResult.Message)));
}
return DeleteAssetAsync(guidResult.Value, token);
}
/// <summary>
/// Move an asset to a new location.
/// </summary>
/// <param name="guid">GUID of the asset to move.</param>
/// <param name="newPath">New path for the asset (relative or absolute).</param>
/// <returns>Result indicating success or failure.</returns>
public async ValueTask<Result> MoveAssetAsync(Guid guid, string newPath, CancellationToken token = default)
{
var oldPathResult = GuidToPath(guid);
if (oldPathResult.IsFailure)
{
return Result.Failure(oldPathResult.Message);
}
var oldFullPathResult = GetFullPath(oldPathResult.Value);
if (oldFullPathResult.IsFailure)
{
return Result.Failure(oldFullPathResult.Message);
}
if (AssetsDirectory == null)
{
return Result.Failure("AssetsDirectory not initialized");
}
// Ensure new path is absolute and within assets directory
if (!Path.IsPathRooted(newPath))
{
newPath = Path.Combine(AssetsDirectory.FullName, newPath);
}
if (!newPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase))
{
return Result.Failure("New path must be within the Assets directory");
}
if (File.Exists(newPath))
{
return Result.Failure("A file already exists at the new path");
}
try
{
var directory = Path.GetDirectoryName(newPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
// Read metadata and calculate hash before moving
var metaResult = await ReadMetaFileAsync(oldFullPathResult.Value, token);
if (metaResult.IsFailure)
{
return Result.Failure(metaResult.Message);
}
var fileHash = await CalculateFileHashAsync(oldFullPathResult.Value, token);
// Move the asset file
File.Move(oldFullPathResult.Value, newPath);
// Move the .gmeta file
var oldMetaPath = oldFullPathResult.Value + Utilities.FileExtensions.META_FILE_EXTENSION;
var newMetaPath = newPath + Utilities.FileExtensions.META_FILE_EXTENSION;
if (File.Exists(oldMetaPath))
{
File.Move(oldMetaPath, newMetaPath);
}
// Update database directly (bypassing file watcher)
await UpsertAssetAsync(newPath, metaResult.Value, fileHash, null, token);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to move asset: {ex.Message}");
}
}
/// <summary>
/// Move an asset to a new location by path.
/// </summary>
/// <param name="oldPath">CurrentApplication path of the asset.</param>
/// <param name="newPath">New path for the asset (relative or absolute).</param>
/// <returns>Result indicating success or failure.</returns>
public ValueTask<Result> MoveAssetAsync(string oldPath, string newPath, CancellationToken token = default)
{
var guidResult = PathToGuid(oldPath);
if (guidResult.IsFailure)
{
return ValueTask.FromResult(Result.Failure(guidResult.Message));
}
return MoveAssetAsync(guidResult.Value, newPath, token);
}
/// <summary>
/// Copy an asset to a new location with a new GUID.
/// </summary>
/// <param name="guid">GUID of the asset to copy.</param>
/// <param name="newPath">New path for the copied asset (relative or absolute).</param>
/// <returns>Result containing the new asset's GUID.</returns>
public async ValueTask<Result<Guid>> CopyAssetAsync(Guid guid, string newPath, CancellationToken token = default)
{
var oldPathResult = GuidToPath(guid);
if (oldPathResult.IsFailure)
{
return Result<Guid>.Failure(oldPathResult.Message);
}
var oldFullPathResult = GetFullPath(oldPathResult.Value);
if (oldFullPathResult.IsFailure)
{
return Result<Guid>.Failure(oldFullPathResult.Message);
}
if (AssetsDirectory == null)
{
return Result<Guid>.Failure("AssetsDirectory not initialized");
}
// Ensure new path is absolute and within assets directory
if (!Path.IsPathRooted(newPath))
{
newPath = Path.Combine(AssetsDirectory.FullName, newPath);
}
if (!newPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase))
{
return Result<Guid>.Failure("New path must be within the Assets directory");
}
if (File.Exists(newPath))
{
return Result<Guid>.Failure("A file already exists at the new path");
}
try
{
var directory = Path.GetDirectoryName(newPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
await using var oldFs = File.OpenRead(oldFullPathResult.Value);
await using var newFs = File.Create(newPath);
await oldFs.CopyToAsync(newFs, token);
// Generate new metadata with new GUID
await GenerateMetaFileAsync(newPath, token);
// Get the new GUID
var newGuidResult = PathToGuid(newPath);
if (newGuidResult.IsFailure)
{
return Result<Guid>.Failure(newGuidResult.Message);
}
return newGuidResult.Value;
}
catch (Exception ex)
{
return Result<Guid>.Failure($"Failed to copy asset: {ex.Message}");
}
}
/// <summary>
/// Copy an asset to a new location by path.
/// </summary>
/// <param name="sourcePath">Path of the asset to copy.</param>
/// <param name="destPath">New path for the copied asset (relative or absolute).</param>
/// <returns>Result containing the new asset's GUID.</returns>
public ValueTask<Result<Guid>> CopyAssetAsync(string sourcePath, string destPath, CancellationToken token = default)
{
var guidResult = PathToGuid(sourcePath);
if (guidResult.IsFailure)
{
return new ValueTask<Result<Guid>>(Task.FromResult(Result<Guid>.Failure(guidResult.Message)));
}
return CopyAssetAsync(guidResult.Value, destPath, token);
}
/// <summary>
/// Mark an asset as dirty for re-importing (in-memory only).
/// </summary>
/// <param name="guid">GUID of the asset to mark dirty.</param>
/// <returns>Result indicating success or failure.</returns>
public Result MarkDirtyAsync(Guid guid, CancellationToken token = default)
{
MarkDirty(guid);
return Result.Success();
}
/// <summary>
/// Import all dirty assets.
/// </summary>
/// <returns>Result indicating success or failure.</returns>
public async Task<Result> ImportDirtyAssetsAsync(CancellationToken token = default)
{
var dirtyGuids = GetDirtyAssets();
foreach (var guid in dirtyGuids)
{
var pathResult = GuidToPath(guid);
if (pathResult.IsFailure)
{
continue;
}
var fullPathResult = GetFullPath(pathResult.Value);
if (fullPathResult.IsFailure)
{
continue;
}
var result = await ImportAssetAsync(fullPathResult.Value, token);
if (result.IsSuccess)
{
ClearDirty(guid);
}
}
return Result.Success();
}
}

View File

@@ -1,122 +0,0 @@
using Ghost.Core;
using System.Reflection;
namespace Ghost.Editor.Core.AssetHandle;
public partial class AssetService
{
private readonly Dictionary<Type, AssetImporter> _importerInstances = new();
/// <summary>
/// Import an asset at the specified path.
/// </summary>
/// <param name="assetPath">Full path to the asset file.</param>
/// <returns>Result indicating success or failure.</returns>
private async ValueTask<Result> ImportAssetAsync(string assetPath, CancellationToken token = default)
{
var extension = Path.GetExtension(assetPath);
if (!_importerTypeLookup.TryGetValue(extension, out var importerType))
{
// No importer registered for this file type
return Result.Success();
}
// Get or create importer instance
if (!_importerInstances.TryGetValue(importerType, out var importerInstance))
{
importerInstance = Activator.CreateInstance(importerType) as AssetImporter;
if (importerInstance is null)
{
return Result.Failure($"Failed to create importer instance for type {importerType.Name}");
}
_importerInstances[importerType] = importerInstance;
}
// Read metadata
var metaResult = await ReadMetaFileAsync(assetPath, token);
if (metaResult.IsFailure)
{
return Result.Failure($"Failed to read asset metadata: {metaResult.Message}");
}
return await importerInstance.ImportAsync(assetPath, metaResult.Value, this, token);
}
/// <summary>
/// Get the importer type for a specific file extension.
/// </summary>
/// <param name="extension">File extension (e.g., ".png").</param>
/// <returns>The importer type if found, otherwise null.</returns>
public Type? GetImporterType(string extension)
{
_importerTypeLookup.TryGetValue(extension, out var importerType);
return importerType;
}
/// <summary>
/// Get all registered importer types and their supported extensions.
/// </summary>
/// <returns>Dictionary mapping extensions to importer types.</returns>
public Dictionary<string, Type> GetAllImporters()
{
return new Dictionary<string, Type>(_importerTypeLookup);
}
/// <summary>
/// Export in-memory asset data to disk.
/// The importer will serialize the data into a format it can later import.
/// </summary>
/// <typeparam name="T">Type of asset data to export.</typeparam>
/// <param name="assetPath">Full path where the asset should be saved.</param>
/// <param name="assetData">In-memory asset data to export.</param>
/// <returns>Result with the GUID of the exported asset.</returns>
public async ValueTask<Result<Guid>> ExportAssetAsync<T>(string assetPath, T assetData, CancellationToken token = default)
where T : class
{
var extension = Path.GetExtension(assetPath);
if (!_importerTypeLookup.TryGetValue(extension, out var importerType))
{
return Result<Guid>.Failure($"No importer registered for extension {extension}");
}
// Get or create importer instance
if (!_importerInstances.TryGetValue(importerType, out var importerInstance))
{
importerInstance = Activator.CreateInstance(importerType) as AssetImporter;
if (importerInstance is null)
{
return Result<Guid>.Failure($"Failed to create importer instance for type {importerType.Name}");
}
_importerInstances[importerType] = importerInstance;
}
// Generate metadata for the new asset
var result = await GenerateMetaFileAsync(assetPath, token);
if (result.IsFailure)
{
return Result<Guid>.Failure($"Failed to generate metadata: {result.Message}");
}
var metaResult = await ReadMetaFileAsync(assetPath, token);
if (metaResult.IsFailure)
{
return Result<Guid>.Failure($"Failed to read metadata: {metaResult.Message}");
}
result = await importerInstance.ExportAsync(assetPath, assetData, metaResult.Value, token);
if (result.IsFailure)
{
return Result<Guid>.Failure(result.Message);
}
// Calculate file hash and update database
var fileHash = await CalculateFileHashAsync(assetPath, token);
await UpsertAssetAsync(assetPath, metaResult.Value, fileHash, null, token);
return metaResult.Value.Guid;
}
}

View File

@@ -1,211 +0,0 @@
using Ghost.Core;
using System.Collections.Concurrent;
using System.Text.Json;
namespace Ghost.Editor.Core.AssetHandle;
public partial class AssetService
{
// Asset cache - stores loaded assets by GUID
private readonly ConcurrentDictionary<Guid, Asset> _assetCache = new();
// LRU tracking - stores access time for each cached asset
private readonly ConcurrentDictionary<Guid, DateTime> _assetAccessTime = new();
// Maximum number of cached assets before eviction starts
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 Result<string> GetImportedAssetsDirectory()
{
if (AssetsDirectory == null)
{
return Result<string>.Failure("AssetsDirectory not initialized");
}
var cacheDir = Path.Combine(AssetsDirectory.Parent!.FullName, EditorApplication.CACHES_FOLDER_NAME, "ImportedAssets");
if (!Directory.Exists(cacheDir))
{
Directory.CreateDirectory(cacheDir);
}
return cacheDir;
}
private Result<string> GetImportedAssetPath(Guid guid)
{
var importedDirResult = GetImportedAssetsDirectory();
if (importedDirResult.IsFailure)
{
return Result<string>.Failure(importedDirResult.Message);
}
// Store imported assets as {GUID}.asset
var assetDataPath = Path.Combine(importedDirResult.Value, $"{guid}.asset");
return assetDataPath;
}
private Result<T> LoadAssetInternal<T>(Guid guid) where T : Asset
{
// Check cache first
if (_assetCache.TryGetValue(guid, out var cachedAsset))
{
// Update access time for LRU
_assetAccessTime[guid] = DateTime.UtcNow;
if (cachedAsset is T typedAsset)
{
return typedAsset;
}
else
{
return Result<T>.Failure($"Cached asset is of type {cachedAsset.GetType().Name}, expected {typeof(T).Name}");
}
}
// Asset not in cache, load from disk
var assetPathResult = GetImportedAssetPath(guid);
if (assetPathResult.IsFailure)
{
return Result<T>.Failure(assetPathResult.Message);
}
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.");
}
try
{
// 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");
}
// Add to cache
CacheAsset(guid, asset);
return asset;
}
catch (Exception ex)
{
return Result<T>.Failure($"Failed to load asset: {ex.Message}");
}
}
public Result<T> LoadAssetAtPath<T>(string assetPath) where T : Asset
{
var guidResult = PathToGuid(assetPath);
if (guidResult.IsFailure)
{
return Result<T>.Failure(guidResult.Message);
}
return LoadAsset<T>(guidResult.Value);
}
private void CacheAsset(Guid guid, Asset asset)
{
// Check if we need to evict old assets
if (_assetCache.Count >= _MAX_CACHED_ASSETS)
{
EvictOldestAssets();
}
_assetCache[guid] = asset;
_assetAccessTime[guid] = DateTime.UtcNow;
}
private void EvictOldestAssets()
{
var evictionCount = (int)(_MAX_CACHED_ASSETS * _CACHE_EVICTION_PERCENTAGE);
// Sort by access time and remove oldest entries
var oldestAssets = _assetAccessTime
.OrderBy(kvp => kvp.Value)
.Take(evictionCount)
.Select(kvp => kvp.Key)
.ToList();
foreach (var guid in oldestAssets)
{
_assetCache.TryRemove(guid, out _);
_assetAccessTime.TryRemove(guid, out _);
}
}
/// <summary>
/// Unload a specific asset from cache.
/// </summary>
/// <param name="guid">GUID of the asset to unload.</param>
public void UnloadAsset(Guid guid)
{
_assetCache.TryRemove(guid, out _);
_assetAccessTime.TryRemove(guid, out _);
}
/// <summary>
/// Unload all assets from cache.
/// </summary>
public void UnloadAllAssets()
{
_assetCache.Clear();
_assetAccessTime.Clear();
}
/// <summary>
/// Check if an asset is currently loaded in cache.
/// </summary>
/// <param name="guid">GUID of the asset.</param>
/// <returns>True if the asset is in cache.</returns>
public bool IsAssetLoaded(Guid guid)
{
return _assetCache.ContainsKey(guid);
}
/// <summary>
/// Get cache statistics.
/// </summary>
/// <returns>Tuple of (current cache size, max cache size).</returns>
public (int currentSize, int maxSize) GetCacheStats()
{
return (_assetCache.Count, _MAX_CACHED_ASSETS);
}
/// <summary>
/// Save an imported asset to disk for later loading.
/// This should be called by importers after processing the source file.
/// </summary>
/// <typeparam name="T">Type of asset data.</typeparam>
/// <param name="guid">GUID of the asset.</param>
/// <param name="assetData">Processed asset data to save.</param>
/// <returns>Result indicating success or failure.</returns>
public Result SaveImportedAsset<T>(Guid guid, T assetData)
where T : Asset
{
var assetPathResult = GetImportedAssetPath(guid);
if (assetPathResult.IsFailure)
{
return Result.Failure(assetPathResult.Message);
}
try
{
var json = JsonSerializer.Serialize(assetData, _defaultJsonOptions);
File.WriteAllText(assetPathResult.Value, json);
// Invalidate cache for this asset so it gets reloaded next time
UnloadAsset(guid);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to save imported asset: {ex.Message}");
}
}
}

View File

@@ -1,203 +0,0 @@
using Ghost.Core;
using System.Text.Json;
namespace Ghost.Editor.Core.AssetHandle;
public partial class AssetService
{
/// <summary>
/// Get the relative path from the assets directory.
/// </summary>
private Result<string> GetRelativePath(string fullPath)
{
if (AssetsDirectory == null)
{
return Result<string>.Failure("AssetsDirectory not initialized");
}
if (!fullPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase))
{
return Result<string>.Failure("Path is not within assets directory");
}
return Path.GetRelativePath(AssetsDirectory.FullName, fullPath);
}
/// <summary>
/// Get the full path from a relative path.
/// </summary>
private Result<string> GetFullPath(string relativePath)
{
if (AssetsDirectory == null)
{
return Result<string>.Failure("AssetsDirectory not initialized");
}
return Path.Combine(AssetsDirectory.FullName, relativePath);
}
/// <summary>
/// Find GUID by asset path.
/// </summary>
/// <param name="assetPath">Full or relative path to the asset.</param>
/// <returns>The GUID of the asset if found.</returns>
public Result<Guid> PathToGuid(string assetPath)
{
var relativePath = assetPath;
// Convert to relative path if it's a full path
if (Path.IsPathRooted(assetPath))
{
var relResult = GetRelativePath(assetPath);
if (relResult.IsFailure)
{
return Result<Guid>.Failure(relResult.Message);
}
relativePath = relResult.Value;
}
// Normalize path separators
relativePath = relativePath.Replace('\\', '/');
lock (_dbLock)
{
if (_pathAssetLookup.TryGetValue(relativePath, out var guid))
{
return guid;
}
}
return Result<Guid>.Failure("Asset not found in database");
}
/// <summary>
/// Find path by GUID.
/// </summary>
/// <param name="guid">GUID of the asset.</param>
/// <returns>The relative path to the asset if found.</returns>
public Result<string> GuidToPath(Guid guid)
{
lock (_dbLock)
{
if (_assetPathLookup.TryGetValue(guid, out var path))
{
return path;
}
}
return Result<string>.Failure("Asset GUID not found in database");
}
/// <summary>
/// Load asset by GUID with caching.
/// </summary>
/// <typeparam name="T">Type of asset to load.</typeparam>
/// <param name="guid">GUID of the asset.</param>
/// <returns>The loaded asset.</returns>
public Result<T> LoadAsset<T>(Guid guid) where T : Asset
{
// Implemented in AssetService.Loader.cs
return LoadAssetInternal<T>(guid);
}
/// <summary>
/// Get asset tags by GUID.
/// </summary>
/// <param name="guid">GUID of the asset.</param>
/// <returns>List of tags associated with the asset.</returns>
public async ValueTask<Result<List<string>>> GetAssetTagsAsync(Guid guid, CancellationToken token = default)
{
var pathResult = GuidToPath(guid);
if (pathResult.IsFailure)
{
return Result<List<string>>.Failure(pathResult.Message);
}
var fullPathResult = GetFullPath(pathResult.Value);
if (fullPathResult.IsFailure)
{
return Result<List<string>>.Failure(fullPathResult.Message);
}
var metaResult = await ReadMetaFileAsync(fullPathResult.Value, token);
if (metaResult.IsFailure)
{
return Result<List<string>>.Failure(metaResult.Message);
}
return metaResult.Value.Tags;
}
/// <summary>
/// Set asset tags by GUID.
/// </summary>
/// <param name="guid">GUID of the asset.</param>
/// <param name="tags">New tags for the asset.</param>
/// <returns>Result indicating success or failure.</returns>
public async ValueTask<Result> SetAssetTagsAsync(Guid guid, List<string> tags, CancellationToken token = default)
{
var pathResult = GuidToPath(guid);
if (pathResult.IsFailure)
{
return Result.Failure(pathResult.Message);
}
var fullPathResult = GetFullPath(pathResult.Value);
if (fullPathResult.IsFailure)
{
return Result.Failure(fullPathResult.Message);
}
var metaResult = await ReadMetaFileAsync(fullPathResult.Value, token);
if (metaResult.IsFailure)
{
return Result.Failure(metaResult.Message);
}
metaResult.Value.Tags = tags;
// Write updated metadata to .gmeta file
var writeResult = await WriteMetaFileAsync(fullPathResult.Value + Utilities.FileExtensions.META_FILE_EXTENSION, metaResult.Value, token);
if (writeResult.IsFailure)
{
return writeResult;
}
// Update database with new tags
var fileHash = await CalculateFileHashAsync(fullPathResult.Value, token);
return await UpsertAssetAsync(fullPathResult.Value, metaResult.Value, fileHash, null, token);
}
/// <summary>
/// Search assets by name pattern.
/// Supports SQL LIKE wildcards: * (any characters) and ? (single character).
/// </summary>
/// <param name="namePattern">Search pattern (e.g., "*.txt", "player?", "test*").</param>
/// <returns>List of matching asset GUIDs.</returns>
public async Task<List<Guid>> FindAssetsByNameAsync(string namePattern, CancellationToken token = default)
{
return await GetAssetsByNameAsync(namePattern, token);
}
/// <summary>
/// Find assets by tag.
/// </summary>
/// <param name="tag">Tag to search for.</param>
/// <returns>List of asset GUIDs with the specified tag.</returns>
public async Task<List<Guid>> FindAssetsByTagAsync(string tag, CancellationToken token = default)
{
return await GetAssetsByTagAsync(tag, token);
}
/// <summary>
/// Get all assets in the database.
/// </summary>
/// <returns>Dictionary mapping GUIDs to relative paths.</returns>
public IReadOnlyDictionary<Guid, string> GetAllAssets()
{
lock (_dbLock)
{
return _assetPathLookup.AsReadOnly();
}
}
}

View File

@@ -1,251 +0,0 @@
using Ghost.Core;
using Ghost.Editor.Core.Utilities;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text.Json;
namespace Ghost.Editor.Core.AssetHandle;
public partial class AssetService
{
private readonly Dictionary<string, Type> _importerTypeLookup = new();
private void InitializeMetaData()
{
if (_watcher == null)
{
throw new InvalidOperationException("AssetDatabase is not initialized. Ensure that Initialize() is called before registering asset importers.");
}
var importerTypes = TypeCache.GetTypes().Where(t => t.GetCustomAttribute<AssetImporterAttribute>() != null);
foreach (var type in importerTypes)
{
var attribute = type.GetCustomAttribute<AssetImporterAttribute>()!;
foreach (var extension in attribute.SupportedExtensions)
{
_importerTypeLookup[extension] = type;
}
}
_watcher.Created += OnFSEvent;
_watcher.Deleted += OnFSEvent;
_watcher.Changed += OnFSEvent;
_watcher.Renamed += OnAssetRenamed;
}
private Result<string> GetMetaFilePath(string assetPath)
{
if (Directory.Exists(assetPath))
{
return Result<string>.Failure("Cannot create metadata for directories");
}
if (Path.GetExtension(assetPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
{
return Result<string>.Failure("Cannot create metadata for metadata files");
}
return assetPath + FileExtensions.META_FILE_EXTENSION;
}
private ImporterSettings? GetDefaultSettingsForAsset(string assetPath)
{
var extension = Path.GetExtension(assetPath);
if (_importerTypeLookup.TryGetValue(extension, out var importerType))
{
var settingsType = importerType.BaseType?.GetGenericArguments()[0];
if (settingsType == null || !typeof(ImporterSettings).IsAssignableFrom(settingsType))
{
return null;
}
return (ImporterSettings?)Activator.CreateInstance(settingsType);
}
return null;
}
/// <summary>
/// Calculate SHA256 hash of a file for change detection.
/// </summary>
private async Task<string> CalculateFileHashAsync(string filePath, CancellationToken token = default)
{
try
{
await using var stream = File.OpenRead(filePath);
var hash = await SHA256.HashDataAsync(stream, token);
return Convert.ToHexString(hash);
}
catch
{
return string.Empty;
}
}
private async Task<Result> WriteMetaFileAsync(string metaFilePath, AssetMeta metaData, CancellationToken token = default)
{
try
{
await using var fileStream = File.Create(metaFilePath);
await JsonSerializer.SerializeAsync(fileStream, metaData, _defaultJsonOptions, token);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure(ex.Message);
}
}
/// <summary>
/// Read metadata from a .gmeta file.
/// </summary>
private async ValueTask<Result<AssetMeta>> ReadMetaFileAsync(string assetPath, CancellationToken token = default)
{
var metaFileResult = GetMetaFilePath(assetPath);
if (metaFileResult.IsFailure)
{
return Result<AssetMeta>.Failure(metaFileResult.Message);
}
if (!File.Exists(metaFileResult.Value))
{
return Result<AssetMeta>.Failure("Metadata file does not exist");
}
try
{
await using var fileStream = File.OpenRead(metaFileResult.Value);
var meta = await JsonSerializer.DeserializeAsync<AssetMeta>(fileStream, _defaultJsonOptions, token);
if (meta == null)
{
return Result<AssetMeta>.Failure("Failed to deserialize metadata");
}
return meta;
}
catch (Exception ex)
{
return Result<AssetMeta>.Failure($"Failed to read metadata: {ex.Message}");
}
}
internal async ValueTask<Result> GenerateMetaFileAsync(string assetPath, CancellationToken token = default)
{
Result r;
var metaFileResult = GetMetaFilePath(assetPath);
if (metaFileResult.IsFailure)
{
return Result.Failure(metaFileResult.Message);
}
if (File.Exists(metaFileResult.Value))
{
var existingMetaResult = await ReadMetaFileAsync(assetPath, token);
if (existingMetaResult.IsSuccess)
{
var existingMeta = existingMetaResult.Value;
if (_assetPathLookup.TryGetValue(existingMeta.Guid, out var path))
{
var relResult = GetRelativePath(assetPath);
if (relResult.IsSuccess && assetPath != path)
{
// GUID conflict - regenerate
existingMeta.Guid = Guid.NewGuid();
r = await WriteMetaFileAsync(metaFileResult.Value, existingMeta, token);
if (r.IsFailure)
{
return r;
}
}
}
// Calculate file hash and update database
var fileHash = await CalculateFileHashAsync(assetPath, token);
await UpsertAssetAsync(assetPath, existingMeta, fileHash, null, token);
return Result.Success();
}
}
// Calculate initial file hash
var fileHash2 = await CalculateFileHashAsync(assetPath, token);
var defaultSettings = GetDefaultSettingsForAsset(assetPath);
var metaData = new AssetMeta
{
Guid = Guid.NewGuid()
};
if (defaultSettings != null)
{
metaData.SetImporterSettings(defaultSettings.GetType().Name, defaultSettings);
}
r = await WriteMetaFileAsync(metaFileResult.Value, metaData, token);
if (r.IsFailure)
{
return r;
}
// Add to database
await UpsertAssetAsync(assetPath, metaData, fileHash2, null, token);
return r;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool IsMetaFile(string path)
{
return Path.GetExtension(path).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase);
}
private async void OnFSEvent(object sender, FileSystemEventArgs e)
{
if (IsMetaFile(e.FullPath))
{
return;
}
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 async void OnAssetRenamed(object sender, RenamedEventArgs e)
{
if (IsMetaFile(e.FullPath))
{
return;
}
await PostCommandAsync(new AssetCommand(AssetCommandType.FileRenamed, e.FullPath, e.OldFullPath, DateTime.UtcNow));
}
/// <summary>
/// Mark all assets that depend on the specified asset as dirty.
/// </summary>
private 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();
foreach (var kvp in allAssets)
{
var dependencies = await GetDependenciesAsync(kvp.Key, CancellationToken.None);
if (dependencies.Contains(assetGuid))
{
MarkDirty(kvp.Key);
}
}
}
}

View File

@@ -1,51 +0,0 @@
using Ghost.Core;
using Ghost.Editor.Core.Utilities;
using System.Diagnostics;
using System.Reflection;
namespace Ghost.Editor.Core.AssetHandle;
public partial class AssetService
{
private readonly Dictionary<string, Action<string>> _assetOpenHandlers = new(StringComparer.OrdinalIgnoreCase);
private void InitializeAssetHandle()
{
var methods = TypeCache.GetTypes()
.SelectMany(t => t.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
.Where(m => m.GetCustomAttribute<AssetOpenHandlerAttribute>() != null &&
m.GetParameters().Length == 1 &&
m.GetParameters()[0].ParameterType == typeof(string));
foreach (var method in methods)
{
var attr = method.GetCustomAttribute<AssetOpenHandlerAttribute>()!;
var del = (Action<string>)Delegate.CreateDelegate(typeof(Action<string>), method);
foreach (var ext in attr.Extensions)
{
if (_assetOpenHandlers.ContainsKey(ext))
{
Logger.LogError($"Duplicate asset open handler for extension '{ext}' found in method '{method.Name}'. Existing handler will be overwritten.");
}
_assetOpenHandlers[ext] = del;
}
}
}
public void OpenAsset(string path)
{
var extension = Path.GetExtension(path);
if (_assetOpenHandlers.TryGetValue(extension, out var handler))
{
handler(path);
}
else
{
Process.Start(new ProcessStartInfo(path)
{
UseShellExecute = true
});
}
}
}

View File

@@ -1,389 +0,0 @@
using Ghost.Core;
using Microsoft.Data.Sqlite;
using System.Text.Json;
namespace Ghost.Editor.Core.AssetHandle;
public partial class AssetService
{
private SqliteConnection? _dbConnection;
/// <summary>
/// Init the SQLite database for asset caching.
/// </summary>
private async Task InitializeDatabaseAsync(CancellationToken token = default)
{
if (AssetsDirectory == null)
{
throw new InvalidOperationException("AssetsDirectory is not set. Initialize() must be called first.");
}
var dbPath = Path.Combine(AssetsDirectory.Parent!.FullName, EditorApplication.CACHES_FOLDER_NAME, "AssetDatabase.db");
var cacheDir = Path.GetDirectoryName(dbPath);
if (!Directory.Exists(cacheDir))
{
Directory.CreateDirectory(cacheDir!);
}
var connectionString = new SqliteConnectionStringBuilder
{
DataSource = dbPath,
Mode = SqliteOpenMode.ReadWriteCreate,
Cache = SqliteCacheMode.Shared
}.ToString();
_dbConnection = new SqliteConnection(connectionString);
await _dbConnection.OpenAsync(token);
// Create tables
await using var cmd = _dbConnection.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS Assets (
Guid TEXT PRIMARY KEY,
Path TEXT NOT NULL,
Version INTEGER NOT NULL,
Tags TEXT,
FileHash TEXT,
DependencyGuids TEXT,
LastModified INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_path ON Assets(Path);
";
await cmd.ExecuteNonQueryAsync(token);
}
/// <summary>
/// Add or update an asset in the database.
/// </summary>
/// <param name="assetPath">Full path to the asset file.</param>
/// <param name="meta">Asset metadata from .gmeta file.</param>
/// <param name="fileHash">SHA256 hash of the asset file content.</param>
/// <param name="dependencies">List of GUIDs this asset depends on (extracted during import).</param>
private async ValueTask<Result> UpsertAssetAsync(string assetPath, AssetMeta meta, string fileHash, List<Guid>? dependencies = null, CancellationToken token = default)
{
if (_dbConnection == null)
{
return Result.Failure("Database not initialized");
}
var relativePath = GetRelativePath(assetPath);
if (relativePath.IsFailure)
{
return Result.Failure(relativePath.Message);
}
try
{
lock (_dbLock)
{
// If this GUID already exists with a different path, remove the old path mapping
if (_assetPathLookup.TryGetValue(meta.Guid, out var oldPath) && oldPath != relativePath.Value)
{
_pathAssetLookup.Remove(oldPath);
}
// Update lookups with new path (normalize path separators for consistency)
var normalizedPath = relativePath.Value.Replace('\\', '/');
_assetPathLookup[meta.Guid] = normalizedPath;
_pathAssetLookup[normalizedPath] = meta.Guid;
}
await using var cmd = _dbConnection.CreateCommand();
cmd.CommandText = @"
INSERT OR REPLACE INTO Assets (Guid, Path, Version, Tags, FileHash, DependencyGuids, LastModified)
VALUES (@guid, @path, @version, @tags, @fileHash, @deps, @modified)
";
cmd.Parameters.AddWithValue("@guid", meta.Guid.ToString());
cmd.Parameters.AddWithValue("@path", relativePath.Value);
cmd.Parameters.AddWithValue("@version", meta.Version);
cmd.Parameters.AddWithValue("@tags", JsonSerializer.Serialize(meta.Tags));
cmd.Parameters.AddWithValue("@fileHash", fileHash);
cmd.Parameters.AddWithValue("@deps", JsonSerializer.Serialize(dependencies ?? new List<Guid>()));
cmd.Parameters.AddWithValue("@modified", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
await cmd.ExecuteNonQueryAsync(token);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to upsert asset: {ex.Message}");
}
}
/// <summary>
/// Remove an asset from the database.
/// </summary>
private async Task<Result> RemoveAssetFromDatabaseAsync(Guid guid, CancellationToken token = default)
{
if (_dbConnection == null)
{
return Result.Failure("Database not initialized");
}
try
{
lock (_dbLock)
{
if (_assetPathLookup.TryGetValue(guid, out var path))
{
_assetPathLookup.Remove(guid);
_pathAssetLookup.Remove(path);
}
}
await using var cmd = _dbConnection.CreateCommand();
cmd.CommandText = "DELETE FROM Assets WHERE Guid = @guid";
cmd.Parameters.AddWithValue("@guid", guid.ToString());
await cmd.ExecuteNonQueryAsync(token);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to remove asset: {ex.Message}");
}
}
/// <summary>
/// Load all assets from the database into memory cache.
/// </summary>
private async Task LoadAssetCacheFromDatabaseAsync(CancellationToken token = default)
{
if (_dbConnection == null)
{
return;
}
try
{
await using var cmd = _dbConnection.CreateCommand();
cmd.CommandText = "SELECT Guid, Path FROM Assets";
await using var reader = await cmd.ExecuteReaderAsync(token);
while (await reader.ReadAsync(token))
{
var guidStr = reader.GetString(0);
var path = reader.GetString(1);
if (Guid.TryParse(guidStr, out var guid))
{
lock (_dbLock)
{
_assetPathLookup[guid] = path;
_pathAssetLookup[path] = guid;
}
}
}
}
catch (Exception ex)
{
Logger.LogError($"Failed to load asset cache: {ex.Message}");
}
}
/// <summary>
/// Get assets by tag.
/// </summary>
private async Task<List<Guid>> GetAssetsByTagAsync(string tag, CancellationToken token = default)
{
var result = new List<Guid>();
if (_dbConnection == null)
{
return result;
}
try
{
await using var cmd = _dbConnection.CreateCommand();
cmd.CommandText = "SELECT Guid, Tags FROM Assets";
await using var reader = await cmd.ExecuteReaderAsync(token);
while (await reader.ReadAsync(token))
{
var guidStr = reader.GetString(0);
var tagsJson = reader.GetString(1);
if (Guid.TryParse(guidStr, out var guid))
{
var tags = JsonSerializer.Deserialize<List<string>>(tagsJson);
if (tags != null && tags.Contains(tag, StringComparer.OrdinalIgnoreCase))
{
result.Add(guid);
}
}
}
}
catch
{
// Silently fail
}
return result;
}
/// <summary>
/// Get the file hash for an asset from the database.
/// </summary>
private async Task<string?> GetFileHashAsync(Guid guid, CancellationToken token = default)
{
if (_dbConnection == null)
{
return null;
}
try
{
await using var cmd = _dbConnection.CreateCommand();
cmd.CommandText = "SELECT FileHash FROM Assets WHERE Guid = @guid";
cmd.Parameters.AddWithValue("@guid", guid.ToString());
var result = await cmd.ExecuteScalarAsync(token);
return result?.ToString();
}
catch
{
return null;
}
}
/// <summary>
/// Get the dependencies for an asset from the database.
/// </summary>
private async Task<List<Guid>> GetDependenciesAsync(Guid guid, CancellationToken token = default)
{
if (_dbConnection == null)
{
return new List<Guid>();
}
try
{
await using var cmd = _dbConnection.CreateCommand();
cmd.CommandText = "SELECT DependencyGuids FROM Assets WHERE Guid = @guid";
cmd.Parameters.AddWithValue("@guid", guid.ToString());
var result = await cmd.ExecuteScalarAsync(token);
if (result != null)
{
var json = result.ToString();
return JsonSerializer.Deserialize<List<Guid>>(json ?? "[]") ?? new List<Guid>();
}
}
catch
{
// Silently fail
}
return new List<Guid>();
}
/// <summary>
/// Find assets by name pattern using database query with wildcards.
/// </summary>
/// <param name="namePattern">Pattern supporting * (any chars) and ? (single char).</param>
private async Task<List<Guid>> GetAssetsByNameAsync(string namePattern, CancellationToken token = default)
{
var results = new List<Guid>();
if (_dbConnection == null)
{
return results;
}
try
{
// Convert wildcard pattern to SQL LIKE pattern
var sqlPattern = namePattern.Replace('*', '%').Replace('?', '_');
await using var cmd = _dbConnection.CreateCommand();
// Extract just the filename from the path for matching
// SQLite doesn't have a built-in path manipulation, so we search in the full path
// and filter by checking if the pattern matches the filename part
cmd.CommandText = @"
SELECT Guid, Path FROM Assets
WHERE Path LIKE '%' || @pattern || '%'
";
cmd.Parameters.AddWithValue("@pattern", sqlPattern);
await using var reader = await cmd.ExecuteReaderAsync(token);
while (await reader.ReadAsync(token))
{
var guidStr = reader.GetString(0);
var path = reader.GetString(1);
// Extract filename and check if it matches the pattern
var fileName = Path.GetFileName(path);
// Convert pattern to regex for proper matching
var regexPattern = "^" + System.Text.RegularExpressions.Regex.Escape(namePattern)
.Replace("\\*", ".*")
.Replace("\\?", ".") + "$";
if (System.Text.RegularExpressions.Regex.IsMatch(fileName, regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase))
{
if (Guid.TryParse(guidStr, out var guid))
{
results.Add(guid);
}
}
}
}
catch
{
// Silently fail
}
return results;
}
/// <summary>
/// Remove orphaned entries from database (assets that no longer exist on disk).
/// </summary>
private async Task RemoveOrphanedEntriesAsync(CancellationToken token = default)
{
if (_dbConnection == null || AssetsDirectory == null)
{
return;
}
try
{
var orphanedGuids = new List<Guid>();
await using var cmd = _dbConnection.CreateCommand();
cmd.CommandText = "SELECT Guid, Path FROM Assets";
await using var reader = await cmd.ExecuteReaderAsync(token);
while (await reader.ReadAsync(token))
{
var guidStr = reader.GetString(0);
var path = reader.GetString(1);
if (Guid.TryParse(guidStr, out var guid))
{
// Check if file exists
var fullPath = Path.Combine(AssetsDirectory.FullName, path);
if (!File.Exists(fullPath))
{
orphanedGuids.Add(guid);
}
}
}
// Remove orphaned entries
foreach (var guid in orphanedGuids)
{
await RemoveAssetFromDatabaseAsync(guid, token);
}
}
catch
{
// Silently fail - cleanup is best effort
}
}
}

View File

@@ -1,524 +0,0 @@
using Ghost.Core;
using Ghost.Editor.Core.Contracts;
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Channels;
namespace Ghost.Editor.Core.AssetHandle;
/// <summary>
/// Command types for asset database operations.
/// </summary>
internal enum AssetCommandType
{
FileCreated,
FileModified,
FileDeleted,
FileRenamed,
ManualRefresh
}
/// <summary>
/// Represents a command to process an asset operation.
/// </summary>
internal readonly record struct AssetCommand(
AssetCommandType Type,
string Path,
string? OldPath = null,
DateTime Timestamp = default
);
/// <summary>
/// Centralized asset database that manages all assets in the project.
/// Handles asset registration, lookup, importing, and dependency management.
/// Uses SQLite for persistent storage and efficient querying.
/// </summary>
public partial class AssetService : IAssetService
{
private FileSystemWatcher? _watcher;
private readonly Lock _dbLock = new();
private readonly Dictionary<Guid, string> _assetPathLookup = new();
private readonly Dictionary<string, Guid> _pathAssetLookup = new();
// In-memory dirty asset tracking (for runtime modifications only)
// TODO: We do not handle the reimporting of dirty assets yet
private readonly HashSet<Guid> _dirtyAssets = new();
// Command buffer pattern - Channel for file system event commands
private Channel<AssetCommand>? _commandChannel;
private Timer? _commandProcessorTimer;
private readonly ConcurrentQueue<AssetCommand> _waitingCommands = new(); // Commands waiting for manual refresh
private bool _autoRefreshEnabled = true;
// Initialization guard
private readonly Lock _initializationLock = new();
private bool _initialized = false;
private readonly TimeSpan _debounceDelay = TimeSpan.FromMilliseconds(100);
private readonly ManualResetEventSlim _resetEventSlim = new(false);
private readonly JsonSerializerOptions _defaultJsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)
}
};
public DirectoryInfo? AssetsDirectory
{
get;
private set;
}
/// <summary>
/// Init the asset database.
/// Must be called after project is loaded.
/// </summary>
internal async Task Init(CancellationToken token = default)
{
lock (_initializationLock)
{
if (_initialized)
{
return;
}
_initialized = true;
}
AssetsDirectory = new DirectoryInfo(Path.Combine(EditorApplication.CurrentProjectPath, EditorApplication.ASSETS_FOLDER_NAME));
_commandChannel = Channel.CreateUnbounded<AssetCommand>(new UnboundedChannelOptions
{
SingleReader = false,
SingleWriter = false
});
// Init command processor timer (starts disabled, triggered by events)
_commandProcessorTimer = new Timer(ProcessPendingCommands, null, Timeout.Infinite, Timeout.Infinite);
await InitializeDatabaseAsync(token);
await LoadAssetCacheFromDatabaseAsync(token);
_watcher = new FileSystemWatcher
{
Path = AssetsDirectory.FullName,
IncludeSubdirectories = true,
EnableRaisingEvents = true,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite
};
InitializeAssetHandle();
InitializeMetaData();
// TODO: Timestamp fake instead of full scan.
await ValidateAndFixDatabaseAsync(token);
}
/// <summary>
/// Validate the asset database and fix any inconsistencies.
/// Checks for missing/corrupted assets and regenerates metadata as needed.
/// </summary>
private async Task<Result> ValidateAndFixDatabaseAsync(CancellationToken token = default)
{
if (AssetsDirectory == null)
{
return Result.Failure("AssetsDirectory not initialized");
}
try
{
// Scan all files in assets directory
var allFiles = Directory.EnumerateFiles(AssetsDirectory.FullName, "*.*", SearchOption.AllDirectories)
.Where(f => !f.EndsWith(Utilities.FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase));
// Ensure all files have metadata
foreach (var file in allFiles)
{
var metaPath = file + Utilities.FileExtensions.META_FILE_EXTENSION;
if (!File.Exists(metaPath))
{
await GenerateMetaFileAsync(file, token);
}
else
{
// Validate and update database
var metaResult = await ReadMetaFileAsync(file, token);
if (metaResult.IsSuccess)
{
var fileHash = await CalculateFileHashAsync(file, token);
await UpsertAssetAsync(file, metaResult.Value, fileHash, null, token);
}
else
{
// Corrupted meta file - regenerate
await GenerateMetaFileAsync(file, token);
}
}
}
// Remove orphaned entries from database (files that no longer exist)
await RemoveOrphanedEntriesAsync(token);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to validate database: {ex.Message}");
}
}
/// <summary>
/// Refresh the asset database manually.
/// Scans the project directory for changes and processes any queued file system events.
/// </summary>
public async Task<Result> RefreshAsync(CancellationToken token = default)
{
// Flush waiting commands to channel
while (_waitingCommands.TryDequeue(out var cmd))
{
_commandChannel?.Writer.TryWrite(cmd);
}
_resetEventSlim.Reset();
_commandChannel?.Writer.TryWrite(new AssetCommand(AssetCommandType.ManualRefresh, string.Empty));
_commandProcessorTimer?.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan);
await Task.Run(_resetEventSlim.Wait, token);
return Result.Success();
}
/// <summary>
/// Mark an asset as dirty (modified in memory but not yet saved).
/// This state is NOT persisted and will be lost on application restart.
/// </summary>
public void MarkDirty(Guid assetGuid)
{
lock (_dbLock)
{
_dirtyAssets.Add(assetGuid);
}
}
/// <summary>
/// Check if an asset is marked as dirty.
/// </summary>
public bool IsDirty(Guid assetGuid)
{
lock (_dbLock)
{
return _dirtyAssets.Contains(assetGuid);
}
}
/// <summary>
/// Get all dirty assets.
/// </summary>
public Guid[] GetDirtyAssets()
{
lock (_dbLock)
{
return _dirtyAssets.ToArray();
}
}
/// <summary>
/// Clear dirty flag for an asset (typically after saving).
/// </summary>
public void ClearDirty(Guid assetGuid)
{
lock (_dbLock)
{
_dirtyAssets.Remove(assetGuid);
}
}
/// <summary>
/// Clear all dirty flags.
/// </summary>
public void ClearAllDirty()
{
lock (_dbLock)
{
_dirtyAssets.Clear();
}
}
/// <summary>
/// Enable or disable automatic asset database refresh.
/// When disabled, file system events are queued and processed only when RefreshAsync() is called.
/// </summary>
public void SetAutoRefresh(bool enabled)
{
_autoRefreshEnabled = enabled;
}
internal void FlushPendingCommands()
{
// Stop timer temporarily
_commandProcessorTimer?.Change(Timeout.Infinite, Timeout.Infinite);
// Give a tiny bit of time for any in-flight file watcher events to post to channel
Thread.Sleep(50);
// Process all commands now
ProcessPendingCommands(null);
}
private async ValueTask PostCommandAsync(AssetCommand command, CancellationToken token = default)
{
if (_commandChannel == null)
{
return;
}
if (_autoRefreshEnabled)
{
await _commandChannel.Writer.WriteAsync(command, token);
_commandProcessorTimer?.Change(_debounceDelay, Timeout.InfiniteTimeSpan);
}
else
{
_waitingCommands.Enqueue(command);
}
}
private async void ProcessPendingCommands(object? state)
{
if (_commandChannel == null)
{
return;
}
try
{
// // Collect all pending commands
// var commands = new List<AssetCommand>();
//
// while (_commandChannel.Reader.TryRead(out var cmd))
// {
// 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;
// }
// NOTE: We handle the temp file filtering in each command handler now
// We should able to remove this allocation heavy code
// Filter out temp files (files that were created then deleted)
// lock (s_commandLock)
// {
// var pathsToProcess = commandsByPath.Keys.ToList();
// foreach (var path in pathsToProcess)
// {
// // If file was created/modified but doesn't exist anymore, skip
// if (!File.Exists(path) && commandsByPath[path].Type != AssetCommandType.FileDeleted)
// {
// commandsByPath.Remove(path);
// }
// }
//
// // Clear pending paths
// s_pendingCommandPaths.Clear();
// }
// Execute commands
// 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 _commandChannel.Reader.ReadAllAsync())
//{
// await ExecuteCommandAsync(cmd);
//}
while (_commandChannel.Reader.TryRead(out var cmd))
{
await ExecuteCommandAsync(cmd);
}
await ImportDirtyAssetsAsync();
}
catch (Exception ex)
{
Logger.LogError($"Error processing commands: {ex.Message}");
}
finally
{
_resetEventSlim.Set();
}
}
private async ValueTask ExecuteCommandAsync(AssetCommand command)
{
switch (command.Type)
{
case AssetCommandType.FileCreated:
await HandleFileCreatedAsync(command.Path);
break;
case AssetCommandType.FileModified:
await HandleFileModifiedAsync(command.Path);
break;
case AssetCommandType.FileDeleted:
await HandleFileDeletedAsync(command.Path);
break;
case AssetCommandType.FileRenamed:
if (command.OldPath != null)
{
await HandleFileRenamedAsync(command.OldPath, command.Path);
}
break;
case AssetCommandType.ManualRefresh:
await ValidateAndFixDatabaseAsync(CancellationToken.None);
break;
}
}
private async ValueTask HandleFileCreatedAsync(string path)
{
if (!File.Exists(path))
{
return;
}
await GenerateMetaFileAsync(path, CancellationToken.None);
}
private async ValueTask HandleFileModifiedAsync(string path)
{
if (!File.Exists(path))
{
return;
}
// Check if file hash changed
var metaResult = await ReadMetaFileAsync(path, CancellationToken.None);
if (metaResult.IsFailure)
{
// No .gmeta file - treat this as a new file creation
await HandleFileCreatedAsync(path);
return;
}
var newHash = await CalculateFileHashAsync(path, CancellationToken.None);
var oldHash = await GetFileHashAsync(metaResult.Value.Guid, CancellationToken.None);
if (oldHash != newHash)
{
// File changed - update database and mark as dirty
await UpsertAssetAsync(path, metaResult.Value, newHash, null, CancellationToken.None);
MarkDirty(metaResult.Value.Guid);
}
}
private async ValueTask HandleFileDeletedAsync(string path)
{
var metaFileResult = GetMetaFilePath(path);
if (metaFileResult.IsSuccess && File.Exists(metaFileResult.Value))
{
try
{
var metaResult = await ReadMetaFileAsync(path, CancellationToken.None);
if (metaResult.IsSuccess)
{
var meta = metaResult.Value;
// Remove from database
await RemoveAssetFromDatabaseAsync(meta.Guid, CancellationToken.None);
// Mark dependent assets as dirty
await MarkDependentAssetsDirtyAsync(meta.Guid);
}
File.Delete(metaFileResult.Value);
}
catch (Exception ex)
{
Logger.LogError($"Error deleting asset metadata: {ex.Message}");
}
}
}
private async ValueTask HandleFileRenamedAsync(string oldPath, string newPath)
{
var oldMetaPath = oldPath + Utilities.FileExtensions.META_FILE_EXTENSION;
var newMetaPath = newPath + Utilities.FileExtensions.META_FILE_EXTENSION;
if (File.Exists(newMetaPath))
{
// Validate and update
await GenerateMetaFileAsync(newPath, CancellationToken.None);
}
else if (File.Exists(oldMetaPath))
{
// Move meta file
File.Move(oldMetaPath, newMetaPath);
// Update database with new path and recalculated hash
var metaResult = await ReadMetaFileAsync(newPath, CancellationToken.None);
if (metaResult.IsSuccess)
{
var fileHash = await CalculateFileHashAsync(newPath, CancellationToken.None);
await UpsertAssetAsync(newPath, metaResult.Value, fileHash, null, CancellationToken.None);
}
}
else
{
// Generate new meta file
await GenerateMetaFileAsync(newPath, CancellationToken.None);
}
// Delete old meta if it still exists
if (File.Exists(oldMetaPath) && oldMetaPath != newMetaPath)
{
try
{
File.Delete(oldMetaPath);
}
catch
{
}
}
}
internal void Shutdown()
{
lock (_initializationLock)
{
if (!_initialized)
{
return;
}
_watcher?.Dispose();
_watcher = null;
_commandProcessorTimer?.Dispose();
_commandProcessorTimer = null;
_dbConnection?.Close();
_dbConnection?.Dispose();
_dbConnection = null;
_assetPathLookup.Clear();
_pathAssetLookup.Clear();
_dirtyAssets.Clear();
_waitingCommands.Clear();
_importerInstances.Clear();
_importerTypeLookup.Clear();
_initialized = false;
}
}
}

View File

@@ -1,115 +0,0 @@
# Asset Database Architecture
This document details the architectural design and data flow of the `AssetHandle` module in Ghost Editor.
## System Overview
The Asset Database acts as the bridge between the raw file system (Source Assets) and the runtime engine (Imported Assets). It maintains a consistent state using a dual-storage approach:
1. **File System**: The source of truth. Contains source files (e.g., `.png`, `.fbx`) and metadata files (`.gmeta`).
2. **SQLite Database**: An acceleration layer (cache) for fast lookups, dependency tracking, and searching.
## Data Flow
### 1. Asset Discovery & Registration
When the editor starts or a file changes:
1. **FileSystemWatcher** detects the change (Create/Delete/Modify/Rename).
2. **Event Handler** queues an `AssetCommand` (debounce mechanism prevents event storms).
3. **Command Processor** executes the command:
* **New File**: Generates a `.gmeta` file with a new GUID and default settings. Adds to SQLite.
* **Modified File**: Checks hash. If changed, marks asset as "Dirty" and updates SQLite.
* **Deleted File**: Removes from SQLite and marks dependents as "Dirty".
### 2. Import Pipeline
The import process converts source formats into engine-ready data.
**Flow:**
1. `AssetDatabase.ImportDirtyAssetsAsync()` or direct `ImportAssetAsync` is called.
2. System looks up the registered `AssetImporter` for the file extension.
3. `AssetImporter.ImportAsync` is invoked with the source path and metadata.
4. Importer reads source file and settings from metadata.
5. Importer processes data (e.g., compiles shaders, compresses textures).
6. Importer calls `AssetDatabase.SaveImportedAsset(guid, data)`.
7. Data is serialized to JSON (or binary) in the `Cache/ImportedAssets` directory as `{GUID}.asset`.
### 3. Loading Pipeline
When the engine requests an asset:
**Flow:**
1. `AssetDatabase.LoadAsset<T>(guid)` is called.
2. **Memory Cache Check**:
* Checks `s_assetCache` (ConcurrentDictionary).
* If found: Updates LRU timestamp and returns object.
* If not found: Proceeds to disk load.
3. **Disk Load**:
* Locates `{GUID}.asset` in `Cache/ImportedAssets`.
* Deserializes the data into the target runtime type (e.g., `TextureAsset`).
4. **Cache Update**:
* Adds new object to `s_assetCache`.
* If cache size > `MAX_CACHED_ASSETS` (1000), evicts oldest 20% based on access time.
## Key Components Diagram
```mermaid
graph TD
User[Editor / User] -->|File Ops| API[AssetDatabase API]
FS[File System] -->|Events| Watcher[FileSystemWatcher]
subgraph AssetDatabase
API --> DB[SQLite Database]
API --> Meta[Meta Handler]
API --> Loader[Asset Loader]
API --> Importer[Import System]
Watcher -->|Queue| Cmd[Command Processor]
Cmd --> Meta
Cmd --> DB
Importer -->|Read| FS
Importer -->|Write| Cache[Imported Assets Cache]
Loader -->|Read| Cache
Loader -->|Check| MemCache[Memory LRU Cache]
end
Meta -->|Read/Write| FS
DB -->|Index| FS
```
## Database Schema (SQLite)
The `AssetDatabase.db` contains a single `Assets` table:
| Column | Type | Description |
|--------|------|-------------|
| **Guid** | TEXT (PK) | The unique identifier of the asset. |
| **Path** | TEXT | Relative path from `Assets/` folder. Indexed for fast lookup. |
| **Version** | INTEGER | Importer version for migration support. |
| **Tags** | TEXT | JSON array of string tags. |
| **FileHash** | TEXT | SHA256 hash of the source file content. |
| **DependencyGuids** | TEXT | JSON array of GUIDs this asset depends on. |
| **LastModified** | INTEGER | Unix timestamp of last modification. |
## Detailed Subsystems
### Metadata System (`.gmeta`)
* **Format**: JSON.
* **Content**: GUID, Version, Tags, ImporterSettings (per importer type).
* **Strategy**: The `.gmeta` file is the *only* place the persistent GUID lives. If the database is corrupted, it can be rebuilt entirely by scanning the file system and reading `.gmeta` files.
### Threading & Safety
* **Locks**:
* `s_dbLock`: Protects in-memory dictionaries (`s_assetPathLookup`) and dirty tracking.
* `s_commandLock`: Protects the command queue for file events.
* **Async**: Heavy I/O operations (DB access, File I/O) are async.
* **Channels**: Uses `System.Threading.Channels` to decouple high-frequency file system events from database processing.
### Importer Registry
* Uses `TypeCache` and reflection to find classes with `[AssetImporter]`.
* Mappings are stored in `s_importerTypeLookup` (Extension -> Type).
* Importers are stateless (instantiated on demand or cached as singletons depending on implementation, currently cached in `s_importerInstances`).
## Future Improvements / Known Limitations
1. **Binary Formats**: Currently, imported assets are stored as JSON. For large assets (textures, models), a binary format is required for performance.
2. **Dependency Graph**: While dependencies are stored, a full graph traversal for complex invalidation (e.g., if A changes, re-import B which depends on A) is partial.
3. **Cross-Process Locking**: SQLite is file-based; concurrent access from multiple editor instances needs careful file locking mode configuration.

View File

@@ -1,131 +0,0 @@
# Asset Database Documentation
The Asset Database is a core component of the Ghost Editor responsible for managing the lifecycle, storage, import, and retrieval of project assets. It provides a unified API for interacting with assets, ensuring that metadata (GUIDs, tags, settings) stays synchronized with files on disk.
## Key Features
- **GUID-based Asset Identification**: Every asset is uniquely identified by a stable GUID, stored in a sidecar `.gmeta` file.
- **Automatic Importing**: Monitors the file system for changes and automatically imports assets using registered importers.
- **Dependency Tracking**: Tracks dependencies between assets to ensure validity and trigger re-imports when dependencies change.
- **Caching**: Implements an LRU (Least Recently Used) cache for loaded assets to optimize performance.
- **SQLite Backed**: Uses a local SQLite database for fast lookups (Path <-> GUID) and metadata queries.
- **Metadata Management**: Handles `.gmeta` files automatically, including generation, validation, and cleanup.
## usage
### Initialization
The Asset Database must be initialized after the project is loaded.
```csharp
await AssetDatabase.Initialize(cancellationToken);
```
### Loading Assets
Assets can be loaded by GUID or by Path.
```csharp
// Load by Path
var result = AssetDatabase.LoadAssetAtPath<TextureAsset>("Assets/Textures/my_texture.png");
if (result.IsSuccess)
{
var texture = result.Value;
}
// Load by GUID
var guid = ...;
var result = AssetDatabase.LoadAsset<TextureAsset>(guid);
```
### File Operations
Always use the `AssetDatabase` API for file operations to ensure metadata is preserved.
```csharp
// Create
await AssetDatabase.CreateAssetAsync("Assets/Data/config.json", dataBytes);
// Move
await AssetDatabase.MoveAssetAsync("Assets/Old/file.txt", "Assets/New/file.txt");
// Copy
await AssetDatabase.CopyAssetAsync("Assets/template.txt", "Assets/instance.txt");
// Delete
await AssetDatabase.DeleteAssetAsync("Assets/garbage.tmp");
```
### Searching
Find assets using wildcards or tags.
```csharp
// Find all PNGs
var guids = await AssetDatabase.FindAssetsByNameAsync("*.png");
// Find assets with a specific tag
var enemyAssets = await AssetDatabase.FindAssetsByTagAsync("Enemy");
```
### Tags
Manage asset tags for organization.
```csharp
// Get tags
var tagsResult = await AssetDatabase.GetAssetTagsAsync(guid);
// Set tags
await AssetDatabase.SetAssetTagsAsync(guid, new List<string> { "Level1", "Prop" });
```
### Opening Assets
Open an asset using its registered handler or the system default.
```csharp
AssetDatabase.OpenAsset("Assets/Docs/readme.txt");
```
## Extending the Asset Database
### Creating a New Importer
To support a new file type, create a class that inherits from `AssetImporter<T>` and decorate it with the `[AssetImporter]` attribute.
```csharp
[AssetImporter(".myfmt")]
internal class MyFormatImporter : AssetImporter<MyFormatSettings>
{
public override async Task<Result> ImportAsync(string assetPath, AssetMeta meta)
{
var settings = GetSettings(meta);
// 1. Read source file
// 2. Process data
// 3. Save imported data using AssetDatabase.SaveImportedAsset
var myAsset = new MyAsset(meta.Guid) { ... };
return AssetDatabase.SaveImportedAsset(meta.Guid, myAsset);
}
}
internal class MyFormatSettings : ImporterSettings
{
public float Scale { get; set; } = 1.0f;
}
```
### Creating an Open Handler
To define custom behavior when an asset is opened (e.g., double-clicked in the editor), use the `[AssetOpenHandler]` attribute.
```csharp
internal static class MyHandlers
{
[AssetOpenHandler(".myfmt")]
private static void OpenMyFormat(string path)
{
// Open custom editor window
}
}
```
## Internal Architecture
- **AssetDatabase.cs**: Core initialization and event coordination.
- **AssetDatabase.SQLite.cs**: Database table management and queries.
- **AssetDatabase.Meta.cs**: `.gmeta` file handling and file system watcher events.
- **AssetDatabase.Importer.cs**: Importer discovery and execution.
- **AssetDatabase.Loader.cs**: Asset loading and caching logic.

View File

@@ -1,83 +0,0 @@
using Ghost.Core;
using Ghost.Editor.Core.Contracts;
namespace Ghost.Editor.Core.AssetHandle;
public abstract class AssetImporter
{
/// <summary>
/// Import the asset at the specified path with the given settings.
/// </summary>
/// <param name="assetPath">Full path to the source asset file.</param>
/// <param name="meta">Metadata for the asset.</param>
/// <param name="token">Cancellation token.</param>
/// <returns>Result indicating success or failure.</returns>
public abstract ValueTask<Result> ImportAsync(string assetPath, AssetMeta meta, IAssetService assetService, CancellationToken token = default);
/// <summary>
/// Export in-memory asset data to disk.
/// Override this method to support creating assets from code.
/// </summary>
/// <typeparam name="T">Type of asset data to export.</typeparam>
/// <param name="assetPath">Full path where the asset should be saved.</param>
/// <param name="assetData">In-memory asset data to serialize.</param>
/// <param name="meta">Metadata for the asset.</param>
/// <param name="token">Cancellation token.</param>
/// <returns>Result indicating success or failure.</returns>
public virtual ValueTask<Result> ExportAsync<T>(string assetPath, T assetData, AssetMeta meta, CancellationToken token = default)
where T : class
{
return ValueTask.FromResult(Result.Failure("This importer does not support exporting assets."));
}
/// <summary>
/// Validate dependencies referenced by this asset.
/// Dependencies are extracted from asset content during import and stored in the database.
/// </summary>
/// <param name="dependencies">List of dependency GUIDs extracted from the asset.</param>
/// <param name="assetService">The asset service instance.</param>
/// <returns>Result indicating if all dependencies are valid.</returns>
protected virtual ValueTask<Result> ValidateDependenciesAsync(List<Guid> dependencies, IAssetService assetService, CancellationToken token = default)
{
foreach (var dependencyGuid in dependencies)
{
var path = assetService.GuidToPath(dependencyGuid);
if (path.IsFailure)
{
return ValueTask.FromResult(Result.Failure($"Missing dependency: {dependencyGuid}"));
}
if (!File.Exists(path.Value))
{
return ValueTask.FromResult(Result.Failure($"Dependency file does not exist: {path.Value}"));
}
}
return ValueTask.FromResult(Result.Success());
}
}
public abstract class AssetImporter<TSettings> : AssetImporter
where TSettings : ImporterSettings, new()
{
/// <summary>
/// Get the settings for this importer from the metadata.
/// Creates default settings if none exist.
/// </summary>
/// <param name="meta">Asset metadata.</param>
/// <returns>The importer settings.</returns>
protected TSettings GetSettings(AssetMeta meta)
{
var typeName = GetType().Name;
var settings = meta.GetImporterSettings<TSettings>(typeName);
if (settings != null)
{
return settings;
}
var defaultSettings = new TSettings();
meta.SetImporterSettings(typeName, defaultSettings);
return defaultSettings;
}
}

View File

@@ -1,85 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Ghost.Editor.Core.AssetHandle;
/// <summary>
/// Metadata for an asset, stored in .gmeta files.
/// Contains GUID, version, tags, and importer settings.
/// FileHash and Dependencies are stored in the database only, not in .gmeta files.
/// </summary>
public class AssetMeta
{
/// <summary>
/// Unique identifier for the asset.
/// </summary>
[JsonPropertyName("Guid")]
public Guid Guid
{
get;
set;
}
/// <summary>
/// Version of the asset pipeline (not the asset itself).
/// Used for migration when the asset pipeline is redesigned.
/// </summary>
[JsonPropertyName("Version")]
public int Version
{
get;
set;
} = 1;
/// <summary>
/// Tags for categorizing and searching assets.
/// </summary>
[JsonPropertyName("Tags")]
public List<string> Tags
{
get;
set;
} = new();
/// <summary>
/// Importer settings specific to this asset.
/// The key is the importer type name, and the value is a JSON element containing the settings.
/// Use GetImporterSettings&lt;T&gt;() and SetImporterSettings&lt;T&gt;() to work with strongly-typed settings.
/// </summary>
[JsonPropertyName("ImporterSettings")]
public Dictionary<string, JsonElement> ImporterSettings
{
get;
set;
} = new();
/// <summary>
/// Get importer settings of a specific type.
/// </summary>
public T? GetImporterSettings<T>(string importerName) where T : ImporterSettings
{
if (ImporterSettings.TryGetValue(importerName, out var element))
{
return element.Deserialize<T>();
}
return null;
}
/// <summary>
/// Set importer settings.
/// </summary>
public void SetImporterSettings<T>(string importerName, T settings) where T : ImporterSettings
{
var element = JsonSerializer.SerializeToElement(settings);
ImporterSettings[importerName] = element;
}
/// <summary>
/// Set importer settings (non-generic overload).
/// </summary>
internal void SetImporterSettings(string importerName, ImporterSettings settings)
{
var element = JsonSerializer.SerializeToElement(settings, settings.GetType());
ImporterSettings[importerName] = element;
}
}

View File

@@ -1,5 +0,0 @@
namespace Ghost.Editor.Core.AssetHandle;
public abstract class ImporterSettings
{
}

View File

@@ -1,71 +0,0 @@
using Ghost.Core;
using Ghost.Editor.Core.Contracts;
namespace Ghost.Editor.Core.AssetHandle.Importers;
/// <summary>
/// Example importer settings for text assets.
/// </summary>
internal class TextImporterSettings : ImporterSettings
{
public string Encoding
{
get;
set;
} = "UTF-8";
public bool TrimWhitespace
{
get;
set;
} = false;
}
/// <summary>
/// Example importer for text files (.txt, .md).
/// This is a simple test importer to demonstrate the asset import system.
/// </summary>
[AssetImporter(".txt", ".md")]
internal class TextImporter : AssetImporter<TextImporterSettings>
{
public override async ValueTask<Result> ImportAsync(string assetPath, AssetMeta meta, IAssetService assetService, CancellationToken token = default)
{
var settings = GetSettings(meta);
// Text files typically don't have dependencies
// If they did, you would extract them from the content here
var dependencies = new List<Guid>();
// Validate dependencies
var depResult = await ValidateDependenciesAsync(dependencies, assetService, token);
if (depResult.IsFailure)
{
return depResult;
}
try
{
// Read the file
var content = await File.ReadAllTextAsync(assetPath, token);
if (settings.TrimWhitespace)
{
content = content.Trim();
}
// TODO: Process the text content
// For example:
// - Convert to a specific format
// - Extract metadata
// - Generate assets
// - Save to output folder
// For now, just report success
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to import text asset: {ex.Message}");
}
}
}

View File

@@ -1,279 +0,0 @@
using Ghost.Core;
using Ghost.Editor.Core.Contracts;
using System.Text.Json;
namespace Ghost.Editor.Core.AssetHandle.Importers;
/// <summary>
/// Importer settings for texture assets.
/// </summary>
internal class TextureImporterSettings : ImporterSettings
{
/// <summary>
/// Whether to generate mipmaps for the texture.
/// </summary>
public bool GenerateMipmaps
{
get;
set;
} = true;
/// <summary>
/// Whether the texture uses sRGB color space.
/// </summary>
public bool SRGB
{
get;
set;
} = true;
/// <summary>
/// Maximum texture size. Images larger than this will be downscaled.
/// </summary>
public uint MaxSize
{
get;
set;
} = 2048;
/// <summary>
/// Texture compression format.
/// Options: "None", "BC1", "BC3", "BC7"
/// </summary>
public string CompressionFormat
{
get;
set;
} = "None";
/// <summary>
/// Texture filter mode.
/// Options: "Point", "Bilinear", "Trilinear"
/// </summary>
public string FilterMode
{
get;
set;
} = "Bilinear";
/// <summary>
/// Texture wrap mode.
/// Options: "Repeat", "Clamp", "Mirror"
/// </summary>
public string WrapMode
{
get;
set;
} = "Repeat";
}
/// <summary>
/// Importer for texture files (.png, .jpg, .jpeg, .dds, .tga, .bmp).
/// Processes image files and converts them into engine-ready texture assets.
/// </summary>
[AssetImporter(".png", ".jpg", ".jpeg", ".dds", ".tga", ".bmp")]
internal class TextureImporter : AssetImporter<TextureImporterSettings>
{
public override async ValueTask<Result> ImportAsync(string assetPath, AssetMeta meta, IAssetService assetService, CancellationToken token = default)
{
var settings = GetSettings(meta);
// Textures typically don't reference other assets as dependencies
//var dependencies = new List<Guid>();
//// Validate dependencies
//var depResult = await ValidateDependenciesAsync(dependencies, assetService, token);
//if (depResult.IsFailure)
//{
// return depResult;
//}
try
{
// Check if file exists
if (!File.Exists(assetPath))
{
return Result.Failure($"Source texture file not found: {assetPath}");
}
// Get image dimensions (simplified - in real implementation would use image library)
var (width, height) = GetImageDimensions(assetPath);
if (width == 0 || height == 0)
{
return Result.Failure("Failed to read image dimensions");
}
// Apply max size constraint
if (width > settings.MaxSize || height > settings.MaxSize)
{
var scale = Math.Min(settings.MaxSize / (float)width, settings.MaxSize / (float)height);
width = (uint)(width * scale);
height = (uint)(height * scale);
}
// Calculate mipmap count
uint mipLevels = 1;
if (settings.GenerateMipmaps)
{
mipLevels = CalculateMipLevels(width, height);
}
// Determine format
var format = settings.CompressionFormat == "None" ? "RGBA8" : settings.CompressionFormat;
// Create texture asset
var textureAsset = new TextureAsset(meta.Guid, Path.GetFileNameWithoutExtension(assetPath))
{
Width = width,
Height = height,
MipLevels = mipLevels,
Format = format,
IsSRGB = settings.SRGB,
SourcePath = assetPath
};
// Save the imported asset data
var saveResult = assetService.SaveImportedAsset(meta.Guid, textureAsset);
if (saveResult.IsFailure)
{
return Result.Failure($"Failed to save texture asset: {saveResult.Message}");
}
// In a real implementation, you would:
// 1. Load the image using a library like ImageSharp or StbImageSharp
// 2. Resize if needed
// 3. Generate mipmaps
// 4. Compress if needed
// 5. Save the processed texture data to the ImportedAssets folder
// 6. Update the hash in database
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to import texture: {ex.Message}");
}
}
/// <summary>
/// Get image dimensions from file.
/// Simplified implementation - in production, use an image library.
/// </summary>
private static (uint width, uint height) GetImageDimensions(string imagePath)
{
// This is a placeholder implementation
// In a real implementation, you would use a library like:
// - ImageSharp
// - StbImageSharp
// - DirectXTex (for DDS files)
var extension = Path.GetExtension(imagePath).ToLowerInvariant();
if (extension == ".dds")
{
// For DDS files, read the header
// DDS header format: https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-header
return ReadDDSHeader(imagePath);
}
else
{
// For PNG/JPG/etc, we would use an image library
// For now, return placeholder values
return (1024, 1024);
}
}
/// <summary>
/// Read DDS file header to get dimensions.
/// </summary>
private static (uint width, uint height) ReadDDSHeader(string ddsPath)
{
try
{
using var stream = File.OpenRead(ddsPath);
using var reader = new BinaryReader(stream);
// Read magic number (should be "DDS ")
var magic = reader.ReadUInt32();
if (magic != 0x20534444) // "DDS " in little-endian
{
return (0, 0);
}
// Read header size (should be 124)
var headerSize = reader.ReadUInt32();
if (headerSize != 124)
{
return (0, 0);
}
// Skip flags
reader.ReadUInt32();
// Read height and width
var height = reader.ReadUInt32();
var width = reader.ReadUInt32();
return (width, height);
}
catch
{
return (0, 0);
}
}
/// <summary>
/// Export a texture asset from memory to disk.
/// </summary>
public override async ValueTask<Result> ExportAsync<T>(string assetPath, T assetData, AssetMeta meta, CancellationToken token = default)
{
if (assetData is not TextureAsset textureAsset)
{
return Result.Failure($"Asset data is not a TextureAsset, got {typeof(T).Name}");
}
try
{
// In a real implementation, you would:
// 1. Convert the texture data to the appropriate format
// 2. Write the image file (PNG, DDS, etc.)
// 3. Save metadata
// For now, just save metadata as JSON
var json = JsonSerializer.Serialize(textureAsset, new JsonSerializerOptions
{
WriteIndented = true
});
await File.WriteAllTextAsync(assetPath, json, token);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to export texture: {ex.Message}");
}
}
/// <summary>
/// Calculate number of mipmap levels for a given texture size.
/// </summary>
private static uint CalculateMipLevels(uint width, uint height)
{
if (width == 0 || height == 0)
{
return 0;
}
uint count = 1;
while (width > 1 || height > 1)
{
width >>= 1;
height >>= 1;
count++;
}
return count;
}
}

View File

@@ -1,22 +0,0 @@
namespace Ghost.Editor.Core.AssetHandle;
/// <summary>
/// The base class for all asset types in the Ghost Editor.
/// </summary>
public abstract class Asset
{
public abstract string Name
{
get; set;
}
public Guid ID
{
get;
}
protected Asset(Guid id)
{
ID = id;
}
}

View File

@@ -1,75 +0,0 @@
namespace Ghost.Editor.Core.AssetHandle;
/// <summary>
/// Represents a texture asset.
/// </summary>
public class TextureAsset : Asset
{
public override string Name
{
get;
set;
}
/// <summary>
/// Width of the texture in pixels.
/// </summary>
public uint Width
{
get;
set;
}
/// <summary>
/// Height of the texture in pixels.
/// </summary>
public uint Height
{
get;
set;
}
/// <summary>
/// Number of mipmap levels.
/// </summary>
public uint MipLevels
{
get;
set;
}
/// <summary>
/// Texture format (e.g., "RGBA8", "BC1", "BC7").
/// </summary>
public string Format
{
get;
set;
}
/// <summary>
/// Whether the texture uses sRGB color space.
/// </summary>
public bool IsSRGB
{
get;
set;
}
/// <summary>
/// Relative path to the source image file.
/// </summary>
public string SourcePath
{
get;
set;
}
public TextureAsset(Guid id, string name) : base(id)
{
Name = name;
Format = "RGBA8";
IsSRGB = true;
SourcePath = string.Empty;
}
}

View File

@@ -1,62 +0,0 @@
using Ghost.Core;
using Ghost.Editor.Core.AssetHandle;
namespace Ghost.Editor.Core.Contracts;
public interface IAssetService
{
DirectoryInfo? AssetsDirectory { get; }
// Lifecycle
Task<Result> RefreshAsync(CancellationToken token = default);
// Dirty tracking
void MarkDirty(Guid assetGuid);
bool IsDirty(Guid assetGuid);
Guid[] GetDirtyAssets();
void ClearDirty(Guid assetGuid);
void ClearAllDirty();
void SetAutoRefresh(bool enabled);
// Path <-> GUID lookup
Result<Guid> PathToGuid(string assetPath);
Result<string> GuidToPath(Guid guid);
// Asset loading
Result<T> LoadAsset<T>(Guid guid) where T : Asset;
Result<T> LoadAssetAtPath<T>(string assetPath) where T : Asset;
void UnloadAsset(Guid guid);
void UnloadAllAssets();
bool IsAssetLoaded(Guid guid);
(int currentSize, int maxSize) GetCacheStats();
Result SaveImportedAsset<T>(Guid guid, T assetData) where T : Asset;
// Asset tags
ValueTask<Result<List<string>>> GetAssetTagsAsync(Guid guid, CancellationToken token = default);
ValueTask<Result> SetAssetTagsAsync(Guid guid, List<string> tags, CancellationToken token = default);
// Asset search
Task<List<Guid>> FindAssetsByNameAsync(string namePattern, CancellationToken token = default);
Task<List<Guid>> FindAssetsByTagAsync(string tag, CancellationToken token = default);
IReadOnlyDictionary<Guid, string> GetAllAssets();
// Asset file operations
ValueTask<Result> CreateAssetAsync(string assetPath, ReadOnlyMemory<byte> content, CancellationToken token = default);
ValueTask<Result> CreateAssetAsync(string assetPath, CancellationToken token = default);
ValueTask<Result> DeleteAssetAsync(Guid guid, CancellationToken token = default);
ValueTask<Result> DeleteAssetAsync(string assetPath, CancellationToken token = default);
ValueTask<Result> MoveAssetAsync(Guid guid, string newPath, CancellationToken token = default);
ValueTask<Result> MoveAssetAsync(string oldPath, string newPath, CancellationToken token = default);
ValueTask<Result<Guid>> CopyAssetAsync(Guid guid, string newPath, CancellationToken token = default);
ValueTask<Result<Guid>> CopyAssetAsync(string sourcePath, string destPath, CancellationToken token = default);
Result MarkDirtyAsync(Guid guid, CancellationToken token = default);
Task<Result> ImportDirtyAssetsAsync(CancellationToken token = default);
// Importer management
Type? GetImporterType(string extension);
Dictionary<string, Type> GetAllImporters();
ValueTask<Result<Guid>> ExportAssetAsync<T>(string assetPath, T assetData, CancellationToken token = default) where T : class;
// Asset opening
void OpenAsset(string path);
}

View File

@@ -1,16 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Ghost.Entities\Ghost.Entities.csproj" />
<ProjectReference Include="..\Ghost.Test.Core\Ghost.Test.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,7 +0,0 @@
using Ghost.Entities.Test;
using Ghost.Test.Core;
using Misaki.HighPerformance.LowLevel.Buffer;
AllocationManager.EnableDebugLayer();
TestRunner.Run<SerializationTest>();
AllocationManager.Dispose();

View File

@@ -1,43 +0,0 @@
<Solution>
<Configurations>
<Platform Name="ARM64" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Folder Name="/Editor/">
<Project Path="Ghost.Editor.Core/Ghost.Editor.Core.csproj" />
<Project Path="Ghost.Editor/Ghost.Editor.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
<Platform Solution="*|x86" Project="x86" />
<Deploy />
</Project>
</Folder>
<Folder Name="/Library/">
<Project Path="Ghost.FMOD/Ghost.FMOD.csproj" />
<Project Path="Ghost.Zeux.MeshOptimizer/Ghost.Zeux.MeshOptimizer.csproj" />
</Folder>
<Folder Name="/Runtime/">
<Project Path="Ghost.Core/Ghost.Core.csproj" />
<Project Path="Ghost.Engine/Ghost.Engine.csproj" />
<Project Path="Ghost.Entities/Ghost.Entities.csproj" />
<Project Path="Ghost.Generator/Ghost.Generator.csproj" />
<Project Path="Ghost.Graphics/Ghost.Graphics.csproj" />
</Folder>
<Folder Name="/Test/">
<Project Path="Ghost.Entities.Test/Ghost.Entities.Test.csproj" />
<Project Path="Ghost.Graphics.Test/Ghost.Graphics.Test.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
<Platform Solution="*|x86" Project="x86" />
<Deploy />
</Project>
<Project Path="Ghost.MicroTest/Ghost.MicroTest.csproj" Id="8c8ffa4b-e1e4-46a1-9221-7b508a109edd" />
<Project Path="Ghost.Shader.Test/Ghost.Shader.Test.csproj" />
<Project Path="Ghost.Test.Core/Ghost.Test.Core.csproj" />
<Project Path="Ghost.UnitTest/Ghost.UnitTest.csproj" Id="4da45668-456b-4dcc-acd8-6bfe154e6837">
<Platform Solution="Debug|x64" Project="x64" />
</Project>
</Folder>
<Project Path="Ghost.DSL/Ghost.DSL.csproj" />
</Solution>

View File

@@ -25,7 +25,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../Ghost.Core/Ghost.Core.csproj" /> <ProjectReference Include="../../Runtime/Ghost.Core/Ghost.Core.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,6 +1,7 @@
using Ghost.Core; using Ghost.Core;
using Ghost.Core.Graphics; using Ghost.Core.Graphics;
using Ghost.DSL.ShaderParser; using Ghost.DSL.ShaderParser;
using System.Runtime.CompilerServices;
using System.Text; using System.Text;
namespace Ghost.DSL.ShaderCompiler; namespace Ghost.DSL.ShaderCompiler;
@@ -44,28 +45,31 @@ internal static class DSLShaderCompiler
}; };
} }
private static uint CalculateCBufferSize(ReadOnlySpan<PropertyDescriptor> properties) private static int LayoutCBufferProperties(Span<PropertyDescriptor> properties)
{ {
if (properties.IsEmpty) if (properties.IsEmpty)
{ {
return 0; return 0;
} }
var currentOffset = 0u; var currentOffset = 0;
foreach (var prop in properties) foreach (ref var prop in properties)
{ {
var size = prop.type.GetSize(); var size = prop.type.GetSize();
if ((currentOffset % 16) + size > 16) if ((currentOffset % 16) + size > 16)
{ {
currentOffset = (currentOffset + 15u) & ~15u; currentOffset = (currentOffset + 15) & ~15;
} }
prop.offset = currentOffset;
prop.size = size;
currentOffset += size; currentOffset += size;
} }
return (currentOffset + 15u) & ~15u; return (currentOffset + 15) & ~15;
} }
// TODO: Implement shader inheritance resolution, including property and pass merging. // TODO: Implement shader inheritance resolution, including property and pass merging.
@@ -98,7 +102,7 @@ internal static class DSLShaderCompiler
descriptor.globalProperties = shaderGlobalProperties ?? Array.Empty<PropertyDescriptor>(); descriptor.globalProperties = shaderGlobalProperties ?? Array.Empty<PropertyDescriptor>();
descriptor.properties = shaderLocalProperties ?? Array.Empty<PropertyDescriptor>(); descriptor.properties = shaderLocalProperties ?? Array.Empty<PropertyDescriptor>();
descriptor.cbufferSize = CalculateCBufferSize(descriptor.properties); descriptor.cbufferSize = LayoutCBufferProperties(descriptor.properties);
if (semantics.passes != null) if (semantics.passes != null)
{ {
@@ -264,7 +268,7 @@ internal static class DSLShaderCompiler
#ifndef {fileDefine} #ifndef {fileDefine}
#define {fileDefine} #define {fileDefine}
#include ""F:/csharp/GhostEngine/Ghost.Graphics/Shaders/Includes/Common.hlsl"""); #include ""F:/csharp/GhostEngine/src/Runtime//Ghost.Graphics/Shaders/Includes/Common.hlsl""");
sb.Append(@" sb.Append(@"
struct PerMaterialData struct PerMaterialData
@@ -303,7 +307,7 @@ struct PerMaterialData
#ifndef GLOBALDATA_G_HLSL #ifndef GLOBALDATA_G_HLSL
#define GLOBALDATA_G_HLSL #define GLOBALDATA_G_HLSL
#include ""F:/csharp/GhostEngine/Ghost.Graphics/Shaders/Includes/Common.hlsl"" #include ""F:/csharp/GhostEngine/src/Runtime//Ghost.Graphics/Shaders/Includes/Common.hlsl""
struct GlobalData struct GlobalData
{"); {");

View File

@@ -0,0 +1,185 @@
using Ghost.Core;
using Ghost.Editor.Core.Contracts;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Serialization;
namespace Ghost.Editor.Core.AssetHandler;
public abstract class Asset
{
public Guid ID
{
get;
}
public abstract Guid TypeID
{
get;
}
public Guid[] Dependencies
{
get;
}
public IAssetSettings? Settings
{
get;
}
protected Asset(Guid id, Guid[] dependencies, IAssetSettings? settings)
{
ID = id;
Dependencies = dependencies;
Settings = settings;
}
public virtual ValueTask RefreshAsync(IAssetRegistry db, CancellationToken token = default)
{
return ValueTask.CompletedTask;
}
}
// Do not change the order of the fields in this struct, as it is used for binary serialization/deserialization.
[StructLayout(LayoutKind.Sequential, Size = SIZE)]
internal struct AssetMetadata
{
public const int CURRENT_FORMAT_VERSION = 1;
public const int SIZE = 128; // Fixed size for metadata header. We choose 128 bytes to allow future expansion without breaking compatibility.
public AssetMetadata(Guid id, Guid typeID)
{
FormatVersion = CURRENT_FORMAT_VERSION;
ID = id;
TypeID = typeID;
}
public int FormatVersion
{
get;
}
public Guid ID
{
get;
}
public Guid TypeID
{
get;
}
public int HandlerVersion
{
get; set;
}
public int DependencyCount
{
get; set;
}
public long DependenciesOffset
{
get; set;
}
public long SettingsOffset
{
get; set;
}
public long SettingsSize
{
get; set;
}
public long ContentOffset
{
get; set;
}
public long ContentSize
{
get; set;
}
public static void WriteToStream(Stream stream, scoped ref readonly AssetMetadata metadata)
{
var buffer = MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in metadata, 1));
stream.Write(buffer);
}
public static AssetMetadata ReadFromStream(Stream stream)
{
Span<byte> buffer = stackalloc byte[SIZE];
stream.ReadExactly(buffer);
return Unsafe.ReadUnaligned<AssetMetadata>(ref MemoryMarshal.GetReference(buffer));
}
}
[StructLayout(LayoutKind.Sequential, Size = SIZE)]
public readonly struct DependencyInfo
{
public const int SIZE = 16;
public Guid ID
{
get; init;
}
public readonly ReadOnlySpan<byte> AsBytes()
{
return MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in this, 1));
}
}
public readonly struct AssetReference : IEquatable<AssetReference>
{
private readonly int _value;
/// <summary>
/// The index of the asset in the dependency list.
/// </summary>
public int Index
{
get => Math.Abs(_value) - 1;
}
public static AssetReference Null => default;
public readonly bool IsInternal => _value >= 0;
public readonly bool IsExternal => _value < 0;
public bool Equals(AssetReference other)
{
return _value == other._value;
}
public override int GetHashCode()
{
return _value.GetHashCode();
}
public override bool Equals(object? obj)
{
return obj is AssetReference reference && Equals(reference);
}
public static bool operator ==(AssetReference left, AssetReference right)
{
return left.Equals(right);
}
public static bool operator !=(AssetReference left, AssetReference right)
{
return !(left == right);
}
}
public interface IAssetSettings
{
ValueTask<Result<long>> WriteToStreamAsync(Stream stream, CancellationToken token = default);
ValueTask<Result<IAssetSettings>> ReadFromStreamAsync(Stream stream, CancellationToken token = default);
}

View File

@@ -0,0 +1,66 @@
using Ghost.Core;
using Ghost.Editor.Core.Contracts;
namespace Ghost.Editor.Core.AssetHandler;
[AttributeUsage(AttributeTargets.Class)]
public sealed class CustomAssetHandlerAttribute : Attribute
{
public required string ID
{
get; init;
}
public bool AllowCaching
{
get; init;
} = true;
public required string[] SupportedExtensions
{
get; init;
}
}
public enum DependencyUpdateType
{
Add,
Remove
}
public interface IAssetExportOptions;
public interface IAssetHandler
{
ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetDatabase, CancellationToken token = default);
ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetDatabase, CancellationToken token = default);
}
public interface IImportableAssetHandler : IAssetHandler
{
ValueTask<Result> ImportAsync(Stream sourceStream, Stream targetStream, Guid id, CancellationToken token = default);
ValueTask<Result> ExportAsync(Stream assetStream, Stream targetStream, IAssetExportOptions? options, CancellationToken token = default);
}
public static class AssetHandlerExtensions
{
public static async ValueTask<Result> ImportAsync(this IImportableAssetHandler handler, string sourceFilePath, string targetFilePath, Guid id, 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);
}
public static async ValueTask<Result> ExportAsync(this IImportableAssetHandler handler, string assetFilePath, string targetFilePath, IAssetExportOptions? options, CancellationToken token = default)
{
await using var assetStream = new FileStream(assetFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
await using var targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
return await handler.ExportAsync(assetStream, targetStream, options, token);
}
public static async ValueTask<Result<Asset>> ReadAsync(this IAssetHandler handler, string assetFilePath, IAssetRegistry assetDatabase, CancellationToken token = default)
{
await using var sourceStream = new FileStream(assetFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
return await handler.LoadAsync(sourceStream, assetDatabase, token);
}
}

View File

@@ -0,0 +1,378 @@
using Ghost.Core;
using Ghost.Editor.Core.Contracts;
using Ghost.Graphics.Core;
using Ghost.Graphics.RHI;
using Misaki.HighPerformance.Image;
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Ghost.Editor.Core.AssetHandler;
public enum TextureType : uint
{
Default,
Normal,
Lightmap,
SingleChannel
}
public enum TextureShape : uint
{
Texture2D,
Texture3D,
TextureCube
}
public enum TextureSize : uint
{
Size256 = 256,
Size512 = 512,
Size1024 = 1024,
Size2048 = 2048,
Size4096 = 4096,
Size8192 = 8192
}
public enum TextureCompressionLevel : uint
{
Low,
Normal,
High
}
public enum TextureCompressionEffort : uint
{
Fastest,
Normal,
Production
}
public enum MipmapFilter : uint
{
Box,
Triangle,
Kaiser,
MitchellNetravali
}
public class TextureAsset : Asset
{
internal const string _TYPE_ID = "0906F4EB-C3F0-431B-BCEA-132C88AB0C3F";
internal static readonly Guid s_typeGuid = Guid.Parse(_TYPE_ID);
public override Guid TypeID => s_typeGuid;
public TextureAsset(Guid id, Guid[] dependencies, IAssetSettings? settings)
: base(id, dependencies, settings)
{
}
}
public class TextureAssetSettings : IAssetSettings
{
public struct BasicSettings()
{
public TextureType TextureType
{
get; set;
} = TextureType.Default;
public TextureShape TextureShape
{
get; set;
} = TextureShape.Texture2D;
public int Columns
{
get; set;
} = 1;
public int Rows
{
get; set;
} = 1;
public bool IsSRGB
{
get; set;
} = true;
}
public struct AdvancedSettings()
{
public bool StretchToPowerOfTwo
{
get; set;
} = true;
public bool VirtualTexture
{
get; set;
} = false;
public bool GenerateMipmaps
{
get; set;
} = true;
public uint MipmapLevelCount
{
get; set;
} = 0; // 0 means generate full mipmap levels.
public bool GammaCorrection
{
get; set;
} = true;
public bool PremultiplyAlpha
{
get; set;
} = false;
public MipmapFilter MipmapFilter
{
get; set;
} = MipmapFilter.Kaiser;
public TextureCompressionLevel CompressionLevel
{
get; set;
} = TextureCompressionLevel.Normal;
public TextureCompressionEffort CompressionEffort
{
get; set;
} = TextureCompressionEffort.Normal;
public bool UseBorderColor
{
get; set;
} = false;
public Color32 BorderColor
{
get; set;
} = new Color32(0, 0, 0, 0);
public bool ZeroAlphaBorder
{
get; set;
} = false;
public bool CutoutAlpha
{
get; set;
} = false;
public byte CutoutAlphaThreshold
{
get; set;
} = 127;
public bool ScaleAlphaForMipCoverage
{
get; set;
} = false;
public byte ScaleAlphaForMipCoverageThreshold
{
get; set;
} = 127;
public bool MipmapStreaming
{
get; set;
} = false;
}
public struct SamplerSettings()
{
public TextureSize MaxSize
{
get; set;
} = TextureSize.Size2048;
public TextureFilterMode FilterMode
{
get; set;
} = TextureFilterMode.Anisotropic;
public TextureAddressMode WrapMode
{
get; set;
} = TextureAddressMode.Repeat;
}
public BasicSettings Basic
{
get; set;
} = new BasicSettings();
public AdvancedSettings Advanced
{
get; set;
} = new AdvancedSettings();
public SamplerSettings Sampler
{
get; set;
} = new SamplerSettings();
public async ValueTask<Result<long>> WriteToStreamAsync(Stream stream, CancellationToken token = default)
{
var size = Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>() + Unsafe.SizeOf<SamplerSettings>();
var tempArray = ArrayPool<byte>.Shared.Rent(size);
try
{
ref byte address = ref MemoryMarshal.GetReference(tempArray);
Unsafe.WriteUnaligned(ref address, Basic);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>()), Advanced);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>()), Sampler);
await stream.WriteAsync(tempArray.AsMemory(0, size), token).ConfigureAwait(false);
return Result.Success<long>(size);
}
catch (Exception ex)
{
return Result.Failure($"Failed to write texture asset settings to stream: {ex.Message}");
}
finally
{
ArrayPool<byte>.Shared.Return(tempArray);
}
}
public async ValueTask<Result<IAssetSettings>> ReadFromStreamAsync(Stream stream, CancellationToken token = default)
{
var size = Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>() + Unsafe.SizeOf<SamplerSettings>();
var tempArray = ArrayPool<byte>.Shared.Rent(size);
try
{
ref byte address = ref MemoryMarshal.GetReference(tempArray);
await stream.ReadAsync(tempArray.AsMemory(0, size), token).ConfigureAwait(false);
var basic = Unsafe.ReadUnaligned<BasicSettings>(ref address);
var advanced = Unsafe.ReadUnaligned<AdvancedSettings>(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>()));
var sampler = Unsafe.ReadUnaligned<SamplerSettings>(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>()));
var settings = new TextureAssetSettings
{
Basic = basic,
Advanced = advanced,
Sampler = sampler
};
return Result.Success<IAssetSettings>(settings);
}
catch (Exception ex)
{
return Result.Failure($"Failed to read texture asset settings from stream: {ex.Message}");
}
finally
{
ArrayPool<byte>.Shared.Return(tempArray);
}
}
}
internal class TextureAssetHandler : IImportableAssetHandler
{
private const int _CURRENT_VERSION = 1;
public ValueTask<Result> ExportAsync(Stream assetStream, Stream targetStream, IAssetExportOptions? options, CancellationToken token = default)
{
throw new NotImplementedException();
}
public async ValueTask<Result> ImportAsync(Stream sourceStream, Stream targetStream, Guid id, CancellationToken token = default)
{
var info = ImageInfo.FromStream(sourceStream);
if (info.BitsPerChannel <= 0)
{
return Result.Failure($"Unsupported image format with {info.BitsPerChannel} bits per channel.");
}
ref byte pData = ref Unsafe.NullRef<byte>();
var imageSize = 0ul;
var isFloat = info.BitsPerChannel > 8;
if (isFloat)
{
using var image = ImageResultFloat.FromStream(sourceStream, info.ColorComponents);
pData = ref MemoryMarshal.GetReference(MemoryMarshal.AsBytes(image.AsSpan()));
imageSize = image.Size;
}
else
{
using var image = ImageResult.FromStream(sourceStream, info.ColorComponents);
pData = ref MemoryMarshal.GetReference(MemoryMarshal.AsBytes(image.AsSpan()));
imageSize = image.Size;
}
var header = new AssetMetadata(id, TextureAsset.s_typeGuid)
{
HandlerVersion = _CURRENT_VERSION,
SettingsOffset = AssetMetadata.SIZE,
};
targetStream.Seek(0, SeekOrigin.Begin);
AssetMetadata.WriteToStream(targetStream, ref header);
targetStream.Seek(header.SettingsOffset, SeekOrigin.Begin);
var settings = new TextureAssetSettings();
var sizeResult = await settings.WriteToStreamAsync(targetStream, token).ConfigureAwait(false);
if (sizeResult.IsFailure)
{
return Result.Failure($"Failed to write texture asset settings: {sizeResult.Message}");
}
header.SettingsSize = sizeResult.Value;
header.ContentOffset = header.SettingsOffset + sizeResult.Value;
header.ContentSize = (long)imageSize;
targetStream.Seek(header.ContentOffset, SeekOrigin.Begin);
var offset = 0;
var tempArray = ArrayPool<byte>.Shared.Rent((int)Math.Min(imageSize, 40960ul));
var remaining = imageSize;
try
{
while (remaining > 0)
{
var chunkSize = (int)Math.Min(remaining, (ulong)tempArray.Length);
Unsafe.CopyBlockUnaligned(ref tempArray[0], ref Unsafe.Add(ref pData, offset), (uint)chunkSize);
await targetStream.WriteAsync(tempArray.AsMemory(0, chunkSize), token).ConfigureAwait(false);
offset += chunkSize;
remaining -= (ulong)chunkSize;
}
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to write texture asset content to stream: {ex.Message}");
}
finally
{
ArrayPool<byte>.Shared.Return(tempArray);
}
}
public ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetDatabase, CancellationToken token = default)
{
throw new NotImplementedException();
}
public ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetDatabase, CancellationToken token = default)
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,49 @@
using Ghost.Core;
using Ghost.Editor.Core.AssetHandler;
namespace Ghost.Editor.Core.Contracts;
public enum AssetChangeType
{
None = 0,
Created,
Deleted,
Modified,
Renamed,
}
public sealed class AssetChangedEventArgs : EventArgs
{
public string AssetPath
{
get;
}
public string? OldAssetPath
{
get;
}
public AssetChangeType ChangeType
{
get;
}
internal AssetChangedEventArgs(string assetPath, string? oldAssetPath, AssetChangeType changeType)
{
AssetPath = assetPath;
OldAssetPath = oldAssetPath;
ChangeType = changeType;
}
}
public interface IAssetRegistry : IDisposable
{
string? GetAssetPath(Guid id);
Guid GetAssetGuid(string assetPath);
ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default);
ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default);
ValueTask<Result<Asset>> LoadAssetAsync(Guid id, CancellationToken token = default);
ValueTask<Result> SaveAssetAsync(Asset asset, CancellationToken token = default);
}

View File

@@ -21,8 +21,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Ghost.Core\Ghost.Core.csproj" /> <ProjectReference Include="..\..\Runtime\Ghost.Core\Ghost.Core.csproj" />
<ProjectReference Include="..\Ghost.Engine\Ghost.Engine.csproj" /> <ProjectReference Include="..\..\Runtime\Ghost.Engine\Ghost.Engine.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,6 @@
namespace TestProject.AssetDB;
internal partial class AssetRegistry
{
// TODO: Sqlite backend implementation
}

View File

@@ -0,0 +1,510 @@
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;
internal class PathComparer : IEqualityComparer<string>
{
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 FileSystemWatcher _watcher;
private readonly ConcurrentDictionary<string, Guid> _pathToGuid;
private readonly ConcurrentDictionary<Guid, string> _guidToPath;
private readonly ConcurrentDictionary<nint, IAssetHandler> _cachedHander;
private readonly ConcurrentDictionary<Guid, WeakReference<Asset>> _loadedAssets;
private readonly Dictionary<Guid, HashSet<Guid>> _referencerGraph;
private readonly Dictionary<Guid, HashSet<Guid>> _dependencyCache;
private readonly ConcurrentDictionary<string, bool> _ignoreFileChanges;
private readonly SemaphoreSlim _cacheSlim;
private readonly Lock _pathLock;
public event EventHandler<IAssetRegistry, AssetChangedEventArgs>? OnAssetChanged;
public AssetRegistry(string rootDirectory)
{
if (!Directory.Exists(rootDirectory))
{
throw new DirectoryNotFoundException("The specified root directory does not exist.");
}
if (!Path.IsPathFullyQualified(rootDirectory))
{
throw new InvalidOperationException("The specified root directory must be an absolute path.");
}
_rootDirectory = rootDirectory;
_watcher = new FileSystemWatcher(rootDirectory)
{
IncludeSubdirectories = true,
EnableRaisingEvents = true,
};
_pathToGuid = new ConcurrentDictionary<string, Guid>(4, 512, new PathComparer());
_guidToPath = new ConcurrentDictionary<Guid, string>(4, 512);
_cachedHander = new ConcurrentDictionary<nint, IAssetHandler>(4, 16);
_loadedAssets = new ConcurrentDictionary<Guid, WeakReference<Asset>>(4, 512);
_referencerGraph = new Dictionary<Guid, HashSet<Guid>>();
_dependencyCache = new Dictionary<Guid, HashSet<Guid>>();
_ignoreFileChanges = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
_cacheSlim = new SemaphoreSlim(1, 1);
_pathLock = new Lock();
LoadExistingAssets();
_watcher.Created += OnFileSystemOp;
_watcher.Deleted += OnFileSystemOp;
_watcher.Changed += OnFileSystemOp;
_watcher.Renamed += OnFileSystemRenameOp;
}
// TODO: DB Cache
private unsafe void LoadExistingAssets()
{
Span<byte> 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<Guid>(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<Guid> 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<Guid>(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<Guid>();
}
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 _))
{
return;
}
var relativePath = Path.GetRelativePath(_rootDirectory, e.FullPath);
var ext = Path.GetExtension(relativePath);
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)
{
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;
}
if (fireEvent)
{
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(relativePath, null, changeType));
}
}
private void OnFileSystemRenameOp(object sender, RenamedEventArgs e)
{
var ext = Path.GetExtension(e.FullPath);
if (!ext.Equals(ASSET_EXTENSION, StringComparison.Ordinal))
{
return;
}
var oldRelativePath = Path.GetRelativePath(_rootDirectory, e.OldFullPath);
var newRelativePath = Path.GetRelativePath(_rootDirectory, e.FullPath);
if (_pathToGuid.Remove(oldRelativePath, out var guid))
{
UpdatePathMapping(newRelativePath, guid);
OnAssetChanged?.Invoke(this, new AssetChangedEventArgs(newRelativePath, oldRelativePath, AssetChangeType.Renamed));
}
}
public string? GetAssetPath(Guid id)
{
lock (_pathLock)
{
if (_guidToPath.TryGetValue(id, out var path))
{
return path;
}
}
return null;
}
public Guid GetAssetGuid(string path)
{
lock (_pathLock)
{
if (_pathToGuid.TryGetValue(path, out var guid))
{
return guid;
}
}
return Guid.Empty;
}
private IAssetHandler GetAssetHandler(Type type)
{
var typeHandle = type.TypeHandle.Value;
if (_cachedHander.TryGetValue(typeHandle, out var handler))
{
return handler;
}
var obj = Activator.CreateInstance(type);
if (obj is not IAssetHandler newHandler)
{
throw new InvalidOperationException($"Type {type.FullName} is not an IAssetHandler.");
}
var attr = type.GetCustomAttribute<CustomAssetHandlerAttribute>(false);
if (attr is null || attr.AllowCaching)
{
_cachedHander[typeHandle] = newHandler;
}
return newHandler;
}
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<CustomAssetHandlerAttribute>(false);
if (attr is not null && attr.SupportedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
return GetAssetHandler(handlerType);
}
}
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<CustomAssetHandlerAttribute>(false);
if (attr is not null && new Guid(attr.ID) == typeId)
{
return GetAssetHandler(handlerType);
}
}
return null;
}
public async ValueTask<Result<Guid>> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default)
{
if (!File.Exists(sourceFilePath))
{
return Result.Failure("Source file not found.");
}
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 guid = Guid.NewGuid();
var fullTargetPath = Path.GetFullPath(targetAssetPath, _rootDirectory);
if (!await importableHandler.ImportAsync(sourceFilePath, fullTargetPath, guid, token: token))
{
return Result.Failure("Asset import failed.");
}
UpdatePathMapping(targetAssetPath, guid);
return guid;
}
public async ValueTask<Result> ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default)
{
var assetPath = GetAssetPath(assetId);
if (string.IsNullOrEmpty(assetPath))
{
return Result.Failure("Asset not found in DB");
}
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);
}
return Result.Success();
}
public async ValueTask<Result<Asset>> 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))
{
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;
}
try
{
var path = GetAssetPath(id);
if (string.IsNullOrEmpty(path))
{
return null;
}
var assetPath = Path.GetFullPath(path, _rootDirectory);
await using var fs = new FileStream(assetPath, FileMode.Open, FileAccess.Read, FileShare.Read);
int sizeofGuid;
unsafe
{
sizeofGuid = sizeof(Guid);
}
Span<byte> typeIdBuffer = stackalloc byte[sizeofGuid];
fs.Seek(sizeof(int) + sizeofGuid, SeekOrigin.Begin);
fs.ReadExactly(typeIdBuffer);
var guid = Unsafe.ReadUnaligned<Guid>(ref MemoryMarshal.GetReference(typeIdBuffer));
var handler = GetAssetHandlerForTypeId(guid);
if (handler == null)
{
return null;
}
var assetResult = await handler.LoadAsync(fs, this, token);
if (assetResult.IsFailure)
{
return assetResult;
}
var asset = assetResult.Value;
_loadedAssets.AddOrUpdate(id, new WeakReference<Asset>(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;
}
finally
{
_cacheSlim.Release();
}
}
public async ValueTask<Result> 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 void Dispose()
{
_cacheSlim.Dispose();
_watcher.Dispose();
}
}

View File

@@ -0,0 +1,53 @@
using Ghost.Editor.Core.AssetHandler;
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Ghost.Editor.Core.Utilities;
public static class AssetHandlerUtility
{
public static async ValueTask SerializeAssetAsync<TSetting>(Stream stream, Guid id, Guid typeID, int handlerVersion, ReadOnlyMemory<Guid> dependencies, IAssetSettings? settings, ReadOnlyMemory<byte> contents, CancellationToken token = default)
where TSetting : IAssetSettings
{
var header = new AssetMetadata(id, TextureAsset.s_typeGuid)
{
HandlerVersion = handlerVersion,
DependenciesOffset = AssetMetadata.SIZE,
DependencyCount = dependencies.Length,
};
var tempArray = ArrayPool<byte>.Shared.Rent(4096);
if (dependencies.Length > 0)
{
stream.Seek(header.DependenciesOffset, SeekOrigin.Begin);
for (var i = 0; i < dependencies.Length; i++)
{
Unsafe.WriteUnaligned(ref MemoryMarshal.GetReference(tempArray.AsSpan(0, 16)), dependencies.Span[i]);
await stream.WriteAsync(tempArray.AsMemory(0, 16), token);
}
}
header.SettingsOffset = stream.Position;
// TODO: We can use source generator to generate optimized serializer for settings.
// For now, we just use reflection for simplicity.
if (settings is not null)
{
var properties = typeof(TSetting).GetProperties();
if (properties.Length > 0)
{
using var bw = new BinaryWriter(stream);
for (var i = 0; (i < properties.Length); i++)
{
var property = properties[i];
var value = property.GetValue(settings);
}
}
}
}
}

View File

Before

Width:  |  Height:  |  Size: 453 B

After

Width:  |  Height:  |  Size: 453 B

View File

Before

Width:  |  Height:  |  Size: 869 B

After

Width:  |  Height:  |  Size: 869 B

View File

Before

Width:  |  Height:  |  Size: 465 B

After

Width:  |  Height:  |  Size: 465 B

View File

Before

Width:  |  Height:  |  Size: 884 B

After

Width:  |  Height:  |  Size: 884 B

View File

Before

Width:  |  Height:  |  Size: 727 B

After

Width:  |  Height:  |  Size: 727 B

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Some files were not shown because too many files have changed in this diff Show More