Ticket 6 — AssetRegistry: Multi-Index Store, Dependency Graph, and Hot-Reload Cascade¶
Priority: P3 — Implement after all VFS tickets 1–5 are live
Status: Implemented; retained as a completed ticket record.
Module: ZEngine/VFS/Registry/ + ZEngine/Managers/AssetManager
Standard: C++20
Estimated effort: 5–7 days (1 engineer)
Depends on: Ticket 1 (VFSPath), Ticket 3 (VFSDirectoryCache, VFSScanner), Ticket 4 (VFSFileWatcher, VFSWatchEvent), Ticket 5 (MetaFileData, MetaFileIO)
Blocks: import-pipeline.md, cook-pipeline.md
Maintenance boundary: the live API is under
ZEngine/ZEngine/Core/VFS/Registry/. It provides UUID/path lookup, dependency propagation, lifecycleAssetState, and ready/stale/removed callbacks. The detailed migration code and test pseudocode below are historical where they diverge from that API. Registry diagnostics and an initial scanner-to-importer batch handoff are not persisted/implemented merely because this ticket is complete.
Table of Contents¶
- Motivation
- Scope
- Directory Layout
- AssetRecord — the registry row
- AssetIndex — multi-index store
- DependencyGraph — forward + reverse adjacency
- AssetRegistry — facade
- Hot-Reload Cascade Algorithm
- AssetRegistry::Query API
- Integration with VFSScanner and VFSFileWatcher
- Migration Plan — AssetManager call sites
- Unit Tests
- Deliverables Checklist
1. Motivation¶
At the start of this ticket, AssetManager used two flat maps — UUIDToHandle
and HandleToUUID — to correlate assets between their stable identity (UUID)
and a runtime slot. That design had four concrete problems:
| Problem | Where it hurts today |
|---|---|
No path index — given a VFSPath you must scan all assets to find the matching UUID |
LoadAssetFile, scene YAML serialization |
No type index — listing all meshes requires iterating UUIDToHandle and filtering |
ProjectViewUIComponent, renderer scene queries |
No dependency tracking — reimporting material.glb cannot determine which meshes reference it |
Future hot-reload |
AssetHandle = uint32_t encodes type in the high bits but has no generation field, so a stale handle to a deleted asset silently aliases a new one at the same index |
GetAsset<T> template specializations |
This ticket introduced AssetRegistry, which supplements the manager's
per-type runtime maps with a multi-indexed record store and a DependencyGraph
for O(k) hot-reload cascade propagation.
The migration is incremental: AssetManager is not deleted in this ticket. It is given a new
field Registry of type AssetRegistry* and its existing methods are redirected one by one.
2. Scope¶
In scope:
- AssetRecord — the central registry row (UUID, type, VFSPath, MetaFileData snapshot, state machine)
- AssetIndex — multi-index store with lookups by UUID, VFSPath, AssetType, and name substring
- DependencyGraph — dual forward+reverse adjacency lists supporting O(k) cascade enumeration
- AssetRegistry — facade that owns AssetIndex + DependencyGraph, wired to VFS callbacks
- Hot-reload cascade algorithm (BFS walk of reverse edges with cycle protection)
- AssetRegistry::Query API — type filter, name pattern, extension filter
- Migration plan from AssetManager::UUIDToHandle / HandleToUUID to AssetRegistry
- Unit tests (10 minimum)
Not in scope:
- GPU resource invalidation during hot-reload (this is the renderer's responsibility; AssetRegistry
only notifies via a callback)
- Persistent registry serialization to disk (all state is rebuilt from .meta files on startup)
- Shader variant registry (separate concern)
- Full replacement of AssetManager (this ticket migrates the index layer; the import pipeline
remains in AssetManager for now)
3. Directory Layout¶
New files (existing files modified in §11):
ZEngine/ZEngine/VFS/Registry/
AssetRecord.h ← AssetRecord, AssetState
AssetIndex.h ← AssetIndex declaration
AssetIndex.cpp ← AssetIndex implementation
DependencyGraph.h ← DependencyGraph declaration
DependencyGraph.cpp ← DependencyGraph implementation
AssetRegistry.h ← AssetRegistry, QueryFilter, QueryResult
AssetRegistry.cpp ← AssetRegistry implementation
ZEngine/tests/VFS/
AssetRegistryTest.cpp ← 10 unit tests
CMake note. ZEngine/ZEngine/CMakeLists.txt uses GLOB_RECURSE with CONFIGURE_DEPENDS
for .cpp files — no manual source list additions needed for the new .cpp files. The new
VFS/Registry/ subdirectory is automatically included.
4. AssetRecord — the registry row¶
4.1 AssetState¶
// ZEngine/VFS/Registry/AssetRecord.h
#pragma once
#include <cstdint>
namespace ZEngine::Core::VFS
{
// Lifecycle state machine for a registered asset.
//
// Unregistered ──Register──► Registered
// Registered ──Import ──► Importing
// Importing ──Done ──► Loaded
// Loaded ──Modify ──► Stale
// Stale ──Import ──► Importing
// Any ──Remove ──► (record erased)
//
enum class AssetState : uint8_t
{
Unregistered = 0, // slot allocated but not yet populated
Registered = 1, // UUID + path known; not yet imported
Importing = 2, // import job is in-flight
Loaded = 3, // GPU/CPU resources are live
Stale = 4, // source changed; awaiting reimport
Failed = 5, // last import attempt returned an error
};
}
4.2 AssetRecord¶
// ZEngine/VFS/Registry/AssetRecord.h (continued)
#pragma once
#include <Core/VFS/VFSPath.h>
#include <VFS/Meta/MetaFileData.h>
#include <VFS/Registry/AssetRecord.h>
#include <Managers/AssetManager.h> // AssetType, AssetHandle (uint32_t)
#include <uuid.h>
#include <cstdint>
namespace ZEngine::Core::VFS
{
// MAX_ASSET_NAME_LEN: name stored inline to avoid arena fragmentation on lookup.
// Derived from VFSPath::Filename() on Register; updated on Rename.
constexpr uint32_t MAX_ASSET_NAME_LEN = 128;
struct AssetRecord
{
// ── Identity ─────────────────────────────────────────────────────────
uuids::uuid UUID = {};
Managers::AssetType Type = Managers::AssetType::MESH;
// ── Location ─────────────────────────────────────────────────────────
VFSPath Path = {}; // canonical VFS path
char Name[MAX_ASSET_NAME_LEN] = {}; // basename, null-terminated
// ── Meta snapshot ────────────────────────────────────────────────────
// Copied from MetaFileData at registration time; updated on reimport.
// The registry does NOT re-read the .meta file except during hot-reload.
AssetMetaSnapshot Meta = {}; // Reduced snapshot — see MetaFileData.h for full import settings
// ── Runtime handle ───────────────────────────────────────────────────
// Generational handle into HandleManager<AssetRecord>.
// Replaces the old AssetHandle (uint32_t) for registry purposes.
// The old uint32_t handle is kept in SlotHandle for backward compat
// during the migration period (see §11).
Managers::AssetHandle SlotHandle = 0;
// ── State ─────────────────────────────────────────────────────────────
AssetState State = AssetState::Unregistered;
// ── Helpers ───────────────────────────────────────────────────────────
bool IsValid() const { return !UUID.is_nil(); }
bool IsLoaded() const { return State == AssetState::Loaded; }
bool IsStale() const { return State == AssetState::Stale; }
};
}
4.3 Design notes¶
AssetRecordis a plain-old-data struct. It lives inside the arena-backed slot array ofAssetIndexand is never heap-allocated individually.Name[]stores only the filename component (e.g."tank.glb"), not the full path. This is populated byVFSPath::Filename()at registration and enables efficient substring search without touching thePathbuffer on every comparison.AssetMetaSnapshot Metais a snapshot. It captures theAssetUUID,ImporterName, andArtifactPathfrom the last successful.metaread. TheStatusfield in the snapshot is alwaysUnknown— runtime state is inAssetState.SlotHandleexists only during the migration period (§11). Once allAssetManagercall sites useAssetRegistry, this field is removed.
5. AssetIndex — multi-index store¶
5.1 Overview¶
AssetIndex is the central store. It owns a HandleManager<AssetRecord> (slot array with
generational safety) and maintains four secondary lookup structures:
| Index | Key type | Value | Purpose |
|---|---|---|---|
| Primary | Helpers::Handle<AssetRecord> |
AssetRecord& |
Generational slot access |
| By UUID | uuids::uuid |
Helpers::Handle<AssetRecord> |
Fast UUID → record |
| By VFSPath | VFSPath (FNV-1a hash) |
Helpers::Handle<AssetRecord> |
Fast path → record |
| By AssetType | Managers::AssetType |
Array<Handle<AssetRecord>> |
Enumerate all of a type |
Name-substring search is a linear scan over the name field; no dedicated index is maintained because it is an editor-only, infrequent operation and the record count is bounded.
5.2 AssetIndex.h¶
// ZEngine/VFS/Registry/AssetIndex.h
#pragma once
#include <Core/Containers/Array.h>
#include <Core/Containers/UnorderedHashMap.h>
#include <Core/Memory/Allocator.h>
#include <Helpers/HandleManager.h>
#include <Managers/AssetManager.h>
#include <VFS/Registry/AssetRecord.h>
#include <uuid.h>
#include <functional>
#include <shared_mutex>
#include <span>
namespace ZEngine::Core::VFS
{
// UUIDHasher — adapts uuids::uuid for UnorderedHashMap
struct UUIDHasher
{
uint64_t operator()(const uuids::uuid& id) const noexcept
{
// FNV-1a over the 16 UUID bytes
constexpr uint64_t FNV_BASIS = 14695981039346656037ULL;
constexpr uint64_t FNV_PRIME = 1099511628211ULL;
const auto bytes = id.as_bytes();
uint64_t h = FNV_BASIS;
for (auto b : bytes) { h ^= static_cast<uint8_t>(b); h *= FNV_PRIME; }
return h;
}
bool operator()(const uuids::uuid& a, const uuids::uuid& b) const noexcept
{
return a == b;
}
};
// VFSPathHasher — adapts VFSPath for UnorderedHashMap using VFSPath::Hash()
struct VFSPathHasher
{
uint64_t operator()(const VFSPath& p) const noexcept { return p.Hash(); }
bool operator()(const VFSPath& a, const VFSPath& b) const noexcept { return a == b; }
};
// RegisterResult — returned by AssetIndex::Register
enum class RegisterError : uint8_t
{
None = 0,
DuplicateUUID = 1, // UUID already present; use Update instead
DuplicatePath = 2, // VFSPath already mapped to a different UUID
SlotExhausted = 3, // HandleManager capacity reached
};
struct RegisterResult
{
Helpers::Handle<AssetRecord> Handle = {};
RegisterError Error = RegisterError::None;
bool IsOk() const { return Error == RegisterError::None && Handle.Valid(); }
};
// AssetIndex
class AssetIndex
{
public:
// Maximum registered assets. Sized for a mid-size game project.
// Increase if needed; the slot array is arena-allocated at init time.
static constexpr uint32_t MAX_ASSETS = 8192;
AssetIndex() = default;
~AssetIndex() = default;
// Must be called before any other method.
// arena: persistent arena (must outlive AssetIndex)
void Initialize(Core::Memory::ArenaAllocator* arena);
// ── Mutation ─────────────────────────────────────────────────────────
// Register a new asset. Fails if UUID or VFSPath is already mapped.
// Populates AssetRecord fields; sets State = Registered.
RegisterResult Register(const uuids::uuid& uuid,
Managers::AssetType type,
const VFSPath& path,
const MetaFileData& meta);
// Update an existing record (e.g. after reimport). Record must exist.
// Only Meta, SlotHandle, State may be updated; UUID and Type are immutable.
bool Update(Helpers::Handle<AssetRecord> handle,
const MetaFileData& new_meta,
AssetState new_state);
// Transition State only (e.g. Registered → Importing, Loaded → Stale).
bool SetState(Helpers::Handle<AssetRecord> handle, AssetState new_state);
// Remove the record and all secondary index entries.
// Returns false if handle is stale (already removed).
bool Remove(Helpers::Handle<AssetRecord> handle);
// Rename: update Path and Name; re-index by-path.
bool Rename(Helpers::Handle<AssetRecord> handle, const VFSPath& new_path);
// ── Lookup ───────────────────────────────────────────────────────────
// Primary — O(1), generational check
AssetRecord* Access(Helpers::Handle<AssetRecord> handle);
const AssetRecord* Access(Helpers::Handle<AssetRecord> handle) const;
// By UUID — O(1) hash lookup
Helpers::Handle<AssetRecord> FindByUUID(const uuids::uuid& uuid) const;
// By VFSPath — O(1) hash lookup
Helpers::Handle<AssetRecord> FindByPath(const VFSPath& path) const;
// By AssetType — O(1) to retrieve span; O(n_type) to iterate
// span is valid until the next Register/Remove call.
std::span<const Helpers::Handle<AssetRecord>>
FindByType(Managers::AssetType type) const;
// Name substring — O(N) linear scan over all live records.
// Appends matches to `out`. Returns match count.
uint32_t FindByNameSubstring(const char* substr,
Core::Containers::Array<Helpers::Handle<AssetRecord>>& out) const;
// Returns true if the handle refers to a live, valid record.
bool IsLive(Helpers::Handle<AssetRecord> handle) const;
// ── Iteration ────────────────────────────────────────────────────────
// Visit all live records. ForEach acquires shared_lock for the duration.
// Do NOT call Register/Remove from within the visitor — deadlock.
void ForEach(void* ctx, void (*visitor)(void*, Helpers::Handle<AssetRecord>, const AssetRecord&));
// ── Stats ─────────────────────────────────────────────────────────────
uint32_t Count() const; // total live records
uint32_t Capacity() const; // MAX_ASSETS
private:
Helpers::HandleManager<AssetRecord> m_handles = {};
// Secondary indices — keyed on immutable fields.
// Access under m_mutex.
Core::Containers::UnorderedHashMap<
uuids::uuid,
Helpers::Handle<AssetRecord>,
UUIDHasher> m_by_uuid = {};
Core::Containers::UnorderedHashMap<
VFSPath,
Helpers::Handle<AssetRecord>,
VFSPathHasher> m_by_path = {};
// Per-type lists. Indexed by static_cast<uint8_t>(AssetType).
// 4 types defined (MESH=0, MATERIAL=1, TEXTURE=2, MESH_HIERARCHY=3).
static constexpr uint8_t ASSET_TYPE_COUNT = 4;
Core::Containers::Array<
Helpers::Handle<AssetRecord>> m_by_type[ASSET_TYPE_COUNT] = {};
mutable std::shared_mutex m_mutex = {};
// Arena passed in Initialize(); used for type-bucket arrays.
Core::Memory::ArenaAllocator* m_arena = nullptr;
};
} // namespace ZEngine::Core::VFS
5.3 Implementation Notes¶
Initialize¶
void AssetIndex::Initialize(Core::Memory::ArenaAllocator* arena)
{
m_arena = arena;
m_handles.Initialize(arena, MAX_ASSETS);
// Pre-allocate type bucket arrays with generous reserve
for (uint8_t i = 0; i < ASSET_TYPE_COUNT; ++i)
m_by_type[i].init(arena, 0, MAX_ASSETS / ASSET_TYPE_COUNT);
}
Register algorithm¶
1. Acquire unique_lock(m_mutex)
2. If m_by_uuid.contains(uuid) → return {.Error = DuplicateUUID}
3. If m_by_path.contains(path) → return {.Error = DuplicatePath}
4. handle = m_handles.Create()
5. If !handle.Valid() → return {.Error = SlotExhausted}
6. record = m_handles[handle] ← reference into slot array
7. record.UUID = uuid
8. record.Type = type
9. record.Path = path
10. copy basename into record.Name (VFSPath::Filename → strncpy)
11. record.Meta = meta
12. record.SlotHandle = 0 ← caller sets this separately
13. record.State = AssetState::Registered
14. m_by_uuid[uuid] = handle
15. m_by_path[path] = handle
16. m_by_type[type_idx].push_back(handle)
17. return {handle, RegisterError::None}
Remove algorithm¶
1. Acquire unique_lock(m_mutex)
2. record = m_handles.Access(handle)
3. If record == nullptr → return false
4. m_by_uuid.erase(record->UUID)
5. m_by_path.erase(record->Path)
6. Erase handle from m_by_type[record->Type] (swap with back, pop)
7. m_handles.Remove(handle) ← bumps generation; slot reusable
8. return true
Swap-and-pop is used in step 6 because type buckets are unsorted and Array does not support
O(1) middle removal. The order of handles in a type bucket is undefined — callers must not
depend on insertion order.
FindByType span safety¶
FindByType returns a std::span into m_by_type[i].Data(). The span is invalidated by any
Register or Remove call that touches the same type bucket. Callers that iterate the span
must hold m_mutex (as a shared_lock) for the entire iteration, or copy the handles out first.
The AssetRegistry::Query API (§9) always copies results into a caller-owned array, so it is
safe without an explicit lock on the caller side.
6. DependencyGraph — forward + reverse adjacency¶
6.1 Concepts¶
An edge A → B means "asset A depends on asset B". Examples:
- A mesh (A) depends on its material (B).
- A material (A) depends on its albedo texture (B).
When B is modified (hot-reload), all assets that depend on B — directly or transitively —
must be invalidated. This is the reverse graph: given B, find all A where A → B.
DependencyGraph maintains:
- Forward: for each asset UUID, the set of UUIDs it directly depends on.
- Reverse: for each asset UUID, the set of UUIDs that directly depend on it.
Both directions are needed:
- Forward is used to validate that dependencies are registered before a dependent.
- Reverse is used for hot-reload cascade enumeration (BFS, §8).
6.2 DependencyGraph.h¶
// ZEngine/VFS/Registry/DependencyGraph.h
#pragma once
#include <Core/Containers/Array.h>
#include <Core/Containers/UnorderedHashMap.h>
#include <Core/Memory/Allocator.h>
#include <VFS/Registry/AssetRecord.h> // UUIDHasher
#include <uuid.h>
#include <functional>
#include <shared_mutex>
#include <span>
namespace ZEngine::Core::VFS
{
// AdjacencyList — arena-backed list of UUIDs.
// Inline storage for small lists (up to INLINE_CAPACITY) avoids arena
// allocations for the common case of a mesh with 1–3 materials.
constexpr uint32_t DEP_INLINE_CAPACITY = 8;
struct AdjacencyList
{
uuids::uuid InlineStorage[DEP_INLINE_CAPACITY] = {};
uint32_t InlineCount = 0;
// Overflow into arena when inline capacity exceeded
Core::Containers::Array<uuids::uuid> Overflow = {};
// Total count (inline + overflow)
uint32_t Count() const
{
return InlineCount + static_cast<uint32_t>(Overflow.size());
}
bool Empty() const { return Count() == 0; }
// Get element i. No bounds check in release builds.
uuids::uuid operator[](uint32_t i) const
{
if (i < InlineCount) return InlineStorage[i];
return Overflow[i - InlineCount];
}
// Returns true if uuid is already present.
bool Contains(const uuids::uuid& uuid) const;
// Append uuid (if not already present). Returns false if already present.
bool Append(const uuids::uuid& uuid, Core::Memory::ArenaAllocator* arena);
// Remove uuid. Returns false if not found.
bool Erase(const uuids::uuid& uuid);
};
// DependencyGraph
class DependencyGraph
{
public:
DependencyGraph() = default;
~DependencyGraph() = default;
void Initialize(Core::Memory::ArenaAllocator* arena);
// ── Mutation ─────────────────────────────────────────────────────────
// AddEdge(dependent, dependency): asset `dependent` uses asset `dependency`.
// Both UUIDs must refer to registered assets (caller's responsibility).
// Returns false if the edge already exists.
bool AddEdge(const uuids::uuid& dependent, const uuids::uuid& dependency);
// RemoveEdge: removes a single directed edge.
// Returns false if no such edge existed.
bool RemoveEdge(const uuids::uuid& dependent, const uuids::uuid& dependency);
// RemoveAsset: removes all edges involving `uuid` (both as source and target).
// Call this before AssetIndex::Remove so that stale UUIDs are not retained.
void RemoveAsset(const uuids::uuid& uuid);
// ── Query ─────────────────────────────────────────────────────────────
// Direct dependencies of `uuid` (assets that `uuid` depends ON).
// Returned span is valid only while m_mutex is held by caller as shared_lock.
// Prefer CopyDependencies for use outside the lock.
void CopyDependencies(const uuids::uuid& uuid,
Core::Containers::Array<uuids::uuid>& out) const;
// Direct dependents of `uuid` (assets that depend ON `uuid`).
void CopyDependents(const uuids::uuid& uuid,
Core::Containers::Array<uuids::uuid>& out) const;
// Returns true if `dependent` directly depends on `dependency`.
bool HasEdge(const uuids::uuid& dependent, const uuids::uuid& dependency) const;
// ── Cascade enumeration (used by hot-reload, §8) ───────────────────
// Fills `out` with all assets that must be invalidated when `changed`
// is hot-reloaded, using BFS over the reverse (dependent) edges.
// `changed` itself is included at index 0.
// Detects cycles (an asset cannot depend on itself transitively) via
// a visited set; cycles are silently broken (the second visit is skipped).
void CollectCascade(const uuids::uuid& changed,
Core::Containers::Array<uuids::uuid>& out,
Core::Memory::ArenaAllocator* scratch) const;
// ── Stats ──────────────────────────────────────────────────────────
uint32_t EdgeCount() const; // total directed edges
uint32_t NodeCount() const; // distinct UUIDs in the graph
private:
// Both maps are keyed by UUID.
// m_forward[A] = list of B where A→B (A depends on B)
// m_reverse[B] = list of A where A→B (B is depended on by A)
Core::Containers::UnorderedHashMap<
uuids::uuid, AdjacencyList, UUIDHasher> m_forward = {};
Core::Containers::UnorderedHashMap<
uuids::uuid, AdjacencyList, UUIDHasher> m_reverse = {};
mutable std::shared_mutex m_mutex = {};
Core::Memory::ArenaAllocator* m_arena = nullptr;
uint32_t m_edges = 0;
};
} // namespace ZEngine::Core::VFS
6.3 Implementation Notes¶
AdjacencyList::Append¶
bool AdjacencyList::Append(const uuids::uuid& uuid, Core::Memory::ArenaAllocator* arena)
{
if (Contains(uuid)) return false;
if (InlineCount < DEP_INLINE_CAPACITY)
{
InlineStorage[InlineCount++] = uuid;
return true;
}
Overflow.push_back(uuid); // arena-backed Array::push_back
return true;
}
AdjacencyList::Erase¶
Scan InlineStorage for uuid.
If found at i:
If Overflow not empty:
InlineStorage[i] = Overflow.back(); Overflow.pop_back()
Else if i != InlineCount-1:
InlineStorage[i] = InlineStorage[--InlineCount]
Else:
--InlineCount
Return true.
Scan Overflow (swap-and-pop).
Return false if not found.
DependencyGraph::AddEdge¶
1. Acquire unique_lock(m_mutex)
2. Get or insert m_forward[dependent]
3. If Append(dependency) returns false (already exists) → return false
4. Get or insert m_reverse[dependency]
5. Append(dependent) to reverse list
6. ++m_edges
7. return true
DependencyGraph::RemoveAsset¶
1. Acquire unique_lock(m_mutex)
2. Forward cleanup:
fwd = m_forward[uuid]
For each dep in fwd:
m_reverse[dep].Erase(uuid)
--m_edges
m_forward.erase(uuid)
3. Reverse cleanup:
rev = m_reverse[uuid]
For each src in rev:
m_forward[src].Erase(uuid)
--m_edges
m_reverse.erase(uuid)
DependencyGraph::CollectCascade¶
See §8 for the full BFS algorithm listing. CollectCascade is the only method that uses a
scratch arena (for the BFS visited set / queue). The scratch arena must be provided by the
caller (typically AssetRegistry's internal scratch sub-arena).
7. AssetRegistry — facade¶
7.1 Hot-Reload Callback¶
// ZEngine/VFS/Registry/AssetRegistry.h
#pragma once
#include <Core/Memory/Allocator.h>
#include <VFS/Registry/AssetIndex.h>
#include <VFS/Registry/DependencyGraph.h>
#include <VFS/VFSFileWatcher.h> // VFSWatchEvent
#include <VFS/VFSScanner.h> // ScanStats (forward compat)
#include <VFS/Meta/MetaFileIO.h>
#include <functional>
#include <span>
namespace ZEngine::Core::VFS
{
7.2 AssetRegistry.h¶
// AssetRegistry
class AssetRegistry
{
public:
AssetRegistry() = default;
~AssetRegistry() = default;
// arena: persistent arena (owns AssetIndex + DependencyGraph storage)
// scratch_size: byte size of the scratch sub-arena (default 64 KiB)
void Initialize(Core::Memory::ArenaAllocator* arena,
uint64_t scratch_size = 65536);
// ── Registration ─────────────────────────────────────────────────────
// Register asset from its MetaFileData (called by VFSScanner callback).
// type is inferred from file extension if not provided explicitly.
RegisterResult Register(const uuids::uuid& uuid,
Managers::AssetType type,
const VFSPath& path,
const MetaFileData& meta);
// Convenience: register and immediately mark as Loaded.
RegisterResult RegisterLoaded(const uuids::uuid& uuid,
Managers::AssetType type,
const VFSPath& path,
const MetaFileData& meta,
Managers::AssetHandle legacy_handle);
// Remove the record and purge its dependency edges.
bool Remove(const uuids::uuid& uuid);
bool Remove(Helpers::Handle<AssetRecord> handle);
// ── Dependency management ─────────────────────────────────────────────
// Declare that `dependent` uses `dependency`.
bool AddDependency(const uuids::uuid& dependent, const uuids::uuid& dependency);
bool RemoveDependency(const uuids::uuid& dependent, const uuids::uuid& dependency);
// ── State transitions ─────────────────────────────────────────────────
bool SetState(const uuids::uuid& uuid, AssetState new_state);
bool UpdateMeta(const uuids::uuid& uuid,
const MetaFileData& new_meta,
AssetState new_state = AssetState::Loaded);
// ── Lookup (delegates to AssetIndex) ─────────────────────────────────
AssetRecord* FindByUUID(const uuids::uuid& uuid);
const AssetRecord* FindByUUID(const uuids::uuid& uuid) const;
AssetRecord* FindByPath(const VFSPath& path);
const AssetRecord* FindByPath(const VFSPath& path) const;
// ── Query (see §9) ────────────────────────────────────────────────────
struct QueryFilter
{
// Set to a valid type to restrict results.
// Set to nullopt to return all types.
std::optional<Managers::AssetType> Type = std::nullopt;
// Non-null: only return records where Name contains this substring.
const char* NameLike = nullptr;
// Non-null: only return records where VFSPath ends with this extension
// (e.g. ".glb", ".png"). Comparison is case-insensitive.
const char* Ext = nullptr;
// Only return records with this state.
// Set to nullopt to return records of any state.
std::optional<AssetState> State = std::nullopt;
};
struct QueryResult
{
Core::Containers::Array<Helpers::Handle<AssetRecord>> Handles;
uint32_t Count = 0;
};
// Execute a query. Results written into caller-supplied array.
// arena: scratch arena for the result array (caller owns lifetime).
QueryResult Query(const QueryFilter& filter,
Core::Memory::ArenaAllocator* arena) const;
// ── Hot-reload ────────────────────────────────────────────────────────
void SetHotReloadCallback(void* ctx, void (*cb)(void*, std::span<const uuids::uuid>));
// Called by VFSFileWatcher integration (§10) when a source asset changes.
// Computes BFS cascade and fires the HotReloadCallback.
// path: the VFSPath of the modified asset.
void OnAssetModified(const VFSPath& path);
// Called by VFSFileWatcher integration when an asset is deleted on disk.
void OnAssetDeleted(const VFSPath& path);
// Called by VFSFileWatcher integration when an asset is renamed on disk.
void OnAssetRenamed(const VFSPath& old_path, const VFSPath& new_path);
// ── VFSScanner integration (§10) ─────────────────────────────────────
// Called per-file from the scanner's ScanComplete callback.
// Reads the .meta file for path, registers the asset if not already known,
// or updates it if the SHA changed (Stale).
void OnScanFileDiscovered(IVFSContext& ctx,
const VFSPath& path,
Managers::AssetType type);
// ── Stats / diagnostics ───────────────────────────────────────────────
uint32_t RecordCount() const;
uint32_t EdgeCount() const;
// Write a DOT-format dependency graph to `out_buf` (for debugging).
// out_buf: caller-supplied buffer; out_len: buffer capacity.
// Returns number of bytes written (0 if buffer too small).
uint32_t DumpGraphDOT(char* out_buf, uint32_t out_len) const;
private:
AssetIndex m_index = {};
DependencyGraph m_graph = {};
void* m_reload_ctx = nullptr;
void (*m_reload_cb)(void*, std::span<const uuids::uuid>) = nullptr;
// Scratch sub-arena: used for BFS queue/visited in CollectCascade.
// Created from the persistent arena passed to Initialize.
Core::Memory::ArenaAllocator m_scratch = {};
// Helpers
static Managers::AssetType InferTypeFromExtension(const VFSPath& path);
static bool ExtensionMatches(const VFSPath& path,
const char* ext);
};
} // namespace ZEngine::Core::VFS
7.3 Implementation Notes¶
Initialize¶
void AssetRegistry::Initialize(Core::Memory::ArenaAllocator* arena, uint64_t scratch_size)
{
m_scratch = arena->CreateSubArena(scratch_size);
m_index.Initialize(arena);
m_graph.Initialize(arena);
}
RegisterLoaded¶
RegisterResult AssetRegistry::RegisterLoaded(
const uuids::uuid& uuid, Managers::AssetType type,
const VFSPath& path, const MetaFileData& meta,
Managers::AssetHandle legacy_handle)
{
RegisterResult result = m_index.Register(uuid, type, path, meta);
if (!result.IsOk()) return result;
AssetRecord* rec = m_index.Access(result.Handle);
ZENGINE_VALIDATE_ASSERT(rec != nullptr, "Register succeeded but Access returned null")
rec->SlotHandle = legacy_handle;
rec->State = AssetState::Loaded;
return result;
}
Remove¶
bool AssetRegistry::Remove(const uuids::uuid& uuid)
{
m_graph.RemoveAsset(uuid); // purge edges first
auto h = m_index.FindByUUID(uuid);
if (!h.Valid()) return false;
return m_index.Remove(h);
}
InferTypeFromExtension¶
Managers::AssetType AssetRegistry::InferTypeFromExtension(const VFSPath& path)
{
auto fn = path.Filename();
if (!fn.Succeeded()) return Managers::AssetType::MESH;
cstring name = fn.Value().CStr();
// Walk backwards to find extension
const char* dot = nullptr;
for (const char* p = name; *p; ++p) if (*p == '.') dot = p;
if (!dot) return Managers::AssetType::MESH;
if (std::strcmp(dot, ".png") == 0 ||
std::strcmp(dot, ".jpg") == 0 ||
std::strcmp(dot, ".jpeg") == 0 ||
std::strcmp(dot, ".hdr") == 0 ||
std::strcmp(dot, ".ktx") == 0 ||
std::strcmp(dot, ".ktx2") == 0)
return Managers::AssetType::TEXTURE;
// .glb, .gltf, .fbx, .obj → could be MESH or MESH_HIERARCHY.
// Default to MESH; the importer sets MESH_HIERARCHY after parsing.
return Managers::AssetType::MESH;
}
8. Hot-Reload Cascade Algorithm¶
8.1 Trigger¶
Hot-reload is triggered by VFSFileWatcher when a source asset file receives a
WatchEventKind::Modified event. The chain is:
VFSFileWatcher::Tick()
→ VFSWatchEvent(Kind=Modified, Path="/project/textures/rock_albedo.png")
→ AssetRegistry::OnAssetModified(VFSPath("/project/textures/rock_albedo.png"))
→ CollectCascade(changed_uuid, ...)
→ HotReloadCallback(cascade)
8.2 OnAssetModified¶
void AssetRegistry::OnAssetModified(const VFSPath& path)
{
auto handle = m_index.FindByPath(path);
if (!handle.Valid()) return; // untracked file; ignore
AssetRecord* rec = m_index.Access(handle);
if (!rec) return;
// Mark source asset Stale
m_index.SetState(handle, AssetState::Stale);
// Compute cascade
m_scratch.Clear(); // reset scratch arena for BFS
Core::Containers::Array<uuids::uuid> cascade;
cascade.init(&m_scratch, 0, 64);
m_graph.CollectCascade(rec->UUID, cascade, &m_scratch);
// Mark all cascade members Stale
for (uint32_t i = 1; i < cascade.size(); ++i) // i=0 is rec itself, already marked
{
auto dep_h = m_index.FindByUUID(cascade[i]);
if (dep_h.Valid())
m_index.SetState(dep_h, AssetState::Stale);
}
// Fire callback
if (m_reload_cb)
m_reload_cb(std::span<const uuids::uuid>(cascade.Data(), cascade.size()));
}
8.3 DependencyGraph::CollectCascade — BFS over reverse edges¶
void DependencyGraph::CollectCascade(
const uuids::uuid& changed,
Core::Containers::Array<uuids::uuid>& out,
Core::Memory::ArenaAllocator* scratch) const
{
// visited set — dynamic open-addressing hash set backed by scratch arena.
// Initial capacity 256 (covers typical dependency chains). Doubles on 66% load.
// Using the scratch arena means no heap allocation and automatic cleanup when
// the caller resets the arena after CollectCascade returns.
uint32_t visited_capacity = 256;
uint32_t visited_count = 0;
uuids::uuid* visited = ZPushArray(scratch, uuids::uuid, visited_capacity);
std::memset(visited, 0, visited_capacity * sizeof(uuids::uuid)); // nil = empty slot
auto visited_rehash = [&]() {
uint32_t new_cap = visited_capacity * 2;
uuids::uuid* new_table = ZPushArray(scratch, uuids::uuid, new_cap);
std::memset(new_table, 0, new_cap * sizeof(uuids::uuid));
for (uint32_t i = 0; i < visited_capacity; ++i)
{
if (visited[i].is_nil()) continue;
uint64_t h = UUIDHasher{}(visited[i]) % new_cap;
for (uint32_t probe = 0; probe < new_cap; ++probe)
{
uint64_t slot = (h + probe) % new_cap;
if (new_table[slot].is_nil()) { new_table[slot] = visited[i]; break; }
}
}
visited = new_table;
visited_capacity = new_cap;
};
auto visited_contains = [&](const uuids::uuid& id) -> bool {
uint64_t h = UUIDHasher{}(id) % visited_capacity;
for (uint32_t probe = 0; probe < visited_capacity; ++probe)
{
uint64_t slot = (h + probe) % visited_capacity;
if (visited[slot].is_nil()) return false;
if (visited[slot] == id) return true;
}
return false;
};
auto visited_insert = [&](const uuids::uuid& id) {
// Grow before inserting if >66% full — keeps linear-probe performance stable.
if (visited_count >= visited_capacity * 2 / 3)
visited_rehash();
uint64_t h = UUIDHasher{}(id) % visited_capacity;
for (uint32_t probe = 0; probe < visited_capacity; ++probe)
{
uint64_t slot = (h + probe) % visited_capacity;
if (visited[slot].is_nil() || visited[slot] == id)
{
visited[slot] = id;
++visited_count;
return;
}
}
};
// BFS queue backed by scratch arena
Core::Containers::Array<uuids::uuid> queue;
queue.init(scratch, 0, 64);
// Enqueue root
out.push_back(changed);
visited_insert(changed);
queue.push_back(changed);
std::shared_lock lock(m_mutex);
while (!queue.empty())
{
uuids::uuid current = queue.back();
queue.pop_back();
auto it = m_reverse.find(current);
if (it == m_reverse.end()) continue; // no dependents
const AdjacencyList& dependents = it->second;
for (uint32_t i = 0; i < dependents.Count(); ++i)
{
const uuids::uuid& dep = dependents[i];
if (!visited_contains(dep))
{
visited_insert(dep);
out.push_back(dep);
queue.push_back(dep);
}
}
}
// out[0] = changed (root); out[1..] = dependents in BFS order
}
Cycle protection: the visited set ensures that even if a cycle exists in the dependency graph
(which indicates an authoring error), the BFS terminates. Correction: CollectCascade returns
void, not VFSResult<void>, and has no failure path on visited-set growth. If the visited table
exceeds its load-factor threshold, it rehashes into a larger table (doubling capacity) and continues
the BFS — it does not fail with VFSError::OutOfMemory or abort the cascade. The caller (e.g.,
OnAssetModified) does not need to handle an out-of-memory case here; the only bound is the actual
heap/arena capacity backing the rehash.
Thread safety: CollectCascade acquires m_mutex as shared_lock for the duration of the
BFS walk. OnAssetModified calls it after acquiring no other locks — no lock inversion with
AssetIndex::m_mutex.
Complexity: O(V + E) where V = cascade size, E = edges traversed. For typical game assets (a material depending on 3–8 textures, a mesh depending on 1–4 materials), cascade sizes are small (< 20 assets) and the walk is sub-microsecond.
9. AssetRegistry::Query API¶
9.1 Design¶
Query is an editor-side API — not on the hot rendering path. It is O(N) where N is the number
of live records, but N is typically small (thousands at most) and the operation is called at
interactive speeds (on keystrokes, not per-frame).
QueryResult AssetRegistry::Query(const QueryFilter& filter,
Core::Memory::ArenaAllocator* arena) const
{
QueryResult result{};
result.Handles.init(arena, 0, 64);
if (filter.Type.has_value())
{
// Fast path: iterate only the type bucket
auto type_handles = m_index.FindByType(filter.Type.value());
// Copy type_handles before releasing internal shared_lock
Core::Containers::Array<Helpers::Handle<AssetRecord>> type_copy;
type_copy.init(arena, 0, static_cast<uint32_t>(type_handles.size()));
for (auto& h : type_handles) type_copy.push_back(h);
for (uint32_t i = 0; i < type_copy.size(); ++i)
{
const AssetRecord* rec = m_index.Access(type_copy[i]);
if (!rec) continue;
if (!PassesFilter(*rec, filter)) continue;
result.Handles.push_back(type_copy[i]);
}
}
else
{
// Full scan — used only when no type filter is specified
m_index.ForEach([&](const AssetRecord& rec) {
if (!PassesFilter(rec, filter)) return;
auto h = m_index.FindByUUID(rec.UUID);
if (h.Valid()) result.Handles.push_back(h);
});
}
result.Count = static_cast<uint32_t>(result.Handles.size());
return result;
}
9.2 PassesFilter predicate¶
// File-local helper
static bool PassesFilter(const AssetRecord& rec, const AssetRegistry::QueryFilter& f)
{
// State filter
if (f.State.has_value() && rec.State != f.State.value())
return false;
// Name substring
if (f.NameLike != nullptr && f.NameLike[0] != '\0')
{
// Case-insensitive substring search on rec.Name
// KMPSearch or simple strstr; rec.Name is always null-terminated
if (std::strstr(rec.Name, f.NameLike) == nullptr)
return false;
}
// Extension filter
if (f.Ext != nullptr && f.Ext[0] != '\0')
{
if (!AssetRegistry::ExtensionMatches(rec.Path, f.Ext))
return false;
}
return true;
}
9.3 ExtensionMatches¶
bool AssetRegistry::ExtensionMatches(const VFSPath& path, const char* ext)
{
cstring raw = path.CStr();
size_t rlen = std::strlen(raw);
size_t elen = std::strlen(ext);
if (elen == 0 || rlen < elen) return false;
const char* suffix = raw + rlen - elen;
for (size_t i = 0; i < elen; ++i)
if (::tolower(static_cast<unsigned char>(suffix[i])) !=
::tolower(static_cast<unsigned char>(ext[i])))
return false;
return true;
}
9.4 Usage examples¶
// All loaded textures
auto result = registry.Query(
{.Type = Managers::AssetType::TEXTURE, .State = AssetState::Loaded},
&scratch_arena);
// All .glb files regardless of type
auto result = registry.Query(
{.Ext = ".glb"},
&scratch_arena);
// All meshes whose name contains "tank"
auto result = registry.Query(
{.Type = Managers::AssetType::MESH, .NameLike = "tank"},
&scratch_arena);
// All stale assets (any type)
auto result = registry.Query(
{.State = AssetState::Stale},
&scratch_arena);
10. Integration with VFSScanner and VFSFileWatcher¶
10.1 VFSScanner integration¶
VFSScanner already calls MetaFileIO::GetOrCreate for each discovered asset file (Ticket 5).
In this ticket, VFSScanner::ScanDirectory additionally calls AssetRegistry::OnScanFileDiscovered
for asset-extension files.
// In VFSScanner::ScanDirectory, after MetaFileIO::GetOrCreate succeeds:
if (meta.IsOk() && m_registry != nullptr)
{
Managers::AssetType type = AssetRegistry::InferTypeFromExtension(entry.VFSPath);
m_registry->OnScanFileDiscovered(*m_ctx, entry.VFSPath, type);
}
AssetRegistry::OnScanFileDiscovered:
void AssetRegistry::OnScanFileDiscovered(
IVFSContext& ctx, const VFSPath& path, Managers::AssetType type)
{
// Read the .meta sidecar (already written by MetaFileIO::GetOrCreate in scanner)
auto meta_result = MetaFileIO::Read(ctx, path);
if (!meta_result.IsOk()) return;
const MetaFileData& meta = meta_result.Value();
// Already registered? → check for SHA change
auto existing = m_index.FindByPath(path);
if (existing.Valid())
{
AssetRecord* rec = m_index.Access(existing);
if (rec && rec->Meta.SourceHash != meta.SourceHash)
{
// SHA changed → mark Stale
m_index.SetState(existing, AssetState::Stale);
m_index.Update(existing, meta, AssetState::Stale);
}
return;
}
// New asset → register it
Register(meta.AssetUUID, type, path, meta);
}
VFSScanner gets a pointer to AssetRegistry injected at construction:
// In VFSScanner.h — add member:
AssetRegistry* m_registry = nullptr;
// New setter:
void SetAssetRegistry(AssetRegistry* registry) { m_registry = registry; }
10.2 VFSFileWatcher integration¶
VFSContext (or Editor) wires the watcher's callback to the registry:
// VFSContext::InitWatcher() — extend existing registration (Ticket 4):
m_file_watcher->Watch(
m_project_root_native.c_str(),
/*recursive=*/true,
[this](const VFSWatchEvent& ev) {
// Existing: invalidate cache and request rescan
m_directory_cache->Invalidate(ev.Path);
m_scanner->RequestScan(VFSPath::FromNative(ev.Path).Value());
// New: notify registry
if (m_registry == nullptr) return;
VFSPath vfs_path = VFSPath::Parse(ev.Path).Value();
switch (ev.Kind)
{
case WatchEventKind::Modified:
m_registry->OnAssetModified(vfs_path);
break;
case WatchEventKind::Deleted:
m_registry->OnAssetDeleted(vfs_path);
break;
case WatchEventKind::Renamed:
{
VFSPath old_path = VFSPath::Parse(ev.OldPath).Value();
m_registry->OnAssetRenamed(old_path, vfs_path);
break;
}
default:
break;
}
});
AssetRegistry::OnAssetDeleted:
void AssetRegistry::OnAssetDeleted(const VFSPath& path)
{
auto handle = m_index.FindByPath(path);
if (!handle.Valid()) return;
AssetRecord* rec = m_index.Access(handle);
if (!rec) return;
// Fire cascade so dependents can respond (e.g. show placeholder)
m_scratch.Clear();
Core::Containers::Array<uuids::uuid> cascade;
cascade.init(&m_scratch, 0, 16);
m_graph.CollectCascade(rec->UUID, cascade, &m_scratch);
if (m_reload_cb && !cascade.empty())
m_reload_cb(std::span<const uuids::uuid>(cascade.Data(), cascade.size()));
// Remove from registry
Remove(rec->UUID);
}
AssetRegistry::OnAssetRenamed:
void AssetRegistry::OnAssetRenamed(const VFSPath& old_path, const VFSPath& new_path)
{
auto handle = m_index.FindByPath(old_path);
if (!handle.Valid()) return;
m_index.Rename(handle, new_path);
}
11. Migration Plan — AssetManager call sites¶
The migration is structured as four incremental phases. Each phase can be reviewed and merged independently without breaking the existing import pipeline.
Phase 1 — Add AssetRegistry to AssetManager (non-breaking)¶
Add AssetRegistry* Registry = nullptr; to AssetManager. Wire its initialization in
AssetManager::Initialize:
// AssetManager.h — add field:
ZEngine::VFS::AssetRegistry* Registry = nullptr;
// AssetManager.cpp — Initialize:
static ZEngine::VFS::AssetRegistry s_registry;
s_registry.Initialize(&instance->Arena);
instance->Registry = &s_registry;
Nothing else changes. All existing UUIDToHandle / HandleToUUID paths continue to work.
Phase 2 — Redirect RegisterAsset to write into both old maps and the registry¶
// AssetManager.cpp — RegisterAsset (MODIFIED):
Managers::AssetHandle AssetManager::RegisterAsset(
AssetType type, const uuids::uuid& uuid, uint32_t asset_id)
{
AssetHandle h = CreateHandle(asset_id, type);
// Existing maps — kept during migration
Instance()->UUIDToHandle.insert({uuid, h});
Instance()->HandleToUUID.insert({h, uuid});
// NEW: registry
if (Instance()->Registry != nullptr)
{
VFS::VFSPath path = {}; // filled in Phase 3
VFS::MetaFileData meta = {}; // filled in Phase 3
meta.AssetUUID = uuid;
Instance()->Registry->RegisterLoaded(uuid, type, path, meta, h);
}
return h;
}
Phase 3 — Pass VFSPath and MetaFileData through the importer pipeline¶
Extend AssetImporterOutput to carry VFSPath and MetaFileData (populated by the scanner
in Ticket 5). Then in RegisterAsset, pass them to RegisterLoaded.
Before:
After:
struct AssetImporterOutput
{
const char* FilePath = nullptr;
VFS::VFSPath VFSAssetPath = {}; // NEW
VFS::MetaFileData Meta = {}; // NEW
// ...
};
Phase 4 — Remove UUIDToHandle / HandleToUUID from AssetManager¶
Once all call sites of UUIDToHandle and HandleToUUID have been redirected to
AssetRegistry::FindByUUID / AssetRegistry::FindByPath, remove the two maps from
AssetManager:
// REMOVE from AssetManager:
Core::Containers::UnorderedHashMap<uuids::uuid, AssetHandle> UUIDToHandle;
Core::Containers::UnorderedHashMap<AssetHandle, uuids::uuid> HandleToUUID;
Call site redirections in Phase 4:
| Old pattern | New pattern |
|---|---|
AssetManager::Instance()->UUIDToHandle.at(id) |
Registry->FindByUUID(id)->SlotHandle |
AssetManager::Instance()->HandleToUUID.at(h) |
Registry->FindByUUID(uuid)->SlotHandle == h check |
UUIDToHandle.contains(id) |
Registry->FindByUUID(id) != nullptr |
GetAsset<T, uuids::uuid>(id) template |
unchanged (uses UUIDToHandle internally; redirect in Phase 4) |
Migration validation checklist¶
After Phase 4 is complete, add a debug-mode assertion in AssetManager::RegisterAsset that
UUIDToHandle and HandleToUUID are never written to, then remove the assertion and the maps
in the final cleanup commit.
12. Unit Tests¶
File: ZEngine/tests/VFS/AssetRegistryTest.cpp
Add VFS/*.cpp to the glob in tests/CMakeLists.txt:
file(GLOB TEST_SOURCES
Memory/*.cpp
Containers/*.cpp
Maths/*.cpp
Misc/*.cpp
VFS/*.cpp) # covers MetaFileIOTest + AssetRegistryTest
All tests use MemoryVFSContext (from Ticket 5) and a stack-allocated ArenaAllocator of
sufficient size (4 MiB is ample for all registry tests).
Test 1 — UUID lookup returns correct record¶
TEST(AssetRegistry, UUIDLookupReturnsCorrectRecord)
{
Core::Memory::ArenaAllocator arena;
arena.Initialize(4 * 1024 * 1024);
VFS::AssetRegistry registry;
registry.Initialize(&arena);
uuids::uuid uuid = uuids::uuid_random_generator{}();
VFSPath path = VFSPath::Parse("/project/mesh.glb").Value();
VFS::MetaFileData meta{};
meta.AssetUUID = uuid;
auto result = registry.Register(uuid, Managers::AssetType::MESH, path, meta);
ASSERT_TRUE(result.IsOk());
const VFS::AssetRecord* rec = registry.FindByUUID(uuid);
ASSERT_NE(rec, nullptr);
EXPECT_EQ(rec->UUID, uuid);
EXPECT_EQ(rec->Type, Managers::AssetType::MESH);
EXPECT_STREQ(rec->Path.CStr(), "/project/mesh.glb");
}
Test 2 — VFSPath lookup returns correct record¶
TEST(AssetRegistry, PathLookupReturnsCorrectRecord)
{
Core::Memory::ArenaAllocator arena;
arena.Initialize(4 * 1024 * 1024);
VFS::AssetRegistry registry;
registry.Initialize(&arena);
uuids::uuid uuid = uuids::uuid_random_generator{}();
VFSPath path = VFSPath::Parse("/project/textures/diffuse.png").Value();
VFS::MetaFileData meta{};
meta.AssetUUID = uuid;
registry.Register(uuid, Managers::AssetType::TEXTURE, path, meta);
const VFS::AssetRecord* rec = registry.FindByPath(path);
ASSERT_NE(rec, nullptr);
EXPECT_EQ(rec->UUID, uuid);
EXPECT_EQ(rec->Type, Managers::AssetType::TEXTURE);
}
Test 3 — Type query returns only matching records¶
TEST(AssetRegistry, TypeQueryReturnsOnlyMatchingType)
{
Core::Memory::ArenaAllocator arena;
arena.Initialize(4 * 1024 * 1024);
VFS::AssetRegistry registry;
registry.Initialize(&arena);
// Register 2 meshes and 1 texture
for (int i = 0; i < 2; ++i)
{
uuids::uuid uuid = uuids::uuid_random_generator{}();
char path_buf[64];
snprintf(path_buf, sizeof(path_buf), "/project/mesh%d.glb", i);
VFSPath path = VFSPath::Parse(path_buf).Value();
VFS::MetaFileData meta{}; meta.AssetUUID = uuid;
registry.Register(uuid, Managers::AssetType::MESH, path, meta);
}
{
uuids::uuid uuid = uuids::uuid_random_generator{}();
VFSPath path = VFSPath::Parse("/project/tex.png").Value();
VFS::MetaFileData meta{}; meta.AssetUUID = uuid;
registry.Register(uuid, Managers::AssetType::TEXTURE, path, meta);
}
VFS::AssetRegistry::QueryResult meshes = registry.Query(
{.Type = Managers::AssetType::MESH}, &arena);
EXPECT_EQ(meshes.Count, 2u);
VFS::AssetRegistry::QueryResult textures = registry.Query(
{.Type = Managers::AssetType::TEXTURE}, &arena);
EXPECT_EQ(textures.Count, 1u);
}
Test 4 — Dependency registration, forward and reverse lookup¶
TEST(AssetRegistry, DependencyRegistrationForwardAndReverse)
{
Core::Memory::ArenaAllocator arena;
arena.Initialize(4 * 1024 * 1024);
VFS::AssetRegistry registry;
registry.Initialize(&arena);
uuids::uuid mesh_uuid = uuids::uuid_random_generator{}();
uuids::uuid mat_uuid = uuids::uuid_random_generator{}();
uuids::uuid tex_uuid = uuids::uuid_random_generator{}();
auto reg = [&](uuids::uuid id, Managers::AssetType t, const char* p) {
VFSPath path = VFSPath::Parse(p).Value();
VFS::MetaFileData meta{}; meta.AssetUUID = id;
registry.Register(id, t, path, meta);
};
reg(mesh_uuid, Managers::AssetType::MESH, "/p/mesh.glb");
reg(mat_uuid, Managers::AssetType::MATERIAL, "/p/mat.glb");
reg(tex_uuid, Managers::AssetType::TEXTURE, "/p/tex.png");
// mesh → mat → tex
EXPECT_TRUE(registry.AddDependency(mesh_uuid, mat_uuid));
EXPECT_TRUE(registry.AddDependency(mat_uuid, tex_uuid));
// Forward: mesh depends on mat
Core::Containers::Array<uuids::uuid> deps;
deps.init(&arena, 0, 8);
// Access via DependencyGraph directly (through registry's graph accessor if exposed,
// or via the registry's internal graph — white-box test)
// Here we verify indirectly via cascade:
Core::Containers::Array<uuids::uuid> cascade;
cascade.init(&arena, 0, 8);
// tex changes → cascade: tex, mat, mesh
// (requires access to graph; expose a const getter if needed)
// For a grey-box test, trigger OnAssetModified and capture the callback:
std::vector<uuids::uuid> fired;
registry.SetHotReloadCallback([&](std::span<const uuids::uuid> c) {
for (auto& u : c) fired.push_back(u);
});
VFSPath tex_path = VFSPath::Parse("/p/tex.png").Value();
registry.OnAssetModified(tex_path);
ASSERT_EQ(fired.size(), 3u);
EXPECT_EQ(fired[0], tex_uuid); // root first
// mat and mesh in BFS order — both must be present
EXPECT_NE(std::find(fired.begin(), fired.end(), mat_uuid), fired.end());
EXPECT_NE(std::find(fired.begin(), fired.end(), mesh_uuid), fired.end());
}
Test 5 — Cascade walk: diamond dependency produces each node once¶
TEST(AssetRegistry, CascadeDiamondNoDuplicates)
{
// Diamond: mesh → matA → tex
// mesh → matB → tex
// Modifying tex should cascade to: tex, matA, matB, mesh (each exactly once)
Core::Memory::ArenaAllocator arena;
arena.Initialize(4 * 1024 * 1024);
VFS::AssetRegistry registry;
registry.Initialize(&arena);
auto mk = [&](const char* p, Managers::AssetType t) -> uuids::uuid {
uuids::uuid id = uuids::uuid_random_generator{}();
VFSPath path = VFSPath::Parse(p).Value();
VFS::MetaFileData meta{}; meta.AssetUUID = id;
registry.Register(id, t, path, meta);
return id;
};
uuids::uuid tex = mk("/p/tex.png", Managers::AssetType::TEXTURE);
uuids::uuid matA = mk("/p/matA.glb", Managers::AssetType::MATERIAL);
uuids::uuid matB = mk("/p/matB.glb", Managers::AssetType::MATERIAL);
uuids::uuid mesh = mk("/p/mesh.glb", Managers::AssetType::MESH);
registry.AddDependency(matA, tex);
registry.AddDependency(matB, tex);
registry.AddDependency(mesh, matA);
registry.AddDependency(mesh, matB);
std::vector<uuids::uuid> fired;
registry.SetHotReloadCallback([&](std::span<const uuids::uuid> c) {
for (auto& u : c) fired.push_back(u);
});
registry.OnAssetModified(VFSPath::Parse("/p/tex.png").Value());
// Exactly 4 unique UUIDs; no duplicates
ASSERT_EQ(fired.size(), 4u);
std::sort(fired.begin(), fired.end());
EXPECT_EQ(std::unique(fired.begin(), fired.end()), fired.end());
}
Test 6 — Duplicate registration returns DuplicateUUID error¶
TEST(AssetRegistry, DuplicateUUIDRegistrationFails)
{
Core::Memory::ArenaAllocator arena;
arena.Initialize(4 * 1024 * 1024);
VFS::AssetRegistry registry;
registry.Initialize(&arena);
uuids::uuid uuid = uuids::uuid_random_generator{}();
VFSPath path = VFSPath::Parse("/p/mesh.glb").Value();
VFS::MetaFileData meta{}; meta.AssetUUID = uuid;
auto r1 = registry.Register(uuid, Managers::AssetType::MESH, path, meta);
ASSERT_TRUE(r1.IsOk());
// Same UUID, different path
VFSPath path2 = VFSPath::Parse("/p/other.glb").Value();
auto r2 = registry.Register(uuid, Managers::AssetType::MESH, path2, meta);
EXPECT_FALSE(r2.IsOk());
EXPECT_EQ(r2.Error, VFS::RegisterError::DuplicateUUID);
// Same path, different UUID
uuids::uuid uuid2 = uuids::uuid_random_generator{}();
VFS::MetaFileData meta2{}; meta2.AssetUUID = uuid2;
auto r3 = registry.Register(uuid2, Managers::AssetType::MESH, path, meta2);
EXPECT_FALSE(r3.IsOk());
EXPECT_EQ(r3.Error, VFS::RegisterError::DuplicatePath);
// Total count still 1
EXPECT_EQ(registry.RecordCount(), 1u);
}
Test 7 — Remove erases all indices and dependency edges¶
TEST(AssetRegistry, RemovePurgesAllIndices)
{
Core::Memory::ArenaAllocator arena;
arena.Initialize(4 * 1024 * 1024);
VFS::AssetRegistry registry;
registry.Initialize(&arena);
uuids::uuid mesh_uuid = uuids::uuid_random_generator{}();
uuids::uuid mat_uuid = uuids::uuid_random_generator{}();
auto reg = [&](uuids::uuid id, Managers::AssetType t, const char* p) {
VFSPath path = VFSPath::Parse(p).Value();
VFS::MetaFileData meta{}; meta.AssetUUID = id;
registry.Register(id, t, path, meta);
};
reg(mesh_uuid, Managers::AssetType::MESH, "/p/mesh.glb");
reg(mat_uuid, Managers::AssetType::MATERIAL, "/p/mat.glb");
registry.AddDependency(mesh_uuid, mat_uuid);
EXPECT_EQ(registry.RecordCount(), 2u);
EXPECT_EQ(registry.EdgeCount(), 1u);
registry.Remove(mat_uuid);
EXPECT_EQ(registry.RecordCount(), 1u);
EXPECT_EQ(registry.EdgeCount(), 0u); // edge purged by RemoveAsset
EXPECT_EQ(registry.FindByUUID(mat_uuid), nullptr);
EXPECT_EQ(registry.FindByPath(VFSPath::Parse("/p/mat.glb").Value()), nullptr);
// Type query should no longer include mat
VFS::AssetRegistry::QueryResult mats = registry.Query(
{.Type = Managers::AssetType::MATERIAL}, &arena);
EXPECT_EQ(mats.Count, 0u);
}
Test 8 — Query by name substring¶
TEST(AssetRegistry, QueryByNameSubstring)
{
Core::Memory::ArenaAllocator arena;
arena.Initialize(4 * 1024 * 1024);
VFS::AssetRegistry registry;
registry.Initialize(&arena);
auto reg = [&](const char* p, Managers::AssetType t) {
uuids::uuid id = uuids::uuid_random_generator{}();
VFSPath path = VFSPath::Parse(p).Value();
VFS::MetaFileData meta{}; meta.AssetUUID = id;
registry.Register(id, t, path, meta);
};
reg("/p/tank_body.glb", Managers::AssetType::MESH);
reg("/p/tank_turret.glb", Managers::AssetType::MESH);
reg("/p/jeep.glb", Managers::AssetType::MESH);
reg("/p/tank_albedo.png", Managers::AssetType::TEXTURE);
// Name substring "tank" → 3 results (tank_body, tank_turret, tank_albedo)
auto result = registry.Query({.NameLike = "tank"}, &arena);
EXPECT_EQ(result.Count, 3u);
// Name + type filter → only mesh tanks (2)
auto result2 = registry.Query(
{.Type = Managers::AssetType::MESH, .NameLike = "tank"}, &arena);
EXPECT_EQ(result2.Count, 2u);
// Name not found → 0
auto result3 = registry.Query({.NameLike = "helicopter"}, &arena);
EXPECT_EQ(result3.Count, 0u);
}
Test 9 — Query by extension¶
TEST(AssetRegistry, QueryByExtension)
{
Core::Memory::ArenaAllocator arena;
arena.Initialize(4 * 1024 * 1024);
VFS::AssetRegistry registry;
registry.Initialize(&arena);
auto reg = [&](const char* p, Managers::AssetType t) {
uuids::uuid id = uuids::uuid_random_generator{}();
VFSPath path = VFSPath::Parse(p).Value();
VFS::MetaFileData meta{}; meta.AssetUUID = id;
registry.Register(id, t, path, meta);
};
reg("/p/a.glb", Managers::AssetType::MESH);
reg("/p/b.gltf", Managers::AssetType::MESH);
reg("/p/c.png", Managers::AssetType::TEXTURE);
reg("/p/d.PNG", Managers::AssetType::TEXTURE); // uppercase extension
auto glb = registry.Query({.Ext = ".glb"}, &arena);
auto png = registry.Query({.Ext = ".png"}, &arena);
auto gltf = registry.Query({.Ext = ".gltf"}, &arena);
EXPECT_EQ(glb.Count, 1u);
EXPECT_EQ(gltf.Count, 1u);
EXPECT_EQ(png.Count, 2u); // case-insensitive; .png matches .PNG
}
Test 10 — SetState transitions propagate; cascade fires only Stale assets¶
TEST(AssetRegistry, StateTransitionAndCascadeMarksStale)
{
Core::Memory::ArenaAllocator arena;
arena.Initialize(4 * 1024 * 1024);
VFS::AssetRegistry registry;
registry.Initialize(&arena);
uuids::uuid tex = uuids::uuid_random_generator{}();
uuids::uuid mat = uuids::uuid_random_generator{}();
auto reg = [&](uuids::uuid id, Managers::AssetType t, const char* p) {
VFSPath path = VFSPath::Parse(p).Value();
VFS::MetaFileData meta{}; meta.AssetUUID = id;
registry.Register(id, t, path, meta);
};
reg(tex, Managers::AssetType::TEXTURE, "/p/tex.png");
reg(mat, Managers::AssetType::MATERIAL, "/p/mat.glb");
registry.AddDependency(mat, tex);
// Initial state: both Registered
EXPECT_EQ(registry.FindByUUID(tex)->State, VFS::AssetState::Registered);
EXPECT_EQ(registry.FindByUUID(mat)->State, VFS::AssetState::Registered);
// Simulate load
registry.SetState(tex, VFS::AssetState::Loaded);
registry.SetState(mat, VFS::AssetState::Loaded);
// Trigger hot-reload on tex
int cb_count = 0;
registry.SetHotReloadCallback([&](std::span<const uuids::uuid>) { ++cb_count; });
registry.OnAssetModified(VFSPath::Parse("/p/tex.png").Value());
EXPECT_EQ(cb_count, 1);
// Both should now be Stale
EXPECT_EQ(registry.FindByUUID(tex)->State, VFS::AssetState::Stale);
EXPECT_EQ(registry.FindByUUID(mat)->State, VFS::AssetState::Stale);
}
13. Deliverables Checklist¶
[ ] ZEngine/VFS/Registry/AssetRecord.h
AssetState enum (6 values)
AssetRecord struct (UUID, Type, Path, Name[128], Meta, SlotHandle, State)
[ ] ZEngine/VFS/Registry/AssetIndex.h
UUIDHasher struct (FNV-1a over uuid bytes)
VFSPathHasher struct (delegates to VFSPath::Hash())
RegisterError enum (4 values)
RegisterResult struct
AssetIndex class (MAX_ASSETS = 8192)
Initialize(arena)
Register(uuid, type, path, meta) → RegisterResult
Update(handle, meta, state) → bool
SetState(handle, state) → bool
Remove(handle) → bool
Rename(handle, new_path) → bool
Access(handle) → AssetRecord* (2 overloads)
FindByUUID → Handle
FindByPath → Handle
FindByType → span<Handle>
FindByNameSubstring → uint32_t
IsLive(handle) → bool
ForEach(visitor)
Count() / Capacity()
[ ] ZEngine/VFS/Registry/AssetIndex.cpp
Initialize: m_handles.Initialize; m_by_type[i] pre-alloc
Register: unique_lock; duplicate checks; slot alloc; 4 index writes
Remove: unique_lock; 3 index erases; swap-and-pop from type bucket; m_handles.Remove
Rename: unique_lock; erase old path index; update record; insert new path index
FindByUUID / FindByPath: shared_lock; hash lookup
FindByType: shared_lock; return span
FindByNameSubstring: shared_lock; linear scan
ForEach: shared_lock; iterate HandleManager slots 0..Head
[ ] ZEngine/VFS/Registry/DependencyGraph.h
AdjacencyList struct (inline 8 + arena overflow)
DependencyGraph class
Initialize(arena)
AddEdge / RemoveEdge / RemoveAsset
CopyDependencies / CopyDependents / HasEdge
CollectCascade(changed, out, scratch)
EdgeCount / NodeCount
[ ] ZEngine/VFS/Registry/DependencyGraph.cpp
AdjacencyList::Contains / Append / Erase
AddEdge: unique_lock; dual insert (forward + reverse); ++m_edges
RemoveEdge: unique_lock; dual erase; --m_edges
RemoveAsset: unique_lock; forward + reverse cleanup; edge counter
CollectCascade: shared_lock; BFS with inline visited table; populate out
[ ] ZEngine/VFS/Registry/AssetRegistry.h
QueryFilter struct (Type, NameLike, Ext, State)
QueryResult struct (Handles array, Count)
AssetRegistry class
Initialize(arena, scratch_size=65536)
Register / RegisterLoaded / Remove(uuid) / Remove(handle)
AddDependency / RemoveDependency
SetState(uuid, state) / UpdateMeta
FindByUUID / FindByPath (2 overloads each)
Query(filter, arena) → QueryResult
SetHotReloadCallback(ctx, cb)
OnAssetModified / OnAssetDeleted / OnAssetRenamed
OnScanFileDiscovered
RecordCount / EdgeCount
DumpGraphDOT(buf, len) → uint32_t
[ ] ZEngine/VFS/Registry/AssetRegistry.cpp
Initialize: m_scratch = arena->CreateSubArena; m_index.Initialize; m_graph.Initialize
Register / RegisterLoaded: delegate to m_index; set SlotHandle if provided
Remove(uuid): m_graph.RemoveAsset → m_index.Remove
OnAssetModified: FindByPath; SetState(Stale); CollectCascade; mark all Stale; fire cb
OnAssetDeleted: cascade fire; Remove
OnAssetRenamed: Rename in index only
OnScanFileDiscovered: MetaFileIO::Read; Update or Register
Query: type-bucket fast path or ForEach full scan; PassesFilter predicate
InferTypeFromExtension: extension → AssetType mapping
ExtensionMatches: case-insensitive suffix compare
DumpGraphDOT: iterate m_graph, write "digraph{...}" DOT syntax
[ ] VFSScanner.h — add AssetRegistry* m_registry and SetAssetRegistry()
[ ] VFSScanner.cpp — call registry->OnScanFileDiscovered after MetaFileIO::GetOrCreate
[ ] VFSContext.cpp / Editor wiring — extend Watch callback to call
OnAssetModified / OnAssetDeleted / OnAssetRenamed
[ ] AssetManager.h — add AssetRegistry* Registry field
[ ] AssetManager.cpp
Phase 1: Initialize s_registry in AssetManager::Initialize
Phase 2: RegisterAsset writes to both old maps and registry (dual-write)
Phase 3: AssetImporterOutput extended with VFSPath + MetaFileData fields
[ ] tests/VFS/AssetRegistryTest.cpp — 10 tests:
1. UUIDLookupReturnsCorrectRecord
2. PathLookupReturnsCorrectRecord
3. TypeQueryReturnsOnlyMatchingType
4. DependencyRegistrationForwardAndReverse
5. CascadeDiamondNoDuplicates
6. DuplicateUUIDRegistrationFails
7. RemovePurgesAllIndices
8. QueryByNameSubstring
9. QueryByExtension
10. StateTransitionAndCascadeMarksStale
[ ] Manual smoke tests:
- Start editor, observe all assets registered (log RecordCount > 0)
- Modify a texture file in Finder → watcher fires → cascade logged for dependent materials
- Delete a material → remove propagates, dependent mesh shows placeholder state
- Rename a mesh file → FindByPath with old path returns null; new path returns record
- Query "tank" in asset browser → AssetRegistry::Query used; no directory_iterator
[ ] Verify grep -r "UUIDToHandle\|HandleToUUID" ZEngine/ZEngine/ after Phase 4 → zero results