Ticket 5 — .meta Sidecars, MetaFileIO, and Stable UUID Persistence¶
Priority: P2 — Implement after Ticket 4
Status: Implemented; retained as a completed ticket record.
Depends on: vfs-ticket4-filewatcher.md
Blocks: vfs-ticket6, scene-serialization.md, import-pipeline.md
Goal: Assign a stable UUID to every project asset on first import; persist that UUID in a
.meta sidecar file (JSON, committed to VCS); load the UUID on subsequent imports instead of
generating a new one. This fixed the pre-ticket UUID-per-launch instability and
made stable asset references available to scene serialization.
Current boundary:
.metasidecars persist asset UUID and importer/source metadata.MetaFileData::Statusis runtime-only; registry lifecycle is held separately inAssetState. Stable asset UUIDs are available today, but the production scene-document schema and staged UUID reference resolution remain work owned byscene-serialization.md. Detailed snippets below are historical where their include paths or surrounding importer integration differ fromZEngine/ZEngine/Core/VFS/Meta/.
1. Problem Statement¶
Before this ticket, imports generated UUIDs ad hoc. That made editor restarts produce different UUIDs for the same file, breaking:
- Scene YAML files that reference
MeshUUID,MaterialUUID, etc. - Incremental re-import (engine cannot tell if the asset changed or was re-imported)
- Multi-engineer workflows (each local checkout has different UUIDs in memory)
The fix: treat the .meta file as the ground truth for UUID. If mesh.glb.meta exists,
read the UUID from it. If not, generate once, write it, never regenerate.
2. Public API¶
2.1 ImportStatus¶
// ZEngine/VFS/Meta/ImportStatus.h
#pragma once
#include <cstdint>
namespace ZEngine::Core::VFS
{
enum class ImportStatus : uint8_t
{
Unknown = 0, // not yet evaluated
UpToDate = 1, // .meta exists, source SHA matches → skip
Stale = 2, // .meta exists, source SHA differs → reimport
New = 3, // no .meta found → first import
};
}
2.2 MetaFileData¶
// ZEngine/VFS/Meta/MetaFileData.h
#pragma once
#include <Core/ZEngineDef.h>
#include <uuid.h>
#include <VFS/Meta/ImportStatus.h>
namespace ZEngine::Core::VFS
{
constexpr uint32_t META_MAX_SETTINGS = 32;
struct MetaKeyValuePair
{
char Key[64] = {};
char Value[128] = {};
};
struct MetaFileData
{
uuids::uuid AssetUUID = {};
char ImporterName[64] = {};
uint64_t SourceHash = 0;
char SourcePath[256] = {};
int64_t LastImportTimeNs = 0;
char ArtifactPath[MAX_FILE_PATH_COUNT] = {};
MetaKeyValuePair Settings[META_MAX_SETTINGS] = {};
uint32_t SettingsCount = 0;
// Runtime only — NOT written to or read from JSON
ImportStatus Status = ImportStatus::Unknown;
};
}
2.3 MetaFileIO¶
// ZEngine/VFS/Meta/MetaFileIO.h
#pragma once
#include <VFS/IVFSContext.h>
#include <VFS/Meta/MetaFileData.h>
#include <VFS/VFSResult.h>
namespace ZEngine::Core::VFS
{
class MetaFileIO
{
public:
// Derive sidecar path: "/project/mesh.glb" → "/project/mesh.glb.meta"
static VFSPath MetaPathFor(const VFSPath& asset_path);
// Read .meta from VFS; fills MetaFileData. Status = Unknown if parse fails.
static VFSResult<MetaFileData> Read(IVFSContext& ctx, const VFSPath& asset_path);
// Write MetaFileData to .meta (creates or overwrites).
// MetaFileIO::Write uses the atomic write protocol:
// 1. Serialize JSON to a memory buffer
// 2. Open <path>.tmp for Write | Create | Truncate
// 3. Write the buffer
// 4. Flush and close
// 5. Rename <path>.tmp → <path> (atomic on POSIX via rename(2); uses ReplaceFileW on Windows)
// If the process crashes between steps 2-4, <path>.tmp is left on disk and
// cleaned up on the next VFSScanner run (tmp files older than 60s are deleted).
// The original <path>.meta is never modified until the rename succeeds.
static VFSResult<void> Write(IVFSContext& ctx, const VFSPath& asset_path,
const MetaFileData& data);
// High-level helper used by scanner/importer:
// - If .meta exists and SHA matches → return UpToDate
// - If .meta exists and SHA differs → update SHA, write, return Stale
// - If no .meta → generate UUID, write, return New
static VFSResult<MetaFileData> GetOrCreate(IVFSContext& ctx,
const VFSPath& asset_path,
const char* importer_name,
uint64_t current_hash);
static VFSResult<uint64_t> ComputeHash(IVFSContext& ctx, const VFSPath& path);
};
}
3. JSON Schema¶
A .meta file is a UTF-8 JSON object. Example for mesh.glb.meta:
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"importer": "AssimpImporter",
"source_hash": 12345678901234567890,
"source_path": "/project/mesh.glb",
"import_time_ns": 1748000000000000000,
"artifact_path": "/.cache/mesh_550e8400.zasset",
"settings": [
{ "key": "GenerateLODs", "value": "false" },
{ "key": "FlipUVs", "value": "true" }
]
}
Fields not present in the schema are silently ignored on read (forward compatibility).
ImportStatus is a runtime field only — it MUST NOT appear in the JSON.
4. Implementation: MetaFileIO::Read¶
Use nlohmann_json no-exception API throughout:
VFSResult<MetaFileData> MetaFileIO::Read(IVFSContext& ctx, const VFSPath& asset_path)
{
VFSPath meta_path = MetaPathFor(asset_path);
auto open_result = ctx.OpenFile(meta_path, VFSOpenFlags::Read);
if (!open_result.IsOk())
return VFSResult<MetaFileData>::Fail(open_result.Error());
auto& file = open_result.Value();
uint64_t size = file->Size();
// Read into a small stack buffer or arena — avoid heap when file is small
Core::Containers::Array<char> buf;
buf.Resize(size + 1);
file->ReadAt(buf.Data(), size, 0);
buf[size] = '\0';
auto j = nlohmann::json::parse(buf.Data(), nullptr, /*allow_exceptions=*/false);
if (j.is_discarded())
return VFSResult<MetaFileData>::Fail(VFSError::InvalidData);
MetaFileData out{};
if (j.contains("uuid") && j["uuid"].is_string())
{
auto parsed = uuids::uuid::from_string(j["uuid"].get<std::string>());
if (parsed.has_value())
out.AssetUUID = parsed.value();
}
auto copy_str = [](const std::string& src, char* dst, size_t cap) {
size_t n = std::min(src.size(), cap - 1);
std::memcpy(dst, src.data(), n);
dst[n] = '\0';
};
if (j.contains("importer") && j["importer"].is_string())
copy_str(j["importer"].get<std::string>(), out.ImporterName, sizeof(out.ImporterName));
if (j.contains("source_hash") && j["source_hash"].is_number_unsigned())
out.SourceHash = j["source_hash"].get<uint64_t>();
if (j.contains("source_path") && j["source_path"].is_string())
copy_str(j["source_path"].get<std::string>(), out.SourcePath, sizeof(out.SourcePath));
if (j.contains("import_time_ns") && j["import_time_ns"].is_number_integer())
out.LastImportTimeNs = j["import_time_ns"].get<int64_t>();
if (j.contains("artifact_path") && j["artifact_path"].is_string())
copy_str(j["artifact_path"].get<std::string>(), out.ArtifactPath, sizeof(out.ArtifactPath));
if (j.contains("settings") && j["settings"].is_array())
{
for (auto& s : j["settings"])
{
if (out.SettingsCount >= META_MAX_SETTINGS) break;
if (!s.contains("key") || !s.contains("value")) continue;
auto& kv = out.Settings[out.SettingsCount++];
copy_str(s["key"].get<std::string>(), kv.Key, sizeof(kv.Key));
copy_str(s["value"].get<std::string>(), kv.Value, sizeof(kv.Value));
}
}
out.Status = ImportStatus::Unknown; // caller sets this
return VFSResult<MetaFileData>::Ok(out);
}
5. Implementation: MetaFileIO::GetOrCreate¶
VFSResult<MetaFileData> MetaFileIO::GetOrCreate(
IVFSContext& ctx,
const VFSPath& asset_path,
const char* importer_name,
uint64_t current_hash)
{
auto read_result = Read(ctx, asset_path);
if (read_result.IsOk())
{
MetaFileData& existing = read_result.Value();
if (existing.SourceHash == current_hash)
{
existing.Status = ImportStatus::UpToDate;
return VFSResult<MetaFileData>::Ok(existing);
}
// Hash changed → update and rewrite
existing.SourceHash = current_hash;
existing.LastImportTimeNs = NowNs();
existing.Status = ImportStatus::Stale;
Write(ctx, asset_path, existing); // best-effort; ignore error
return VFSResult<MetaFileData>::Ok(existing);
}
// No .meta, or .meta is corrupt/invalid → generate a fresh UUID.
// Both VFSError::NotFound (file absent) and VFSError::InvalidData (corrupt JSON
// or missing uuid field) fall through here. In both cases we treat this as a
// first-import: generate a new UUID and write a clean .meta file.
MetaFileData fresh{};
fresh.AssetUUID = uuids::uuid_random_generator{}();
std::strncpy(fresh.ImporterName, importer_name, sizeof(fresh.ImporterName) - 1);
fresh.SourceHash = current_hash;
fresh.LastImportTimeNs = NowNs();
fresh.Status = ImportStatus::New;
Write(ctx, asset_path, fresh);
return VFSResult<MetaFileData>::Ok(fresh);
}
NowNs() is a file-local helper:
static int64_t NowNs()
{
return std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
}
6. AssetManager Modifications¶
6.1 New method: GetOrCreateUUID¶
// ZEngine/Managers/AssetManager.h — add to struct AssetManager
struct AssetManager
{
// ... existing fields ...
// Returns the stable UUID for asset_path.
// Reads .meta if present; generates + writes one if not.
// Never returns a null UUID.
static uuids::uuid GetOrCreateUUID(
VFS::IVFSContext& ctx,
const VFS::VFSPath& asset_path,
const char* importer_name);
};
6.2 Remove UUID generation from AssimpImporter¶
Before (problematic):
After:
// AssimpImporter.cpp — REPLACE with
auto hash_result = VFS::MetaFileIO::ComputeHash(ctx, vfs_path);
uint64_t hash = hash_result.IsOk() ? hash_result.Value() : 0;
auto meta = VFS::MetaFileIO::GetOrCreate(ctx, vfs_path, "AssimpImporter", hash);
asset.MeshUUID = meta.IsOk() ? meta.Value().AssetUUID : uuids::uuid_random_generator{}();
The fallback uuid_random_generator is kept for the case where VFS is not yet available
(e.g., unit tests that construct AssetMesh directly).
7. Scanner Integration¶
VFSScanner::ScanDirectory is extended to call MetaFileIO::GetOrCreate for every discovered
asset file. Add a scan result counter:
// VFSScanner.h — add to ScanStats
struct ScanStats
{
uint32_t FilesVisited = 0;
uint32_t DirsVisited = 0;
uint32_t MetasCreated = 0; // NEW
uint32_t MetasUpdated = 0; // NEW (SHA changed)
uint32_t MetasUpToDate = 0; // NEW
};
In ScanDirectory, after pushing a file entry to the cache:
if (IsAssetExtension(entry.Name))
{
auto hash_result = MetaFileIO::ComputeHash(*m_ctx, entry.VFSPath);
uint64_t hash = hash_result.IsOk() ? hash_result.Value() : 0;
auto meta = MetaFileIO::GetOrCreate(*m_ctx, entry.VFSPath,
"VFSScanner", hash);
if (meta.IsOk())
{
switch (meta.Value().Status)
{
case ImportStatus::New: ++m_stats.MetasCreated; break;
case ImportStatus::Stale: ++m_stats.MetasUpdated; break;
case ImportStatus::UpToDate: ++m_stats.MetasUpToDate; break;
default: break;
}
}
}
IsAssetExtension checks for .glb, .gltf, .fbx, .png, .jpg, .hdr, .ktx.
8. VCS Integration Notes¶
.metafiles MUST be committed to version control alongside the assets they describe.- The canonical
.gitignoreentry for this project should not exclude*.meta. ArtifactPath(the compiled.zassetbinary) should be in.gitignore(it's a build artifact).- During CI import,
GetOrCreatereturnsUpToDatefor all files → no UUID churn, deterministic builds.
9. Unit Tests¶
File: ZEngine/tests/VFS/MetaFileIOTest.cpp
Test 1 — Round-trip: write then read returns identical data¶
TEST(MetaFileIO, RoundTrip)
{
MemoryVFSContext ctx;
VFSPath path = VFSPath::Parse("/project/mesh.glb").Value();
MetaFileData in{};
in.AssetUUID = uuids::uuid_random_generator{}();
snprintf(in.ImporterName, sizeof(in.ImporterName), "AssimpImporter");
in.SourceHash = 0xabc123def456abc1ULL;
snprintf(in.SourcePath, sizeof(in.SourcePath), "/project/mesh.glb");
in.LastImportTimeNs = 1748000000000000000LL;
in.SettingsCount = 1;
snprintf(in.Settings[0].Key, sizeof(in.Settings[0].Key), "FlipUVs");
snprintf(in.Settings[0].Value, sizeof(in.Settings[0].Value), "true");
ASSERT_TRUE(MetaFileIO::Write(ctx, path, in).IsOk());
auto out_result = MetaFileIO::Read(ctx, path);
ASSERT_TRUE(out_result.IsOk());
const MetaFileData& out = out_result.Value();
EXPECT_EQ(in.AssetUUID, out.AssetUUID);
EXPECT_STREQ(in.ImporterName, out.ImporterName);
EXPECT_EQ(in.SourceHash, out.SourceHash);
EXPECT_STREQ(in.SourcePath, out.SourcePath);
EXPECT_EQ(in.LastImportTimeNs, out.LastImportTimeNs);
ASSERT_EQ(out.SettingsCount, 1u);
EXPECT_STREQ(out.Settings[0].Key, "FlipUVs");
EXPECT_STREQ(out.Settings[0].Value, "true");
}
Test 2 — Read returns Fail when file does not exist¶
TEST(MetaFileIO, ReadMissingReturnsError)
{
MemoryVFSContext ctx; // empty filesystem
VFSPath path = VFSPath::Parse("/project/ghost.glb").Value();
auto result = MetaFileIO::Read(ctx, path);
EXPECT_FALSE(result.IsOk());
}
Test 3 — GetOrCreate on missing file creates .meta and returns New¶
TEST(MetaFileIO, GetOrCreateNewFile)
{
MemoryVFSContext ctx;
ctx.WriteFile("/project/mesh.glb", "dummy_binary_content");
VFSPath path = VFSPath::Parse("/project/mesh.glb").Value();
auto result = MetaFileIO::GetOrCreate(ctx, path, "AssimpImporter", 1ULL);
ASSERT_TRUE(result.IsOk());
EXPECT_EQ(result.Value().Status, ImportStatus::New);
EXPECT_FALSE(result.Value().AssetUUID.is_nil());
// .meta file must now exist on VFS
EXPECT_TRUE(ctx.FileExists("/project/mesh.glb.meta"));
}
Test 4 — GetOrCreate with matching SHA returns UpToDate¶
TEST(MetaFileIO, GetOrCreateMatchingSHAReturnsUpToDate)
{
MemoryVFSContext ctx;
VFSPath path = VFSPath::Parse("/project/mesh.glb").Value();
// First call creates it
auto first = MetaFileIO::GetOrCreate(ctx, path, "AssimpImporter", 1ULL);
ASSERT_TRUE(first.IsOk());
// Second call with same SHA
auto second = MetaFileIO::GetOrCreate(ctx, path, "AssimpImporter", 1ULL);
ASSERT_TRUE(second.IsOk());
EXPECT_EQ(second.Value().Status, ImportStatus::UpToDate);
EXPECT_EQ(first.Value().AssetUUID, second.Value().AssetUUID); // UUID must not change
}
Test 5 — GetOrCreate with different SHA returns Stale, UUID unchanged¶
TEST(MetaFileIO, GetOrCreateChangedSHAReturnsStale)
{
MemoryVFSContext ctx;
VFSPath path = VFSPath::Parse("/project/mesh.glb").Value();
auto first = MetaFileIO::GetOrCreate(ctx, path, "AssimpImporter", 1ULL);
ASSERT_TRUE(first.IsOk());
uuids::uuid original_uuid = first.Value().AssetUUID;
auto second = MetaFileIO::GetOrCreate(ctx, path, "AssimpImporter", 2ULL);
ASSERT_TRUE(second.IsOk());
EXPECT_EQ(second.Value().Status, ImportStatus::Stale);
EXPECT_EQ(second.Value().AssetUUID, original_uuid); // UUID preserved across reimport
}
Test 6 — MetaPathFor appends .meta suffix¶
TEST(MetaFileIO, MetaPathForAppendsMetaSuffix)
{
VFSPath asset = VFSPath::Parse("/project/textures/diffuse.png").Value();
VFSPath meta = MetaFileIO::MetaPathFor(asset);
EXPECT_STREQ(meta.CStr(), "/project/textures/diffuse.png.meta");
}
Test 7 — Truncated key/value strings do not overflow buffers¶
TEST(MetaFileIO, OverlongSettingsAreTruncated)
{
MemoryVFSContext ctx;
VFSPath path = VFSPath::Parse("/project/x.glb").Value();
// Build a JSON .meta with a key that is 200 chars long
std::string long_key(200, 'k');
std::string json = R"({"uuid":"550e8400-e29b-41d4-a716-446655440000",)"
R"("importer":"Test","source_hash":0,"source_path":"/project/x.glb","import_time_ns":0,)"
R"("artifact_path":"","settings":[{"key":")" + long_key + R"(","value":"v"}]})";
ctx.WriteFile("/project/x.glb.meta", json);
auto result = MetaFileIO::Read(ctx, path);
ASSERT_TRUE(result.IsOk());
// Key must be null-terminated within 64 bytes
EXPECT_EQ(result.Value().Settings[0].Key[63], '\0');
}
Test 8 — Malformed JSON returns Fail¶
TEST(MetaFileIO, MalformedJSONReturnsError)
{
MemoryVFSContext ctx;
VFSPath path = VFSPath::Parse("/project/x.glb").Value();
ctx.WriteFile("/project/x.glb.meta", "{not valid json{{{{");
auto result = MetaFileIO::Read(ctx, path);
EXPECT_FALSE(result.IsOk());
}
Test 9 — ImportStatus field is never written to JSON¶
TEST(MetaFileIO, StatusNotSerialised)
{
MemoryVFSContext ctx;
VFSPath path = VFSPath::Parse("/project/x.glb").Value();
MetaFileData d{};
d.AssetUUID = uuids::uuid_random_generator{}();
d.Status = ImportStatus::Stale; // should NOT appear in output
MetaFileIO::Write(ctx, path, d);
std::string raw = ctx.ReadRaw("/project/x.glb.meta");
EXPECT_EQ(raw.find("status"), std::string::npos);
EXPECT_EQ(raw.find("stale"), std::string::npos);
}
Test 10 — Settings count capped at META_MAX_SETTINGS¶
TEST(MetaFileIO, SettingsCountCappedAtMax)
{
MemoryVFSContext ctx;
VFSPath path = VFSPath::Parse("/project/x.glb").Value();
// Build JSON with META_MAX_SETTINGS + 5 settings entries
nlohmann::json j;
j["uuid"] = uuids::to_string(uuids::uuid_random_generator{}());
j["importer"] = "Test";
j["source_hash"] = 0;
j["source_path"] = "";
j["import_time_ns"] = 0;
j["artifact_path"] = "";
j["settings"] = nlohmann::json::array();
for (int i = 0; i < static_cast<int>(META_MAX_SETTINGS) + 5; ++i)
j["settings"].push_back({{"key", std::to_string(i)}, {"value", "v"}});
ctx.WriteFile("/project/x.glb.meta", j.dump());
auto result = MetaFileIO::Read(ctx, path);
ASSERT_TRUE(result.IsOk());
EXPECT_EQ(result.Value().SettingsCount, META_MAX_SETTINGS);
}
10. Deliverables Checklist¶
- [x]
ZEngine/VFS/Meta/ImportStatus.h - [x]
ZEngine/VFS/Meta/MetaFileData.h - [x]
ZEngine/VFS/Meta/MetaFileIO.h+MetaFileIO.cpp - [x]
AssetManager::GetOrCreateUUID()added - [x]
AssimpImporter: UUID generation replaced withMetaFileIO::GetOrCreate - [x]
VFSScanner::ScanDirectory:.metacreation integrated,ScanStatsupdated - [x]
tests/VFS/MetaFileIOTest.cpp(10 tests) - [x]
.gitignore: ensure*.metais NOT excluded;*.zassetIS excluded - [x] Manual smoke test: delete
mesh.glb.meta, restart editor → same UUID reappears; modifymesh.glb→ UUID preserved, SHA updated