From b70e2d21d2f852987d732d43f2233383c9e45a74 Mon Sep 17 00:00:00 2001 From: Patedam Date: Fri, 16 Oct 2026 21:33:57 -0400 Subject: [PATCH] entity per world --- Game/Entity/Entity.cpp | 42 ++ Game/Entity/Entity.h | 32 ++ Game/Entity/EntityManager.cpp | 25 + Game/Entity/EntityManager.h | 4 +- Game/Entity/entity_common.h | 6 + .../include/Core/HAL/Filesystem/Filesystem.h | 9 +- Juliet/include/Core/HAL/IO/IOStream.h | 19 +- Juliet/include/Engine/user.h | 2 +- Juliet/src/Core/HAL/Filesystem/Filesystem.cpp | 23 +- .../Core/HAL/Filesystem/Filesystem_Platform.h | 4 +- .../HAL/Filesystem/Win32/Win32Filesystem.cpp | 21 +- Juliet/src/Core/HAL/IO/IOStream.cpp | 46 ++ Juliet/src/Engine/Engine.cpp | 6 +- Juliet/src/UnitTest/RunUnitTests.cpp | 2 + Juliet/src/UnitTest/iostream_test.cpp | 472 ++++++++++++++++++ 15 files changed, 694 insertions(+), 19 deletions(-) create mode 100644 Juliet/src/UnitTest/iostream_test.cpp diff --git a/Game/Entity/Entity.cpp b/Game/Entity/Entity.cpp index c9a87a6..9bd0a1e 100644 --- a/Game/Entity/Entity.cpp +++ b/Game/Entity/Entity.cpp @@ -21,6 +21,48 @@ internal void serialize(Archive& ar, uint16 /*version*/, Entity& entity) SERIALIZE(ar, position, entity.position); } +String format_entity_id(NonNullPtr arena, EntityID entity_id) +{ + // Format: + // U = UserID + // LC = Local Entity Counter + // UU_LCLCLCLCLCLCLC + uint8 user_id = get_user_id(entity_id); + uint64 counter = get_local_counter(entity_id); + + constexpr size_t kBufferSize = 18; + char* buffer = ArenaPushArray(arena, kBufferSize); + Assert(buffer); + + juliet_snprintf(buffer, kBufferSize, "%02X_%014llX", (unsigned int)user_id, counter); + return { buffer, kBufferSize - 1 }; +} + +bool parse_entity_id(String str, EntityID& out_id) +{ + bool result = false; + out_id = kInvalid_entity_id; + + if (!(str.Size != 17 || str.Str == nullptr || str.Str[2] != '_')) + { + char user_hex[3]{ str.Str[0], str.Str[1], '\0' }; + char* end = user_hex; + uint8 user_id = strtoul(user_hex, &end, 16); + if (end != user_hex && user_id != kInvalid_user_id) + { + char* counter_hex = str.Str + 3; + end = counter_hex; + uint64 counter = strtoull(counter_hex, &end, 16); + if (end != counter_hex && counter < kEntityID_user_id_mask) + { + out_id = make_entity_id(user_id, counter); + result = true; + } + } + } + return result; +} + const Class* resolve_entity_class(uint8 kind, uint32 crc) { Assert(kind <= ENTITY(Count)); diff --git a/Game/Entity/Entity.h b/Game/Entity/Entity.h index ab1326b..5e1bb9a 100644 --- a/Game/Entity/Entity.h +++ b/Game/Entity/Entity.h @@ -30,6 +30,32 @@ concept EntityConcept = requires(EntityType entity) { requires std::same_as; }; +inline constexpr EntityID make_entity_id(uint8 user_id, uint64 local_counter) +{ + Assert(user_id != kInvalid_user_id); + Assert((local_counter & kEntityID_user_id_mask) == kInvalid_entity_id); + + EntityID result = kInvalid_user_id; + result = ((EntityID)user_id << kEntityID_user_id_shift) | (local_counter & kEntityID_user_id_mask); + return result; +} + +inline constexpr uint8 get_user_id(EntityID entity_id) +{ + return (((uint8)entity_id & kEntityID_user_id_mask) << kEntityID_user_id_shift); +} + +inline constexpr uint64 get_local_counter(EntityID entity_id) +{ + return (uint64)entity_id & kEntityID_user_id_mask; +} + +inline constexpr bool is_entity_id_valid(EntityID entity_id) +{ + bool result = entity_id != kInvalid_entity_id; + return result; +} + template requires EntityConcept [[nodiscard]] bool IsA(const Entity* entity) @@ -66,6 +92,12 @@ template void serialize(Archive& ar, NonNullPtr entity); +JULIET_API uint8 get_local_user_id(); + +JULIET_API String format_entity_id(NonNullPtr arena, EntityID id); + +JULIET_API bool parse_entity_id(String str, EntityID& outId); + [[nodiscard]] const Class* find_class_by_name(String name); [[nodiscard]] const Class* resolve_entity_class(uint8 kind, uint32 crc); diff --git a/Game/Entity/EntityManager.cpp b/Game/Entity/EntityManager.cpp index 30811f1..290a417 100644 --- a/Game/Entity/EntityManager.cpp +++ b/Game/Entity/EntityManager.cpp @@ -7,6 +7,7 @@ #include #include #include +#include "Engine/user.h" EntityID EntityManager::ID = 0; @@ -45,6 +46,17 @@ EntityManager& GetEntityManager() return *entityManager; } +EntityID generate_next_entity_id(EntityManager& manager) +{ + UserID user = get_local_user_id(); + Assert(user != kInvalid_user_id); + Assert(manager.next_local_counter != kEntityID_user_id_mask); + + EntityID new_id = make_entity_id(user, manager.next_local_counter); + manager.next_local_counter += 1ULL; + return new_id; +} + EntityTemplate* RegisterEntity(EntityManager& manager, Entity* base, DerivedType entity) { base->ID = EntityManager::ID++; @@ -143,3 +155,16 @@ Entity* deserialize_entity(Archive& ar, EntityManager& manager) return entity_base; } + +void update_local_counter(EntityManager& manager, EntityID entity_id) +{ + uint8 user_id = get_local_user_id(); + if (get_user_id(entity_id) == user_id) + { + uint64 counter = get_local_counter(entity_id); + if (counter >= manager.next_local_counter) + { + manager.next_local_counter = counter + 1ULL; + } + } +} diff --git a/Game/Entity/EntityManager.h b/Game/Entity/EntityManager.h index 11a8368..8034b15 100644 --- a/Game/Entity/EntityManager.h +++ b/Game/Entity/EntityManager.h @@ -18,6 +18,7 @@ struct typed_entity_array struct EntityManager { static EntityID ID; + uint64 next_local_counter = 1ULL; // TODO: Should be a pool VectorArena Entities; @@ -31,4 +32,5 @@ EntityManager& GetEntityManager(); EntityTemplate* RegisterEntity(EntityManager& manager, Entity* base, DerivedType entity); [[nodiscard]] Entity* allocate_entity(EntityManager& manager, NonNullPtr derived_type_class); void UpdateEntityManager(EntityManager& manager); -[[nodisacrd]] Entity* deserialize_entity(Archive& archive, EntityManager& manager); +[[nodiscard]] Entity* deserialize_entity(Archive& archive, EntityManager& manager); +void update_local_counter(EntityManager& manager, EntityID entity_id); diff --git a/Game/Entity/entity_common.h b/Game/Entity/entity_common.h index 276c69a..f829cdd 100644 --- a/Game/Entity/entity_common.h +++ b/Game/Entity/entity_common.h @@ -7,6 +7,12 @@ struct Class; using DerivedType = void*; using EntityID = uint64_t; +constexpr uint8 kInvalid_user_id = 0U; +constexpr EntityID kInvalid_entity_id = 0ULL; + +constexpr EntityID kEntityID_user_id_shift = 56ULL; +constexpr EntityID kEntityID_user_id_mask = 0xFF00'0000'0000'0000ULL; + // clang-format off #define ENTITY_TYPE_LIST(X) X(Inert), diff --git a/Juliet/include/Core/HAL/Filesystem/Filesystem.h b/Juliet/include/Core/HAL/Filesystem/Filesystem.h index 54679d2..648ab6d 100644 --- a/Juliet/include/Core/HAL/Filesystem/Filesystem.h +++ b/Juliet/include/Core/HAL/Filesystem/Filesystem.h @@ -1,4 +1,4 @@ -#pragma once +#pragma once #include @@ -33,3 +33,10 @@ JULIET_API Path fs_create_clean_path(NonNullPtr arena, String path); JULIET_API bool fs_create_directory(String path, bool recursive); JULIET_API bool fs_path_exists(String path); + +JULIET_API bool fs_move_file(Path old_path, Path new_path); + +JULIET_API bool fs_delete_file(String path); + +using fs_iterate_director_callback = void (*)(Path path, bool is_director, void* user_payload); +JULIET_API void fs_iterate_directory(Path directory_path, String extension, void* user_payload, fs_iterate_director_callback callback); diff --git a/Juliet/include/Core/HAL/IO/IOStream.h b/Juliet/include/Core/HAL/IO/IOStream.h index c4bfb2e..f08a985 100644 --- a/Juliet/include/Core/HAL/IO/IOStream.h +++ b/Juliet/include/Core/HAL/IO/IOStream.h @@ -3,6 +3,7 @@ #include #include #include +#include "Core/HAL/Filesystem/Filesystem.h" // Opaque type struct IOStream; @@ -52,15 +53,17 @@ extern JULIET_API IOStream* IOFromInterface(NonNullPtr arena, NonNullPtr< NonNullPtr payload); // Write formatted string into the stream. -extern JULIET_API size_t IOPrintf(NonNullPtr stream, _Printf_format_string_ const char* format, ...); -extern JULIET_API size_t IOWrite(NonNullPtr stream, ByteBuffer inBuffer); +JULIET_API size_t IOPrintf(NonNullPtr stream, _Printf_format_string_ const char* format, ...); +JULIET_API size_t IOWrite(NonNullPtr stream, ByteBuffer inBuffer); -extern JULIET_API size_t IORead(NonNullPtr stream, void* ptr, size_t size); -extern JULIET_API int64 IOSeek(NonNullPtr stream, int64 offset, IOStreamSeekPivot pivot); +JULIET_API size_t IORead(NonNullPtr stream, void* ptr, size_t size); +JULIET_API int64 IOSeek(NonNullPtr stream, int64 offset, IOStreamSeekPivot pivot); -extern JULIET_API int64 IOSize(NonNullPtr stream); +JULIET_API int64 IOSize(NonNullPtr stream); -extern JULIET_API ByteBuffer LoadFile(NonNullPtr arena, String filename); -extern JULIET_API ByteBuffer LoadFile(NonNullPtr arena, NonNullPtr stream, bool closeStreamWhenDone); +JULIET_API ByteBuffer LoadFile(NonNullPtr arena, String filename); +JULIET_API ByteBuffer LoadFile(NonNullPtr arena, NonNullPtr stream, bool closeStreamWhenDone); -extern JULIET_API bool IOClose(NonNullPtr stream); +JULIET_API size_t IO_atomic_file_write(Path filepath, ByteBuffer file_content); + +JULIET_API bool IOClose(NonNullPtr stream); diff --git a/Juliet/include/Engine/user.h b/Juliet/include/Engine/user.h index e0944b6..7a0b8ba 100644 --- a/Juliet/include/Engine/user.h +++ b/Juliet/include/Engine/user.h @@ -5,4 +5,4 @@ using UserID = uint8; void initialize_user_system(); void shutdown_user_system(); -uint8 get_local_user_id(); +JULIET_API uint8 get_local_user_id(); diff --git a/Juliet/src/Core/HAL/Filesystem/Filesystem.cpp b/Juliet/src/Core/HAL/Filesystem/Filesystem.cpp index debdf3e..d71018e 100644 --- a/Juliet/src/Core/HAL/Filesystem/Filesystem.cpp +++ b/Juliet/src/Core/HAL/Filesystem/Filesystem.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include #include @@ -106,6 +106,27 @@ bool fs_path_exists(String path) return Platform::fs_path_exists(path); } +bool fs_move_file(Path old_path, Path new_path) +{ + return Platform::fs_move_file(old_path, new_path); +} + +bool fs_delete_file(String path) +{ + Assert(IsValid(path)); + return Platform::fs_delete_file(path); +} + +void fs_iterate_directory(Path directory_path, String extension, void* user_payload, fs_iterate_director_callback callback) +{ + Assert(IsValid(directory_path)); + Assert(callback); + + TempArena scratch = scratch_begin(nullptr, 0); + + scratch_end(scratch); +} + void InitFilesystem(NonNullPtr arena) { CachedBasePath = Platform::GetBasePath(arena); diff --git a/Juliet/src/Core/HAL/Filesystem/Filesystem_Platform.h b/Juliet/src/Core/HAL/Filesystem/Filesystem_Platform.h index 87604b3..ced6d50 100644 --- a/Juliet/src/Core/HAL/Filesystem/Filesystem_Platform.h +++ b/Juliet/src/Core/HAL/Filesystem/Filesystem_Platform.h @@ -1,4 +1,4 @@ -#pragma once +#pragma once #include @@ -9,4 +9,6 @@ namespace Platform Path fs_create_clean_path(NonNullPtr arena, String path); bool fs_create_directory(String path, bool recursive); bool fs_path_exists(String path); + bool fs_move_file(Path old_path, Path new_path); + bool fs_delete_file(String path); } // namespace Platform diff --git a/Juliet/src/Core/HAL/Filesystem/Win32/Win32Filesystem.cpp b/Juliet/src/Core/HAL/Filesystem/Win32/Win32Filesystem.cpp index 23fcd1b..6d8a778 100644 --- a/Juliet/src/Core/HAL/Filesystem/Win32/Win32Filesystem.cpp +++ b/Juliet/src/Core/HAL/Filesystem/Win32/Win32Filesystem.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include #include @@ -167,10 +167,9 @@ namespace Platform if (!create_result) { const DWORD error = GetLastError(); - if (error != ERROR_ALREADY_EXISTS) + if (error == ERROR_ALREADY_EXISTS) { LogDebug(LogCategory::Core, "Folder already exists"); - result = false; } else { @@ -191,4 +190,20 @@ namespace Platform const bool result = (attribs != INVALID_FILE_ATTRIBUTES); return result; } + + bool fs_move_file(Path old_path, Path new_path) + { + bool result = MoveFileExA(CStr(old_path.FullPath), CStr(new_path.FullPath), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH); + return result; + } + + bool fs_delete_file(String path) + { + if (!DeleteFileA(CStr(path))) + { + LogError(LogCategory::Core, "Failed to delete file %s. Error: %lu", CStr(path), GetLastError()); + return false; + } + return true; + } } // namespace Platform diff --git a/Juliet/src/Core/HAL/IO/IOStream.cpp b/Juliet/src/Core/HAL/IO/IOStream.cpp index 6f15eb5..d5661dc 100644 --- a/Juliet/src/Core/HAL/IO/IOStream.cpp +++ b/Juliet/src/Core/HAL/IO/IOStream.cpp @@ -5,6 +5,7 @@ #include #include #include +#include "Core/Thread/ThreadContext.h" IOStream* IOFromFile(NonNullPtr arena, String filename, String mode) { @@ -212,6 +213,51 @@ ByteBuffer LoadFile(NonNullPtr arena, NonNullPtr stream, bool c return resultBuffer; } +size_t IO_atomic_file_write(Path filepath, ByteBuffer file_content) +{ + size_t written_size = 0; + + TempArena temp_arena = scratch_begin(nullptr, 0); + + size_t temp_size = filepath.FullPath.Size + sizeof(".tmp"); + char* tmp_str = (char*)ArenaPushSize(temp_arena.Arena, temp_size, 8, true JULIET_DEBUG_PARAM("Temp path")); + juliet_snprintf(tmp_str, temp_size, "%s.tmp", CStr(filepath.FullPath)); + Path temp_path = fs_create_clean_path(temp_arena.Arena, WrapString(tmp_str)); + + if (IOStream* temp_stream = IOFromFile(temp_arena.Arena, temp_path.FullPath, ConstString("w+"))) + { + written_size = IOWrite(temp_stream, file_content); + bool closed = IOClose(temp_stream); + if (closed && written_size == file_content.Size) + { + if (!fs_move_file(temp_path, filepath)) + { + LogError(LogCategory::Core, "IO_atomic_file_write [%s] failed to move from temp to final file. Aborting", + CStr(filepath.FullPath)); + written_size = 0; + } + } + else + { + LogError(LogCategory::Core, "IO_atomic_file_write [%s] failed to write into temporary file. Aborting", + CStr(filepath.FullPath)); + } + + if (written_size == 0) + { + fs_delete_file(temp_path.FullPath); + } + } + else + { + LogError(LogCategory::Core, "IO_atomic_file_write [%s] failed creating temporary file. Aborting", CStr(filepath.FullPath)); + } + + scratch_end(temp_arena); + + return written_size; +} + bool IOClose(NonNullPtr stream) { bool result = true; diff --git a/Juliet/src/Engine/Engine.cpp b/Juliet/src/Engine/Engine.cpp index de997df..e82dfc7 100644 --- a/Juliet/src/Engine/Engine.cpp +++ b/Juliet/src/Engine/Engine.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include @@ -154,12 +154,12 @@ void InitializeEngine(JulietInit_Flags flags) InitializeLogManager(); + InitFilesystem(EngineInstance.PlatformArena); + #if JULIET_DEBUG UnitTest::RunUnitTests(); #endif - InitFilesystem(EngineInstance.PlatformArena); - initialize_user_system(); JulietInit(flags); diff --git a/Juliet/src/UnitTest/RunUnitTests.cpp b/Juliet/src/UnitTest/RunUnitTests.cpp index 1d501c1..e918c43 100644 --- a/Juliet/src/UnitTest/RunUnitTests.cpp +++ b/Juliet/src/UnitTest/RunUnitTests.cpp @@ -12,6 +12,7 @@ namespace UnitTest void TestMemoryArena(); void SerializationUnitTest(); void os_unit_test(); + void iostream_unit_test(); void RunUnitTests() { @@ -21,6 +22,7 @@ namespace UnitTest TestMemoryArena(); VectorUnitTest(); SerializationUnitTest(); + iostream_unit_test(); LogMessage(LogCategory::Core, "Unit Tests Completed Successfully."); } diff --git a/Juliet/src/UnitTest/iostream_test.cpp b/Juliet/src/UnitTest/iostream_test.cpp new file mode 100644 index 0000000..83aed10 --- /dev/null +++ b/Juliet/src/UnitTest/iostream_test.cpp @@ -0,0 +1,472 @@ +#if JULIET_DEBUG + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace UnitTest +{ + struct IOStreamTestMemoryPayload : IOStreamDataPayload + { + Byte* data; + size_t capacity; + size_t size; + size_t position; + bool is_closed; + }; + + internal IOStreamTestMemoryPayload* iostream_test_get_payload(NonNullPtr payload) + { + return static_cast(payload.Get()); + } + + internal int64 iostream_test_mem_size(NonNullPtr payload) + { + return static_cast(iostream_test_get_payload(payload)->size); + } + + internal int64 iostream_test_mem_seek(NonNullPtr payload, int64 offset, IOStreamSeekPivot pivot) + { + auto* memory = iostream_test_get_payload(payload); + + int64 base = 0; + switch (pivot) + { + case IOStreamSeekPivot::Begin: base = 0; break; + case IOStreamSeekPivot::Current: base = static_cast(memory->position); break; + case IOStreamSeekPivot::End: base = static_cast(memory->size); break; + case IOStreamSeekPivot::Count: return -1; + } + + int64 new_position = base + offset; + if ((new_position < 0) || (new_position > static_cast(memory->size))) + { + return -1; + } + + memory->position = static_cast(new_position); + return new_position; + } + + internal size_t iostream_test_mem_read(NonNullPtr payload, void* out_buffer, size_t size, + NonNullPtr /*status*/) + { + Assert(out_buffer != nullptr); + + auto* memory = iostream_test_get_payload(payload); + size_t bytes_left = memory->size - memory->position; + size_t to_read = Min(size, bytes_left); + MemCopy(out_buffer, memory->data + memory->position, to_read); + memory->position += to_read; + return to_read; + } + + internal size_t iostream_test_mem_write(NonNullPtr payload, ByteBuffer in_buffer, + NonNullPtr /*status*/) + { + Assert(IsValid(in_buffer)); + + auto* memory = iostream_test_get_payload(payload); + size_t space_left = memory->capacity - memory->position; + size_t to_write = Min(in_buffer.Size, space_left); + MemCopy(memory->data + memory->position, in_buffer.Data, to_write); + memory->position += to_write; + memory->size = Max(memory->size, memory->position); + return to_write; + } + + internal bool iostream_test_mem_flush(NonNullPtr /*payload*/, NonNullPtr /*status*/) + { + return true; + } + + internal bool iostream_test_mem_close(NonNullPtr payload) + { + iostream_test_get_payload(payload)->is_closed = true; + return true; + } + + internal IOStreamInterface iostream_test_make_full_interface() + { + IOStreamInterface stream_interface = {}; + stream_interface.Version = sizeof(IOStreamInterface); + stream_interface.Size = iostream_test_mem_size; + stream_interface.Seek = iostream_test_mem_seek; + stream_interface.Read = iostream_test_mem_read; + stream_interface.Write = iostream_test_mem_write; + stream_interface.Flush = iostream_test_mem_flush; + stream_interface.Close = iostream_test_mem_close; + return stream_interface; + } + + internal IOStreamTestMemoryPayload iostream_test_make_payload(NonNullPtr arena, size_t capacity) + { + Assert(capacity > 0); + + IOStreamTestMemoryPayload payload = {}; + payload.data = ArenaPushArray(arena, capacity); + payload.capacity = capacity; + Assert(payload.data != nullptr); + return payload; + } + + internal ByteBuffer iostream_test_buffer_from_string(String str) + { + Assert(IsValid(str)); + return { .Data = reinterpret_cast(str.Str), .Size = str.Size }; + } + + internal bool iostream_test_buffer_equals(ByteBuffer buffer, String expected) + { + return (buffer.Size == expected.Size) && (MemCompare(buffer.Data, expected.Str, expected.Size) == 0); + } + + internal String iostream_test_append_suffix(NonNullPtr arena, String str, String suffix) + { + Assert(IsValid(str)); + Assert(IsValid(suffix)); + + size_t total_size = str.Size + suffix.Size; + char* buffer = ArenaPushArray(arena, total_size + 1); + Assert(buffer != nullptr); + + MemCopy(buffer, str.Str, str.Size); + MemCopy(buffer + str.Size, suffix.Str, suffix.Size); + buffer[total_size] = '\0'; + return { buffer, total_size }; + } + + internal void iostream_test_delete_file(String filename) + { + Assert(IsValid(filename)); + if (fs_path_exists(filename)) + { + Assert(fs_delete_file(filename)); + } + Assert(!fs_path_exists(filename)); + } + + // Test files live in /Intermediate/UnitTest/ (exe is in /bin/x64-/) + internal Path iostream_test_make_path(NonNullPtr arena, String filename) + { + Assert(IsValid(filename)); + + Path base_path = GetBasePath(); + String raw_directory = iostream_test_append_suffix(arena, base_path.Directories, + ConstString("..\\..\\Intermediate\\UnitTest\\")); + Path test_directory = fs_create_clean_path(arena, raw_directory); + Assert(IsValid(test_directory)); + + if (!fs_path_exists(test_directory.FullPath)) + { + Assert(fs_create_directory(test_directory.FullPath, true)); + } + + Path file_path = fs_create_clean_path(arena, iostream_test_append_suffix(arena, test_directory.FullPath, filename)); + Assert(IsValid(file_path)); + return file_path; + } + + internal void iostream_test_write_file(String filename, String mode, String content) + { + TempArena scratch = scratch_begin(nullptr, 0); + + IOStream* stream = IOFromFile(scratch.Arena, filename, mode); + Assert(stream != nullptr); + Assert(IOWrite(stream, iostream_test_buffer_from_string(content)) == content.Size); + Assert(IOClose(stream)); + + scratch_end(scratch); + } + + // IOFromInterface + IOWrite / IOPrintf / IOSize / IOSeek / IORead / IOClose on a full interface + internal void iostream_test_memory_stream() + { + TempArena scratch = scratch_begin(nullptr, 0); + + IOStreamInterface stream_interface = iostream_test_make_full_interface(); + IOStreamTestMemoryPayload payload = iostream_test_make_payload(scratch.Arena, 256); + + IOStream* stream = IOFromInterface(scratch.Arena, &stream_interface, &payload); + Assert(stream != nullptr); + + String hello = ConstString("Hello"); + Assert(IOWrite(stream, iostream_test_buffer_from_string(hello)) == hello.Size); + Assert(IOWrite(stream, {}) == 0); + Assert(IOPrintf(stream, " %s %d", "World", 42) == 9); + + String expected = ConstString("Hello World 42"); + Assert(IOSize(stream) == static_cast(expected.Size)); + + Assert(IOSeek(stream, 0, IOStreamSeekPivot::Begin) == 0); + char read_buffer[64] = {}; + Assert(IORead(stream, read_buffer, expected.Size) == expected.Size); + Assert(MemCompare(read_buffer, expected.Str, expected.Size) == 0); + + Assert(IORead(stream, read_buffer, sizeof(read_buffer)) == 0); + Assert(IORead(stream, read_buffer, 0) == 0); + + Assert(IOSeek(stream, -2, IOStreamSeekPivot::End) == static_cast(expected.Size - 2)); + Assert(IORead(stream, read_buffer, 2) == 2); + Assert(MemCompare(read_buffer, "42", 2) == 0); + + Assert(IOSeek(stream, -8, IOStreamSeekPivot::Current) == 6); + Assert(IORead(stream, read_buffer, 5) == 5); + Assert(MemCompare(read_buffer, "World", 5) == 0); + + Assert(!payload.is_closed); + Assert(IOClose(stream)); + Assert(payload.is_closed); + + scratch_end(scratch); + LogMessage(LogCategory::Core, "iostream_test_memory_stream passed."); + } + + // Behavior when the interface is missing some functions + internal void iostream_test_capability_fallbacks() + { + TempArena scratch = scratch_begin(nullptr, 0); + + String content = ConstString("Hello World 42"); + + // Read only, no seek, no size, no close + { + IOStreamInterface read_only_interface = {}; + read_only_interface.Version = sizeof(IOStreamInterface); + read_only_interface.Read = iostream_test_mem_read; + + IOStreamTestMemoryPayload payload = iostream_test_make_payload(scratch.Arena, 64); + MemCopy(payload.data, content.Str, content.Size); + payload.size = content.Size; + + IOStream* stream = IOFromInterface(scratch.Arena, &read_only_interface, &payload); + Assert(stream != nullptr); + + Assert(IOWrite(stream, iostream_test_buffer_from_string(content)) == 0); + Assert(IOSeek(stream, 0, IOStreamSeekPivot::Begin) == -1); + Assert(IOSize(stream) == -1); + + char read_buffer[64] = {}; + Assert(IORead(stream, read_buffer, content.Size) == content.Size); + Assert(MemCompare(read_buffer, content.Str, content.Size) == 0); + + Assert(IOClose(stream)); + Assert(!payload.is_closed); + } + + // Write only + { + IOStreamInterface write_only_interface = {}; + write_only_interface.Version = sizeof(IOStreamInterface); + write_only_interface.Write = iostream_test_mem_write; + + IOStreamTestMemoryPayload payload = iostream_test_make_payload(scratch.Arena, 64); + + IOStream* stream = IOFromInterface(scratch.Arena, &write_only_interface, &payload); + Assert(stream != nullptr); + + Assert(IOWrite(stream, iostream_test_buffer_from_string(content)) == content.Size); + + char read_buffer[64] = {}; + Assert(IORead(stream, read_buffer, content.Size) == 0); + Assert(IOClose(stream)); + } + + // No Size function: IOSize falls back on Seek and restores the position + { + IOStreamInterface seek_interface = iostream_test_make_full_interface(); + seek_interface.Size = nullptr; + + IOStreamTestMemoryPayload payload = iostream_test_make_payload(scratch.Arena, 64); + MemCopy(payload.data, content.Str, content.Size); + payload.size = content.Size; + + IOStream* stream = IOFromInterface(scratch.Arena, &seek_interface, &payload); + Assert(stream != nullptr); + + Assert(IOSeek(stream, 3, IOStreamSeekPivot::Begin) == 3); + Assert(IOSize(stream) == static_cast(content.Size)); + Assert(IOSeek(stream, 0, IOStreamSeekPivot::Current) == 3); + Assert(IOClose(stream)); + } + + scratch_end(scratch); + LogMessage(LogCategory::Core, "iostream_test_capability_fallbacks passed."); + } + + // LoadFile(arena, stream, closeStreamWhenDone) with known size and chunked fallback + internal void iostream_test_load_file_from_stream() + { + TempArena scratch = scratch_begin(nullptr, 0); + + String content = ConstString("Juliet LoadFile content"); + + // Known size, stream closed when done + { + IOStreamInterface stream_interface = iostream_test_make_full_interface(); + IOStreamTestMemoryPayload payload = iostream_test_make_payload(scratch.Arena, 64); + MemCopy(payload.data, content.Str, content.Size); + payload.size = content.Size; + + IOStream* stream = IOFromInterface(scratch.Arena, &stream_interface, &payload); + Assert(stream != nullptr); + + ByteBuffer loaded = LoadFile(scratch.Arena, stream, true); + Assert(iostream_test_buffer_equals(loaded, content)); + Assert(loaded.Data[loaded.Size] == Byte{ 0 }); + Assert(payload.is_closed); + } + + // Known size, stream kept open + { + IOStreamInterface stream_interface = iostream_test_make_full_interface(); + IOStreamTestMemoryPayload payload = iostream_test_make_payload(scratch.Arena, 64); + MemCopy(payload.data, content.Str, content.Size); + payload.size = content.Size; + + IOStream* stream = IOFromInterface(scratch.Arena, &stream_interface, &payload); + Assert(stream != nullptr); + + ByteBuffer loaded = LoadFile(scratch.Arena, stream, false); + Assert(iostream_test_buffer_equals(loaded, content)); + Assert(!payload.is_closed); + Assert(IOClose(stream)); + } + + // Unknown size (no Size / Seek): loaded chunk by chunk, bigger than one chunk + { + constexpr size_t k_big_content_size = 3000; + + IOStreamInterface stream_interface = iostream_test_make_full_interface(); + stream_interface.Size = nullptr; + stream_interface.Seek = nullptr; + + IOStreamTestMemoryPayload payload = iostream_test_make_payload(scratch.Arena, k_big_content_size); + for (size_t index = 0; index < k_big_content_size; ++index) + { + payload.data[index] = static_cast('a' + (index % 26)); + } + payload.size = k_big_content_size; + + IOStream* stream = IOFromInterface(scratch.Arena, &stream_interface, &payload); + Assert(stream != nullptr); + + ByteBuffer loaded = LoadFile(scratch.Arena, stream, true); + Assert(loaded.Size == k_big_content_size); + Assert(MemCompare(loaded.Data, payload.data, k_big_content_size) == 0); + Assert(loaded.Data[loaded.Size] == Byte{ 0 }); + Assert(payload.is_closed); + } + + scratch_end(scratch); + LogMessage(LogCategory::Core, "iostream_test_load_file_from_stream passed."); + } + + // IOFromFile + LoadFile(arena, filename) on a real file + internal void iostream_test_file_stream() + { + TempArena scratch = scratch_begin(nullptr, 0); + + Path file_path = iostream_test_make_path(scratch.Arena, ConstString("juliet_iostream_unit_test.bin")); + Assert(IsValid(file_path)); + iostream_test_delete_file(file_path.FullPath); + + Assert(IOFromFile(scratch.Arena, {}, ConstString("rb")) == nullptr); + Assert(IOFromFile(scratch.Arena, file_path.FullPath, {}) == nullptr); + Assert(IOFromFile(scratch.Arena, file_path.FullPath, ConstString("rb")) == nullptr); + + String first_part = ConstString("First part."); + String second_part = ConstString(" Second part."); + String full = ConstString("First part. Second part."); + + iostream_test_write_file(file_path.FullPath, ConstString("wb"), first_part); + Assert(fs_path_exists(file_path.FullPath)); + Assert(iostream_test_buffer_equals(LoadFile(scratch.Arena, file_path.FullPath), first_part)); + + iostream_test_write_file(file_path.FullPath, ConstString("ab"), second_part); + Assert(iostream_test_buffer_equals(LoadFile(scratch.Arena, file_path.FullPath), full)); + + // Seek / Size / partial read on a file stream + { + IOStream* stream = IOFromFile(scratch.Arena, file_path.FullPath, ConstString("rb")); + Assert(stream != nullptr); + Assert(IOSize(stream) == static_cast(full.Size)); + Assert(IOSeek(stream, static_cast(first_part.Size), IOStreamSeekPivot::Begin) == + static_cast(first_part.Size)); + + char read_buffer[64] = {}; + Assert(IORead(stream, read_buffer, second_part.Size) == second_part.Size); + Assert(MemCompare(read_buffer, second_part.Str, second_part.Size) == 0); + Assert(IORead(stream, read_buffer, sizeof(read_buffer)) == 0); + Assert(IOWrite(stream, iostream_test_buffer_from_string(first_part)) == 0); + Assert(IOClose(stream)); + } + + // "w" truncates + iostream_test_write_file(file_path.FullPath, ConstString("wb"), second_part); + Assert(iostream_test_buffer_equals(LoadFile(scratch.Arena, file_path.FullPath), second_part)); + + iostream_test_delete_file(file_path.FullPath); + + scratch_end(scratch); + LogMessage(LogCategory::Core, "iostream_test_file_stream passed."); + } + + // IO_atomic_file_write: writes to .tmp then moves it to + internal void iostream_test_atomic_file_write() + { + TempArena scratch = scratch_begin(nullptr, 0); + + Path file_path = iostream_test_make_path(scratch.Arena, ConstString("juliet_iostream_atomic_unit_test.bin")); + Assert(IsValid(file_path)); + String temp_filename = iostream_test_append_suffix(scratch.Arena, file_path.FullPath, ConstString(".tmp")); + + iostream_test_delete_file(file_path.FullPath); + iostream_test_delete_file(temp_filename); + + // Create a new file + String first_content = ConstString("Atomic content that is long enough"); + Assert(IO_atomic_file_write(file_path, iostream_test_buffer_from_string(first_content)) == first_content.Size); + Assert(fs_path_exists(file_path.FullPath)); + Assert(!fs_path_exists(temp_filename)); + Assert(iostream_test_buffer_equals(LoadFile(scratch.Arena, file_path.FullPath), first_content)); + + // Replace an existing file with shorter content + String second_content = ConstString("Shorter"); + Assert(IO_atomic_file_write(file_path, iostream_test_buffer_from_string(second_content)) == second_content.Size); + Assert(!fs_path_exists(temp_filename)); + Assert(iostream_test_buffer_equals(LoadFile(scratch.Arena, file_path.FullPath), second_content)); + + // A leftover temp file from a previous crash must not break the write + iostream_test_write_file(temp_filename, ConstString("wb"), ConstString("Stale temp data")); + Assert(IO_atomic_file_write(file_path, iostream_test_buffer_from_string(first_content)) == first_content.Size); + Assert(!fs_path_exists(temp_filename)); + Assert(iostream_test_buffer_equals(LoadFile(scratch.Arena, file_path.FullPath), first_content)); + + iostream_test_delete_file(file_path.FullPath); + + scratch_end(scratch); + LogMessage(LogCategory::Core, "iostream_test_atomic_file_write passed."); + } + + void iostream_unit_test() + { + LogMessage(LogCategory::Core, "=== Running IOStream Unit Tests ==="); + + iostream_test_memory_stream(); + iostream_test_capability_fallbacks(); + iostream_test_load_file_from_stream(); + iostream_test_file_stream(); + iostream_test_atomic_file_write(); + + LogMessage(LogCategory::Core, "=== All IOStream Unit Tests Passed Successfully ==="); + } +} // namespace UnitTest + +#endif