From 637ead2d2e144bba0deb6da4a9f2048b27e83eb8 Mon Sep 17 00:00:00 2001 From: Jnyfah Date: Sat, 25 Jul 2026 20:40:48 +0200 Subject: [PATCH 1/6] feat(vfs): add VFSMemoryBackend --- ZEngine/ZEngine/Core/VFS/VFSMemoryBackend.cpp | 461 ++++++++++++++++++ ZEngine/ZEngine/Core/VFS/VFSMemoryBackend.h | 99 ++++ ZEngine/tests/Misc/VFSMemoryBackend_test.cpp | 218 +++++++++ 3 files changed, 778 insertions(+) create mode 100644 ZEngine/ZEngine/Core/VFS/VFSMemoryBackend.cpp create mode 100644 ZEngine/ZEngine/Core/VFS/VFSMemoryBackend.h create mode 100644 ZEngine/tests/Misc/VFSMemoryBackend_test.cpp diff --git a/ZEngine/ZEngine/Core/VFS/VFSMemoryBackend.cpp b/ZEngine/ZEngine/Core/VFS/VFSMemoryBackend.cpp new file mode 100644 index 00000000..eac4d08e --- /dev/null +++ b/ZEngine/ZEngine/Core/VFS/VFSMemoryBackend.cpp @@ -0,0 +1,461 @@ +#include +#include +#include + +namespace ZEngine::Core::VFS +{ + bool VFSMemoryFile::IsWriteMode() const + { + return HasFlag(m_flags, VFSOpenFlags::Write) || HasFlag(m_flags, VFSOpenFlags::Append); + } + + VFSResult VFSMemoryFile::Read(Core::Containers::ArrayView buffer, uint64_t offset) + { + if (!m_node || m_closed) + { + return VFSResult::Fail(VFSError::IOError); + } + + const Core::Containers::Array& data = m_node->Data; + if (offset >= data.size()) + { + return VFSResult::Ok(0); + } + + const size_t available = data.size() - static_cast(offset); + const size_t n = buffer.size() < available ? buffer.size() : available; + if (n > 0) + { + Helpers::secure_memcpy(buffer.data(), buffer.size(), data.data() + offset, n); + } + return VFSResult::Ok(n); + } + + VFSResult VFSMemoryFile::Write(Core::Containers::ArrayView buffer, uint64_t offset) + { + if (!IsWriteMode() || m_closed) + { + return VFSResult::Fail(VFSError::Unsupported); + } + + const size_t end = static_cast(offset) + buffer.size(); + if (m_write_buf.capacity() < end) + { + m_write_buf.reserve(end); + } + while (m_write_buf.size() < end) + { + m_write_buf.push(0); + } + if (buffer.size() > 0) + { + Helpers::secure_memcpy(m_write_buf.data() + offset, m_write_buf.size() - static_cast(offset), buffer.data(), buffer.size()); + } + return VFSResult::Ok(buffer.size()); + } + + VFSResult VFSMemoryFile::Size() const + { + if (!m_node) + { + return VFSResult::Fail(VFSError::IOError); + } + const uint64_t sz = IsWriteMode() ? m_write_buf.size() : m_node->Data.size(); + return VFSResult::Ok(sz); + } + + VFSResult VFSMemoryFile::Stat() const + { + if (!m_node) + { + return VFSResult::Fail(VFSError::IOError); + } + VFSFileStat stat; + stat.IsDirectory = m_node->NodeKind == MemNode::Kind::Directory; + stat.SizeBytes = IsWriteMode() ? m_write_buf.size() : m_node->Data.size(); + stat.IsReadOnly = false; + return VFSResult::Ok(stat); + } + + VFSResult VFSMemoryFile::Commit() + { + if (!IsWriteMode() || !m_node || !m_backend_mutex) + { + return VFSResult::Ok(); + } + + std::unique_lock lock(*m_backend_mutex); + const size_t n = m_write_buf.size(); + m_node->Data.init(m_arena, n, n); + if (n > 0) + { + Helpers::secure_memcpy(m_node->Data.data(), n, m_write_buf.data(), n); + } + return VFSResult::Ok(); + } + + VFSResult VFSMemoryFile::Flush() + { + if (m_closed) + { + return VFSResult::Ok(); + } + return Commit(); + } + + const VFSPath& VFSMemoryFile::Path() const + { + return m_node->Path; + } + + VFSResult VFSMemoryFile::Close() + { + if (m_closed) + { + return VFSResult::Ok(); + } + + VFSResult result = VFSResult::Ok(); + if (IsWriteMode()) + { + result = Commit(); + } + m_closed = true; + if (m_read_lock.owns_lock()) + { + m_read_lock.unlock(); + } + return result; + } + + VFSResult VFSMemoryFile::MemoryMap(uint64_t& out_size) + { + if (IsWriteMode()) + { + return VFSResult::Fail(VFSError::Unsupported); + } + if (!m_node) + { + return VFSResult::Fail(VFSError::IOError); + } + out_size = m_node->Data.size(); + return VFSResult::Ok(static_cast(m_node->Data.data())); + } + + VFSMemoryFile::~VFSMemoryFile() + { + if (!m_closed && IsWriteMode()) + { + Commit(); + } + } + + VFSMemoryBackend::VFSMemoryBackend() = default; + + VFSMemoryBackend::~VFSMemoryBackend() + { + std::unique_lock lock(m_mutex); + for (const auto& kv : m_nodes) + { + kv.second->~MemNode(); + } + m_nodes.clear(); + } + + void VFSMemoryBackend::Initialize(Memory::ArenaAllocator* arena) + { + ZENGINE_VALIDATE_ASSERT(arena != nullptr, "VFSMemoryBackend requires a valid arena") + m_arena = arena; + m_nodes.init(arena, 64); + m_file_pool.Initialize(arena, sizeof(VFSMemoryFile) * kVFSMaxOpenMemoryFiles, sizeof(VFSMemoryFile)); + } + + MemNode* VFSMemoryBackend::FindNode(const VFSPath& path) + { + MemNode** slot = m_nodes.find(path.CStr()); + return slot ? *slot : nullptr; + } + + const MemNode* VFSMemoryBackend::FindNode(const VFSPath& path) const + { + const MemNode* const* slot = m_nodes.find(path.CStr()); + return slot ? *slot : nullptr; + } + + MemNode* VFSMemoryBackend::CreateNode(const VFSPath& path, MemNode::Kind kind) + { + MemNode* node = ZPushStructCtor(m_arena, MemNode); + node->NodeKind = kind; + node->Path = path; + m_nodes.insert(node->Path.CStr(), node); + return node; + } + + VFSMemoryFile* VFSMemoryBackend::AllocFile() + { + std::lock_guard pool_lock(m_file_pool_mutex); + void* mem = m_file_pool.Allocate(); + if (!mem) + { + return nullptr; + } + return ZConstruct(mem, VFSMemoryFile); + } + + void VFSMemoryBackend::FreeFile(IVFSFile* file) + { + std::lock_guard pool_lock(m_file_pool_mutex); + m_file_pool.Free(file); + } + + void VFSMemoryBackend::EnsureParentExists(const VFSPath& path) + { + VFSPath parent = path.Parent(); + while (parent.IsValid() && !parent.IsRoot()) + { + if (FindNode(parent)) + { + break; + } + CreateNode(parent, MemNode::Kind::Directory); + parent = parent.Parent(); + } + } + + bool VFSMemoryBackend::HasChildren(const VFSPath& dir) const + { + for (const auto& kv : m_nodes) + { + if (kv.second->Path.Parent() == dir) + { + return true; + } + } + return false; + } + + VFSResult VFSMemoryBackend::Open(const VFSPath& relative_path, VFSOpenFlags flags) + { + const bool wants_write = HasFlag(flags, VFSOpenFlags::Write) || HasFlag(flags, VFSOpenFlags::Append); + + if (wants_write) + { + std::unique_lock lock(m_mutex); + + MemNode* node = FindNode(relative_path); + if (!node) + { + if (!HasFlag(flags, VFSOpenFlags::Write)) + { + return VFSResult::Fail(VFSError::NotFound); + } + EnsureParentExists(relative_path); + node = CreateNode(relative_path, MemNode::Kind::File); + } + if (node->NodeKind == MemNode::Kind::Directory) + { + return VFSResult::Fail(VFSError::NotAFile); + } + + VFSMemoryFile* file = AllocFile(); + if (!file) + { + return VFSResult::Fail(VFSError::OutOfMemory); + } + file->Owner = this; + file->m_node = node; + file->m_flags = flags; + file->m_arena = m_arena; + file->m_backend_mutex = &m_mutex; + + if (HasFlag(flags, VFSOpenFlags::Append) && node->Data.size() > 0) + { + const size_t n = node->Data.size(); + file->m_write_buf.init(m_arena, n, n); + Helpers::secure_memcpy(file->m_write_buf.data(), n, node->Data.data(), n); + } + else + { + file->m_write_buf.init(m_arena, 16); + } + return VFSResult::Ok(file); + } + + std::shared_lock lock(m_mutex); + MemNode* node = FindNode(relative_path); + if (!node) + { + return VFSResult::Fail(VFSError::NotFound); + } + if (node->NodeKind == MemNode::Kind::Directory) + { + return VFSResult::Fail(VFSError::NotAFile); + } + + VFSMemoryFile* file = AllocFile(); + if (!file) + { + return VFSResult::Fail(VFSError::OutOfMemory); + } + file->Owner = this; + file->m_node = node; + file->m_flags = flags; + file->m_arena = m_arena; + file->m_backend_mutex = &m_mutex; + file->m_read_lock = std::move(lock); + return VFSResult::Ok(file); + } + + void VFSMemoryBackend::Close(IVFSFile* file) + { + if (!file) + { + return; + } + file->Close(); + file->~IVFSFile(); + FreeFile(file); + } + + VFSResult VFSMemoryBackend::Stat(const VFSPath& path) const + { + std::shared_lock lock(m_mutex); + const MemNode* node = FindNode(path); + if (!node) + { + return VFSResult::Fail(VFSError::NotFound); + } + VFSFileStat stat; + stat.IsDirectory = node->NodeKind == MemNode::Kind::Directory; + stat.SizeBytes = stat.IsDirectory ? 0 : node->Data.size(); + stat.IsReadOnly = false; + return VFSResult::Ok(stat); + } + + bool VFSMemoryBackend::Exists(const VFSPath& path) const + { + std::shared_lock lock(m_mutex); + return FindNode(path) != nullptr; + } + + VFSResult> VFSMemoryBackend::List(Core::Memory::ArenaAllocator* arena, const VFSPath& dir) const + { + + std::shared_lock lock(m_mutex); + + if (!dir.IsRoot()) + { + const MemNode* dir_node = FindNode(dir); + if (!dir_node) + { + return VFSResult>::Fail(VFSError::NotFound); + } + if (dir_node->NodeKind != MemNode::Kind::Directory) + { + return VFSResult>::Fail(VFSError::NotADirectory); + } + } + + Core::Containers::Array entries; + entries.init(arena, 16); + + for (const auto& kv : m_nodes) + { + const MemNode* node = kv.second; + if (node->Path.Parent() == dir) + { + VFSDirEntry entry; + entry.Path = node->Path; + entry.IsDirectory = node->NodeKind == MemNode::Kind::Directory; + entry.Stat.IsDirectory = entry.IsDirectory; + entry.Stat.SizeBytes = entry.IsDirectory ? 0 : node->Data.size(); + entries.push(entry); + } + } + return VFSResult>::Ok(std::move(entries)); + } + + VFSResult VFSMemoryBackend::CreateDir(const VFSPath& relative_path) + { + std::unique_lock lock(m_mutex); + if (FindNode(relative_path)) + { + return VFSResult::Fail(VFSError::AlreadyExists); + } + EnsureParentExists(relative_path); + CreateNode(relative_path, MemNode::Kind::Directory); + return VFSResult::Ok(); + } + + VFSResult VFSMemoryBackend::Remove(const VFSPath& relative_path) + { + std::unique_lock lock(m_mutex); + MemNode* node = FindNode(relative_path); + if (!node) + { + return VFSResult::Fail(VFSError::NotFound); + } + if (node->NodeKind == MemNode::Kind::Directory && HasChildren(relative_path)) + { + return VFSResult::Fail(VFSError::IOError); + } + m_nodes.remove(relative_path.CStr()); + node->~MemNode(); + return VFSResult::Ok(); + } + + VFSResult VFSMemoryBackend::Rename(const VFSPath& rel_src, const VFSPath& rel_dst) + { + std::unique_lock lock(m_mutex); + MemNode* src = FindNode(rel_src); + if (!src) + { + return VFSResult::Fail(VFSError::NotFound); + } + if (FindNode(rel_dst)) + { + return VFSResult::Fail(VFSError::AlreadyExists); + } + + m_nodes.remove(rel_src.CStr()); + src->Path = rel_dst; + EnsureParentExists(rel_dst); + m_nodes.insert(src->Path.CStr(), src); + return VFSResult::Ok(); + } + + cstring VFSMemoryBackend::BackendType() const + { + return "memory"; + } + + VFSBackendCaps VFSMemoryBackend::Capabilities() const + { + return VFSBackendCaps::Read | VFSBackendCaps::Write | VFSBackendCaps::List | VFSBackendCaps::MemoryMap; + } + + VFSResult VFSMemoryBackend::WriteFile(const VFSPath& path, Core::Containers::ArrayView data) + { + std::unique_lock lock(m_mutex); + + MemNode* node = FindNode(path); + if (!node) + { + EnsureParentExists(path); + node = CreateNode(path, MemNode::Kind::File); + } + else if (node->NodeKind == MemNode::Kind::Directory) + { + return VFSResult::Fail(VFSError::NotAFile); + } + + const size_t n = data.size(); + node->Data.init(m_arena, n, n); + if (n > 0) + { + Helpers::secure_memcpy(node->Data.data(), n, data.data(), n); + } + return VFSResult::Ok(); + } + +} // namespace ZEngine::Core::VFS diff --git a/ZEngine/ZEngine/Core/VFS/VFSMemoryBackend.h b/ZEngine/ZEngine/Core/VFS/VFSMemoryBackend.h new file mode 100644 index 00000000..c8040e1c --- /dev/null +++ b/ZEngine/ZEngine/Core/VFS/VFSMemoryBackend.h @@ -0,0 +1,99 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZEngine::Core::VFS +{ + static constexpr size_t kVFSMaxOpenMemoryFiles = 256; + + struct MemNode + { + enum class Kind : uint8_t + { + File = 0, + Directory = 1 + }; + + Kind NodeKind = Kind::File; + VFSPath Path = {}; + Core::Containers::Array Data = {}; + }; + + struct VFSMemoryFile final : public IVFSFile + { + VFSMemoryFile() = default; + ~VFSMemoryFile() override; + + VFSResult Read(Core::Containers::ArrayView buffer, uint64_t offset) override; + VFSResult Write(Core::Containers::ArrayView buffer, uint64_t offset) override; + VFSResult Size() const override; + VFSResult Stat() const override; + VFSResult Flush() override; + const VFSPath& Path() const override; + VFSResult Close() override; + + VFSResult MemoryMap(uint64_t& out_size); + + private: + friend struct VFSMemoryBackend; + + MemNode* m_node = nullptr; + VFSOpenFlags m_flags = VFSOpenFlags::None; + bool m_closed = false; + + std::shared_lock m_read_lock; + + Core::Containers::Array m_write_buf = {}; + Memory::ArenaAllocator* m_arena = nullptr; + + std::shared_mutex* m_backend_mutex = nullptr; + + bool IsWriteMode() const; + VFSResult Commit(); + }; + + struct VFSMemoryBackend final : public IVFSBackend + { + VFSMemoryBackend(); + ~VFSMemoryBackend() override; + + void Initialize(Memory::ArenaAllocator* arena); + + [[nodiscard]] VFSResult Open(const VFSPath& relative_path, VFSOpenFlags flags) override; + void Close(IVFSFile* file) override; + [[nodiscard]] VFSResult Stat(const VFSPath& path) const override; + bool Exists(const VFSPath& path) const override; + [[nodiscard]] VFSResult> List(Core::Memory::ArenaAllocator* arena, const VFSPath& dir) const override; + [[nodiscard]] VFSResult CreateDir(const VFSPath& relative_path) override; + [[nodiscard]] VFSResult Remove(const VFSPath& relative_path) override; + [[nodiscard]] VFSResult Rename(const VFSPath& rel_src, const VFSPath& rel_dst) override; + cstring BackendType() const override; + VFSBackendCaps Capabilities() const override; + + [[nodiscard]] VFSResult WriteFile(const VFSPath& path, Core::Containers::ArrayView data); + + private: + Core::Containers::UnorderedHashMap m_nodes; + mutable std::shared_mutex m_mutex; + Memory::ArenaAllocator* m_arena = nullptr; + + Memory::PoolAllocator m_file_pool = {}; + std::mutex m_file_pool_mutex; + + MemNode* FindNode(const VFSPath& path); + const MemNode* FindNode(const VFSPath& path) const; + MemNode* CreateNode(const VFSPath& path, MemNode::Kind kind); + void EnsureParentExists(const VFSPath& path); + bool HasChildren(const VFSPath& dir) const; + + VFSMemoryFile* AllocFile(); + void FreeFile(IVFSFile* file); + }; + +} // namespace ZEngine::Core::VFS diff --git a/ZEngine/tests/Misc/VFSMemoryBackend_test.cpp b/ZEngine/tests/Misc/VFSMemoryBackend_test.cpp new file mode 100644 index 00000000..9f0fb9b6 --- /dev/null +++ b/ZEngine/tests/Misc/VFSMemoryBackend_test.cpp @@ -0,0 +1,218 @@ +#include +#include +#include +#include +#include +#include +#include + +using namespace ZEngine; +using namespace ZEngine::Core::VFS; +using namespace ZEngine::Core::Memory; +using ZEngine::Core::Containers::Array; +using ZEngine::Core::Containers::ArrayView; + +namespace +{ + ArrayView Bytes(const void* data, size_t size) + { + return ArrayView(static_cast(data), size); + } +} // namespace + +class VFSMemoryBackendTest : public ::testing::Test +{ +protected: + MemoryManager m_manager; + ArenaAllocator* m_arena = nullptr; + VFSMemoryBackend m_backend; + + void SetUp() override + { + m_manager.Initialize({.BufferSize = ZMega(16)}); + m_arena = &m_manager.MainArena; + m_backend.Initialize(m_arena); + } + + void TearDown() override + { + m_manager.Shutdown(); + } + + VFSPath P(const char* raw) + { + auto result = VFSPath::Parse(raw); + EXPECT_TRUE(result.Succeeded()) << "failed to parse path: " << raw; + return result.Value(); + } +}; + +TEST_F(VFSMemoryBackendTest, WriteFile_and_Read) +{ + const uint8_t png[] = {0x89, 0x50, 0x4e, 0x47}; + ASSERT_TRUE(m_backend.WriteFile(P("/tex/logo.png"), Bytes(png, sizeof(png))).Succeeded()); + + auto open_result = m_backend.Open(P("/tex/logo.png"), VFSOpenFlags::Read); + ASSERT_TRUE(open_result.Succeeded()); + IVFSFile* file = open_result.Value(); + + uint8_t buffer[4] = {}; + auto read = file->Read(ArrayView(buffer, sizeof(buffer)), 0); + ASSERT_TRUE(read.Succeeded()); + EXPECT_EQ(read.Value(), 4u); + EXPECT_EQ(0, Helpers::secure_memcmp(buffer, sizeof(buffer), png, sizeof(png), sizeof(png))); + + m_backend.Close(file); +} + +TEST_F(VFSMemoryBackendTest, MemoryMap_ReturnsZeroCopyPointer) +{ + uint8_t pattern[1024]; + for (size_t i = 0; i < sizeof(pattern); ++i) + { + pattern[i] = static_cast(i & 0xFF); + } + ASSERT_TRUE(m_backend.WriteFile(P("/data.bin"), Bytes(pattern, sizeof(pattern))).Succeeded()); + + auto open_result = m_backend.Open(P("/data.bin"), VFSOpenFlags::Read); + ASSERT_TRUE(open_result.Succeeded()); + auto* file = static_cast(open_result.Value()); + + uint64_t mapped_size = 0; + auto mapped = file->MemoryMap(mapped_size); + ASSERT_TRUE(mapped.Succeeded()); + EXPECT_EQ(mapped_size, 1024u); + EXPECT_EQ(0, Helpers::secure_memcmp(mapped.Value(), mapped_size, pattern, sizeof(pattern), sizeof(pattern))); + + m_backend.Close(file); +} + +TEST_F(VFSMemoryBackendTest, WriteFile_Overwrites_Existing) +{ + ASSERT_TRUE(m_backend.WriteFile(P("/a.txt"), Bytes("hello", 5)).Succeeded()); + ASSERT_TRUE(m_backend.WriteFile(P("/a.txt"), Bytes("world", 5)).Succeeded()); + + auto open_result = m_backend.Open(P("/a.txt"), VFSOpenFlags::Read); + ASSERT_TRUE(open_result.Succeeded()); + IVFSFile* file = open_result.Value(); + + char buffer[5] = {}; + auto read = file->Read(ArrayView(reinterpret_cast(buffer), sizeof(buffer)), 0); + ASSERT_TRUE(read.Succeeded()); + EXPECT_EQ(read.Value(), 5u); + EXPECT_EQ(0, Helpers::secure_memcmp(buffer, sizeof(buffer), "world", 5, 5)); + + m_backend.Close(file); +} + +TEST_F(VFSMemoryBackendTest, Open_Write_Accumulates_And_Commits) +{ + auto open_write = m_backend.Open(P("/b.txt"), VFSOpenFlags::Write); + ASSERT_TRUE(open_write.Succeeded()); + IVFSFile* writer = open_write.Value(); + + const uint8_t first[] = {1, 2, 3}; + const uint8_t second[] = {4, 5}; + ASSERT_TRUE(writer->Write(Bytes(first, sizeof(first)), 0).Succeeded()); + ASSERT_TRUE(writer->Write(Bytes(second, sizeof(second)), 3).Succeeded()); + ASSERT_TRUE(writer->Flush().Succeeded()); + m_backend.Close(writer); + + auto open_read = m_backend.Open(P("/b.txt"), VFSOpenFlags::Read); + ASSERT_TRUE(open_read.Succeeded()); + IVFSFile* reader = open_read.Value(); + + uint8_t buffer[5] = {}; + auto read = reader->Read(ArrayView(buffer, sizeof(buffer)), 0); + ASSERT_TRUE(read.Succeeded()); + EXPECT_EQ(read.Value(), 5u); + const uint8_t expected[] = {1, 2, 3, 4, 5}; + EXPECT_EQ(0, Helpers::secure_memcmp(buffer, sizeof(buffer), expected, sizeof(expected), sizeof(expected))); + + m_backend.Close(reader); +} + +TEST_F(VFSMemoryBackendTest, CreateDir_and_List) +{ + ASSERT_TRUE(m_backend.CreateDir(P("/assets/textures")).Succeeded()); + ASSERT_TRUE(m_backend.WriteFile(P("/assets/textures/a.png"), Bytes(nullptr, 0)).Succeeded()); + ASSERT_TRUE(m_backend.WriteFile(P("/assets/textures/b.png"), Bytes(nullptr, 0)).Succeeded()); + + auto listing = m_backend.List(m_arena, P("/assets/textures")); + ASSERT_TRUE(listing.Succeeded()); + EXPECT_EQ(listing.Value().size(), 2u); +} + +TEST_F(VFSMemoryBackendTest, Remove_File) +{ + const uint8_t byte = 0; + ASSERT_TRUE(m_backend.WriteFile(P("/tmp.bin"), Bytes(&byte, 1)).Succeeded()); + ASSERT_TRUE(m_backend.Remove(P("/tmp.bin")).Succeeded()); + + auto open_result = m_backend.Open(P("/tmp.bin"), VFSOpenFlags::Read); + EXPECT_TRUE(open_result.Failed()); + EXPECT_EQ(open_result.Error(), VFSError::NotFound); +} + +TEST_F(VFSMemoryBackendTest, Rename_File) +{ + const uint8_t data[] = {1, 2}; + ASSERT_TRUE(m_backend.WriteFile(P("/old.txt"), Bytes(data, sizeof(data))).Succeeded()); + ASSERT_TRUE(m_backend.Rename(P("/old.txt"), P("/new.txt")).Succeeded()); + + EXPECT_TRUE(m_backend.Open(P("/old.txt"), VFSOpenFlags::Read).Failed()); + + auto open_new = m_backend.Open(P("/new.txt"), VFSOpenFlags::Read); + ASSERT_TRUE(open_new.Succeeded()); + IVFSFile* file = open_new.Value(); + EXPECT_EQ(file->Size().Value(), 2u); + m_backend.Close(file); +} + +TEST_F(VFSMemoryBackendTest, Capabilities) +{ + const VFSBackendCaps caps = m_backend.Capabilities(); + EXPECT_TRUE(HasCap(caps, VFSBackendCaps::Read)); + EXPECT_TRUE(HasCap(caps, VFSBackendCaps::Write)); + EXPECT_TRUE(HasCap(caps, VFSBackendCaps::List)); + EXPECT_TRUE(HasCap(caps, VFSBackendCaps::MemoryMap)); +} + +TEST_F(VFSMemoryBackendTest, ConcurrentReads_NoDeadlock) +{ + Array data; + data.init(m_arena, 4096, 4096); + for (size_t i = 0; i < data.size(); ++i) + { + data[i] = static_cast(i & 0xFF); + } + ASSERT_TRUE(m_backend.WriteFile(P("/shared.bin"), Bytes(data.data(), data.size())).Succeeded()); + + std::atomic success{0}; + std::vector threads; + threads.reserve(8); + for (int t = 0; t < 8; ++t) + { + threads.emplace_back([&] { + auto open_result = m_backend.Open(P("/shared.bin"), VFSOpenFlags::Read); + if (!open_result.Succeeded()) + { + return; + } + IVFSFile* file = open_result.Value(); + + uint8_t buffer[4096] = {}; + auto read = file->Read(ArrayView(buffer, sizeof(buffer)), 0); + if (read.Succeeded() && read.Value() == 4096u) + { + ++success; + } + m_backend.Close(file); + }); + } + for (auto& thread : threads) + { + thread.join(); + } + EXPECT_EQ(success.load(), 8); +} From d9956cd35d78e7b4766803fca2536d9a46f6d46d Mon Sep 17 00:00:00 2001 From: Jnyfah Date: Sun, 26 Jul 2026 13:34:06 +0200 Subject: [PATCH 2/6] feat(vfs): add VFSDirectoryCache --- .../ZEngine/Core/VFS/VFSDirectoryCache.cpp | 80 +++++++++ ZEngine/ZEngine/Core/VFS/VFSDirectoryCache.h | 46 +++++ ZEngine/tests/Misc/VFSDirectoryCache_test.cpp | 167 ++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 ZEngine/ZEngine/Core/VFS/VFSDirectoryCache.cpp create mode 100644 ZEngine/ZEngine/Core/VFS/VFSDirectoryCache.h create mode 100644 ZEngine/tests/Misc/VFSDirectoryCache_test.cpp diff --git a/ZEngine/ZEngine/Core/VFS/VFSDirectoryCache.cpp b/ZEngine/ZEngine/Core/VFS/VFSDirectoryCache.cpp new file mode 100644 index 00000000..eee76339 --- /dev/null +++ b/ZEngine/ZEngine/Core/VFS/VFSDirectoryCache.cpp @@ -0,0 +1,80 @@ +#include +#include + +namespace ZEngine::Core::VFS +{ + + void VFSDirectoryCache::Initialize(Memory::ArenaAllocator* arena) + { + m_arena = arena; + m_entries.init(arena, 64); + } + + Core::Containers::ArrayView VFSDirectoryCache::GetListing(const VFSPath& dir) const + { + std::shared_lock lock(m_mutex); + const CacheEntry* entry = m_entries.find(dir.Hash()); + if (!entry) + { + return Core::Containers::ArrayView(nullptr, 0); + } + return Core::Containers::ArrayView(entry->Entries.data(), entry->Entries.size()); + } + + void VFSDirectoryCache::SetListing(const VFSPath& dir, Core::Containers::Array&& entries) + { + std::unique_lock lock(m_mutex); + + CacheEntry& entry = m_entries[dir.Hash()]; + entry.Entries = std::move(entries); + entry.Stale = false; + entry.Dir = dir; + } + + bool VFSDirectoryCache::IsStale(const VFSPath& dir) const + { + std::shared_lock lock(m_mutex); + const auto entry = m_entries.find(dir.Hash()); + + if (!entry) + { + return true; + } + + return entry->Stale; + } + + void VFSDirectoryCache::Clear() + { + std::unique_lock lock(m_mutex); + m_entries.clear(); + } + + size_t VFSDirectoryCache::Size() const + { + std::shared_lock lock(m_mutex); + return m_entries.size(); + } + + void VFSDirectoryCache::ForEachDir(std::function)> visitor) const + { + std::shared_lock lock(m_mutex); + + for (const auto& kv : m_entries) + { + visitor(kv.second.Dir, {kv.second.Entries.data(), kv.second.Entries.size()}); + } + } + + void VFSDirectoryCache::Invalidate(const VFSPath& dir) + { + std::unique_lock lock(m_mutex); + CacheEntry* entry = m_entries.find(dir.Hash()); + if (!entry) + { + return; + } + entry->Stale = true; + } + +} // namespace ZEngine::Core::VFS \ No newline at end of file diff --git a/ZEngine/ZEngine/Core/VFS/VFSDirectoryCache.h b/ZEngine/ZEngine/Core/VFS/VFSDirectoryCache.h new file mode 100644 index 00000000..eb756a7a --- /dev/null +++ b/ZEngine/ZEngine/Core/VFS/VFSDirectoryCache.h @@ -0,0 +1,46 @@ + +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace ZEngine::Core::VFS +{ + struct VFSDirectoryCache + { + VFSDirectoryCache() = default; + ~VFSDirectoryCache() = default; + + void Initialize(Memory::ArenaAllocator* arena); + Core::Containers::ArrayView GetListing(const VFSPath& dir) const; + + void SetListing(const VFSPath& dir, Core::Containers::Array&& entries); + + void Invalidate(const VFSPath& dir); + + bool IsStale(const VFSPath& dir) const; + + void Clear(); + + size_t Size() const; + + void ForEachDir(std::function)> visitor) const; + + private: + struct CacheEntry + { + VFSPath Dir = {}; + Core::Containers::Array Entries = {}; + bool Stale = true; + }; + + Core::Containers::UnorderedHashMap m_entries; + mutable std::shared_mutex m_mutex; + Memory::ArenaAllocator* m_arena = nullptr; + }; + +} // namespace ZEngine::Core::VFS \ No newline at end of file diff --git a/ZEngine/tests/Misc/VFSDirectoryCache_test.cpp b/ZEngine/tests/Misc/VFSDirectoryCache_test.cpp new file mode 100644 index 00000000..f9e99c46 --- /dev/null +++ b/ZEngine/tests/Misc/VFSDirectoryCache_test.cpp @@ -0,0 +1,167 @@ +#include +#include +#include +#include +#include +#include + +using namespace ZEngine; +using namespace ZEngine::Core::VFS; +using namespace ZEngine::Core::Memory; +using ZEngine::Core::Containers::Array; +using ZEngine::Core::Containers::ArrayView; + +class VFSDirectoryCacheTest : public ::testing::Test +{ +protected: + MemoryManager m_manager; + ArenaAllocator* m_arena = nullptr; + VFSDirectoryCache m_cache; + + void SetUp() override + { + m_manager.Initialize({.BufferSize = ZMega(16)}); + m_arena = &m_manager.MainArena; + m_cache.Initialize(m_arena); + } + + void TearDown() override + { + m_manager.Shutdown(); + } + + VFSPath P(const char* raw) + { + auto result = VFSPath::Parse(raw); + EXPECT_TRUE(result.Succeeded()) << "failed to parse path: " << raw; + return result.Value(); + } + + VFSDirEntry Entry(const char* path, bool is_directory) + { + VFSDirEntry entry; + entry.Path = P(path); + entry.IsDirectory = is_directory; + entry.Stat.IsDirectory = is_directory; + return entry; + } + + Array Listing(std::initializer_list items) + { + Array entries; + entries.init(m_arena, items.size() == 0 ? 1 : items.size()); + for (const auto& item : items) + { + entries.push(item); + } + return entries; + } +}; + +TEST_F(VFSDirectoryCacheTest, GetListing_EmptyCache_ReturnsEmptySpan) +{ + auto view = m_cache.GetListing(P("/not/scanned/yet")); + EXPECT_EQ(view.size(), 0u); +} + +TEST_F(VFSDirectoryCacheTest, SetListing_then_GetListing) +{ + m_cache.SetListing(P("/assets"), Listing({Entry("/assets/a.png", false), Entry("/assets/textures", true)})); + + auto view = m_cache.GetListing(P("/assets")); + EXPECT_EQ(view.size(), 2u); +} + +TEST_F(VFSDirectoryCacheTest, IsStale_BeforeSet_True) +{ + EXPECT_TRUE(m_cache.IsStale(P("/never/set"))); +} + +TEST_F(VFSDirectoryCacheTest, IsStale_AfterSet_False) +{ + m_cache.SetListing(P("/d"), Listing({})); + EXPECT_FALSE(m_cache.IsStale(P("/d"))); +} + +TEST_F(VFSDirectoryCacheTest, Invalidate_MarksStale_KeepsData) +{ + m_cache.SetListing(P("/d"), Listing({Entry("/d/file.txt", false)})); + + m_cache.Invalidate(P("/d")); + + EXPECT_TRUE(m_cache.IsStale(P("/d"))); + EXPECT_EQ(m_cache.GetListing(P("/d")).size(), 1u); +} + +TEST_F(VFSDirectoryCacheTest, SetListing_Overwrites_Existing) +{ + m_cache.SetListing(P("/d"), Listing({Entry("/d/old.txt", false)})); + m_cache.SetListing(P("/d"), Listing({Entry("/d/a.txt", false), Entry("/d/b.txt", false)})); + + EXPECT_EQ(m_cache.GetListing(P("/d")).size(), 2u); + EXPECT_EQ(m_cache.Size(), 1u); +} + +TEST_F(VFSDirectoryCacheTest, Clear_EmptiesAllEntries) +{ + m_cache.SetListing(P("/a"), Listing({Entry("/a/x", false)})); + m_cache.SetListing(P("/b"), Listing({Entry("/b/x", false)})); + m_cache.SetListing(P("/c"), Listing({Entry("/c/x", false)})); + ASSERT_EQ(m_cache.Size(), 3u); + + m_cache.Clear(); + EXPECT_EQ(m_cache.Size(), 0u); +} + +TEST_F(VFSDirectoryCacheTest, ForEachDir_VisitsAllCachedDirectories) +{ + m_cache.SetListing(P("/a"), Listing({Entry("/a/x", false)})); + m_cache.SetListing(P("/b"), Listing({Entry("/b/x", false), Entry("/b/y", false)})); + + size_t dir_count = 0; + size_t entry_count = 0; + m_cache.ForEachDir([&](const VFSPath& /*dir*/, ArrayView entries) { + ++dir_count; + entry_count += entries.size(); + }); + + EXPECT_EQ(dir_count, 2u); + EXPECT_EQ(entry_count, 3u); +} + +TEST_F(VFSDirectoryCacheTest, ConcurrentReadWrite_NoDeadlock) +{ + VFSPath dirs[4] = {P("/dir0"), P("/dir1"), P("/dir2"), P("/dir3")}; + + constexpr int kIterations = 200; + std::vector threads; + threads.reserve(8); + + for (int w = 0; w < 4; ++w) + { + threads.emplace_back([&, w] { + for (int i = 0; i < kIterations; ++i) + { + m_cache.SetListing(dirs[w], Array{}); + } + }); + } + + for (int r = 0; r < 4; ++r) + { + threads.emplace_back([&, r] { + for (int i = 0; i < kIterations; ++i) + { + volatile size_t n = m_cache.GetListing(dirs[r]).size(); + (void) n; + } + }); + } + + for (auto& thread : threads) + { + thread.join(); + } + + EXPECT_EQ(m_cache.Size(), 4u); +} From fe0db85255f91f0ddc041f65c373a0dabb87b9cc Mon Sep 17 00:00:00 2001 From: Jnyfah Date: Sun, 26 Jul 2026 19:21:56 +0200 Subject: [PATCH 3/6] feat(vfs): add VFSScanner --- ZEngine/ZEngine/Core/VFS/VFSScanner.cpp | 158 ++++++++++++++ ZEngine/ZEngine/Core/VFS/VFSScanner.h | 69 ++++++ ZEngine/tests/Misc/VFSScanner_test.cpp | 269 ++++++++++++++++++++++++ 3 files changed, 496 insertions(+) create mode 100644 ZEngine/ZEngine/Core/VFS/VFSScanner.cpp create mode 100644 ZEngine/ZEngine/Core/VFS/VFSScanner.h create mode 100644 ZEngine/tests/Misc/VFSScanner_test.cpp diff --git a/ZEngine/ZEngine/Core/VFS/VFSScanner.cpp b/ZEngine/ZEngine/Core/VFS/VFSScanner.cpp new file mode 100644 index 00000000..505c1d73 --- /dev/null +++ b/ZEngine/ZEngine/Core/VFS/VFSScanner.cpp @@ -0,0 +1,158 @@ +#include +#include + +namespace ZEngine::Core::VFS +{ + void VFSScanner::Initialize(Core::Memory::ArenaAllocator* page_source) + { + ZENGINE_VALIDATE_ASSERT(page_source != nullptr, "VFSScanner::Initialize requires a valid arena for its page size") + for (int i = 0; i < MaxConcurrentDirLists; ++i) + { + m_slot_arenas[i].Initialize(SlotArenaReserve, page_source->m_mem_page_size); + m_slot_in_use[i].store(false, std::memory_order_relaxed); + } + m_arenas_ready = true; + } + + int VFSScanner::AcquireSlot() + { + for (;;) + { + for (int i = 0; i < MaxConcurrentDirLists; ++i) + { + bool expected = false; + if (m_slot_in_use[i].compare_exchange_strong(expected, true, std::memory_order_acquire)) + { + return i; + } + } + std::this_thread::yield(); + } + } + + void VFSScanner::ReleaseSlot(int slot) + { + m_slot_in_use[slot].store(false, std::memory_order_release); + } + + void VFSScanner::Scan(IVFSContext* context, VFSPath root, VFSDirectoryCache* cache) + { + ZENGINE_VALIDATE_ASSERT(m_arenas_ready, "VFSScanner::Initialize must be called before Scan") + + if (IsScanning()) + { + m_cancel_requested = true; + while (m_pending_tasks.load() > 0) + { + std::this_thread::yield(); + } + } + + m_cancel_requested = false; + m_files_found = 0; + m_dirs_found = 0; + m_pending_tasks = 1; + m_is_scanning = true; + m_scan_start = std::chrono::steady_clock::now(); + + ScanContext ctx{context, root, cache}; + + ZEngine::Helpers::ThreadPoolHelper::Submit([this, ctx, root] { + ScanDirectory(ctx, root); + OnTaskComplete(m_cancel_requested.load()); + }); + } + + void VFSScanner::ScanDirectory(ScanContext ctx, VFSPath dir) + { + if (m_cancel_requested) + { + return; + } + + m_dir_semaphore.acquire(); + const int slot = AcquireSlot(); + auto result = ctx.Context->List(dir, &m_slot_arenas[slot]); + ReleaseSlot(slot); + m_dir_semaphore.release(); + + if (result.Failed()) + { + return; + } + + Containers::Array& entries = result.Value(); + + for (size_t i = 0; i < entries.size(); ++i) + { + if (m_cancel_requested) + { + return; + } + + if (entries[i].IsDirectory) + { + m_dirs_found++; + m_pending_tasks++; + VFSPath sub = entries[i].Path; + ZEngine::Helpers::ThreadPoolHelper::Submit([this, ctx, sub] { + ScanDirectory(ctx, sub); + OnTaskComplete(m_cancel_requested.load()); + }); + } + else + { + m_files_found++; + } + } + + ctx.Cache->SetListing(dir, std::move(entries)); + } + + void VFSScanner::OnTaskComplete(bool cancelled) + { + const int32_t remaining = m_pending_tasks.fetch_sub(1) - 1; + if (remaining > 0) + { + return; + } + + m_is_scanning = false; + + if (!cancelled && m_complete_callback) + { + ScanStats stats; + stats.FilesFound = m_files_found.load(); + stats.DirsFound = m_dirs_found.load(); + stats.DurationMs = static_cast(std::chrono::duration_cast(std::chrono::steady_clock::now() - m_scan_start).count()); + m_complete_callback(stats); + } + } + + void VFSScanner::Cancel() + { + m_cancel_requested = true; + } + + bool VFSScanner::IsScanning() const + { + return m_is_scanning.load(); + } + + void VFSScanner::SetOnScanComplete(std::function callback) + { + m_complete_callback = std::move(callback); + } + + VFSScanner::VFSScanner() = default; + + VFSScanner::~VFSScanner() + { + m_cancel_requested = true; + while (m_pending_tasks.load() > 0) + { + std::this_thread::yield(); + } + } + +} // namespace ZEngine::Core::VFS \ No newline at end of file diff --git a/ZEngine/ZEngine/Core/VFS/VFSScanner.h b/ZEngine/ZEngine/Core/VFS/VFSScanner.h new file mode 100644 index 00000000..c7a7362e --- /dev/null +++ b/ZEngine/ZEngine/Core/VFS/VFSScanner.h @@ -0,0 +1,69 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZEngine::Core::VFS +{ + struct ScanStats + { + uint64_t FilesFound = 0; + uint64_t DirsFound = 0; + uint64_t DurationMs = 0; + }; + + struct VFSScanner + { + static constexpr int MaxConcurrentDirLists = 4; + static constexpr size_t SlotArenaReserve = ZMega(128); + + VFSScanner(); + ~VFSScanner(); + + void Initialize(Core::Memory::ArenaAllocator* page_source); + void Scan(IVFSContext* context, VFSPath root, VFSDirectoryCache* cache); + + void Cancel(); + + bool IsScanning() const; + + void SetOnScanComplete(std::function callback); + + private: + struct ScanContext + { + IVFSContext* Context = nullptr; + VFSPath Root = {}; + VFSDirectoryCache* Cache = nullptr; + }; + + void ScanDirectory(ScanContext ctx, VFSPath dir); + void OnTaskComplete(bool cancelled); + + int AcquireSlot(); + void ReleaseSlot(int slot); + + std::function m_complete_callback; + + std::atomic m_is_scanning{false}; + std::atomic m_cancel_requested{false}; + std::atomic m_pending_tasks{0}; + std::atomic m_files_found{0}; + std::atomic m_dirs_found{0}; + std::chrono::steady_clock::time_point m_scan_start{}; + + std::counting_semaphore m_dir_semaphore{MaxConcurrentDirLists}; + + Core::Memory::ArenaAllocator m_slot_arenas[MaxConcurrentDirLists]; + std::atomic m_slot_in_use[MaxConcurrentDirLists]; + bool m_arenas_ready = false; + }; + +} // namespace ZEngine::Core::VFS \ No newline at end of file diff --git a/ZEngine/tests/Misc/VFSScanner_test.cpp b/ZEngine/tests/Misc/VFSScanner_test.cpp new file mode 100644 index 00000000..c5a247e0 --- /dev/null +++ b/ZEngine/tests/Misc/VFSScanner_test.cpp @@ -0,0 +1,269 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace ZEngine; +using namespace ZEngine::Core::VFS; +using namespace ZEngine::Core::Memory; +using ZEngine::Core::Containers::Array; +using ZEngine::Core::Containers::UnorderedHashMap; + +namespace +{ + + struct MockVFSContext : IVFSContext + { + UnorderedHashMap> Tree; + ArenaAllocator* TreeArena = nullptr; + std::atomic Concurrent{0}; + std::atomic MaxConcurrent{0}; + std::atomic ListCalls{0}; + std::chrono::milliseconds ListDelay{0}; + + void Initialize(ArenaAllocator* arena) + { + TreeArena = arena; + Tree.init(arena, 256); + } + + void AddChild(const char* dir_path, const char* child_path, bool is_dir) + { + const uint64_t key = VFSPath::Parse(dir_path).Value().Hash(); + Array& arr = Tree[key]; + if (arr.m_allocator == nullptr) // freshly default-constructed by operator[] + { + arr.init(TreeArena, 8); + } + + VFSDirEntry entry; + entry.Path = VFSPath::Parse(child_path).Value(); + entry.IsDirectory = is_dir; + entry.Stat.IsDirectory = is_dir; + arr.push(entry); + } + + VFSResult> List(const VFSPath& absolute_dir, ArenaAllocator* out_arena) override + { + using ResultT = VFSResult>; + ListCalls.fetch_add(1); + + const int current = Concurrent.fetch_add(1) + 1; + int prev = MaxConcurrent.load(); + while (current > prev && !MaxConcurrent.compare_exchange_weak(prev, current)) + { + // prev refreshed by compare_exchange_weak; retry + } + + if (ListDelay.count() > 0) + { + std::this_thread::sleep_for(ListDelay); + } + + Array entries; + entries.init(out_arena, 8); + + const Array* listing = Tree.find(absolute_dir.Hash()); + if (listing) + { + for (size_t i = 0; i < listing->size(); ++i) + { + entries.push((*listing)[i]); + } + } + + Concurrent.fetch_sub(1); + return ResultT::Ok(std::move(entries)); + } + + // ---- unused stubs ---- + VFSResult Open(const VFSPath&, VFSOpenFlags) override + { + return VFSResult::Fail(VFSError::Unsupported); + } + VFSResult Stat(const VFSPath&) override + { + return VFSResult::Fail(VFSError::Unsupported); + } + VFSResult Exists(const VFSPath&) override + { + return VFSResult::Fail(VFSError::Unsupported); + } + VFSResult Mount(IVFSBackend*, const VFSPath&, int) override + { + return VFSResult::Fail(VFSError::Unsupported); + } + VFSResult Unmount(const VFSPath&) override + { + return VFSResult::Fail(VFSError::Unsupported); + } + VFSResult CreateDir(const VFSPath&) override + { + return VFSResult::Fail(VFSError::Unsupported); + } + VFSResult Remove(const VFSPath&) override + { + return VFSResult::Fail(VFSError::Unsupported); + } + VFSResult Rename(const VFSPath&, const VFSPath&) override + { + return VFSResult::Fail(VFSError::Unsupported); + } + void Close(IVFSFile* const) override {} + }; + + void BuildTree(MockVFSContext& mock, const char* root, int breadth, int depth) + { + if (depth <= 0) + { + return; + } + for (int i = 0; i < breadth; ++i) + { + char child[VFS_MAX_PATH]; + std::snprintf(child, sizeof(child), "%s/d%d", root, i); + mock.AddChild(root, child, true); + BuildTree(mock, child, breadth, depth - 1); + } + } +} // namespace + +class VFSScannerTest : public ::testing::Test +{ +protected: + std::atomic m_completed{false}; + ScanStats m_stats{}; + + MemoryManager m_cache_mgr; + MemoryManager m_scan_mgr; + ArenaAllocator* m_scan_arena = nullptr; + VFSDirectoryCache m_cache; + MockVFSContext m_mock; + VFSScanner m_scanner; + + void SetUp() override + { + m_cache_mgr.Initialize({.BufferSize = ZMega(16)}); + m_scan_mgr.Initialize({.BufferSize = ZMega(64)}); + m_scan_arena = &m_scan_mgr.MainArena; + m_cache.Initialize(&m_cache_mgr.MainArena); + m_scanner.Initialize(m_scan_arena); // slot arenas reuse this arena's page size + m_mock.Initialize(m_scan_arena); // mock tree lives here (only written during setup) + + m_scanner.SetOnScanComplete([this](ScanStats stats) { + m_stats = stats; + m_completed.store(true); + }); + } + + VFSPath P(const char* raw) + { + return VFSPath::Parse(raw).Value(); + } + + bool WaitForCompletion(int timeout_ms) + { + const auto start = std::chrono::steady_clock::now(); + while (!m_completed.load()) + { + if (std::chrono::steady_clock::now() - start > std::chrono::milliseconds(timeout_ms)) + { + return false; + } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + return true; + } + + bool WaitUntilNotScanning(int timeout_ms) + { + const auto start = std::chrono::steady_clock::now(); + while (m_scanner.IsScanning()) + { + if (std::chrono::steady_clock::now() - start > std::chrono::milliseconds(timeout_ms)) + { + return false; + } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + return true; + } +}; + +TEST_F(VFSScannerTest, Scan_EmptyRoot_FiresComplete) +{ + m_scanner.Scan(&m_mock, P("/"), &m_cache); + + ASSERT_TRUE(WaitForCompletion(2000)); + EXPECT_EQ(m_stats.FilesFound, 0u); + EXPECT_EQ(m_stats.DirsFound, 0u); +} + +TEST_F(VFSScannerTest, Scan_TwoLevels_PopulatesCache) +{ + m_mock.AddChild("/root", "/root/sub", true); + m_mock.AddChild("/root/sub", "/root/sub/a.txt", false); + m_mock.AddChild("/root/sub", "/root/sub/b.png", false); + + m_scanner.Scan(&m_mock, P("/root"), &m_cache); + ASSERT_TRUE(WaitForCompletion(2000)); + + EXPECT_EQ(m_stats.FilesFound, 2u); + EXPECT_EQ(m_stats.DirsFound, 1u); + EXPECT_EQ(m_cache.GetListing(P("/root")).size(), 1u); + EXPECT_EQ(m_cache.GetListing(P("/root/sub")).size(), 2u); +} + +TEST_F(VFSScannerTest, Cancel_StopsWalk_NoCallback) +{ + m_mock.ListDelay = std::chrono::milliseconds(3); + BuildTree(m_mock, "/root", 6, 3); // 259 List calls with delay → still running at 10ms + + m_scanner.Scan(&m_mock, P("/root"), &m_cache); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + m_scanner.Cancel(); + + ASSERT_TRUE(WaitUntilNotScanning(2000)); + EXPECT_FALSE(m_scanner.IsScanning()); + EXPECT_FALSE(m_completed.load()); // completion callback must NOT fire on cancel +} + +TEST_F(VFSScannerTest, ConcurrentDirLists_LimitedTo4) +{ + m_mock.ListDelay = std::chrono::milliseconds(5); + BuildTree(m_mock, "/root", 20, 1); // root + 20 subdirs, all listed with a delay + + m_scanner.Scan(&m_mock, P("/root"), &m_cache); + ASSERT_TRUE(WaitForCompletion(5000)); + + EXPECT_LE(m_mock.MaxConcurrent.load(), VFSScanner::MaxConcurrentDirLists); +} + +TEST_F(VFSScannerTest, Rescan_After_Cancel_StartsFresh) +{ + m_mock.ListDelay = std::chrono::milliseconds(3); + BuildTree(m_mock, "/root", 6, 3); + + m_scanner.Scan(&m_mock, P("/root"), &m_cache); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + m_scanner.Cancel(); + ASSERT_TRUE(WaitUntilNotScanning(2000)); + + m_completed.store(false); + + MockVFSContext fresh; + fresh.Initialize(m_scan_arena); + fresh.AddChild("/proj", "/proj/sub", true); + fresh.AddChild("/proj", "/proj/root.txt", false); + fresh.AddChild("/proj/sub", "/proj/sub/a.txt", false); + + m_scanner.Scan(&fresh, P("/proj"), &m_cache); + ASSERT_TRUE(WaitForCompletion(2000)); + + EXPECT_EQ(m_stats.FilesFound, 2u); // /proj/root.txt + /proj/sub/a.txt + EXPECT_EQ(m_stats.DirsFound, 1u); // /proj/sub +} From 1d817a47034aa2e98ae34baaa989615ca5b2f87a Mon Sep 17 00:00:00 2001 From: Jnyfah Date: Sun, 26 Jul 2026 21:14:36 +0200 Subject: [PATCH 4/6] feat(vfs): migrate project view --- .../Components/ProjectViewUIComponent.cpp | 232 +++++++++++------- .../Components/ProjectViewUIComponent.h | 67 ++--- Tetragrama/Editor.cpp | 8 + Tetragrama/Editor.h | 25 +- Tetragrama/Layers/ImguiLayer.cpp | 2 + Tetragrama/Layers/ImguiLayer.h | 4 + .../ZEngine/Applications/GameApplication.cpp | 6 + .../ZEngine/Applications/GameApplication.h | 4 + 8 files changed, 223 insertions(+), 125 deletions(-) diff --git a/Tetragrama/Components/ProjectViewUIComponent.cpp b/Tetragrama/Components/ProjectViewUIComponent.cpp index baae72f7..d87b061a 100644 --- a/Tetragrama/Components/ProjectViewUIComponent.cpp +++ b/Tetragrama/Components/ProjectViewUIComponent.cpp @@ -3,10 +3,12 @@ #include #include #include +#include #include #include using namespace ZEngine::Helpers; +using namespace ZEngine::Core::VFS; namespace Tetragrama::Components { @@ -19,8 +21,22 @@ namespace Tetragrama::Components UIComponent::Initialize(parent, name, visibility, closed); parent->LocalArena.CreateSubArena(ZMega(1), &m_local_arena); - m_assets_directory = ParentLayer->CurrentApp->WorkingSpacePath; - m_current_directory = m_assets_directory; + m_vfs_context = ParentLayer->CurrentApp->GetVFSContext(); + m_directory_cache = &ParentLayer->Cache; + m_scanner = &ParentLayer->Scanner; + + m_assets_vfs_root = VFSPath::Root(); + m_current_vfs_dir = m_assets_vfs_root; + + { + std::filesystem::path ws(ParentLayer->CurrentApp->WorkingSpacePath); + std::string label = ws.filename().string(); + if (label.empty()) + { + label = ws.string(); + } + secure_strcpy(m_root_label, sizeof(m_root_label), label.c_str()); + } auto asset_mgr = ZEngine::Managers::AssetManager::Instance(); @@ -30,6 +46,36 @@ namespace Tetragrama::Components m_directory_icon = asset_mgr->LoadTextureFileAsAsset(directory_icon_path.c_str(), true); m_file_icon = asset_mgr->LoadTextureFileAsAsset(file_icon_path.c_str(), true); + + TriggerScan(); + } + static void VfsFilename(const VFSPath& path, char* out, size_t out_size) + { + VFSPathComponent fn = path.Filename(); + size_t n = (fn.Length < out_size - 1) ? fn.Length : (out_size - 1); + if (fn.Data && n > 0) + { + secure_memcpy(out, out_size, fn.Data, n); + } + out[n] = '\0'; + } + + void ProjectViewUIComponent::TriggerScan() + { + if (m_scanner && m_vfs_context && m_directory_cache) + { + m_scanner->Scan(m_vfs_context, m_assets_vfs_root, m_directory_cache); + } + } + + std::filesystem::path ProjectViewUIComponent::VfsToNative(const VFSPath& path) const + { + std::filesystem::path native(ParentLayer->CurrentApp->WorkingSpacePath); + if (!path.IsRoot()) + { + native += path.CStr(); // CStr() begins with '/' + } + return native; } void ProjectViewUIComponent::Update(ZEngine::Core::TimeStep dt) {} @@ -55,22 +101,26 @@ namespace Tetragrama::Components } if (ImGui::BeginPopup("ContextMenu")) { - RenderContextMenu(ContextMenuType::RightPane, m_current_directory); + RenderContextMenu(ContextMenuType::RightPane, VfsToNative(m_current_vfs_dir)); ImGui::EndPopup(); } RenderPopUpMenu(); RenderBackButton(); + ImGui::SameLine(); + if (ImGui::Button("Refresh")) + { + TriggerScan(); + } + ImGui::SameLine(); ImGui::SetNextItemWidth(300); ImGui::InputTextWithHint("##Search", "Search ...", m_search_buffer, IM_ARRAYSIZE(m_search_buffer)); ImGui::SameLine(); ImGui::PushFont(ImGui::GetIO().Fonts->Fonts[0]); - char relative_path[MAX_FILE_PATH_COUNT] = {0}; - MakeRelative(m_current_directory, m_assets_directory.parent_path(), relative_path); - ImGui::Text(relative_path); + ImGui::Text("%s", m_current_vfs_dir.CStr()); ImGui::PopFont(); ImGui::Separator(); @@ -105,24 +155,25 @@ namespace Tetragrama::Components } else { - for (const auto& entry : std::filesystem::directory_iterator(m_current_directory)) + auto listing = m_directory_cache->GetListing(m_current_vfs_dir); + for (size_t i = 0; i < listing.size(); ++i) { ImGui::TableNextColumn(); - RenderContentTile(renderer, entry); + RenderContentTile(renderer, listing[i]); } } ImGui::EndTable(); } } - void ProjectViewUIComponent::RenderContentTile(ZEngine::Rendering::Renderers::GraphicRenderer* const renderer, const std::filesystem::directory_entry& entry) + void ProjectViewUIComponent::RenderContentTile(ZEngine::Rendering::Renderers::GraphicRenderer* const renderer, const VFSDirEntry& entry) { - auto relativePath = std::filesystem::relative(entry.path(), m_assets_directory); - std::string name = relativePath.filename().string(); + char name[MAX_FILE_PATH_COUNT]; + VfsFilename(entry.Path, name, sizeof(name)); - ImGui::PushID(name.c_str()); + ImGui::PushID(entry.Path.CStr()); - ImTextureID icon = entry.is_directory() ? (ImTextureID) m_directory_icon->Handle.Index : (ImTextureID) m_file_icon->Handle.Index; + ImTextureID icon = entry.IsDirectory ? (ImTextureID) m_directory_icon->Handle.Index : (ImTextureID) m_file_icon->Handle.Index; const float margin = 5.0f; ImVec2 cursorPos = ImGui::GetCursorPos(); @@ -137,11 +188,11 @@ namespace Tetragrama::Components * Drag and Drop scene file * We don't support drag-and-drop for folder for now */ - if (!entry.is_directory()) + if (!entry.IsDirectory) { if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) { - std::string itemPath = entry.path().string(); + std::string itemPath = VfsToNative(entry.Path).string(); ImGui::SetDragDropPayload("CONTENT_BROWSER_FILE_DRAG_OP", itemPath.c_str(), (itemPath.length() + 1) * sizeof(char)); ImGui::EndDragDropSource(); } @@ -149,10 +200,9 @@ namespace Tetragrama::Components if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { - if (entry.is_directory()) + if (entry.IsDirectory) { - auto relativePath = std::filesystem::relative(entry.path(), m_assets_directory); - m_current_directory = m_assets_directory / relativePath; + m_current_vfs_dir = entry.Path; secure_memset(m_search_buffer, 0, sizeof(m_search_buffer), sizeof(m_search_buffer)); } } @@ -163,17 +213,18 @@ namespace Tetragrama::Components } if (ImGui::BeginPopup("ItemContextMenu")) { - entry.is_directory() ? RenderContextMenu(ContextMenuType::Folder, entry) : RenderContextMenu(ContextMenuType::File, entry); + std::filesystem::path native = VfsToNative(entry.Path); + entry.IsDirectory ? RenderContextMenu(ContextMenuType::Folder, native) : RenderContextMenu(ContextMenuType::File, native); ImGui::EndPopup(); } // centered label - float textWidth = ImGui::CalcTextSize(name.c_str()).x; + float textWidth = ImGui::CalcTextSize(name).x; float cursorPosX = ImGui::GetCursorPosX(); float centerPosX = cursorPosX + (m_thumbnail_size - textWidth) * 0.5f; ImGui::SetCursorPosX(centerPosX); - ImGui::TextWrapped(name.c_str()); + ImGui::TextWrapped("%s", name); ImGui::PopID(); } @@ -181,31 +232,42 @@ namespace Tetragrama::Components void ProjectViewUIComponent::RenderFilteredContent(ZEngine::Rendering::Renderers::GraphicRenderer* const renderer, const char* searchTerm) { auto scratch = ZGetScratch(&m_local_arena); + char name_lower[MAX_FILE_PATH_COUNT]; - for (const auto& entry : std::filesystem::recursive_directory_iterator(m_assets_directory)) - { - if (entry.is_regular_file() || entry.is_directory()) + m_directory_cache->ForEachDir([&](const VFSPath& /*dir*/, ZEngine::Core::Containers::ArrayView entries) { + for (size_t i = 0; i < entries.size(); ++i) { - std::string nameLower = entry.path().filename().string(); + const VFSDirEntry& entry = entries[i]; - if (Helpers::KMPSearch(scratch.Arena, nameLower.c_str(), searchTerm)) + char raw[MAX_FILE_PATH_COUNT]; + VfsFilename(entry.Path, raw, sizeof(raw)); + + size_t len = secure_strlen(raw); + size_t copy = (len < sizeof(name_lower) - 1) ? len : sizeof(name_lower) - 1; + for (size_t j = 0; j < copy; ++j) + { + name_lower[j] = static_cast(::tolower(static_cast(raw[j]))); + } + name_lower[copy] = '\0'; + + if (Helpers::KMPSearch(scratch.Arena, name_lower, searchTerm)) { ImGui::TableNextColumn(); RenderContentTile(renderer, entry); } } - } + }); ZReleaseScratch(scratch); } void ProjectViewUIComponent::RenderTreeBrowser() { - bool nodeOpen = ImGui::TreeNodeEx(m_assets_directory.filename().string().c_str(), ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_DefaultOpen); + bool nodeOpen = ImGui::TreeNodeEx(m_root_label, ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_DefaultOpen); if (ImGui::IsItemClicked()) { - m_current_directory = m_assets_directory; + m_current_vfs_dir = m_assets_vfs_root; secure_memset(m_search_buffer, 0, sizeof(m_search_buffer), sizeof(m_search_buffer)); } @@ -216,56 +278,63 @@ namespace Tetragrama::Components if (ImGui::BeginPopup("RootContextMenu")) { - RenderContextMenu(ContextMenuType::LeftPane, m_assets_directory); + RenderContextMenu(ContextMenuType::LeftPane, VfsToNative(m_assets_vfs_root)); ImGui::EndPopup(); } if (nodeOpen) { - RenderDirectoryNode(m_assets_directory); + RenderDirectoryNode(m_assets_vfs_root); ImGui::TreePop(); } } - void ProjectViewUIComponent::RenderDirectoryNode(const std::filesystem::path& directory) + void ProjectViewUIComponent::RenderDirectoryNode(const VFSPath& directory) { - for (const auto& entry : std::filesystem::directory_iterator(directory)) + auto listing = m_directory_cache->GetListing(directory); + for (size_t i = 0; i < listing.size(); ++i) { - if (entry.is_directory()) + const VFSDirEntry& entry = listing[i]; + if (!entry.IsDirectory) { - ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow; - if (m_current_directory == entry.path()) - { - flags |= ImGuiTreeNodeFlags_Selected; - } + continue; + } - std::string popupId = "Dir_" + entry.path().filename().string(); + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow; + if (entry.Path == m_current_vfs_dir) + { + flags |= ImGuiTreeNodeFlags_Selected; + } - bool nodeOpen = ImGui::TreeNodeEx(entry.path().filename().string().c_str(), flags); + char label[MAX_FILE_PATH_COUNT]; + VfsFilename(entry.Path, label, sizeof(label)); - if (ImGui::IsItemClicked()) - { - auto relativePath = std::filesystem::relative(entry.path(), m_assets_directory); - m_current_directory = m_assets_directory / relativePath; - secure_memset(m_search_buffer, 0, sizeof(m_search_buffer), sizeof(m_search_buffer)); - } + char popup_id[MAX_FILE_PATH_COUNT + 8]; + std::snprintf(popup_id, sizeof(popup_id), "Dir_%s", entry.Path.CStr()); - if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) - { - ImGui::OpenPopup(popupId.c_str()); - } + bool nodeOpen = ImGui::TreeNodeEx(label, flags); - if (ImGui::BeginPopup(popupId.c_str())) - { - RenderContextMenu(ContextMenuType::LeftPane, std::filesystem::absolute(entry.path())); - ImGui::EndPopup(); - } + if (ImGui::IsItemClicked()) + { + m_current_vfs_dir = entry.Path; + secure_memset(m_search_buffer, 0, sizeof(m_search_buffer), sizeof(m_search_buffer)); + } - if (nodeOpen) - { - RenderDirectoryNode(entry.path()); - ImGui::TreePop(); - } + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) + { + ImGui::OpenPopup(popup_id); + } + + if (ImGui::BeginPopup(popup_id)) + { + RenderContextMenu(ContextMenuType::LeftPane, VfsToNative(entry.Path)); + ImGui::EndPopup(); + } + + if (nodeOpen) + { + RenderDirectoryNode(entry.Path); + ImGui::TreePop(); } } } @@ -295,6 +364,7 @@ namespace Tetragrama::Components { file.close(); m_active_popup = PopupType::None; + TriggerScan(); ImGui::CloseCurrentPopup(); } else @@ -346,6 +416,7 @@ namespace Tetragrama::Components { std::filesystem::create_directory(newPath); m_active_popup = PopupType::None; + TriggerScan(); ImGui::CloseCurrentPopup(); } else @@ -370,7 +441,7 @@ namespace Tetragrama::Components void ProjectViewUIComponent::HandleRenameFolderPopup(const std::filesystem::path& path) { - if (m_popup_target_path == m_assets_directory) + if (m_popup_target_path == VfsToNative(m_assets_vfs_root)) { ZENGINE_CORE_ERROR("Cannot rename root folder"); m_active_popup = PopupType::None; @@ -403,13 +474,11 @@ namespace Tetragrama::Components if (!std::filesystem::exists(newPath)) { std::filesystem::rename(path, newPath); - if (m_current_directory == path) - { - m_current_directory = newPath; - } + m_current_vfs_dir = m_assets_vfs_root; - m_active_popup = PopupType::None; - initialized = false; + m_active_popup = PopupType::None; + initialized = false; + TriggerScan(); ImGui::CloseCurrentPopup(); } else @@ -453,18 +522,13 @@ namespace Tetragrama::Components if (ImGui::Button("Delete", ImVec2(120, 0))) { - if (std::filesystem::exists(path)) { - if (m_current_directory == path) - { - m_current_directory = path.parent_path(); - } - std::filesystem::remove(path); } m_active_popup = PopupType::None; + TriggerScan(); ImGui::CloseCurrentPopup(); } @@ -482,7 +546,7 @@ namespace Tetragrama::Components void ProjectViewUIComponent::HandleDeleteFolderPopup(const std::filesystem::path& path) { - if (m_popup_target_path == m_assets_directory) + if (m_popup_target_path == VfsToNative(m_assets_vfs_root)) { ZENGINE_CORE_ERROR("Cannot rename root folder"); m_active_popup = PopupType::None; @@ -510,15 +574,10 @@ namespace Tetragrama::Components if (ImGui::Button("Delete", ImVec2(120, 0))) { - auto parentPath = path; - - if (m_current_directory == path || m_current_directory.string().find(path.string()) == 0) - { - m_current_directory = path.parent_path(); - } - - std::filesystem::remove_all(parentPath); - m_active_popup = PopupType::None; + std::filesystem::remove_all(path); + m_current_vfs_dir = m_assets_vfs_root; + m_active_popup = PopupType::None; + TriggerScan(); ImGui::CloseCurrentPopup(); } @@ -565,6 +624,7 @@ namespace Tetragrama::Components std::filesystem::rename(path, newPath); m_active_popup = PopupType::None; initialized = false; + TriggerScan(); ImGui::CloseCurrentPopup(); } else @@ -593,12 +653,12 @@ namespace Tetragrama::Components void ProjectViewUIComponent::RenderBackButton() { - bool canGoBack = (m_current_directory != m_assets_directory); + bool canGoBack = !(m_current_vfs_dir == m_assets_vfs_root); if (canGoBack) { if (ImGui::ArrowButton("##left", ImGuiDir_Left)) { - m_current_directory = m_current_directory.parent_path(); + m_current_vfs_dir = m_current_vfs_dir.Parent(); } } else diff --git a/Tetragrama/Components/ProjectViewUIComponent.h b/Tetragrama/Components/ProjectViewUIComponent.h index f83d6190..4bd336f2 100644 --- a/Tetragrama/Components/ProjectViewUIComponent.h +++ b/Tetragrama/Components/ProjectViewUIComponent.h @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -32,43 +33,53 @@ namespace Tetragrama::Components ProjectViewUIComponent(); virtual ~ProjectViewUIComponent(); - void Initialize(Layers::ImguiLayer* parent = nullptr, const char* name = "Project", bool visibility = true, bool closed = false) override; + void Initialize(Layers::ImguiLayer* parent = nullptr, const char* name = "Project", bool visibility = true, bool closed = false) override; - void Update(ZEngine::Core::TimeStep dt) override; + void Update(ZEngine::Core::TimeStep dt) override; - virtual void Render(ZEngine::Rendering::Renderers::GraphicRenderer* const renderer, ZEngine::Hardwares::CommandBuffer* const command_buffer) override; + virtual void Render(ZEngine::Rendering::Renderers::GraphicRenderer* const renderer, ZEngine::Hardwares::CommandBuffer* const command_buffer) override; // Render Panes - void RenderContentBrowser(ZEngine::Rendering::Renderers::GraphicRenderer* const renderer); - void RenderFilteredContent(ZEngine::Rendering::Renderers::GraphicRenderer* const renderer, const char* searchTerm); - void RenderDirectoryNode(const std::filesystem::path& directory); - void RenderContentTile(ZEngine::Rendering::Renderers::GraphicRenderer* const renderer, const std::filesystem::directory_entry& entry); - void RenderBackButton(); - void RenderTreeBrowser(); + void RenderContentBrowser(ZEngine::Rendering::Renderers::GraphicRenderer* const renderer); + void RenderFilteredContent(ZEngine::Rendering::Renderers::GraphicRenderer* const renderer, const char* searchTerm); + void RenderDirectoryNode(const ZEngine::Core::VFS::VFSPath& directory); + void RenderContentTile(ZEngine::Rendering::Renderers::GraphicRenderer* const renderer, const ZEngine::Core::VFS::VFSDirEntry& entry); + void RenderBackButton(); + void RenderTreeBrowser(); // Popup helpers - void RenderContextMenu(ContextMenuType type, const std::filesystem::path& targetPath); - void RenderPopUpMenu(); - void HandleCreateFolderPopup(const std::filesystem::path& path); - void HandleCreateFilePopup(const std::filesystem::path& path); + void RenderContextMenu(ContextMenuType type, const std::filesystem::path& targetPath); + void RenderPopUpMenu(); + void HandleCreateFolderPopup(const std::filesystem::path& path); + void HandleCreateFilePopup(const std::filesystem::path& path); - void HandleRenameFolderPopup(const std::filesystem::path& path); - void HandleDeleteFolderPopup(const std::filesystem::path& path); - void HandleRenameFilePopup(const std::filesystem::path& path); - void HandleDeleteFilePopup(const std::filesystem::path& path); + void HandleRenameFolderPopup(const std::filesystem::path& path); + void HandleDeleteFolderPopup(const std::filesystem::path& path); + void HandleRenameFilePopup(const std::filesystem::path& path); + void HandleDeleteFilePopup(const std::filesystem::path& path); - void MakeRelative(const std::filesystem::path& path, const std::filesystem::path& base, char* output); + void MakeRelative(const std::filesystem::path& path, const std::filesystem::path& base, char* output); + + void TriggerScan(); + std::filesystem::path VfsToNative(const ZEngine::Core::VFS::VFSPath& path) const; private: - ZEngine::Core::Memory::ArenaAllocator m_local_arena = {}; - std::filesystem::path m_assets_directory; - std::filesystem::path m_current_directory; - PopupType m_active_popup = PopupType::None; - std::filesystem::path m_popup_target_path; - - ZEngine::Importers::AssetTexture* m_directory_icon; - ZEngine::Importers::AssetTexture* m_file_icon; - static constexpr float m_thumbnail_size = 64.0f; - char m_search_buffer[MAX_FILE_PATH_COUNT] = ""; + ZEngine::Core::Memory::ArenaAllocator m_local_arena = {}; + + ZEngine::Core::VFS::IVFSContext* m_vfs_context = nullptr; + ZEngine::Core::VFS::VFSDirectoryCache* m_directory_cache = nullptr; + ZEngine::Core::VFS::VFSScanner* m_scanner = nullptr; + + ZEngine::Core::VFS::VFSPath m_assets_vfs_root = {}; + ZEngine::Core::VFS::VFSPath m_current_vfs_dir = {}; + char m_root_label[MAX_FILE_PATH_COUNT] = ""; + + PopupType m_active_popup = PopupType::None; + std::filesystem::path m_popup_target_path; + + ZEngine::Importers::AssetTexture* m_directory_icon; + ZEngine::Importers::AssetTexture* m_file_icon; + static constexpr float m_thumbnail_size = 64.0f; + char m_search_buffer[MAX_FILE_PATH_COUNT] = ""; }; } // namespace Tetragrama::Components diff --git a/Tetragrama/Editor.cpp b/Tetragrama/Editor.cpp index 7db4886d..136bb77b 100644 --- a/Tetragrama/Editor.cpp +++ b/Tetragrama/Editor.cpp @@ -32,6 +32,14 @@ namespace Tetragrama Configuration->ActiveSceneName.append(active_scene); } WorkingSpacePath = Configuration->WorkingSpacePath.c_str(); + if (WorkingSpacePath && WorkingSpacePath[0] != '\0') + { + WorkingSpaceBackend.Initialize(WorkingSpacePath, ZEngine::Core::VFS::VFSBackendCaps::Read | ZEngine::Core::VFS::VFSBackendCaps::List, Arena); + if (GetVFSContext()->Mount(&WorkingSpaceBackend, ZEngine::Core::VFS::VFSPath::Root(), 0).Failed()) + { + ZENGINE_CORE_ERROR("Failed to mount working space '{}' into the VFS", WorkingSpacePath) + } + } } void Editor::OverrideWindowConfiguration() diff --git a/Tetragrama/Editor.h b/Tetragrama/Editor.h index f192f8cf..1d6ae2b0 100644 --- a/Tetragrama/Editor.h +++ b/Tetragrama/Editor.h @@ -3,6 +3,7 @@ #include #include #include +#include #include namespace Tetragrama::Serializers @@ -34,21 +35,23 @@ namespace Tetragrama virtual ~Editor() {} - ZRawPtr(Layers::ImguiLayer) UILayer = nullptr; + ZRawPtr(Layers::ImguiLayer) UILayer = nullptr; - virtual void OnInitializing() override; - virtual void OverrideWindowConfiguration() override; - virtual void OnInitialized() override; + ZEngine::Core::VFS::VFSDiskBackend WorkingSpaceBackend = {}; - virtual void OnUpdate(float dt) override; - virtual void OnEvent(ZEngine::Core::CoreEvent&) override; + virtual void OnInitializing() override; + virtual void OverrideWindowConfiguration() override; + virtual void OnInitialized() override; - virtual void OnPreRender() override; - virtual void OnPostRender() override; - virtual void OnRenderUI() override; + virtual void OnUpdate(float dt) override; + virtual void OnEvent(ZEngine::Core::CoreEvent&) override; - virtual void OnClosing() override; - virtual void OnClosed() override; + virtual void OnPreRender() override; + virtual void OnPostRender() override; + virtual void OnRenderUI() override; + + virtual void OnClosing() override; + virtual void OnClosed() override; }; ZDEFINE_PTR(Editor); diff --git a/Tetragrama/Layers/ImguiLayer.cpp b/Tetragrama/Layers/ImguiLayer.cpp index 532635da..9f85046f 100644 --- a/Tetragrama/Layers/ImguiLayer.cpp +++ b/Tetragrama/Layers/ImguiLayer.cpp @@ -31,6 +31,8 @@ namespace Tetragrama::Layers CurrentApp = app; arena->CreateSubArena(ZMega(10), &LocalArena); + Cache.Initialize(arena); + NodeHierarchies.init(arena, 10, 0); NodeUIComponents.init(arena); NodeToRender.init(arena, 10); diff --git a/Tetragrama/Layers/ImguiLayer.h b/Tetragrama/Layers/ImguiLayer.h index 3369b64b..6db76cd7 100644 --- a/Tetragrama/Layers/ImguiLayer.h +++ b/Tetragrama/Layers/ImguiLayer.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -25,6 +26,9 @@ namespace Tetragrama::Layers ZEngine::Core::Containers::UnorderedHashMap NodeUIComponents = {}; ZEngine::Core::Containers::UnorderedHashMap KeyEntries = {}; + ZEngine::Core::VFS::VFSScanner Scanner = {}; + ZEngine::Core::VFS::VFSDirectoryCache Cache = {}; + virtual void Initialize(ZEngine::Core::Memory::ArenaAllocator* arena, ZEngine::Applications::GameApplicationPtr app) override; virtual void Deinitialize() override; diff --git a/ZEngine/ZEngine/Applications/GameApplication.cpp b/ZEngine/ZEngine/Applications/GameApplication.cpp index 04398183..2a829b8f 100644 --- a/ZEngine/ZEngine/Applications/GameApplication.cpp +++ b/ZEngine/ZEngine/Applications/GameApplication.cpp @@ -9,6 +9,7 @@ namespace ZEngine::Applications State = ZPushStructCtor(Arena, ApplicationState); + VFS.Initialize(Arena); OnInitializing(); OverrideWindowConfiguration(); @@ -17,6 +18,11 @@ namespace ZEngine::Applications OnInitialized(); } + Core::VFS::IVFSContext* GameApplication::GetVFSContext() + { + return &VFS; + } + void GameApplication::Run() { Engine::Run(); diff --git a/ZEngine/ZEngine/Applications/GameApplication.h b/ZEngine/ZEngine/Applications/GameApplication.h index bf2efc3f..ba781940 100644 --- a/ZEngine/ZEngine/Applications/GameApplication.h +++ b/ZEngine/ZEngine/Applications/GameApplication.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +39,9 @@ namespace ZEngine::Applications Controllers::ICameraControllerPtr CameraController = nullptr; Rendering::Scenes::RenderScenePtr CurrentScene = nullptr; + Core::VFS::VFSContext VFS = {}; + Core::VFS::IVFSContext* GetVFSContext(); + void Initialize(Core::Memory::ArenaAllocator* arena); void Update(Core::TimeStep dt); void ProcessEvent(Core::CoreEvent&); From 2e3f9c421abef7b3c7830bee7176896b93f47513 Mon Sep 17 00:00:00 2001 From: Jennifer Chukwu Date: Mon, 27 Jul 2026 23:37:52 +0200 Subject: [PATCH 5/6] feat(vfs): update --- Tetragrama/Components/ProjectViewUIComponent.cpp | 10 +++++----- Tetragrama/Components/ProjectViewUIComponent.h | 4 ++-- Tetragrama/Layers/ImguiLayer.cpp | 1 + ZEngine/ZEngine/Core/VFS/VFSContext.cpp | 4 +++- ZEngine/ZEngine/Core/VFS/VFSContext.h | 2 ++ 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/Tetragrama/Components/ProjectViewUIComponent.cpp b/Tetragrama/Components/ProjectViewUIComponent.cpp index d87b061a..c0cfb971 100644 --- a/Tetragrama/Components/ProjectViewUIComponent.cpp +++ b/Tetragrama/Components/ProjectViewUIComponent.cpp @@ -682,7 +682,7 @@ namespace Tetragrama::Components case ContextMenuType::RightPane: if (ImGui::MenuItem("Create New File")) { - m_active_popup = PopupType::CreateFile; + m_active_popup = PopupType::NewFile; } if (ImGui::MenuItem("Create New Folder")) { @@ -697,7 +697,7 @@ namespace Tetragrama::Components } if (ImGui::MenuItem("Create New File")) { - m_active_popup = PopupType::CreateFile; + m_active_popup = PopupType::NewFile; } if (ImGui::MenuItem("Delete Folder")) { @@ -716,7 +716,7 @@ namespace Tetragrama::Components } if (ImGui::MenuItem("Delete File")) { - m_active_popup = PopupType::DeleteFile; + m_active_popup = PopupType::RemoveFile; } break; @@ -746,10 +746,10 @@ namespace Tetragrama::Components case PopupType::DeleteFolder: HandleDeleteFolderPopup(m_popup_target_path); break; - case PopupType::CreateFile: + case PopupType::NewFile: HandleCreateFilePopup(m_popup_target_path); break; - case PopupType::DeleteFile: + case PopupType::RemoveFile: HandleDeleteFilePopup(m_popup_target_path); break; case PopupType::RenameFile: diff --git a/Tetragrama/Components/ProjectViewUIComponent.h b/Tetragrama/Components/ProjectViewUIComponent.h index 4bd336f2..9a671883 100644 --- a/Tetragrama/Components/ProjectViewUIComponent.h +++ b/Tetragrama/Components/ProjectViewUIComponent.h @@ -20,11 +20,11 @@ namespace Tetragrama::Components { None, CreateFolder, - CreateFile, + NewFile, RenameFolder, RenameFile, DeleteFolder, - DeleteFile, + RemoveFile, }; class ProjectViewUIComponent : public UIComponent diff --git a/Tetragrama/Layers/ImguiLayer.cpp b/Tetragrama/Layers/ImguiLayer.cpp index 9f85046f..fbc1ac6b 100644 --- a/Tetragrama/Layers/ImguiLayer.cpp +++ b/Tetragrama/Layers/ImguiLayer.cpp @@ -31,6 +31,7 @@ namespace Tetragrama::Layers CurrentApp = app; arena->CreateSubArena(ZMega(10), &LocalArena); + Scanner.Initialize(arena); Cache.Initialize(arena); NodeHierarchies.init(arena, 10, 0); diff --git a/ZEngine/ZEngine/Core/VFS/VFSContext.cpp b/ZEngine/ZEngine/Core/VFS/VFSContext.cpp index 07929d55..42302a1f 100644 --- a/ZEngine/ZEngine/Core/VFS/VFSContext.cpp +++ b/ZEngine/ZEngine/Core/VFS/VFSContext.cpp @@ -60,8 +60,9 @@ namespace ZEngine::Core::VFS VFSResult> VFSContext::List(const VFSPath& absolute_dir, Memory::ArenaAllocator* out_arena) { - using ResultT = VFSResult>; + using ResultT = VFSResult>; + std::lock_guard arena_lock(m_arena_mutex); auto scratch = ZGetScratch(m_arena); Containers::Array matches; @@ -187,6 +188,7 @@ namespace ZEngine::Core::VFS VFSResult VFSContext::ResolveWritable(const VFSPath& path) const { + std::lock_guard arena_lock(m_arena_mutex); auto scratch = ZGetScratch(m_arena); Containers::Array matches; diff --git a/ZEngine/ZEngine/Core/VFS/VFSContext.h b/ZEngine/ZEngine/Core/VFS/VFSContext.h index bb3382da..7cbf9bd0 100644 --- a/ZEngine/ZEngine/Core/VFS/VFSContext.h +++ b/ZEngine/ZEngine/Core/VFS/VFSContext.h @@ -1,6 +1,7 @@ #pragma once #include #include +#include namespace ZEngine::Core::VFS { @@ -32,6 +33,7 @@ namespace ZEngine::Core::VFS VFSMountTable m_mount_table = {}; Memory::ArenaAllocator* m_arena = nullptr; + mutable std::mutex m_arena_mutex; }; } // namespace ZEngine::Core::VFS From cb07ace84f7b3eb587e71e413645e2983ae49c58 Mon Sep 17 00:00:00 2001 From: Jennifer Chukwu Date: Tue, 28 Jul 2026 20:13:43 +0200 Subject: [PATCH 6/6] feat(vfs): update --- ZEngine/tests/Misc/VFSMemoryBackend_test.cpp | 5 ----- 1 file changed, 5 deletions(-) diff --git a/ZEngine/tests/Misc/VFSMemoryBackend_test.cpp b/ZEngine/tests/Misc/VFSMemoryBackend_test.cpp index 9f0fb9b6..65098ce4 100644 --- a/ZEngine/tests/Misc/VFSMemoryBackend_test.cpp +++ b/ZEngine/tests/Misc/VFSMemoryBackend_test.cpp @@ -34,11 +34,6 @@ class VFSMemoryBackendTest : public ::testing::Test m_backend.Initialize(m_arena); } - void TearDown() override - { - m_manager.Shutdown(); - } - VFSPath P(const char* raw) { auto result = VFSPath::Parse(raw);