diff --git a/.gitignore b/.gitignore index 52d9dd1..f2193b2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ *.user *.userosscache *.sln.docstates +AGENTS.md # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 8abdc53..0000000 --- a/AGENTS.md +++ /dev/null @@ -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(); // Run specific test -TestRunner.Run(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 _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 GetValue() -{ - if (success) - return Result.Success(value); - else - return Result.Failure("Error message"); -} - -public Result 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 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)stackalloc Entity[1]; - -// Using allocation scope -using var scope = AllocationManager.CreateStackScope(); -var batchDestroy = new UnsafeList(entities.Length, scope.AllocationHandle); - -// Ref returns for zero-copy access -public ref T GetSingleton() where T : unmanaged, IComponent -{ - var ptr = GetSingleton(ComponentTypeID.Value); - return ref *(T*)ptr; -} -``` - -### Type Safety Patterns - -**Strongly-typed identifiers**: -```csharp -Identifier componentID; -Identifier archetypeID; -Handle resourceHandle; -``` - -**Generic constraints**: -```csharp -public void Method() where T : unmanaged, IComponent -public void Method() 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 -/// -/// Create an entity with specified components. -/// -/// A set of component space IDs to add to the entities. -/// The created entity. -/// -/// This method causes structural changes and is not thread-safe. -/// Use to defer changes. -/// -public Entity CreateEntity(ComponentSet set) { } -``` - -### Common Patterns - -**ECS Component Registration**: -```csharp -// Type-safe component ID -ComponentTypeID.Value - -// Component sets for archetypes -var set = new ComponentSet(ComponentTypeID.Value, ComponentTypeID.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 diff --git a/Ghost.Editor.Core/AssetHandle/AssetDBPlan.md b/Ghost.Editor.Core/AssetHandle/AssetDBPlan.md deleted file mode 100644 index 0b0a1ad..0000000 --- a/Ghost.Editor.Core/AssetHandle/AssetDBPlan.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.FileOps.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.FileOps.cs deleted file mode 100644 index b95a65a..0000000 --- a/Ghost.Editor.Core/AssetHandle/AssetDatabase.FileOps.cs +++ /dev/null @@ -1,355 +0,0 @@ -using Ghost.Core; - -namespace Ghost.Editor.Core.AssetHandle; - -public partial class AssetService -{ - /// - /// Create a new asset at the specified path. - /// Generates metadata and adds it to the database. - /// - /// Path to create the asset at. - /// Content to write to the asset file. - /// Result indicating success or failure. - public async ValueTask CreateAssetAsync(string assetPath, ReadOnlyMemory 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}"); - } - } - - /// - /// Create an empty asset at the specified path. - /// Generates metadata and adds it to the database. - /// - /// Path to create the asset at. - /// Result indicating success or failure. - public ValueTask CreateAssetAsync(string assetPath, CancellationToken token = default) - { - return CreateAssetAsync(assetPath, ReadOnlyMemory.Empty, token); - } - - /// - /// Delete an asset and its metadata. - /// - /// GUID of the asset to delete. - /// Result indicating success or failure. - public async ValueTask 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}"); - } - } - - /// - /// Delete an asset and its metadata by path. - /// - /// Path to the asset to delete. - /// Result indicating success or failure. - public ValueTask DeleteAssetAsync(string assetPath, CancellationToken token = default) - { - var guidResult = PathToGuid(assetPath); - if (guidResult.IsFailure) - { - return new ValueTask(Task.FromResult(Result.Failure(guidResult.Message))); - } - - return DeleteAssetAsync(guidResult.Value, token); - } - - /// - /// Move an asset to a new location. - /// - /// GUID of the asset to move. - /// New path for the asset (relative or absolute). - /// Result indicating success or failure. - public async ValueTask 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}"); - } - } - - /// - /// Move an asset to a new location by path. - /// - /// CurrentApplication path of the asset. - /// New path for the asset (relative or absolute). - /// Result indicating success or failure. - public ValueTask 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); - } - - /// - /// Copy an asset to a new location with a new GUID. - /// - /// GUID of the asset to copy. - /// New path for the copied asset (relative or absolute). - /// Result containing the new asset's GUID. - public async ValueTask> CopyAssetAsync(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); - } - - 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.Failure(newGuidResult.Message); - } - - return newGuidResult.Value; - } - catch (Exception ex) - { - return Result.Failure($"Failed to copy asset: {ex.Message}"); - } - } - - /// - /// Copy an asset to a new location by path. - /// - /// Path of the asset to copy. - /// New path for the copied asset (relative or absolute). - /// Result containing the new asset's GUID. - public ValueTask> CopyAssetAsync(string sourcePath, string destPath, CancellationToken token = default) - { - var guidResult = PathToGuid(sourcePath); - if (guidResult.IsFailure) - { - return new ValueTask>(Task.FromResult(Result.Failure(guidResult.Message))); - } - - return CopyAssetAsync(guidResult.Value, destPath, token); - } - - /// - /// Mark an asset as dirty for re-importing (in-memory only). - /// - /// GUID of the asset to mark dirty. - /// Result indicating success or failure. - public Result MarkDirtyAsync(Guid guid, CancellationToken token = default) - { - MarkDirty(guid); - return Result.Success(); - } - - /// - /// Import all dirty assets. - /// - /// Result indicating success or failure. - public async Task 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(); - } -} diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Importer.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.Importer.cs deleted file mode 100644 index 4af1208..0000000 --- a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Importer.cs +++ /dev/null @@ -1,122 +0,0 @@ -using Ghost.Core; -using System.Reflection; - -namespace Ghost.Editor.Core.AssetHandle; - -public partial class AssetService -{ - private readonly Dictionary _importerInstances = new(); - - /// - /// Import an asset at the specified path. - /// - /// Full path to the asset file. - /// Result indicating success or failure. - private async ValueTask 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); - } - - /// - /// Get the importer type for a specific file extension. - /// - /// File extension (e.g., ".png"). - /// The importer type if found, otherwise null. - public Type? GetImporterType(string extension) - { - _importerTypeLookup.TryGetValue(extension, out var importerType); - return importerType; - } - - /// - /// Get all registered importer types and their supported extensions. - /// - /// Dictionary mapping extensions to importer types. - public Dictionary GetAllImporters() - { - return new Dictionary(_importerTypeLookup); - } - - /// - /// Export in-memory asset data to disk. - /// The importer will serialize the data into a format it can later import. - /// - /// Type of asset data to export. - /// Full path where the asset should be saved. - /// In-memory asset data to export. - /// Result with the GUID of the exported asset. - public async ValueTask> ExportAssetAsync(string assetPath, T assetData, CancellationToken token = default) - where T : class - { - var extension = Path.GetExtension(assetPath); - - if (!_importerTypeLookup.TryGetValue(extension, out var importerType)) - { - return Result.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.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.Failure($"Failed to generate metadata: {result.Message}"); - } - - var metaResult = await ReadMetaFileAsync(assetPath, token); - if (metaResult.IsFailure) - { - return Result.Failure($"Failed to read metadata: {metaResult.Message}"); - } - - result = await importerInstance.ExportAsync(assetPath, assetData, metaResult.Value, token); - if (result.IsFailure) - { - return Result.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; - } -} diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Loader.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.Loader.cs deleted file mode 100644 index 30f9280..0000000 --- a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Loader.cs +++ /dev/null @@ -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 _assetCache = new(); - - // LRU tracking - stores access time for each cached asset - private readonly ConcurrentDictionary _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 GetImportedAssetsDirectory() - { - if (AssetsDirectory == null) - { - return Result.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 GetImportedAssetPath(Guid guid) - { - var importedDirResult = GetImportedAssetsDirectory(); - if (importedDirResult.IsFailure) - { - return Result.Failure(importedDirResult.Message); - } - - // Store imported assets as {GUID}.asset - var assetDataPath = Path.Combine(importedDirResult.Value, $"{guid}.asset"); - return assetDataPath; - } - - private Result LoadAssetInternal(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.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.Failure(assetPathResult.Message); - } - - var assetDataPath = assetPathResult.Value; - if (!File.Exists(assetDataPath)) - { - return Result.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(json); - if (asset == null) - { - return Result.Failure("Failed to deserialize asset data"); - } - - // Add to cache - CacheAsset(guid, asset); - return asset; - } - catch (Exception ex) - { - return Result.Failure($"Failed to load asset: {ex.Message}"); - } - } - - public Result LoadAssetAtPath(string assetPath) where T : Asset - { - var guidResult = PathToGuid(assetPath); - if (guidResult.IsFailure) - { - return Result.Failure(guidResult.Message); - } - - return LoadAsset(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 _); - } - } - - /// - /// Unload a specific asset from cache. - /// - /// GUID of the asset to unload. - public void UnloadAsset(Guid guid) - { - _assetCache.TryRemove(guid, out _); - _assetAccessTime.TryRemove(guid, out _); - } - - /// - /// Unload all assets from cache. - /// - public void UnloadAllAssets() - { - _assetCache.Clear(); - _assetAccessTime.Clear(); - } - - /// - /// Check if an asset is currently loaded in cache. - /// - /// GUID of the asset. - /// True if the asset is in cache. - public bool IsAssetLoaded(Guid guid) - { - return _assetCache.ContainsKey(guid); - } - - /// - /// Get cache statistics. - /// - /// Tuple of (current cache size, max cache size). - public (int currentSize, int maxSize) GetCacheStats() - { - return (_assetCache.Count, _MAX_CACHED_ASSETS); - } - - /// - /// Save an imported asset to disk for later loading. - /// This should be called by importers after processing the source file. - /// - /// Type of asset data. - /// GUID of the asset. - /// Processed asset data to save. - /// Result indicating success or failure. - public Result SaveImportedAsset(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}"); - } - } -} diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Lookup.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.Lookup.cs deleted file mode 100644 index 1084086..0000000 --- a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Lookup.cs +++ /dev/null @@ -1,203 +0,0 @@ -using Ghost.Core; -using System.Text.Json; - -namespace Ghost.Editor.Core.AssetHandle; - -public partial class AssetService -{ - /// - /// Get the relative path from the assets directory. - /// - private Result GetRelativePath(string fullPath) - { - if (AssetsDirectory == null) - { - return Result.Failure("AssetsDirectory not initialized"); - } - - if (!fullPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase)) - { - return Result.Failure("Path is not within assets directory"); - } - - return Path.GetRelativePath(AssetsDirectory.FullName, fullPath); - } - - /// - /// Get the full path from a relative path. - /// - private Result GetFullPath(string relativePath) - { - if (AssetsDirectory == null) - { - return Result.Failure("AssetsDirectory not initialized"); - } - - return Path.Combine(AssetsDirectory.FullName, relativePath); - } - - /// - /// Find GUID by asset path. - /// - /// Full or relative path to the asset. - /// The GUID of the asset if found. - public Result 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.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.Failure("Asset not found in database"); - } - - /// - /// Find path by GUID. - /// - /// GUID of the asset. - /// The relative path to the asset if found. - public Result GuidToPath(Guid guid) - { - lock (_dbLock) - { - if (_assetPathLookup.TryGetValue(guid, out var path)) - { - return path; - } - } - - return Result.Failure("Asset GUID not found in database"); - } - - /// - /// Load asset by GUID with caching. - /// - /// Type of asset to load. - /// GUID of the asset. - /// The loaded asset. - public Result LoadAsset(Guid guid) where T : Asset - { - // Implemented in AssetService.Loader.cs - return LoadAssetInternal(guid); - } - - /// - /// Get asset tags by GUID. - /// - /// GUID of the asset. - /// List of tags associated with the asset. - public async ValueTask>> GetAssetTagsAsync(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); - } - - var metaResult = await ReadMetaFileAsync(fullPathResult.Value, token); - if (metaResult.IsFailure) - { - return Result>.Failure(metaResult.Message); - } - - return metaResult.Value.Tags; - } - - /// - /// Set asset tags by GUID. - /// - /// GUID of the asset. - /// New tags for the asset. - /// Result indicating success or failure. - public async ValueTask SetAssetTagsAsync(Guid guid, List 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); - } - - /// - /// Search assets by name pattern. - /// Supports SQL LIKE wildcards: * (any characters) and ? (single character). - /// - /// Search pattern (e.g., "*.txt", "player?", "test*"). - /// List of matching asset GUIDs. - public async Task> FindAssetsByNameAsync(string namePattern, CancellationToken token = default) - { - return await GetAssetsByNameAsync(namePattern, token); - } - - /// - /// Find assets by tag. - /// - /// Tag to search for. - /// List of asset GUIDs with the specified tag. - public async Task> FindAssetsByTagAsync(string tag, CancellationToken token = default) - { - return await GetAssetsByTagAsync(tag, token); - } - - /// - /// Get all assets in the database. - /// - /// Dictionary mapping GUIDs to relative paths. - public IReadOnlyDictionary GetAllAssets() - { - lock (_dbLock) - { - return _assetPathLookup.AsReadOnly(); - } - } -} diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Meta.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.Meta.cs deleted file mode 100644 index 14ad358..0000000 --- a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Meta.cs +++ /dev/null @@ -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 _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() != null); - foreach (var type in importerTypes) - { - var attribute = type.GetCustomAttribute()!; - foreach (var extension in attribute.SupportedExtensions) - { - _importerTypeLookup[extension] = type; - } - } - - _watcher.Created += OnFSEvent; - _watcher.Deleted += OnFSEvent; - _watcher.Changed += OnFSEvent; - _watcher.Renamed += OnAssetRenamed; - } - - private Result GetMetaFilePath(string assetPath) - { - if (Directory.Exists(assetPath)) - { - return Result.Failure("Cannot create metadata for directories"); - } - - if (Path.GetExtension(assetPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) - { - return Result.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; - } - - /// - /// Calculate SHA256 hash of a file for change detection. - /// - private async Task 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 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); - } - } - - /// - /// Read metadata from a .gmeta file. - /// - private async ValueTask> ReadMetaFileAsync(string assetPath, CancellationToken token = default) - { - var metaFileResult = GetMetaFilePath(assetPath); - if (metaFileResult.IsFailure) - { - return Result.Failure(metaFileResult.Message); - } - - if (!File.Exists(metaFileResult.Value)) - { - return Result.Failure("Metadata file does not exist"); - } - - try - { - await using var fileStream = File.OpenRead(metaFileResult.Value); - var meta = await JsonSerializer.DeserializeAsync(fileStream, _defaultJsonOptions, token); - if (meta == null) - { - return Result.Failure("Failed to deserialize metadata"); - } - - return meta; - } - catch (Exception ex) - { - return Result.Failure($"Failed to read metadata: {ex.Message}"); - } - } - - internal async ValueTask 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)); - } - - /// - /// Mark all assets that depend on the specified asset as dirty. - /// - 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); - } - } - } -} diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Open.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.Open.cs deleted file mode 100644 index b106251..0000000 --- a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Open.cs +++ /dev/null @@ -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> _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() != null && - m.GetParameters().Length == 1 && - m.GetParameters()[0].ParameterType == typeof(string)); - - foreach (var method in methods) - { - var attr = method.GetCustomAttribute()!; - var del = (Action)Delegate.CreateDelegate(typeof(Action), 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 - }); - } - } -} diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.SQLite.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.SQLite.cs deleted file mode 100644 index 19d57ca..0000000 --- a/Ghost.Editor.Core/AssetHandle/AssetDatabase.SQLite.cs +++ /dev/null @@ -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; - - /// - /// Init the SQLite database for asset caching. - /// - 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); - } - - /// - /// Add or update an asset in the database. - /// - /// Full path to the asset file. - /// Asset metadata from .gmeta file. - /// SHA256 hash of the asset file content. - /// List of GUIDs this asset depends on (extracted during import). - private async ValueTask UpsertAssetAsync(string assetPath, AssetMeta meta, string fileHash, List? 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())); - 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}"); - } - } - - /// - /// Remove an asset from the database. - /// - private async Task 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}"); - } - } - - - - /// - /// Load all assets from the database into memory cache. - /// - 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}"); - } - } - - /// - /// Get assets by tag. - /// - private async Task> GetAssetsByTagAsync(string tag, CancellationToken token = default) - { - var result = new List(); - - 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>(tagsJson); - if (tags != null && tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) - { - result.Add(guid); - } - } - } - } - catch - { - // Silently fail - } - - return result; - } - - /// - /// Get the file hash for an asset from the database. - /// - private async Task 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; - } - } - - /// - /// Get the dependencies for an asset from the database. - /// - private async Task> GetDependenciesAsync(Guid guid, CancellationToken token = default) - { - if (_dbConnection == null) - { - return new List(); - } - - 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>(json ?? "[]") ?? new List(); - } - } - catch - { - // Silently fail - } - - return new List(); - } - - /// - /// Find assets by name pattern using database query with wildcards. - /// - /// Pattern supporting * (any chars) and ? (single char). - private async Task> GetAssetsByNameAsync(string namePattern, CancellationToken token = default) - { - var results = new List(); - - 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; - } - - /// - /// Remove orphaned entries from database (assets that no longer exist on disk). - /// - private async Task RemoveOrphanedEntriesAsync(CancellationToken token = default) - { - if (_dbConnection == null || AssetsDirectory == null) - { - return; - } - - try - { - var orphanedGuids = new List(); - - 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 - } - } -} diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.cs deleted file mode 100644 index 08e2aaf..0000000 --- a/Ghost.Editor.Core/AssetHandle/AssetDatabase.cs +++ /dev/null @@ -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; - -/// -/// Command types for asset database operations. -/// -internal enum AssetCommandType -{ - FileCreated, - FileModified, - FileDeleted, - FileRenamed, - ManualRefresh -} - -/// -/// Represents a command to process an asset operation. -/// -internal readonly record struct AssetCommand( - AssetCommandType Type, - string Path, - string? OldPath = null, - DateTime Timestamp = default -); - -/// -/// 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. -/// -public partial class AssetService : IAssetService -{ - private FileSystemWatcher? _watcher; - private readonly Lock _dbLock = new(); - private readonly Dictionary _assetPathLookup = new(); - private readonly Dictionary _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 _dirtyAssets = new(); - - // Command buffer pattern - Channel for file system event commands - private Channel? _commandChannel; - private Timer? _commandProcessorTimer; - private readonly ConcurrentQueue _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; - } - - /// - /// Init the asset database. - /// Must be called after project is loaded. - /// - 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(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); - } - - /// - /// Validate the asset database and fix any inconsistencies. - /// Checks for missing/corrupted assets and regenerates metadata as needed. - /// - private async Task 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}"); - } - } - - /// - /// Refresh the asset database manually. - /// Scans the project directory for changes and processes any queued file system events. - /// - public async Task 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(); - } - - /// - /// Mark an asset as dirty (modified in memory but not yet saved). - /// This state is NOT persisted and will be lost on application restart. - /// - public void MarkDirty(Guid assetGuid) - { - lock (_dbLock) - { - _dirtyAssets.Add(assetGuid); - } - } - - /// - /// Check if an asset is marked as dirty. - /// - public bool IsDirty(Guid assetGuid) - { - lock (_dbLock) - { - return _dirtyAssets.Contains(assetGuid); - } - } - - /// - /// Get all dirty assets. - /// - public Guid[] GetDirtyAssets() - { - lock (_dbLock) - { - return _dirtyAssets.ToArray(); - } - } - - /// - /// Clear dirty flag for an asset (typically after saving). - /// - public void ClearDirty(Guid assetGuid) - { - lock (_dbLock) - { - _dirtyAssets.Remove(assetGuid); - } - } - - /// - /// Clear all dirty flags. - /// - public void ClearAllDirty() - { - lock (_dbLock) - { - _dirtyAssets.Clear(); - } - } - - /// - /// Enable or disable automatic asset database refresh. - /// When disabled, file system events are queued and processed only when RefreshAsync() is called. - /// - 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(); - // - // while (_commandChannel.Reader.TryRead(out var cmd)) - // { - // commands.Add(cmd); - // } - - // // Group commands by path (last command wins) - // var commandsByPath = new Dictionary(); - // 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; - } - } -} diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase_Architecture.md b/Ghost.Editor.Core/AssetHandle/AssetDatabase_Architecture.md deleted file mode 100644 index 93d7710..0000000 --- a/Ghost.Editor.Core/AssetHandle/AssetDatabase_Architecture.md +++ /dev/null @@ -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(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. diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase_Documentation.md b/Ghost.Editor.Core/AssetHandle/AssetDatabase_Documentation.md deleted file mode 100644 index 5e759c1..0000000 --- a/Ghost.Editor.Core/AssetHandle/AssetDatabase_Documentation.md +++ /dev/null @@ -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("Assets/Textures/my_texture.png"); -if (result.IsSuccess) -{ - var texture = result.Value; -} - -// Load by GUID -var guid = ...; -var result = AssetDatabase.LoadAsset(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 { "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` and decorate it with the `[AssetImporter]` attribute. - -```csharp -[AssetImporter(".myfmt")] -internal class MyFormatImporter : AssetImporter -{ - public override async Task 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. diff --git a/Ghost.Editor.Core/AssetHandle/AssetImporter.cs b/Ghost.Editor.Core/AssetHandle/AssetImporter.cs deleted file mode 100644 index 90954b2..0000000 --- a/Ghost.Editor.Core/AssetHandle/AssetImporter.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Ghost.Core; -using Ghost.Editor.Core.Contracts; - -namespace Ghost.Editor.Core.AssetHandle; - -public abstract class AssetImporter -{ - /// - /// Import the asset at the specified path with the given settings. - /// - /// Full path to the source asset file. - /// Metadata for the asset. - /// Cancellation token. - /// Result indicating success or failure. - public abstract ValueTask ImportAsync(string assetPath, AssetMeta meta, IAssetService assetService, CancellationToken token = default); - - /// - /// Export in-memory asset data to disk. - /// Override this method to support creating assets from code. - /// - /// Type of asset data to export. - /// Full path where the asset should be saved. - /// In-memory asset data to serialize. - /// Metadata for the asset. - /// Cancellation token. - /// Result indicating success or failure. - public virtual ValueTask ExportAsync(string assetPath, T assetData, AssetMeta meta, CancellationToken token = default) - where T : class - { - return ValueTask.FromResult(Result.Failure("This importer does not support exporting assets.")); - } - - /// - /// Validate dependencies referenced by this asset. - /// Dependencies are extracted from asset content during import and stored in the database. - /// - /// List of dependency GUIDs extracted from the asset. - /// The asset service instance. - /// Result indicating if all dependencies are valid. - protected virtual ValueTask ValidateDependenciesAsync(List 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 : AssetImporter - where TSettings : ImporterSettings, new() -{ - /// - /// Get the settings for this importer from the metadata. - /// Creates default settings if none exist. - /// - /// Asset metadata. - /// The importer settings. - protected TSettings GetSettings(AssetMeta meta) - { - var typeName = GetType().Name; - var settings = meta.GetImporterSettings(typeName); - - if (settings != null) - { - return settings; - } - - var defaultSettings = new TSettings(); - meta.SetImporterSettings(typeName, defaultSettings); - return defaultSettings; - } -} diff --git a/Ghost.Editor.Core/AssetHandle/AssetMeta.cs b/Ghost.Editor.Core/AssetHandle/AssetMeta.cs deleted file mode 100644 index eb49a2c..0000000 --- a/Ghost.Editor.Core/AssetHandle/AssetMeta.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Ghost.Editor.Core.AssetHandle; - -/// -/// 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. -/// -public class AssetMeta -{ - /// - /// Unique identifier for the asset. - /// - [JsonPropertyName("Guid")] - public Guid Guid - { - get; - set; - } - - /// - /// Version of the asset pipeline (not the asset itself). - /// Used for migration when the asset pipeline is redesigned. - /// - [JsonPropertyName("Version")] - public int Version - { - get; - set; - } = 1; - - /// - /// Tags for categorizing and searching assets. - /// - [JsonPropertyName("Tags")] - public List Tags - { - get; - set; - } = new(); - - /// - /// 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<T>() and SetImporterSettings<T>() to work with strongly-typed settings. - /// - [JsonPropertyName("ImporterSettings")] - public Dictionary ImporterSettings - { - get; - set; - } = new(); - - /// - /// Get importer settings of a specific type. - /// - public T? GetImporterSettings(string importerName) where T : ImporterSettings - { - if (ImporterSettings.TryGetValue(importerName, out var element)) - { - return element.Deserialize(); - } - return null; - } - - /// - /// Set importer settings. - /// - public void SetImporterSettings(string importerName, T settings) where T : ImporterSettings - { - var element = JsonSerializer.SerializeToElement(settings); - ImporterSettings[importerName] = element; - } - - /// - /// Set importer settings (non-generic overload). - /// - internal void SetImporterSettings(string importerName, ImporterSettings settings) - { - var element = JsonSerializer.SerializeToElement(settings, settings.GetType()); - ImporterSettings[importerName] = element; - } -} diff --git a/Ghost.Editor.Core/AssetHandle/ImporterSettings.cs b/Ghost.Editor.Core/AssetHandle/ImporterSettings.cs deleted file mode 100644 index dbfbca1..0000000 --- a/Ghost.Editor.Core/AssetHandle/ImporterSettings.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Ghost.Editor.Core.AssetHandle; - -public abstract class ImporterSettings -{ -} diff --git a/Ghost.Editor.Core/AssetHandle/Importers/TextImporter.cs b/Ghost.Editor.Core/AssetHandle/Importers/TextImporter.cs deleted file mode 100644 index 13404ad..0000000 --- a/Ghost.Editor.Core/AssetHandle/Importers/TextImporter.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Ghost.Core; -using Ghost.Editor.Core.Contracts; - -namespace Ghost.Editor.Core.AssetHandle.Importers; - -/// -/// Example importer settings for text assets. -/// -internal class TextImporterSettings : ImporterSettings -{ - public string Encoding - { - get; - set; - } = "UTF-8"; - - public bool TrimWhitespace - { - get; - set; - } = false; -} - -/// -/// Example importer for text files (.txt, .md). -/// This is a simple test importer to demonstrate the asset import system. -/// -[AssetImporter(".txt", ".md")] -internal class TextImporter : AssetImporter -{ - public override async ValueTask 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(); - - // 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}"); - } - } -} diff --git a/Ghost.Editor.Core/AssetHandle/Importers/TextureImporter.cs b/Ghost.Editor.Core/AssetHandle/Importers/TextureImporter.cs deleted file mode 100644 index 168de03..0000000 --- a/Ghost.Editor.Core/AssetHandle/Importers/TextureImporter.cs +++ /dev/null @@ -1,279 +0,0 @@ -using Ghost.Core; -using Ghost.Editor.Core.Contracts; -using System.Text.Json; - -namespace Ghost.Editor.Core.AssetHandle.Importers; - -/// -/// Importer settings for texture assets. -/// -internal class TextureImporterSettings : ImporterSettings -{ - /// - /// Whether to generate mipmaps for the texture. - /// - public bool GenerateMipmaps - { - get; - set; - } = true; - - /// - /// Whether the texture uses sRGB color space. - /// - public bool SRGB - { - get; - set; - } = true; - - /// - /// Maximum texture size. Images larger than this will be downscaled. - /// - public uint MaxSize - { - get; - set; - } = 2048; - - /// - /// Texture compression format. - /// Options: "None", "BC1", "BC3", "BC7" - /// - public string CompressionFormat - { - get; - set; - } = "None"; - - /// - /// Texture filter mode. - /// Options: "Point", "Bilinear", "Trilinear" - /// - public string FilterMode - { - get; - set; - } = "Bilinear"; - - /// - /// Texture wrap mode. - /// Options: "Repeat", "Clamp", "Mirror" - /// - public string WrapMode - { - get; - set; - } = "Repeat"; -} - -/// -/// Importer for texture files (.png, .jpg, .jpeg, .dds, .tga, .bmp). -/// Processes image files and converts them into engine-ready texture assets. -/// -[AssetImporter(".png", ".jpg", ".jpeg", ".dds", ".tga", ".bmp")] -internal class TextureImporter : AssetImporter -{ - public override async ValueTask 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(); - - //// 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}"); - } - } - - /// - /// Get image dimensions from file. - /// Simplified implementation - in production, use an image library. - /// - 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); - } - } - - /// - /// Read DDS file header to get dimensions. - /// - 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); - } - } - - /// - /// Export a texture asset from memory to disk. - /// - public override async ValueTask ExportAsync(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}"); - } - } - - /// - /// Calculate number of mipmap levels for a given texture size. - /// - 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; - } -} diff --git a/Ghost.Editor.Core/AssetHandle/Models/Asset.cs b/Ghost.Editor.Core/AssetHandle/Models/Asset.cs deleted file mode 100644 index 4c3f3eb..0000000 --- a/Ghost.Editor.Core/AssetHandle/Models/Asset.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Ghost.Editor.Core.AssetHandle; - -/// -/// The base class for all asset types in the Ghost Editor. -/// -public abstract class Asset -{ - public abstract string Name - { - get; set; - } - - public Guid ID - { - get; - } - - protected Asset(Guid id) - { - ID = id; - } -} diff --git a/Ghost.Editor.Core/AssetHandle/Models/TextureAsset.cs b/Ghost.Editor.Core/AssetHandle/Models/TextureAsset.cs deleted file mode 100644 index 9757b8c..0000000 --- a/Ghost.Editor.Core/AssetHandle/Models/TextureAsset.cs +++ /dev/null @@ -1,75 +0,0 @@ -namespace Ghost.Editor.Core.AssetHandle; - -/// -/// Represents a texture asset. -/// -public class TextureAsset : Asset -{ - public override string Name - { - get; - set; - } - - /// - /// Width of the texture in pixels. - /// - public uint Width - { - get; - set; - } - - /// - /// Height of the texture in pixels. - /// - public uint Height - { - get; - set; - } - - /// - /// Number of mipmap levels. - /// - public uint MipLevels - { - get; - set; - } - - /// - /// Texture format (e.g., "RGBA8", "BC1", "BC7"). - /// - public string Format - { - get; - set; - } - - /// - /// Whether the texture uses sRGB color space. - /// - public bool IsSRGB - { - get; - set; - } - - /// - /// Relative path to the source image file. - /// - public string SourcePath - { - get; - set; - } - - public TextureAsset(Guid id, string name) : base(id) - { - Name = name; - Format = "RGBA8"; - IsSRGB = true; - SourcePath = string.Empty; - } -} diff --git a/Ghost.Editor.Core/Contracts/IAssetService.cs b/Ghost.Editor.Core/Contracts/IAssetService.cs deleted file mode 100644 index b07138d..0000000 --- a/Ghost.Editor.Core/Contracts/IAssetService.cs +++ /dev/null @@ -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 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 PathToGuid(string assetPath); - Result GuidToPath(Guid guid); - - // Asset loading - Result LoadAsset(Guid guid) where T : Asset; - Result LoadAssetAtPath(string assetPath) where T : Asset; - void UnloadAsset(Guid guid); - void UnloadAllAssets(); - bool IsAssetLoaded(Guid guid); - (int currentSize, int maxSize) GetCacheStats(); - Result SaveImportedAsset(Guid guid, T assetData) where T : Asset; - - // Asset tags - ValueTask>> GetAssetTagsAsync(Guid guid, CancellationToken token = default); - ValueTask SetAssetTagsAsync(Guid guid, List tags, CancellationToken token = default); - - // Asset search - Task> FindAssetsByNameAsync(string namePattern, CancellationToken token = default); - Task> FindAssetsByTagAsync(string tag, CancellationToken token = default); - IReadOnlyDictionary GetAllAssets(); - - // Asset file operations - ValueTask CreateAssetAsync(string assetPath, ReadOnlyMemory content, CancellationToken token = default); - ValueTask CreateAssetAsync(string assetPath, CancellationToken token = default); - ValueTask DeleteAssetAsync(Guid guid, CancellationToken token = default); - ValueTask DeleteAssetAsync(string assetPath, CancellationToken token = default); - ValueTask MoveAssetAsync(Guid guid, string newPath, CancellationToken token = default); - ValueTask MoveAssetAsync(string oldPath, string newPath, CancellationToken token = default); - ValueTask> CopyAssetAsync(Guid guid, string newPath, CancellationToken token = default); - ValueTask> CopyAssetAsync(string sourcePath, string destPath, CancellationToken token = default); - Result MarkDirtyAsync(Guid guid, CancellationToken token = default); - Task ImportDirtyAssetsAsync(CancellationToken token = default); - - // Importer management - Type? GetImporterType(string extension); - Dictionary GetAllImporters(); - ValueTask> ExportAssetAsync(string assetPath, T assetData, CancellationToken token = default) where T : class; - - // Asset opening - void OpenAsset(string path); -} diff --git a/Ghost.Entities.Test/Ghost.Entities.Test.csproj b/Ghost.Entities.Test/Ghost.Entities.Test.csproj deleted file mode 100644 index 32c399e..0000000 --- a/Ghost.Entities.Test/Ghost.Entities.Test.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - Exe - net10.0 - enable - enable - True - - - - - - - - diff --git a/Ghost.Entities.Test/Program.cs b/Ghost.Entities.Test/Program.cs deleted file mode 100644 index efc5351..0000000 --- a/Ghost.Entities.Test/Program.cs +++ /dev/null @@ -1,7 +0,0 @@ -using Ghost.Entities.Test; -using Ghost.Test.Core; -using Misaki.HighPerformance.LowLevel.Buffer; - -AllocationManager.EnableDebugLayer(); -TestRunner.Run(); -AllocationManager.Dispose(); diff --git a/GhostEngine.slnx b/GhostEngine.slnx deleted file mode 100644 index ff2d29f..0000000 --- a/GhostEngine.slnx +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/.editorconfig b/src/.editorconfig similarity index 100% rename from .editorconfig rename to src/.editorconfig diff --git a/Ghost.DSL/AssemblyInfo.cs b/src/Editor/Ghost.DSL/AssemblyInfo.cs similarity index 100% rename from Ghost.DSL/AssemblyInfo.cs rename to src/Editor/Ghost.DSL/AssemblyInfo.cs diff --git a/Ghost.DSL/Generator/ShaderStructGenerator.cs b/src/Editor/Ghost.DSL/Generator/ShaderStructGenerator.cs similarity index 100% rename from Ghost.DSL/Generator/ShaderStructGenerator.cs rename to src/Editor/Ghost.DSL/Generator/ShaderStructGenerator.cs diff --git a/Ghost.DSL/Ghost.DSL.csproj b/src/Editor/Ghost.DSL/Ghost.DSL.csproj similarity index 90% rename from Ghost.DSL/Ghost.DSL.csproj rename to src/Editor/Ghost.DSL/Ghost.DSL.csproj index 17e40af..4e8bf85 100644 --- a/Ghost.DSL/Ghost.DSL.csproj +++ b/src/Editor/Ghost.DSL/Ghost.DSL.csproj @@ -25,7 +25,7 @@ - + diff --git a/Ghost.DSL/Grammar/GhostShaderLexer.g4 b/src/Editor/Ghost.DSL/Grammar/GhostShaderLexer.g4 similarity index 100% rename from Ghost.DSL/Grammar/GhostShaderLexer.g4 rename to src/Editor/Ghost.DSL/Grammar/GhostShaderLexer.g4 diff --git a/Ghost.DSL/Grammar/GhostShaderParser.g4 b/src/Editor/Ghost.DSL/Grammar/GhostShaderParser.g4 similarity index 100% rename from Ghost.DSL/Grammar/GhostShaderParser.g4 rename to src/Editor/Ghost.DSL/Grammar/GhostShaderParser.g4 diff --git a/Ghost.DSL/ShaderCompiler/DSLShaderCompiler.cs b/src/Editor/Ghost.DSL/ShaderCompiler/DSLShaderCompiler.cs similarity index 94% rename from Ghost.DSL/ShaderCompiler/DSLShaderCompiler.cs rename to src/Editor/Ghost.DSL/ShaderCompiler/DSLShaderCompiler.cs index ba19056..31a2d3b 100644 --- a/Ghost.DSL/ShaderCompiler/DSLShaderCompiler.cs +++ b/src/Editor/Ghost.DSL/ShaderCompiler/DSLShaderCompiler.cs @@ -1,6 +1,7 @@ using Ghost.Core; using Ghost.Core.Graphics; using Ghost.DSL.ShaderParser; +using System.Runtime.CompilerServices; using System.Text; namespace Ghost.DSL.ShaderCompiler; @@ -44,28 +45,31 @@ internal static class DSLShaderCompiler }; } - private static uint CalculateCBufferSize(ReadOnlySpan properties) + private static int LayoutCBufferProperties(Span properties) { if (properties.IsEmpty) { return 0; } - var currentOffset = 0u; + var currentOffset = 0; - foreach (var prop in properties) + foreach (ref var prop in properties) { var size = prop.type.GetSize(); if ((currentOffset % 16) + size > 16) { - currentOffset = (currentOffset + 15u) & ~15u; + currentOffset = (currentOffset + 15) & ~15; } + prop.offset = currentOffset; + prop.size = size; + currentOffset += size; } - return (currentOffset + 15u) & ~15u; + return (currentOffset + 15) & ~15; } // TODO: Implement shader inheritance resolution, including property and pass merging. @@ -98,7 +102,7 @@ internal static class DSLShaderCompiler descriptor.globalProperties = shaderGlobalProperties ?? Array.Empty(); descriptor.properties = shaderLocalProperties ?? Array.Empty(); - descriptor.cbufferSize = CalculateCBufferSize(descriptor.properties); + descriptor.cbufferSize = LayoutCBufferProperties(descriptor.properties); if (semantics.passes != null) { @@ -264,7 +268,7 @@ internal static class DSLShaderCompiler #ifndef {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(@" struct PerMaterialData @@ -303,7 +307,7 @@ struct PerMaterialData #ifndef 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 {"); diff --git a/Ghost.DSL/ShaderCompiler/DSLShaderSemantics.cs b/src/Editor/Ghost.DSL/ShaderCompiler/DSLShaderSemantics.cs similarity index 100% rename from Ghost.DSL/ShaderCompiler/DSLShaderSemantics.cs rename to src/Editor/Ghost.DSL/ShaderCompiler/DSLShaderSemantics.cs diff --git a/Ghost.DSL/ShaderParser/AntlrShaderCompiler.cs b/src/Editor/Ghost.DSL/ShaderParser/AntlrShaderCompiler.cs similarity index 100% rename from Ghost.DSL/ShaderParser/AntlrShaderCompiler.cs rename to src/Editor/Ghost.DSL/ShaderParser/AntlrShaderCompiler.cs diff --git a/Ghost.DSL/ShaderParser/Model/ShaderModel.cs b/src/Editor/Ghost.DSL/ShaderParser/Model/ShaderModel.cs similarity index 100% rename from Ghost.DSL/ShaderParser/Model/ShaderModel.cs rename to src/Editor/Ghost.DSL/ShaderParser/Model/ShaderModel.cs diff --git a/Ghost.DSL/ShaderParser/ShaderVisitor.cs b/src/Editor/Ghost.DSL/ShaderParser/ShaderVisitor.cs similarity index 100% rename from Ghost.DSL/ShaderParser/ShaderVisitor.cs rename to src/Editor/Ghost.DSL/ShaderParser/ShaderVisitor.cs diff --git a/Ghost.Data/AssemblyInfo.cs b/src/Editor/Ghost.Data/AssemblyInfo.cs similarity index 100% rename from Ghost.Data/AssemblyInfo.cs rename to src/Editor/Ghost.Data/AssemblyInfo.cs diff --git a/Ghost.Data/Assets/ProjectTemplates/Empty.zip b/src/Editor/Ghost.Data/Assets/ProjectTemplates/Empty.zip similarity index 100% rename from Ghost.Data/Assets/ProjectTemplates/Empty.zip rename to src/Editor/Ghost.Data/Assets/ProjectTemplates/Empty.zip diff --git a/Ghost.Data/Ghost.Data.csproj b/src/Editor/Ghost.Data/Ghost.Data.csproj similarity index 100% rename from Ghost.Data/Ghost.Data.csproj rename to src/Editor/Ghost.Data/Ghost.Data.csproj diff --git a/Ghost.Data/JsonContext.cs b/src/Editor/Ghost.Data/JsonContext.cs similarity index 100% rename from Ghost.Data/JsonContext.cs rename to src/Editor/Ghost.Data/JsonContext.cs diff --git a/Ghost.Data/Models/ProjectInfo.cs b/src/Editor/Ghost.Data/Models/ProjectInfo.cs similarity index 100% rename from Ghost.Data/Models/ProjectInfo.cs rename to src/Editor/Ghost.Data/Models/ProjectInfo.cs diff --git a/Ghost.Data/Models/ProjectMetadata.cs b/src/Editor/Ghost.Data/Models/ProjectMetadata.cs similarity index 100% rename from Ghost.Data/Models/ProjectMetadata.cs rename to src/Editor/Ghost.Data/Models/ProjectMetadata.cs diff --git a/Ghost.Data/Models/TemplateInfo.cs b/src/Editor/Ghost.Data/Models/TemplateInfo.cs similarity index 100% rename from Ghost.Data/Models/TemplateInfo.cs rename to src/Editor/Ghost.Data/Models/TemplateInfo.cs diff --git a/Ghost.Data/Repository/ProjectRepository.cs b/src/Editor/Ghost.Data/Repository/ProjectRepository.cs similarity index 100% rename from Ghost.Data/Repository/ProjectRepository.cs rename to src/Editor/Ghost.Data/Repository/ProjectRepository.cs diff --git a/Ghost.Data/Resources/AssetsPath.cs b/src/Editor/Ghost.Data/Resources/AssetsPath.cs similarity index 100% rename from Ghost.Data/Resources/AssetsPath.cs rename to src/Editor/Ghost.Data/Resources/AssetsPath.cs diff --git a/Ghost.Data/Resources/DataPath.cs b/src/Editor/Ghost.Data/Resources/DataPath.cs similarity index 100% rename from Ghost.Data/Resources/DataPath.cs rename to src/Editor/Ghost.Data/Resources/DataPath.cs diff --git a/Ghost.Data/Services/ProjectService.cs b/src/Editor/Ghost.Data/Services/ProjectService.cs similarity index 100% rename from Ghost.Data/Services/ProjectService.cs rename to src/Editor/Ghost.Data/Services/ProjectService.cs diff --git a/Ghost.Editor.Core/AssemblyInfo.cs b/src/Editor/Ghost.Editor.Core/AssemblyInfo.cs similarity index 100% rename from Ghost.Editor.Core/AssemblyInfo.cs rename to src/Editor/Ghost.Editor.Core/AssemblyInfo.cs diff --git a/src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs b/src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs new file mode 100644 index 0000000..1ff9b50 --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/AssetHandler/Asset.cs @@ -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 buffer = stackalloc byte[SIZE]; + stream.ReadExactly(buffer); + return Unsafe.ReadUnaligned(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 AsBytes() + { + return MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(in this, 1)); + } +} + +public readonly struct AssetReference : IEquatable +{ + private readonly int _value; + + /// + /// The index of the asset in the dependency list. + /// + 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> WriteToStreamAsync(Stream stream, CancellationToken token = default); + ValueTask> ReadFromStreamAsync(Stream stream, CancellationToken token = default); +} diff --git a/src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandler.cs b/src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandler.cs new file mode 100644 index 0000000..58a86f3 --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/AssetHandler/AssetHandler.cs @@ -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> LoadAsync(Stream sourceStream, IAssetRegistry assetDatabase, CancellationToken token = default); + ValueTask SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetDatabase, CancellationToken token = default); +} + +public interface IImportableAssetHandler : IAssetHandler +{ + ValueTask ImportAsync(Stream sourceStream, Stream targetStream, Guid id, CancellationToken token = default); + ValueTask ExportAsync(Stream assetStream, Stream targetStream, IAssetExportOptions? options, CancellationToken token = default); +} + +public static class AssetHandlerExtensions +{ + public static async ValueTask ImportAsync(this IImportableAssetHandler handler, string sourceFilePath, string targetFilePath, Guid id, CancellationToken token = default) + { + 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 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> 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); + } +} diff --git a/src/Editor/Ghost.Editor.Core/AssetHandler/TextureAsset.cs b/src/Editor/Ghost.Editor.Core/AssetHandler/TextureAsset.cs new file mode 100644 index 0000000..509b122 --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/AssetHandler/TextureAsset.cs @@ -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> WriteToStreamAsync(Stream stream, CancellationToken token = default) + { + var size = Unsafe.SizeOf() + Unsafe.SizeOf() + Unsafe.SizeOf(); + var tempArray = ArrayPool.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()), Advanced); + Unsafe.WriteUnaligned(ref Unsafe.Add(ref address, Unsafe.SizeOf() + Unsafe.SizeOf()), Sampler); + + await stream.WriteAsync(tempArray.AsMemory(0, size), token).ConfigureAwait(false); + + return Result.Success(size); + } + catch (Exception ex) + { + return Result.Failure($"Failed to write texture asset settings to stream: {ex.Message}"); + } + finally + { + ArrayPool.Shared.Return(tempArray); + } + } + + public async ValueTask> ReadFromStreamAsync(Stream stream, CancellationToken token = default) + { + var size = Unsafe.SizeOf() + Unsafe.SizeOf() + Unsafe.SizeOf(); + var tempArray = ArrayPool.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(ref address); + var advanced = Unsafe.ReadUnaligned(ref Unsafe.Add(ref address, Unsafe.SizeOf())); + var sampler = Unsafe.ReadUnaligned(ref Unsafe.Add(ref address, Unsafe.SizeOf() + Unsafe.SizeOf())); + + var settings = new TextureAssetSettings + { + Basic = basic, + Advanced = advanced, + Sampler = sampler + }; + + return Result.Success(settings); + } + catch (Exception ex) + { + return Result.Failure($"Failed to read texture asset settings from stream: {ex.Message}"); + } + finally + { + ArrayPool.Shared.Return(tempArray); + } + } +} + +internal class TextureAssetHandler : IImportableAssetHandler +{ + private const int _CURRENT_VERSION = 1; + + public ValueTask ExportAsync(Stream assetStream, Stream targetStream, IAssetExportOptions? options, CancellationToken token = default) + { + throw new NotImplementedException(); + } + + public async ValueTask 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(); + 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.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.Shared.Return(tempArray); + } + } + + public ValueTask> LoadAsync(Stream sourceStream, IAssetRegistry assetDatabase, CancellationToken token = default) + { + throw new NotImplementedException(); + } + + public ValueTask SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetDatabase, CancellationToken token = default) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Ghost.Editor.Core/Attributes.cs b/src/Editor/Ghost.Editor.Core/Attributes.cs similarity index 100% rename from Ghost.Editor.Core/Attributes.cs rename to src/Editor/Ghost.Editor.Core/Attributes.cs diff --git a/src/Editor/Ghost.Editor.Core/Contracts/IAssetRegistry.cs b/src/Editor/Ghost.Editor.Core/Contracts/IAssetRegistry.cs new file mode 100644 index 0000000..311c646 --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/Contracts/IAssetRegistry.cs @@ -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> ImportAssetAsync(string sourceFilePath, string targetAssetPath, CancellationToken token = default); + ValueTask ReimportAssetAsync(Guid assetId, string sourceFilePath, CancellationToken token = default); + ValueTask> LoadAssetAsync(Guid id, CancellationToken token = default); + ValueTask SaveAssetAsync(Asset asset, CancellationToken token = default); +} diff --git a/Ghost.Editor.Core/Contracts/IInspectable.cs b/src/Editor/Ghost.Editor.Core/Contracts/IInspectable.cs similarity index 100% rename from Ghost.Editor.Core/Contracts/IInspectable.cs rename to src/Editor/Ghost.Editor.Core/Contracts/IInspectable.cs diff --git a/Ghost.Editor.Core/Contracts/IInspectorService.cs b/src/Editor/Ghost.Editor.Core/Contracts/IInspectorService.cs similarity index 100% rename from Ghost.Editor.Core/Contracts/IInspectorService.cs rename to src/Editor/Ghost.Editor.Core/Contracts/IInspectorService.cs diff --git a/Ghost.Editor.Core/Contracts/INavigationAware.cs b/src/Editor/Ghost.Editor.Core/Contracts/INavigationAware.cs similarity index 100% rename from Ghost.Editor.Core/Contracts/INavigationAware.cs rename to src/Editor/Ghost.Editor.Core/Contracts/INavigationAware.cs diff --git a/Ghost.Editor.Core/Contracts/INotificationService.cs b/src/Editor/Ghost.Editor.Core/Contracts/INotificationService.cs similarity index 100% rename from Ghost.Editor.Core/Contracts/INotificationService.cs rename to src/Editor/Ghost.Editor.Core/Contracts/INotificationService.cs diff --git a/Ghost.Editor.Core/Contracts/IPreviewService.cs b/src/Editor/Ghost.Editor.Core/Contracts/IPreviewService.cs similarity index 100% rename from Ghost.Editor.Core/Contracts/IPreviewService.cs rename to src/Editor/Ghost.Editor.Core/Contracts/IPreviewService.cs diff --git a/Ghost.Editor.Core/Contracts/IProgressService.cs b/src/Editor/Ghost.Editor.Core/Contracts/IProgressService.cs similarity index 100% rename from Ghost.Editor.Core/Contracts/IProgressService.cs rename to src/Editor/Ghost.Editor.Core/Contracts/IProgressService.cs diff --git a/Ghost.Editor.Core/Controls/BasicInput/Float3Field.cs b/src/Editor/Ghost.Editor.Core/Controls/BasicInput/Float3Field.cs similarity index 100% rename from Ghost.Editor.Core/Controls/BasicInput/Float3Field.cs rename to src/Editor/Ghost.Editor.Core/Controls/BasicInput/Float3Field.cs diff --git a/Ghost.Editor.Core/Controls/BasicInput/Float3Field.xaml b/src/Editor/Ghost.Editor.Core/Controls/BasicInput/Float3Field.xaml similarity index 100% rename from Ghost.Editor.Core/Controls/BasicInput/Float3Field.xaml rename to src/Editor/Ghost.Editor.Core/Controls/BasicInput/Float3Field.xaml diff --git a/Ghost.Editor.Core/Controls/BasicInput/PropertyField.cs b/src/Editor/Ghost.Editor.Core/Controls/BasicInput/PropertyField.cs similarity index 100% rename from Ghost.Editor.Core/Controls/BasicInput/PropertyField.cs rename to src/Editor/Ghost.Editor.Core/Controls/BasicInput/PropertyField.cs diff --git a/Ghost.Editor.Core/Controls/BasicInput/PropertyField.xaml b/src/Editor/Ghost.Editor.Core/Controls/BasicInput/PropertyField.xaml similarity index 100% rename from Ghost.Editor.Core/Controls/BasicInput/PropertyField.xaml rename to src/Editor/Ghost.Editor.Core/Controls/BasicInput/PropertyField.xaml diff --git a/Ghost.Editor.Core/Controls/ControlsDictionary.cs b/src/Editor/Ghost.Editor.Core/Controls/ControlsDictionary.cs similarity index 100% rename from Ghost.Editor.Core/Controls/ControlsDictionary.cs rename to src/Editor/Ghost.Editor.Core/Controls/ControlsDictionary.cs diff --git a/Ghost.Editor.Core/Controls/ControlsDictionary.xaml b/src/Editor/Ghost.Editor.Core/Controls/ControlsDictionary.xaml similarity index 100% rename from Ghost.Editor.Core/Controls/ControlsDictionary.xaml rename to src/Editor/Ghost.Editor.Core/Controls/ControlsDictionary.xaml diff --git a/Ghost.Editor.Core/Controls/Internal/ComponentView.cs b/src/Editor/Ghost.Editor.Core/Controls/Internal/ComponentView.cs similarity index 100% rename from Ghost.Editor.Core/Controls/Internal/ComponentView.cs rename to src/Editor/Ghost.Editor.Core/Controls/Internal/ComponentView.cs diff --git a/Ghost.Editor.Core/Controls/Internal/ComponentView.xaml b/src/Editor/Ghost.Editor.Core/Controls/Internal/ComponentView.xaml similarity index 100% rename from Ghost.Editor.Core/Controls/Internal/ComponentView.xaml rename to src/Editor/Ghost.Editor.Core/Controls/Internal/ComponentView.xaml diff --git a/Ghost.Editor.Core/Controls/Internal/NavigationTabView.cs b/src/Editor/Ghost.Editor.Core/Controls/Internal/NavigationTabView.cs similarity index 100% rename from Ghost.Editor.Core/Controls/Internal/NavigationTabView.cs rename to src/Editor/Ghost.Editor.Core/Controls/Internal/NavigationTabView.cs diff --git a/Ghost.Editor.Core/Controls/Menu/ContextFlyout.cs b/src/Editor/Ghost.Editor.Core/Controls/Menu/ContextFlyout.cs similarity index 100% rename from Ghost.Editor.Core/Controls/Menu/ContextFlyout.cs rename to src/Editor/Ghost.Editor.Core/Controls/Menu/ContextFlyout.cs diff --git a/Ghost.Editor.Core/Controls/ValueControl.cs b/src/Editor/Ghost.Editor.Core/Controls/ValueControl.cs similarity index 100% rename from Ghost.Editor.Core/Controls/ValueControl.cs rename to src/Editor/Ghost.Editor.Core/Controls/ValueControl.cs diff --git a/Ghost.Editor.Core/EditorApplication.cs b/src/Editor/Ghost.Editor.Core/EditorApplication.cs similarity index 100% rename from Ghost.Editor.Core/EditorApplication.cs rename to src/Editor/Ghost.Editor.Core/EditorApplication.cs diff --git a/Ghost.Editor.Core/Event/ValueChangedEventHandler.cs b/src/Editor/Ghost.Editor.Core/Event/ValueChangedEventHandler.cs similarity index 100% rename from Ghost.Editor.Core/Event/ValueChangedEventHandler.cs rename to src/Editor/Ghost.Editor.Core/Event/ValueChangedEventHandler.cs diff --git a/Ghost.Editor.Core/Ghost.Editor.Core.csproj b/src/Editor/Ghost.Editor.Core/Ghost.Editor.Core.csproj similarity index 90% rename from Ghost.Editor.Core/Ghost.Editor.Core.csproj rename to src/Editor/Ghost.Editor.Core/Ghost.Editor.Core.csproj index 06ba2a9..4039b49 100644 --- a/Ghost.Editor.Core/Ghost.Editor.Core.csproj +++ b/src/Editor/Ghost.Editor.Core/Ghost.Editor.Core.csproj @@ -21,8 +21,8 @@ - - + + diff --git a/Ghost.Editor.Core/Inspector/ComponentEditor.cs b/src/Editor/Ghost.Editor.Core/Inspector/ComponentEditor.cs similarity index 100% rename from Ghost.Editor.Core/Inspector/ComponentEditor.cs rename to src/Editor/Ghost.Editor.Core/Inspector/ComponentEditor.cs diff --git a/Ghost.Editor.Core/Inspector/ComponentObject.cs b/src/Editor/Ghost.Editor.Core/Inspector/ComponentObject.cs similarity index 100% rename from Ghost.Editor.Core/Inspector/ComponentObject.cs rename to src/Editor/Ghost.Editor.Core/Inspector/ComponentObject.cs diff --git a/Ghost.Editor.Core/Notifications/MessageType.cs b/src/Editor/Ghost.Editor.Core/Notifications/MessageType.cs similarity index 100% rename from Ghost.Editor.Core/Notifications/MessageType.cs rename to src/Editor/Ghost.Editor.Core/Notifications/MessageType.cs diff --git a/Ghost.Editor.Core/Resources/EditorIconSource.cs b/src/Editor/Ghost.Editor.Core/Resources/EditorIconSource.cs similarity index 100% rename from Ghost.Editor.Core/Resources/EditorIconSource.cs rename to src/Editor/Ghost.Editor.Core/Resources/EditorIconSource.cs diff --git a/Ghost.Editor.Core/Resources/StaticResource.cs b/src/Editor/Ghost.Editor.Core/Resources/StaticResource.cs similarity index 100% rename from Ghost.Editor.Core/Resources/StaticResource.cs rename to src/Editor/Ghost.Editor.Core/Resources/StaticResource.cs diff --git a/Ghost.Editor.Core/SceneGraph/EntityNode.cs b/src/Editor/Ghost.Editor.Core/SceneGraph/EntityNode.cs similarity index 100% rename from Ghost.Editor.Core/SceneGraph/EntityNode.cs rename to src/Editor/Ghost.Editor.Core/SceneGraph/EntityNode.cs diff --git a/Ghost.Editor.Core/SceneGraph/SceneGraph Plan.md b/src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraph Plan.md similarity index 100% rename from Ghost.Editor.Core/SceneGraph/SceneGraph Plan.md rename to src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraph Plan.md diff --git a/Ghost.Editor.Core/SceneGraph/SceneGraphNode.cs b/src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphNode.cs similarity index 100% rename from Ghost.Editor.Core/SceneGraph/SceneGraphNode.cs rename to src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphNode.cs diff --git a/Ghost.Editor.Core/SceneGraph/SceneNode.cs b/src/Editor/Ghost.Editor.Core/SceneGraph/SceneNode.cs similarity index 100% rename from Ghost.Editor.Core/SceneGraph/SceneNode.cs rename to src/Editor/Ghost.Editor.Core/SceneGraph/SceneNode.cs diff --git a/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.Backend.cs b/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.Backend.cs new file mode 100644 index 0000000..c3f961f --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.Backend.cs @@ -0,0 +1,6 @@ +namespace TestProject.AssetDB; + +internal partial class AssetRegistry +{ + // TODO: Sqlite backend implementation +} diff --git a/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs b/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs new file mode 100644 index 0000000..86395d5 --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/Services/AssetRegistry.cs @@ -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 +{ + 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 _pathToGuid; + private readonly ConcurrentDictionary _guidToPath; + + private readonly ConcurrentDictionary _cachedHander; + private readonly ConcurrentDictionary> _loadedAssets; + + private readonly Dictionary> _referencerGraph; + private readonly Dictionary> _dependencyCache; + + private readonly ConcurrentDictionary _ignoreFileChanges; + + private readonly SemaphoreSlim _cacheSlim; + private readonly Lock _pathLock; + + public event EventHandler? 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(4, 512, new PathComparer()); + _guidToPath = new ConcurrentDictionary(4, 512); + _cachedHander = new ConcurrentDictionary(4, 16); + _loadedAssets = new ConcurrentDictionary>(4, 512); + + _referencerGraph = new Dictionary>(); + _dependencyCache = new Dictionary>(); + + _ignoreFileChanges = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + _cacheSlim = new SemaphoreSlim(1, 1); + _pathLock = new Lock(); + + LoadExistingAssets(); + + _watcher.Created += OnFileSystemOp; + _watcher.Deleted += OnFileSystemOp; + _watcher.Changed += OnFileSystemOp; + _watcher.Renamed += OnFileSystemRenameOp; + } + + // TODO: DB Cache + private unsafe void LoadExistingAssets() + { + Span guidBuffer = stackalloc byte[sizeof(Guid)]; + foreach (var filePath in Directory.EnumerateFiles(_rootDirectory, $"*{ASSET_EXTENSION}", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(_rootDirectory, filePath); + + try + { + var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + try + { + fs.Seek(4, SeekOrigin.Begin); // Skip format version + fs.ReadExactly(guidBuffer); + + var guid = Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(guidBuffer)); + UpdatePathMapping(relativePath, guid); + } + finally + { + fs.Dispose(); + } + } + catch (Exception +#if DEBUG + ex +#endif + ) + { +#if DEBUG + System.Diagnostics.Debugger.BreakForUserUnhandledException(ex); +#endif + continue; + } + } + } + + private void UpdateGraph(Guid assetId, IEnumerable newDependencies) + { + // 1. Clean up old references (reverse) + if (_dependencyCache.TryGetValue(assetId, out var oldDeps)) + { + foreach (var dep in oldDeps) + { + if (_referencerGraph.TryGetValue(dep, out var refs)) + { + refs.Remove(assetId); + } + } + } + + // 2. Set new forward dependencies + var newDepSet = new HashSet(newDependencies); + _dependencyCache[assetId] = newDepSet; + + // 3. Add new references (reverse) + foreach (var dep in newDepSet) + { + ref var referencers = ref CollectionsMarshal.GetValueRefOrAddDefault(_referencerGraph, dep, out var exists); + if (!exists || referencers is null) + { + referencers = new HashSet(); + } + + referencers.Add(assetId); + } + } + + private void UpdatePathMapping(string relativePath, Guid guid) + { + lock (_pathLock) + { + _pathToGuid[relativePath] = guid; + _guidToPath[guid] = relativePath; + } + } + + private bool RemovePathMappingByPath(string relativePath) + { + lock (_pathLock) + { + if (_pathToGuid.Remove(relativePath, out var guid)) + { + return _guidToPath.TryRemove(guid, out _); + } + } + + return false; + } + + private async void OnFileSystemOp(object sender, FileSystemEventArgs e) + { + if (_ignoreFileChanges.TryRemove(e.FullPath, out _)) + { + 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(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(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(false); + if (attr is not null && new Guid(attr.ID) == typeId) + { + return GetAssetHandler(handlerType); + } + } + + return null; + } + + public async ValueTask> 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 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> 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 typeIdBuffer = stackalloc byte[sizeofGuid]; + fs.Seek(sizeof(int) + sizeofGuid, SeekOrigin.Begin); + fs.ReadExactly(typeIdBuffer); + + var guid = Unsafe.ReadUnaligned(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), (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 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(); + } +} diff --git a/Ghost.Editor.Core/Services/InspectorService.cs b/src/Editor/Ghost.Editor.Core/Services/InspectorService.cs similarity index 100% rename from Ghost.Editor.Core/Services/InspectorService.cs rename to src/Editor/Ghost.Editor.Core/Services/InspectorService.cs diff --git a/Ghost.Editor.Core/Services/NotificationService.cs b/src/Editor/Ghost.Editor.Core/Services/NotificationService.cs similarity index 100% rename from Ghost.Editor.Core/Services/NotificationService.cs rename to src/Editor/Ghost.Editor.Core/Services/NotificationService.cs diff --git a/Ghost.Editor.Core/Services/PreviewService.cs b/src/Editor/Ghost.Editor.Core/Services/PreviewService.cs similarity index 100% rename from Ghost.Editor.Core/Services/PreviewService.cs rename to src/Editor/Ghost.Editor.Core/Services/PreviewService.cs diff --git a/Ghost.Editor.Core/Services/ProgressService.cs b/src/Editor/Ghost.Editor.Core/Services/ProgressService.cs similarity index 100% rename from Ghost.Editor.Core/Services/ProgressService.cs rename to src/Editor/Ghost.Editor.Core/Services/ProgressService.cs diff --git a/src/Editor/Ghost.Editor.Core/Utilities/AssetHandlerUtility.cs b/src/Editor/Ghost.Editor.Core/Utilities/AssetHandlerUtility.cs new file mode 100644 index 0000000..c1ecec5 --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/Utilities/AssetHandlerUtility.cs @@ -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(Stream stream, Guid id, Guid typeID, int handlerVersion, ReadOnlyMemory dependencies, IAssetSettings? settings, ReadOnlyMemory 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.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); + } + } + } + } +} diff --git a/Ghost.Editor.Core/Utilities/FileExtensions.cs b/src/Editor/Ghost.Editor.Core/Utilities/FileExtensions.cs similarity index 100% rename from Ghost.Editor.Core/Utilities/FileExtensions.cs rename to src/Editor/Ghost.Editor.Core/Utilities/FileExtensions.cs diff --git a/Ghost.Editor.Core/Utilities/TypeCache.cs b/src/Editor/Ghost.Editor.Core/Utilities/TypeCache.cs similarity index 100% rename from Ghost.Editor.Core/Utilities/TypeCache.cs rename to src/Editor/Ghost.Editor.Core/Utilities/TypeCache.cs diff --git a/Ghost.Editor/ActivationHandler.cs b/src/Editor/Ghost.Editor/ActivationHandler.cs similarity index 100% rename from Ghost.Editor/ActivationHandler.cs rename to src/Editor/Ghost.Editor/ActivationHandler.cs diff --git a/Ghost.Editor/App.xaml b/src/Editor/Ghost.Editor/App.xaml similarity index 100% rename from Ghost.Editor/App.xaml rename to src/Editor/Ghost.Editor/App.xaml diff --git a/Ghost.Editor/App.xaml.cs b/src/Editor/Ghost.Editor/App.xaml.cs similarity index 100% rename from Ghost.Editor/App.xaml.cs rename to src/Editor/Ghost.Editor/App.xaml.cs diff --git a/Ghost.Editor/AssemblyInfo.cs b/src/Editor/Ghost.Editor/AssemblyInfo.cs similarity index 100% rename from Ghost.Editor/AssemblyInfo.cs rename to src/Editor/Ghost.Editor/AssemblyInfo.cs diff --git a/Ghost.Editor/Assets/EditorIcons/document-0.png b/src/Editor/Ghost.Editor/Assets/EditorIcons/document-0.png similarity index 100% rename from Ghost.Editor/Assets/EditorIcons/document-0.png rename to src/Editor/Ghost.Editor/Assets/EditorIcons/document-0.png diff --git a/Ghost.Editor/Assets/EditorIcons/document-1.png b/src/Editor/Ghost.Editor/Assets/EditorIcons/document-1.png similarity index 100% rename from Ghost.Editor/Assets/EditorIcons/document-1.png rename to src/Editor/Ghost.Editor/Assets/EditorIcons/document-1.png diff --git a/Ghost.Editor/Assets/EditorIcons/folder-0.png b/src/Editor/Ghost.Editor/Assets/EditorIcons/folder-0.png similarity index 100% rename from Ghost.Editor/Assets/EditorIcons/folder-0.png rename to src/Editor/Ghost.Editor/Assets/EditorIcons/folder-0.png diff --git a/Ghost.Editor/Assets/EditorIcons/folder-1.png b/src/Editor/Ghost.Editor/Assets/EditorIcons/folder-1.png similarity index 100% rename from Ghost.Editor/Assets/EditorIcons/folder-1.png rename to src/Editor/Ghost.Editor/Assets/EditorIcons/folder-1.png diff --git a/Ghost.Editor/Assets/EditorIcons/image-0.png b/src/Editor/Ghost.Editor/Assets/EditorIcons/image-0.png similarity index 100% rename from Ghost.Editor/Assets/EditorIcons/image-0.png rename to src/Editor/Ghost.Editor/Assets/EditorIcons/image-0.png diff --git a/Ghost.Editor/Assets/EditorIcons/image-1.png b/src/Editor/Ghost.Editor/Assets/EditorIcons/image-1.png similarity index 100% rename from Ghost.Editor/Assets/EditorIcons/image-1.png rename to src/Editor/Ghost.Editor/Assets/EditorIcons/image-1.png diff --git a/Ghost.Editor/Assets/LockScreenLogo.scale-200.png b/src/Editor/Ghost.Editor/Assets/LockScreenLogo.scale-200.png similarity index 100% rename from Ghost.Editor/Assets/LockScreenLogo.scale-200.png rename to src/Editor/Ghost.Editor/Assets/LockScreenLogo.scale-200.png diff --git a/Ghost.Editor/Assets/SplashScreen.scale-200.png b/src/Editor/Ghost.Editor/Assets/SplashScreen.scale-200.png similarity index 100% rename from Ghost.Editor/Assets/SplashScreen.scale-200.png rename to src/Editor/Ghost.Editor/Assets/SplashScreen.scale-200.png diff --git a/Ghost.Editor/Assets/Square150x150Logo.scale-200.png b/src/Editor/Ghost.Editor/Assets/Square150x150Logo.scale-200.png similarity index 100% rename from Ghost.Editor/Assets/Square150x150Logo.scale-200.png rename to src/Editor/Ghost.Editor/Assets/Square150x150Logo.scale-200.png diff --git a/Ghost.Editor/Assets/StoreLogo.png b/src/Editor/Ghost.Editor/Assets/StoreLogo.png similarity index 100% rename from Ghost.Editor/Assets/StoreLogo.png rename to src/Editor/Ghost.Editor/Assets/StoreLogo.png diff --git a/Ghost.Editor/Assets/Wide310x150Logo.scale-200.png b/src/Editor/Ghost.Editor/Assets/Wide310x150Logo.scale-200.png similarity index 100% rename from Ghost.Editor/Assets/Wide310x150Logo.scale-200.png rename to src/Editor/Ghost.Editor/Assets/Wide310x150Logo.scale-200.png diff --git a/Ghost.Editor/Assets/icon.ico b/src/Editor/Ghost.Editor/Assets/icon.ico similarity index 100% rename from Ghost.Editor/Assets/icon.ico rename to src/Editor/Ghost.Editor/Assets/icon.ico diff --git a/Ghost.Editor/Assets/Icon.scale-100.png b/src/Editor/Ghost.Editor/Assets/icon.scale-100.png similarity index 100% rename from Ghost.Editor/Assets/Icon.scale-100.png rename to src/Editor/Ghost.Editor/Assets/icon.scale-100.png diff --git a/Ghost.Editor/Assets/Icon.scale-125.png b/src/Editor/Ghost.Editor/Assets/icon.scale-125.png similarity index 100% rename from Ghost.Editor/Assets/Icon.scale-125.png rename to src/Editor/Ghost.Editor/Assets/icon.scale-125.png diff --git a/Ghost.Editor/Assets/Icon.scale-150.png b/src/Editor/Ghost.Editor/Assets/icon.scale-150.png similarity index 100% rename from Ghost.Editor/Assets/Icon.scale-150.png rename to src/Editor/Ghost.Editor/Assets/icon.scale-150.png diff --git a/Ghost.Editor/Assets/Icon.scale-200.png b/src/Editor/Ghost.Editor/Assets/icon.scale-200.png similarity index 100% rename from Ghost.Editor/Assets/Icon.scale-200.png rename to src/Editor/Ghost.Editor/Assets/icon.scale-200.png diff --git a/Ghost.Editor/Assets/Icon.scale-400.png b/src/Editor/Ghost.Editor/Assets/icon.scale-400.png similarity index 100% rename from Ghost.Editor/Assets/Icon.scale-400.png rename to src/Editor/Ghost.Editor/Assets/icon.scale-400.png diff --git a/Ghost.Editor/Assets/icon.svg b/src/Editor/Ghost.Editor/Assets/icon.svg similarity index 100% rename from Ghost.Editor/Assets/icon.svg rename to src/Editor/Ghost.Editor/Assets/icon.svg diff --git a/Ghost.Editor/Assets/Icon.targetsize-16.png b/src/Editor/Ghost.Editor/Assets/icon.targetsize-16.png similarity index 100% rename from Ghost.Editor/Assets/Icon.targetsize-16.png rename to src/Editor/Ghost.Editor/Assets/icon.targetsize-16.png diff --git a/Ghost.Editor/Assets/icon.targetsize-16_altform-lightunplated.png b/src/Editor/Ghost.Editor/Assets/icon.targetsize-16_altform-lightunplated.png similarity index 100% rename from Ghost.Editor/Assets/icon.targetsize-16_altform-lightunplated.png rename to src/Editor/Ghost.Editor/Assets/icon.targetsize-16_altform-lightunplated.png diff --git a/Ghost.Editor/Assets/icon.targetsize-16_altform-unplated.png b/src/Editor/Ghost.Editor/Assets/icon.targetsize-16_altform-unplated.png similarity index 100% rename from Ghost.Editor/Assets/icon.targetsize-16_altform-unplated.png rename to src/Editor/Ghost.Editor/Assets/icon.targetsize-16_altform-unplated.png diff --git a/Ghost.Editor/Assets/Icon.targetsize-24.png b/src/Editor/Ghost.Editor/Assets/icon.targetsize-24.png similarity index 100% rename from Ghost.Editor/Assets/Icon.targetsize-24.png rename to src/Editor/Ghost.Editor/Assets/icon.targetsize-24.png diff --git a/Ghost.Editor/Assets/icon.targetsize-24_altform-lightunplated.png b/src/Editor/Ghost.Editor/Assets/icon.targetsize-24_altform-lightunplated.png similarity index 100% rename from Ghost.Editor/Assets/icon.targetsize-24_altform-lightunplated.png rename to src/Editor/Ghost.Editor/Assets/icon.targetsize-24_altform-lightunplated.png diff --git a/Ghost.Editor/Assets/icon.targetsize-24_altform-unplated.png b/src/Editor/Ghost.Editor/Assets/icon.targetsize-24_altform-unplated.png similarity index 100% rename from Ghost.Editor/Assets/icon.targetsize-24_altform-unplated.png rename to src/Editor/Ghost.Editor/Assets/icon.targetsize-24_altform-unplated.png diff --git a/Ghost.Editor/Assets/Icon.targetsize-256.png b/src/Editor/Ghost.Editor/Assets/icon.targetsize-256.png similarity index 100% rename from Ghost.Editor/Assets/Icon.targetsize-256.png rename to src/Editor/Ghost.Editor/Assets/icon.targetsize-256.png diff --git a/Ghost.Editor/Assets/icon.targetsize-256_altform-lightunplated.png b/src/Editor/Ghost.Editor/Assets/icon.targetsize-256_altform-lightunplated.png similarity index 100% rename from Ghost.Editor/Assets/icon.targetsize-256_altform-lightunplated.png rename to src/Editor/Ghost.Editor/Assets/icon.targetsize-256_altform-lightunplated.png diff --git a/Ghost.Editor/Assets/icon.targetsize-256_altform-unplated.png b/src/Editor/Ghost.Editor/Assets/icon.targetsize-256_altform-unplated.png similarity index 100% rename from Ghost.Editor/Assets/icon.targetsize-256_altform-unplated.png rename to src/Editor/Ghost.Editor/Assets/icon.targetsize-256_altform-unplated.png diff --git a/Ghost.Editor/Assets/Icon.targetsize-32.png b/src/Editor/Ghost.Editor/Assets/icon.targetsize-32.png similarity index 100% rename from Ghost.Editor/Assets/Icon.targetsize-32.png rename to src/Editor/Ghost.Editor/Assets/icon.targetsize-32.png diff --git a/Ghost.Editor/Assets/icon.targetsize-32_altform-lightunplated.png b/src/Editor/Ghost.Editor/Assets/icon.targetsize-32_altform-lightunplated.png similarity index 100% rename from Ghost.Editor/Assets/icon.targetsize-32_altform-lightunplated.png rename to src/Editor/Ghost.Editor/Assets/icon.targetsize-32_altform-lightunplated.png diff --git a/Ghost.Editor/Assets/icon.targetsize-32_altform-unplated.png b/src/Editor/Ghost.Editor/Assets/icon.targetsize-32_altform-unplated.png similarity index 100% rename from Ghost.Editor/Assets/icon.targetsize-32_altform-unplated.png rename to src/Editor/Ghost.Editor/Assets/icon.targetsize-32_altform-unplated.png diff --git a/Ghost.Editor/Assets/Icon.targetsize-48.png b/src/Editor/Ghost.Editor/Assets/icon.targetsize-48.png similarity index 100% rename from Ghost.Editor/Assets/Icon.targetsize-48.png rename to src/Editor/Ghost.Editor/Assets/icon.targetsize-48.png diff --git a/Ghost.Editor/Assets/icon.targetsize-48_altform-lightunplated.png b/src/Editor/Ghost.Editor/Assets/icon.targetsize-48_altform-lightunplated.png similarity index 100% rename from Ghost.Editor/Assets/icon.targetsize-48_altform-lightunplated.png rename to src/Editor/Ghost.Editor/Assets/icon.targetsize-48_altform-lightunplated.png diff --git a/Ghost.Editor/Assets/icon.targetsize-48_altform-unplated.png b/src/Editor/Ghost.Editor/Assets/icon.targetsize-48_altform-unplated.png similarity index 100% rename from Ghost.Editor/Assets/icon.targetsize-48_altform-unplated.png rename to src/Editor/Ghost.Editor/Assets/icon.targetsize-48_altform-unplated.png diff --git a/Ghost.Editor/Components/HierarchyEditor.cs b/src/Editor/Ghost.Editor/Components/HierarchyEditor.cs similarity index 100% rename from Ghost.Editor/Components/HierarchyEditor.cs rename to src/Editor/Ghost.Editor/Components/HierarchyEditor.cs diff --git a/Ghost.Editor/Components/LocalToWorldEditor.cs b/src/Editor/Ghost.Editor/Components/LocalToWorldEditor.cs similarity index 100% rename from Ghost.Editor/Components/LocalToWorldEditor.cs rename to src/Editor/Ghost.Editor/Components/LocalToWorldEditor.cs diff --git a/Ghost.Editor/Ghost.Editor.csproj b/src/Editor/Ghost.Editor/Ghost.Editor.csproj similarity index 96% rename from Ghost.Editor/Ghost.Editor.csproj rename to src/Editor/Ghost.Editor/Ghost.Editor.csproj index 85af85b..6a908f6 100644 --- a/Ghost.Editor/Ghost.Editor.csproj +++ b/src/Editor/Ghost.Editor/Ghost.Editor.csproj @@ -11,6 +11,9 @@ preview + + + @@ -41,8 +44,8 @@ - - + + @@ -137,6 +140,9 @@ PreserveNewest + + MSBuild:Compile + diff --git a/Ghost.Editor/Models/AssetItem.cs b/src/Editor/Ghost.Editor/Models/AssetItem.cs similarity index 100% rename from Ghost.Editor/Models/AssetItem.cs rename to src/Editor/Ghost.Editor/Models/AssetItem.cs diff --git a/Ghost.Editor/Models/ExplorerItem.cs b/src/Editor/Ghost.Editor/Models/ExplorerItem.cs similarity index 100% rename from Ghost.Editor/Models/ExplorerItem.cs rename to src/Editor/Ghost.Editor/Models/ExplorerItem.cs diff --git a/Ghost.Editor/Models/LaunchArguments.cs b/src/Editor/Ghost.Editor/Models/LaunchArguments.cs similarity index 100% rename from Ghost.Editor/Models/LaunchArguments.cs rename to src/Editor/Ghost.Editor/Models/LaunchArguments.cs diff --git a/Ghost.Editor/Package.appxmanifest b/src/Editor/Ghost.Editor/Package.appxmanifest similarity index 100% rename from Ghost.Editor/Package.appxmanifest rename to src/Editor/Ghost.Editor/Package.appxmanifest diff --git a/Ghost.Editor/Properties/Resources.Designer.cs b/src/Editor/Ghost.Editor/Properties/Resources.Designer.cs similarity index 100% rename from Ghost.Editor/Properties/Resources.Designer.cs rename to src/Editor/Ghost.Editor/Properties/Resources.Designer.cs diff --git a/Ghost.Editor/Properties/launchSettings.json b/src/Editor/Ghost.Editor/Properties/launchSettings.json similarity index 100% rename from Ghost.Editor/Properties/launchSettings.json rename to src/Editor/Ghost.Editor/Properties/launchSettings.json diff --git a/Ghost.Editor/Themes/Generic.xaml b/src/Editor/Ghost.Editor/Themes/Generic.xaml similarity index 97% rename from Ghost.Editor/Themes/Generic.xaml rename to src/Editor/Ghost.Editor/Themes/Generic.xaml index df492fe..b4da144 100644 --- a/Ghost.Editor/Themes/Generic.xaml +++ b/src/Editor/Ghost.Editor/Themes/Generic.xaml @@ -46,7 +46,7 @@