From 15df77c078e07ca377b79995377edfff5560f143 Mon Sep 17 00:00:00 2001 From: Tamely Date: Sat, 23 May 2026 14:06:24 -0500 Subject: [PATCH 1/8] Packaged scene asset --- Axiom/Assets/SceneFile.cpp | 124 ++++++++++++++++-- Axiom/Assets/SceneFile.h | 12 ++ Axiom/Project/ProjectSystem.cpp | 40 ++++-- Axiom/Project/ProjectSystem.h | 5 +- .../components/engine/project-browser.tsx | 2 +- EditorFrontend/components/wraith-engine.tsx | 5 +- Headless/RemoteViewportServer.cpp | 11 +- Tests/ProjectTests.cpp | 17 ++- Tests/SceneLifecycleTests.cpp | 90 +++++++++++++ 9 files changed, 270 insertions(+), 36 deletions(-) diff --git a/Axiom/Assets/SceneFile.cpp b/Axiom/Assets/SceneFile.cpp index e8e5e765..db0a1e4f 100644 --- a/Axiom/Assets/SceneFile.cpp +++ b/Axiom/Assets/SceneFile.cpp @@ -10,6 +10,8 @@ #include #include +#include +#include #include #include #include @@ -29,6 +31,9 @@ namespace Axiom::Assets { namespace { +constexpr char kCookedSceneMagic[] = {'W', 'S', 'C', 'N'}; +constexpr std::uint32_t kCookedSceneVersion = 1; + std::string EscStr(std::string_view S) { std::string Out; Out.reserve(S.size() + 2); @@ -382,8 +387,8 @@ void ExpandMeshAssetIntoScene(EditorSceneState &State, std::string_view RootObje // Save // --------------------------------------------------------------------------- -bool SaveSceneToFile(const std::filesystem::path &Path, - const EditorSceneState &Scene) { +std::string SerializeSceneToJsonString(const std::filesystem::path &Path, + const EditorSceneState &Scene) { const std::filesystem::path ContentRoot = ResolveContentRootForScenePath(Path); std::unordered_map AssetPathByObjectId; for (const auto &[ObjectId, Details] : Scene.ObjectDetailsById) { @@ -519,15 +524,46 @@ bool SaveSceneToFile(const std::filesystem::path &Path, FirstMesh = false; Out << " " << EscStr(It->second.DisplayName) << ": " << EscStr(Instance.ObjectId); } - Out << "\n }\n"; + Out << "\n },\n"; + Out << " \"worldSettings\": {\n" + << " \"skyboxColorTop\": " + << SerializeVec3(Scene.WorldSettings.SkyboxColorTop) << ",\n" + << " \"skyboxColorBottom\": " + << SerializeVec3(Scene.WorldSettings.SkyboxColorBottom) << ",\n" + << " \"skyboxHDRPath\": " + << EscStr(Scene.WorldSettings.SkyboxHDRPath) << "\n" + << " }\n"; Out << "}\n"; + return Out.str(); +} +bool SaveSceneToFile(const std::filesystem::path &Path, + const EditorSceneState &Scene) { std::ofstream File(Path); if (!File.is_open()) { A_CORE_ERROR("SceneFile: could not open {0} for writing", Path.string()); return false; } - File << Out.str(); + File << SerializeSceneToJsonString(Path, Scene); + return File.good(); +} + +bool SaveCookedSceneToFile(const std::filesystem::path &Path, + const EditorSceneState &Scene) { + const std::string Payload = SerializeSceneToJsonString(Path, Scene); + std::ofstream File(Path, std::ios::binary); + if (!File.is_open()) { + A_CORE_ERROR("SceneFile: could not open cooked scene {0} for writing", + Path.string()); + return false; + } + + const std::uint64_t PayloadSize = static_cast(Payload.size()); + File.write(kCookedSceneMagic, sizeof(kCookedSceneMagic)); + File.write(reinterpret_cast(&kCookedSceneVersion), + sizeof(kCookedSceneVersion)); + File.write(reinterpret_cast(&PayloadSize), sizeof(PayloadSize)); + File.write(Payload.data(), static_cast(Payload.size())); return File.good(); } @@ -699,15 +735,11 @@ EditorSceneItemKind KindFromStr(std::string_view S) { // --------------------------------------------------------------------------- std::optional -LoadSceneFromFile(const std::filesystem::path &Path) { +DeserializeSceneFromJsonString(const std::filesystem::path &Path, + std::string_view Text) { const std::filesystem::path ContentRoot = ResolveContentRootForScenePath(Path); const bool CookedOnlyContent = IsCookedOnlyContentPath(ContentRoot); - std::ifstream File(Path); - if (!File.is_open()) return std::nullopt; - const std::string Text((std::istreambuf_iterator(File)), - std::istreambuf_iterator()); - Parser P{Text}; // --- Stage 1: parse flat data --- @@ -737,6 +769,7 @@ LoadSceneFromFile(const std::filesystem::path &Path) { std::vector Nodes; std::unordered_map Objects; std::unordered_map MeshNameToObjectId; + EditorWorldSettings WorldSettings; bool Ok = P.ParseObject([&](const std::string &Key) -> bool { if (Key == "version") { P.ParseNumber(); return true; } @@ -912,6 +945,27 @@ LoadSceneFromFile(const std::filesystem::path &Path) { }); return true; } + if (Key == "worldSettings") { + P.ParseObject([&](const std::string &K) -> bool { + if (K == "skyboxColorTop") { + auto V = P.ParseVec3(); + if (V) WorldSettings.SkyboxColorTop = *V; + return true; + } + if (K == "skyboxColorBottom") { + auto V = P.ParseVec3(); + if (V) WorldSettings.SkyboxColorBottom = *V; + return true; + } + if (K == "skyboxHDRPath") { + auto V = P.ParseString(); + if (V) WorldSettings.SkyboxHDRPath = *V; + return true; + } + return false; + }); + return true; + } return false; }); @@ -958,6 +1012,7 @@ LoadSceneFromFile(const std::filesystem::path &Path) { // --- Stage 3: rebuild ObjectDetailsById --- EditorSceneState State; State.Items = std::move(RootItems); + State.WorldSettings = WorldSettings; for (auto &[Id, Data] : Objects) { EditorObjectDetails Details; Details.ObjectId = Id; @@ -1105,4 +1160,53 @@ LoadSceneFromFile(const std::filesystem::path &Path) { return State; } +std::optional +LoadSceneFromFile(const std::filesystem::path &Path) { + std::ifstream File(Path); + if (!File.is_open()) return std::nullopt; + const std::string Text((std::istreambuf_iterator(File)), + std::istreambuf_iterator()); + return DeserializeSceneFromJsonString(Path, Text); +} + +std::optional +LoadCookedSceneFromFile(const std::filesystem::path &Path) { + std::ifstream File(Path, std::ios::binary); + if (!File.is_open()) { + return std::nullopt; + } + + char Magic[sizeof(kCookedSceneMagic)]; + File.read(Magic, sizeof(Magic)); + if (!File.good() || std::memcmp(Magic, kCookedSceneMagic, sizeof(Magic)) != 0) { + A_CORE_ERROR("SceneFile: invalid cooked scene header in {0}", Path.string()); + return std::nullopt; + } + + std::uint32_t Version = 0; + std::uint64_t PayloadSize = 0; + File.read(reinterpret_cast(&Version), sizeof(Version)); + File.read(reinterpret_cast(&PayloadSize), sizeof(PayloadSize)); + if (!File.good()) { + A_CORE_ERROR("SceneFile: failed to read cooked scene header from {0}", + Path.string()); + return std::nullopt; + } + if (Version != kCookedSceneVersion) { + A_CORE_ERROR("SceneFile: unsupported cooked scene version {} in {}", + Version, Path.string()); + return std::nullopt; + } + + std::string Payload(PayloadSize, '\0'); + File.read(Payload.data(), static_cast(PayloadSize)); + if (!File.good()) { + A_CORE_ERROR("SceneFile: failed to read cooked scene payload from {0}", + Path.string()); + return std::nullopt; + } + + return DeserializeSceneFromJsonString(Path, Payload); +} + } // namespace Axiom::Assets diff --git a/Axiom/Assets/SceneFile.h b/Axiom/Assets/SceneFile.h index a0e95ed2..f81cadaa 100644 --- a/Axiom/Assets/SceneFile.h +++ b/Axiom/Assets/SceneFile.h @@ -12,9 +12,21 @@ namespace Axiom::Assets { bool SaveSceneToFile(const std::filesystem::path &Path, const EditorSceneState &Scene); +// Saves the current scene state to a cooked packaged-scene container on disk. +// The payload preserves the same logical scene data as SaveSceneToFile, but is +// wrapped in an engine-owned binary container for packaged runtime use. +bool SaveCookedSceneToFile(const std::filesystem::path &Path, + const EditorSceneState &Scene); + // Loads a previously saved scene file and reconstructs EditorSceneState. // Returns nullopt if the file does not exist or cannot be parsed. std::optional LoadSceneFromFile(const std::filesystem::path &Path); +// Loads a previously saved cooked packaged-scene container and reconstructs +// EditorSceneState. Returns nullopt if the file does not exist or cannot be +// parsed. +std::optional +LoadCookedSceneFromFile(const std::filesystem::path &Path); + } // namespace Axiom::Assets diff --git a/Axiom/Project/ProjectSystem.cpp b/Axiom/Project/ProjectSystem.cpp index 1ec3c5ea..d6902ffb 100644 --- a/Axiom/Project/ProjectSystem.cpp +++ b/Axiom/Project/ProjectSystem.cpp @@ -2,6 +2,7 @@ #include "Assets/AssetCookManifest.h" #include "Assets/AssetCooker.h" +#include "Assets/SceneFile.h" #include "Core/Log.h" #include @@ -572,8 +573,8 @@ bool SavePackageManifestFile(const ProjectDescriptor &Project, << "\",\n" << " \"name\": \"" << EscapeJsonString(Project.Manifest.Name) << "\",\n" << " \"slug\": \"" << EscapeJsonString(Project.Manifest.Slug) << "\",\n" - << " \"contentMode\": \"transitional-scene-plus-cooked-assets\",\n" - << " \"sceneFile\": \"Content/scene.json\",\n" + << " \"contentMode\": \"cooked-only-v1\",\n" + << " \"sceneAsset\": \"Content/Cooked/scene.wscene\",\n" << " \"cookedDir\": \"Content/Cooked\",\n" << " \"assetCookManifest\": \"Content/Cooked/AssetCookManifest.json\",\n" << " \"engineContentDir\": \"Content/Engine\",\n" @@ -639,7 +640,8 @@ ProjectOutputLayout ResolveProjectOutputLayout(const ProjectRoot &Root) { .PackagedCookManifestPath = Root.RootPath / "Package" / "Content" / "Cooked" / "AssetCookManifest.json", - .PackagedSceneFilePath = Root.RootPath / "Package" / "Content" / "scene.json", + .PackagedSceneAssetPath = + Root.RootPath / "Package" / "Content" / "Cooked" / "scene.wscene", .PackagedEngineContentDir = Root.RootPath / "Package" / "Content" / "Engine", .PackageManifestPath = Root.RootPath / "Package" / "package.wraith.json", @@ -1066,16 +1068,26 @@ PackageProjectContent(const ProjectDescriptor &Project, return std::nullopt; } - if (std::filesystem::exists(Project.Root.SceneFilePath)) { - std::filesystem::copy_file( - Project.Root.SceneFilePath, Project.Output.PackagedSceneFilePath, - std::filesystem::copy_options::overwrite_existing, Error); - if (Error) { - if (FailureReason != nullptr) { - *FailureReason = "Failed to copy the project scene into the package."; - } - return std::nullopt; + if (!std::filesystem::exists(Project.Root.SceneFilePath)) { + if (FailureReason != nullptr) { + *FailureReason = "Failed to package the project scene because scene.json is missing."; } + return std::nullopt; + } + + const auto LoadedScene = Assets::LoadSceneFromFile(Project.Root.SceneFilePath); + if (!LoadedScene.has_value()) { + if (FailureReason != nullptr) { + *FailureReason = "Failed to load the project scene before packaging."; + } + return std::nullopt; + } + if (!Assets::SaveCookedSceneToFile(Project.Output.PackagedSceneAssetPath, + *LoadedScene)) { + if (FailureReason != nullptr) { + *FailureReason = "Failed to write the cooked packaged scene asset."; + } + return std::nullopt; } const auto EngineContentDir = @@ -1090,9 +1102,11 @@ PackageProjectContent(const ProjectDescriptor &Project, ProjectPackageResult Result{ .Cook = *CookResult, .PackagedFileCount = CountPackagedFiles(Project.Output.PackageDir), - .IncludedSceneFile = std::filesystem::exists(Project.Output.PackagedSceneFilePath), + .IncludedSceneAsset = + std::filesystem::exists(Project.Output.PackagedSceneAssetPath), .IncludedEngineContent = std::filesystem::exists(Project.Output.PackagedEngineContentDir), + .SceneAssetPath = Project.Output.PackagedSceneAssetPath, }; if (!SavePackageManifestFile(Project, Result)) { if (FailureReason != nullptr) { diff --git a/Axiom/Project/ProjectSystem.h b/Axiom/Project/ProjectSystem.h index de6d640c..ac35fd88 100644 --- a/Axiom/Project/ProjectSystem.h +++ b/Axiom/Project/ProjectSystem.h @@ -44,7 +44,7 @@ struct ProjectOutputLayout { std::filesystem::path PackagedContentDir; std::filesystem::path PackagedCookedDir; std::filesystem::path PackagedCookManifestPath; - std::filesystem::path PackagedSceneFilePath; + std::filesystem::path PackagedSceneAssetPath; std::filesystem::path PackagedEngineContentDir; std::filesystem::path PackageManifestPath; }; @@ -58,8 +58,9 @@ struct ProjectCookResult { struct ProjectPackageResult { ProjectCookResult Cook; std::size_t PackagedFileCount{0}; - bool IncludedSceneFile{false}; + bool IncludedSceneAsset{false}; bool IncludedEngineContent{false}; + std::filesystem::path SceneAssetPath; }; struct ProjectDescriptor { diff --git a/EditorFrontend/components/engine/project-browser.tsx b/EditorFrontend/components/engine/project-browser.tsx index 3b77f504..9c128c9b 100644 --- a/EditorFrontend/components/engine/project-browser.tsx +++ b/EditorFrontend/components/engine/project-browser.tsx @@ -23,7 +23,7 @@ export interface ProjectDescriptor { packageDir: string packagedContentDir: string packagedCookedDir: string - packagedSceneFilePath: string + packagedSceneAssetPath: string packageManifestPath: string engineContentDir: string sceneFilePath: string diff --git a/EditorFrontend/components/wraith-engine.tsx b/EditorFrontend/components/wraith-engine.tsx index d983cb30..e01f0723 100644 --- a/EditorFrontend/components/wraith-engine.tsx +++ b/EditorFrontend/components/wraith-engine.tsx @@ -68,8 +68,9 @@ interface ProjectPackageResponse { cookedSourceAssetCount: number manifestEntryCount: number packagedFileCount: number - includedSceneFile: boolean + includedSceneAsset: boolean includedEngineContent: boolean + sceneAssetPath: string packageDir: string packageManifestPath: string } @@ -310,7 +311,7 @@ export function WraithEngine() { kind: "package", status: "success", title: "Package Complete", - message: `Packaged ${payload.project.name} to ${payload.packageDir}. ${payload.packagedFileCount} files staged with cooked assets${payload.includedSceneFile ? " and scene state" : ""}.`, + message: `Packaged ${payload.project.name} to ${payload.packageDir}. ${payload.packagedFileCount} files staged with cooked assets${payload.includedSceneAsset ? ` and ${payload.sceneAssetPath}` : ""}.`, }) setActiveProject(payload.project) await refreshProjects() diff --git a/Headless/RemoteViewportServer.cpp b/Headless/RemoteViewportServer.cpp index 7cea7163..2d08e799 100644 --- a/Headless/RemoteViewportServer.cpp +++ b/Headless/RemoteViewportServer.cpp @@ -519,8 +519,8 @@ std::string SerializeProjectJson(const Project::ProjectDescriptor &Project) { << EscapeJsonString(Project.Output.PackagedContentDir.string()) << "\",\"packagedCookedDir\":\"" << EscapeJsonString(Project.Output.PackagedCookedDir.string()) - << "\",\"packagedSceneFilePath\":\"" - << EscapeJsonString(Project.Output.PackagedSceneFilePath.string()) + << "\",\"packagedSceneAssetPath\":\"" + << EscapeJsonString(Project.Output.PackagedSceneAssetPath.string()) << "\",\"packageManifestPath\":\"" << EscapeJsonString(Project.Output.PackageManifestPath.string()) << "\",\"engineContentDir\":\"" @@ -590,10 +590,13 @@ std::string SerializeProjectPackageResult( << ",\"cookedSourceAssetCount\":" << Result.Cook.CookedSourceAssetCount << ",\"manifestEntryCount\":" << Result.Cook.ManifestEntryCount << ",\"packagedFileCount\":" << Result.PackagedFileCount - << ",\"includedSceneFile\":" - << (Result.IncludedSceneFile ? "true" : "false") + << ",\"includedSceneAsset\":" + << (Result.IncludedSceneAsset ? "true" : "false") << ",\"includedEngineContent\":" << (Result.IncludedEngineContent ? "true" : "false") + << ",\"sceneAssetPath\":\"" + << EscapeJsonString(Result.SceneAssetPath.string()) + << "\"" << ",\"packageDir\":\"" << EscapeJsonString(Result.Cook.Output.PackageDir.string()) << "\",\"packageManifestPath\":\"" diff --git a/Tests/ProjectTests.cpp b/Tests/ProjectTests.cpp index baa67c36..3b7b9e74 100644 --- a/Tests/ProjectTests.cpp +++ b/Tests/ProjectTests.cpp @@ -284,10 +284,14 @@ TEST_F(ProjectSystemTests, PackageProjectContentStagesCookedProjectOutput) { EXPECT_TRUE(std::filesystem::exists(Created->Output.CookManifestPath)); EXPECT_TRUE(std::filesystem::exists(Created->Output.PackagedCookedDir)); EXPECT_TRUE(std::filesystem::exists(Created->Output.PackagedCookManifestPath)); - EXPECT_TRUE(std::filesystem::exists(Created->Output.PackagedSceneFilePath)); + EXPECT_TRUE(std::filesystem::exists(Created->Output.PackagedSceneAssetPath)); EXPECT_TRUE(std::filesystem::exists(Created->Output.PackagedEngineContentDir)); EXPECT_TRUE(std::filesystem::exists(Created->Output.PackageManifestPath)); EXPECT_GT(PackageResult->PackagedFileCount, 0u); + EXPECT_TRUE(PackageResult->IncludedSceneAsset); + EXPECT_EQ(PackageResult->SceneAssetPath, Created->Output.PackagedSceneAssetPath); + EXPECT_FALSE(std::filesystem::exists(Created->Output.PackageDir / "Content" / + "scene.json")); std::ifstream PackageManifestFile(Created->Output.PackageManifestPath); ASSERT_TRUE(PackageManifestFile.is_open()); @@ -295,11 +299,14 @@ TEST_F(ProjectSystemTests, PackageProjectContentStagesCookedProjectOutput) { (std::istreambuf_iterator(PackageManifestFile)), std::istreambuf_iterator()); EXPECT_NE(PackageManifestText.find( - "\"contentMode\": \"transitional-scene-plus-cooked-assets\""), + "\"contentMode\": \"cooked-only-v1\""), std::string::npos); EXPECT_NE(PackageManifestText.find( "\"assetCookManifest\": \"Content/Cooked/AssetCookManifest.json\""), std::string::npos); + EXPECT_NE(PackageManifestText.find( + "\"sceneAsset\": \"Content/Cooked/scene.wscene\""), + std::string::npos); } TEST_F(ProjectSystemTests, PackagedProjectLoadsSceneFromCookedAssetsWithoutSourceFiles) { @@ -376,8 +383,8 @@ TEST_F(ProjectSystemTests, PackagedProjectLoadsSceneFromCookedAssetsWithoutSourc EXPECT_FALSE(std::filesystem::exists(Created->Output.PackageDir / "Content" / "crate.jpg")); - const auto Loaded = Axiom::Assets::LoadSceneFromFile( - Created->Output.PackagedSceneFilePath); + const auto Loaded = Axiom::Assets::LoadCookedSceneFromFile( + Created->Output.PackagedSceneAssetPath); ASSERT_TRUE(Loaded.has_value()); ASSERT_EQ(Loaded->MeshInstances.size(), 1u); EXPECT_EQ(Loaded->MeshInstances[0].ObjectId, "crate-1"); @@ -388,6 +395,8 @@ TEST_F(ProjectSystemTests, PackagedProjectLoadsSceneFromCookedAssetsWithoutSourc ASSERT_TRUE(DetailsIt->second.Material.has_value()); ASSERT_TRUE(DetailsIt->second.Material->TextureAssetPath.has_value()); EXPECT_EQ(*DetailsIt->second.Material->TextureAssetPath, "crate.jpg"); + EXPECT_FALSE(std::filesystem::exists(Created->Output.PackageDir / "Content" / + "scene.json")); } TEST_F(ProjectSystemTests, PackagedContentRequiresSceneFileAndWillNotFallback) { diff --git a/Tests/SceneLifecycleTests.cpp b/Tests/SceneLifecycleTests.cpp index e55a78a9..52691e2a 100644 --- a/Tests/SceneLifecycleTests.cpp +++ b/Tests/SceneLifecycleTests.cpp @@ -2178,6 +2178,96 @@ TEST(SceneLifecycleTests, SceneFile_SaveLoadRoundTripsCookedMaterialState) { "Engine/tf2 coconut.jpg"); } +TEST(SceneLifecycleTests, CookedSceneFile_SaveLoadRoundTripsWorldAndMaterialState) { + const auto TempRoot = + std::filesystem::temp_directory_path() / "wraithengine-cooked-scene-test"; + std::error_code RemoveError; + std::filesystem::remove_all(TempRoot, RemoveError); + std::filesystem::create_directories(TempRoot / "Content" / "Cooked"); + std::filesystem::create_directories(TempRoot / "Content" / "Engine"); + WriteSingleMeshObj(TempRoot / "Content", "singlemesh.obj"); + std::filesystem::copy_file( + std::filesystem::path(AXIOM_CONTENT_DIR) / "Engine" / "tf2 coconut.jpg", + TempRoot / "Content" / "Engine" / "tf2 coconut.jpg", + std::filesystem::copy_options::overwrite_existing); + + Axiom::EditorSceneState Scene; + Scene.Items = {{ + .Id = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .Children = {{ + .Id = "crate-1", + .DisplayName = "Crate", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + }}, + }}; + Scene.ObjectDetailsById["world"] = Axiom::EditorObjectDetails{ + .ObjectId = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .SupportsTransform = false, + .TransformReadOnly = true, + }; + Scene.ObjectDetailsById["crate-1"] = Axiom::EditorObjectDetails{ + .ObjectId = "crate-1", + .DisplayName = "Crate", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = Axiom::EditorTransformDetails{ + .Location = glm::vec3(1.0f, 2.0f, 3.0f), + .RotationDegrees = glm::vec3(10.0f, 20.0f, 30.0f), + .Scale = glm::vec3(1.0f), + }, + .Material = Axiom::EditorMaterialProperties{ + .BaseColorFactor = glm::vec4(0.8f, 0.2f, 0.1f, 1.0f), + .Metallic = 0.9f, + .Roughness = 0.05f, + .TextureAssetPath = std::string("Engine/tf2 coconut.jpg"), + }, + .AssetRelativePath = "singlemesh.obj", + }; + Scene.WorldSettings.SkyboxColorTop = glm::vec3(0.1f, 0.2f, 0.3f); + Scene.WorldSettings.SkyboxColorBottom = glm::vec3(0.4f, 0.5f, 0.6f); + Scene.WorldSettings.SkyboxHDRPath = "Skies/studio.hdr"; + + auto Material = std::make_shared(); + Material->BaseColorFactor = glm::vec4(0.8f, 0.2f, 0.1f, 1.0f); + Material->Metallic = 0.9f; + Material->Roughness = 0.05f; + Material->TextureAssetPath = "Engine/tf2 coconut.jpg"; + Scene.MeshInstances = {{ + .ObjectId = "crate-1", + .Mesh = {}, + .Material = Material, + .RenderPath = Axiom::MeshRenderPath::Graphics, + .Transform = glm::mat4(1.0f), + .AssetRelativePath = "singlemesh.obj", + }}; + + const auto ScenePath = TempRoot / "Content" / "Cooked" / "scene.wscene"; + ASSERT_TRUE(Axiom::Assets::SaveCookedSceneToFile(ScenePath, Scene)); + + const auto Loaded = Axiom::Assets::LoadCookedSceneFromFile(ScenePath); + ASSERT_TRUE(Loaded.has_value()); + EXPECT_EQ(Loaded->WorldSettings.SkyboxHDRPath, "Skies/studio.hdr"); + EXPECT_FLOAT_EQ(Loaded->WorldSettings.SkyboxColorTop.x, 0.1f); + EXPECT_FLOAT_EQ(Loaded->WorldSettings.SkyboxColorBottom.z, 0.6f); + + const auto DetailsIt = Loaded->ObjectDetailsById.find("crate-1"); + ASSERT_NE(DetailsIt, Loaded->ObjectDetailsById.end()); + ASSERT_TRUE(DetailsIt->second.Material.has_value()); + EXPECT_FLOAT_EQ(DetailsIt->second.Material->BaseColorFactor.r, 0.8f); + ASSERT_TRUE(DetailsIt->second.Material->TextureAssetPath.has_value()); + EXPECT_EQ(*DetailsIt->second.Material->TextureAssetPath, + "Engine/tf2 coconut.jpg"); +} + TEST(SceneLifecycleTests, SetPhysicsPropertiesUpdatesAuthoritativeDetails) { Axiom::EditorSession Session = MakeWorldSession(); RecordingSubscriber Subscriber; From 5203294b7e0559c5ac5f368f8afae6173e194fea Mon Sep 17 00:00:00 2001 From: Tamely Date: Sat, 23 May 2026 14:25:06 -0500 Subject: [PATCH 2/8] Packaged desktop runtime and app staging --- Axiom/Assets/CookedAssetRuntime.cpp | 144 ++++++++++++++++++ Axiom/Assets/CookedAssetRuntime.h | 13 ++ Axiom/Core/Application.cpp | 1 + Axiom/Core/Application.h | 6 +- Axiom/Project/ProjectSystem.cpp | 41 +++++ Axiom/Project/ProjectSystem.h | 3 + Axiom/Session/StartupScene.cpp | 39 ++++- .../components/engine/project-browser.tsx | 1 + EditorFrontend/components/wraith-engine.tsx | 4 +- Headless/AxiomPackagedRuntime.cpp | 95 ++++++++++++ Headless/CMakeLists.txt | 15 ++ Headless/PackagedRuntimeHost.cpp | 45 ++++++ Headless/PackagedRuntimeHost.h | 26 ++++ Headless/RemoteViewportServer.cpp | 6 + Tests/ProjectTests.cpp | 16 ++ 15 files changed, 446 insertions(+), 9 deletions(-) create mode 100644 Headless/AxiomPackagedRuntime.cpp create mode 100644 Headless/PackagedRuntimeHost.cpp create mode 100644 Headless/PackagedRuntimeHost.h diff --git a/Axiom/Assets/CookedAssetRuntime.cpp b/Axiom/Assets/CookedAssetRuntime.cpp index e44fb761..c194b158 100644 --- a/Axiom/Assets/CookedAssetRuntime.cpp +++ b/Axiom/Assets/CookedAssetRuntime.cpp @@ -5,7 +5,96 @@ #include "Assets/CookedTextureAsset.h" #include "Assets/IAssetSource.h" +#include +#include + namespace Axiom::Assets { +namespace { +bool ReadPackageManifestFields( + const std::filesystem::path &ManifestPath, + std::unordered_map &Fields) { + std::ifstream File(ManifestPath); + if (!File.is_open()) { + return false; + } + + const std::string Text((std::istreambuf_iterator(File)), + std::istreambuf_iterator()); + std::size_t Position = 0; + auto SkipWs = [&]() { + while (Position < Text.size() && + (Text[Position] == ' ' || Text[Position] == '\n' || + Text[Position] == '\r' || Text[Position] == '\t')) { + ++Position; + } + }; + auto ParseString = [&]() -> std::optional { + SkipWs(); + if (Position >= Text.size() || Text[Position] != '"') { + return std::nullopt; + } + ++Position; + std::string Result; + while (Position < Text.size()) { + const char Character = Text[Position++]; + if (Character == '"') { + return Result; + } + if (Character == '\\' && Position < Text.size()) { + Result.push_back(Text[Position++]); + } else { + Result.push_back(Character); + } + } + return std::nullopt; + }; + + SkipWs(); + if (Position >= Text.size() || Text[Position] != '{') { + return false; + } + ++Position; + while (true) { + SkipWs(); + if (Position >= Text.size()) { + return false; + } + if (Text[Position] == '}') { + return true; + } + + const auto Key = ParseString(); + if (!Key.has_value()) { + return false; + } + SkipWs(); + if (Position >= Text.size() || Text[Position] != ':') { + return false; + } + ++Position; + const auto Value = ParseString(); + if (Value.has_value()) { + Fields[*Key] = *Value; + } else { + SkipWs(); + const std::size_t ValueStart = Position; + while (Position < Text.size() && Text[Position] != ',' && Text[Position] != '}') { + ++Position; + } + Fields[*Key] = Text.substr(ValueStart, Position - ValueStart); + } + + SkipWs(); + if (Position < Text.size() && Text[Position] == ',') { + ++Position; + continue; + } + if (Position < Text.size() && Text[Position] == '}') { + return true; + } + } +} +} // namespace bool IsCookedOnlyContentPath(const std::filesystem::path &Path) { const auto ContentRoot = FindContentRootForPath(Path); @@ -17,6 +106,61 @@ bool IsCookedOnlyContentPath(const std::filesystem::path &Path) { return std::filesystem::exists(PackageManifestPath); } +std::optional +ResolvePackagedContentDescriptor(const std::filesystem::path &Path, + std::string *FailureReason) { + const auto ContentRoot = FindContentRootForPath(Path); + if (!ContentRoot.has_value()) { + if (FailureReason != nullptr) { + *FailureReason = "Path is not inside a packaged Content directory."; + } + return std::nullopt; + } + + const auto PackageRoot = ContentRoot->parent_path(); + const auto PackageManifestPath = PackageRoot / "package.wraith.json"; + if (!std::filesystem::exists(PackageManifestPath)) { + if (FailureReason != nullptr) { + *FailureReason = "package.wraith.json is missing."; + } + return std::nullopt; + } + + std::unordered_map Fields; + if (!ReadPackageManifestFields(PackageManifestPath, Fields)) { + if (FailureReason != nullptr) { + *FailureReason = "package.wraith.json could not be parsed."; + } + return std::nullopt; + } + + const auto ContentModeIt = Fields.find("contentMode"); + const auto SceneAssetIt = Fields.find("sceneAsset"); + const auto CookManifestIt = Fields.find("assetCookManifest"); + const auto EngineContentIt = Fields.find("engineContentDir"); + if (ContentModeIt == Fields.end() || SceneAssetIt == Fields.end() || + CookManifestIt == Fields.end() || EngineContentIt == Fields.end()) { + if (FailureReason != nullptr) { + *FailureReason = "package.wraith.json is missing required packaged runtime fields."; + } + return std::nullopt; + } + if (ContentModeIt->second != "cooked-only-v1") { + if (FailureReason != nullptr) { + *FailureReason = "package.wraith.json contentMode is not cooked-only-v1."; + } + return std::nullopt; + } + + return PackagedContentDescriptor{ + .PackageRoot = PackageRoot, + .ContentRoot = *ContentRoot, + .SceneAssetPath = PackageRoot / SceneAssetIt->second, + .CookManifestPath = PackageRoot / CookManifestIt->second, + .EngineContentDir = PackageRoot / EngineContentIt->second, + }; +} + std::optional FindContentRootForPath(const std::filesystem::path &Path) { if (Path.empty()) { diff --git a/Axiom/Assets/CookedAssetRuntime.h b/Axiom/Assets/CookedAssetRuntime.h index 4eeb3846..cc8d7087 100644 --- a/Axiom/Assets/CookedAssetRuntime.h +++ b/Axiom/Assets/CookedAssetRuntime.h @@ -6,14 +6,27 @@ #include #include +#include namespace Axiom::Assets { +struct PackagedContentDescriptor { + std::filesystem::path PackageRoot; + std::filesystem::path ContentRoot; + std::filesystem::path SceneAssetPath; + std::filesystem::path CookManifestPath; + std::filesystem::path EngineContentDir; +}; + std::optional FindContentRootForPath(const std::filesystem::path &Path); bool IsCookedOnlyContentPath(const std::filesystem::path &Path); +std::optional +ResolvePackagedContentDescriptor(const std::filesystem::path &Path, + std::string *FailureReason = nullptr); + std::optional LoadCookedMeshAssetIfAvailable(const std::filesystem::path &Path); diff --git a/Axiom/Core/Application.cpp b/Axiom/Core/Application.cpp index ce939f6b..d9710cd7 100644 --- a/Axiom/Core/Application.cpp +++ b/Axiom/Core/Application.cpp @@ -20,6 +20,7 @@ Application::Application(const ApplicationConfig &Config, switch (m_Config.Mode) { case RuntimeMode::LocalWindowedEditor: + case RuntimeMode::LocalPackagedGame: m_Window = std::make_unique(m_Config.Title, m_Config.Width, m_Config.Height); m_RenderSurface = std::make_shared(*m_Window); diff --git a/Axiom/Core/Application.h b/Axiom/Core/Application.h index ffad56bd..0f3c3c6d 100644 --- a/Axiom/Core/Application.h +++ b/Axiom/Core/Application.h @@ -20,7 +20,11 @@ struct ApplicationArgs { int ArgumentCount; }; -enum class RuntimeMode { LocalWindowedEditor, HeadlessEditorSession }; +enum class RuntimeMode { + LocalWindowedEditor, + LocalPackagedGame, + HeadlessEditorSession +}; struct ApplicationConfig { std::string Title{"Axiom Engine"}; diff --git a/Axiom/Project/ProjectSystem.cpp b/Axiom/Project/ProjectSystem.cpp index d6902ffb..c2cfb32c 100644 --- a/Axiom/Project/ProjectSystem.cpp +++ b/Axiom/Project/ProjectSystem.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -26,6 +27,10 @@ #define AXIOM_CONTENT_DIR "Content" #endif +#ifndef AXIOM_PACKAGED_RUNTIME_BINARY_PATH +#define AXIOM_PACKAGED_RUNTIME_BINARY_PATH "" +#endif + namespace Axiom::Project { namespace { constexpr std::string_view kDefaultStarterScriptClassName = "StarterScript"; @@ -645,6 +650,9 @@ ProjectOutputLayout ResolveProjectOutputLayout(const ProjectRoot &Root) { .PackagedEngineContentDir = Root.RootPath / "Package" / "Content" / "Engine", .PackageManifestPath = Root.RootPath / "Package" / "package.wraith.json", + .StagedRuntimeBinaryPath = + Root.RootPath / "Package" / + std::filesystem::path(AXIOM_PACKAGED_RUNTIME_BINARY_PATH).filename(), }; } @@ -1099,6 +1107,36 @@ PackageProjectContent(const ProjectDescriptor &Project, return std::nullopt; } + const std::filesystem::path RuntimeBinaryPath = + std::filesystem::path(AXIOM_PACKAGED_RUNTIME_BINARY_PATH); + if (RuntimeBinaryPath.empty() || !std::filesystem::exists(RuntimeBinaryPath)) { + if (FailureReason != nullptr) { + *FailureReason = + "Failed to stage AxiomPackagedRuntime because the built runtime binary was not found."; + } + return std::nullopt; + } + + Error.clear(); + std::filesystem::copy_file( + RuntimeBinaryPath, Project.Output.StagedRuntimeBinaryPath, + std::filesystem::copy_options::overwrite_existing, Error); + if (Error) { + if (FailureReason != nullptr) { + *FailureReason = + "Failed to copy AxiomPackagedRuntime into the package output."; + } + return std::nullopt; + } +#ifndef _WIN32 + std::filesystem::permissions( + Project.Output.StagedRuntimeBinaryPath, + std::filesystem::perms::owner_exec | std::filesystem::perms::group_exec | + std::filesystem::perms::others_exec, + std::filesystem::perm_options::add, Error); + Error.clear(); +#endif + ProjectPackageResult Result{ .Cook = *CookResult, .PackagedFileCount = CountPackagedFiles(Project.Output.PackageDir), @@ -1106,7 +1144,10 @@ PackageProjectContent(const ProjectDescriptor &Project, std::filesystem::exists(Project.Output.PackagedSceneAssetPath), .IncludedEngineContent = std::filesystem::exists(Project.Output.PackagedEngineContentDir), + .IncludedRuntimeBinary = + std::filesystem::exists(Project.Output.StagedRuntimeBinaryPath), .SceneAssetPath = Project.Output.PackagedSceneAssetPath, + .RuntimeBinaryPath = Project.Output.StagedRuntimeBinaryPath, }; if (!SavePackageManifestFile(Project, Result)) { if (FailureReason != nullptr) { diff --git a/Axiom/Project/ProjectSystem.h b/Axiom/Project/ProjectSystem.h index ac35fd88..1e4a22e8 100644 --- a/Axiom/Project/ProjectSystem.h +++ b/Axiom/Project/ProjectSystem.h @@ -47,6 +47,7 @@ struct ProjectOutputLayout { std::filesystem::path PackagedSceneAssetPath; std::filesystem::path PackagedEngineContentDir; std::filesystem::path PackageManifestPath; + std::filesystem::path StagedRuntimeBinaryPath; }; struct ProjectCookResult { @@ -60,7 +61,9 @@ struct ProjectPackageResult { std::size_t PackagedFileCount{0}; bool IncludedSceneAsset{false}; bool IncludedEngineContent{false}; + bool IncludedRuntimeBinary{false}; std::filesystem::path SceneAssetPath; + std::filesystem::path RuntimeBinaryPath; }; struct ProjectDescriptor { diff --git a/Axiom/Session/StartupScene.cpp b/Axiom/Session/StartupScene.cpp index 2db604c4..232290b3 100644 --- a/Axiom/Session/StartupScene.cpp +++ b/Axiom/Session/StartupScene.cpp @@ -350,6 +350,38 @@ bool LoadStartupScene(EditorSession &Session) { Session.GetContentDir().empty() ? std::filesystem::path(AXIOM_CONTENT_DIR) : Session.GetContentDir(); const bool CookedOnlyContent = Assets::IsCookedOnlyContentPath(ContentRoot); + if (CookedOnlyContent) { + std::string FailureReason; + const auto Descriptor = + Assets::ResolvePackagedContentDescriptor(ContentRoot, &FailureReason); + if (!Descriptor.has_value()) { + A_CORE_ERROR("StartupScene: packaged content at '{}' is invalid: {}", + ContentRoot.string(), FailureReason); + return false; + } + if (!std::filesystem::exists(Descriptor->SceneAssetPath)) { + A_CORE_ERROR("StartupScene: packaged scene asset is missing at '{}'", + Descriptor->SceneAssetPath.string()); + return false; + } + if (!std::filesystem::exists(Descriptor->CookManifestPath)) { + A_CORE_ERROR("StartupScene: packaged asset cook manifest is missing at '{}'", + Descriptor->CookManifestPath.string()); + return false; + } + auto Loaded = Assets::LoadCookedSceneFromFile(Descriptor->SceneAssetPath); + if (!Loaded.has_value()) { + A_CORE_ERROR("StartupScene: failed to load packaged scene asset '{}'", + Descriptor->SceneAssetPath.string()); + return false; + } + EnsureWorldFolder(*Loaded); + A_CORE_INFO("StartupScene: loaded packaged scene from {0}", + Descriptor->SceneAssetPath.string()); + Session.SetSceneState(std::move(*Loaded)); + return true; + } + const Assets::LocalAssetSource ContentDir{ContentRoot}; const auto SceneFilePath = ContentDir.ResolveRelative("scene.json"); @@ -366,13 +398,6 @@ bool LoadStartupScene(EditorSession &Session) { "falling back to defaults"); } - if (CookedOnlyContent) { - A_CORE_ERROR( - "StartupScene: packaged cooked-only content at '{}' requires a valid scene.json and will not fall back to editor defaults", - ContentRoot.string()); - return false; - } - EditorSceneState SceneState = BuildStartupSceneState(ContentRoot); if (SceneState.MeshInstances.empty()) { return false; diff --git a/EditorFrontend/components/engine/project-browser.tsx b/EditorFrontend/components/engine/project-browser.tsx index 9c128c9b..bf45b492 100644 --- a/EditorFrontend/components/engine/project-browser.tsx +++ b/EditorFrontend/components/engine/project-browser.tsx @@ -24,6 +24,7 @@ export interface ProjectDescriptor { packagedContentDir: string packagedCookedDir: string packagedSceneAssetPath: string + stagedRuntimeBinaryPath: string packageManifestPath: string engineContentDir: string sceneFilePath: string diff --git a/EditorFrontend/components/wraith-engine.tsx b/EditorFrontend/components/wraith-engine.tsx index e01f0723..b1523566 100644 --- a/EditorFrontend/components/wraith-engine.tsx +++ b/EditorFrontend/components/wraith-engine.tsx @@ -70,7 +70,9 @@ interface ProjectPackageResponse { packagedFileCount: number includedSceneAsset: boolean includedEngineContent: boolean + includedRuntimeBinary: boolean sceneAssetPath: string + runtimeBinaryPath: string packageDir: string packageManifestPath: string } @@ -311,7 +313,7 @@ export function WraithEngine() { kind: "package", status: "success", title: "Package Complete", - message: `Packaged ${payload.project.name} to ${payload.packageDir}. ${payload.packagedFileCount} files staged with cooked assets${payload.includedSceneAsset ? ` and ${payload.sceneAssetPath}` : ""}.`, + message: `Packaged ${payload.project.name} to ${payload.packageDir}. ${payload.packagedFileCount} files staged${payload.includedRuntimeBinary ? `, including ${payload.runtimeBinaryPath}` : ""}${payload.includedSceneAsset ? ` and ${payload.sceneAssetPath}` : ""}.`, }) setActiveProject(payload.project) await refreshProjects() diff --git a/Headless/AxiomPackagedRuntime.cpp b/Headless/AxiomPackagedRuntime.cpp new file mode 100644 index 00000000..fa6673f6 --- /dev/null +++ b/Headless/AxiomPackagedRuntime.cpp @@ -0,0 +1,95 @@ +#include "PackagedRuntimeHost.h" + +#include +#include + +#include +#include +#include +#include + +namespace { + +struct PackagedRuntimeOptions { + std::filesystem::path PackageRoot; + uint32_t Width{1600}; + uint32_t Height{900}; +}; + +std::optional +ParsePackagedRuntimeOptions(int argc, char **argv, std::string &Error) { + PackagedRuntimeOptions Options; + if (argc > 0 && argv != nullptr && argv[0] != nullptr) { + std::error_code Ec; + Options.PackageRoot = + std::filesystem::weakly_canonical(std::filesystem::path(argv[0]), Ec) + .parent_path(); + if (Ec) { + Options.PackageRoot = std::filesystem::absolute(std::filesystem::path(argv[0])) + .parent_path(); + } + } else { + Options.PackageRoot = std::filesystem::current_path(); + } + + for (int Index = 1; Index < argc; ++Index) { + const std::string_view Argument = argv[Index]; + if (Argument == "--package-root") { + if (Index + 1 >= argc) { + Error = "Missing value for --package-root."; + return std::nullopt; + } + Options.PackageRoot = argv[++Index]; + continue; + } + Error = "Unknown argument: " + std::string(Argument); + return std::nullopt; + } + + return Options; +} + +} // namespace + +int main(int argc, char **argv) { + std::string Error; + const auto Options = ParsePackagedRuntimeOptions(argc, argv, Error); + if (!Options.has_value()) { + std::cerr << Error << std::endl; + return 1; + } + + const std::filesystem::path ContentRoot = Options->PackageRoot / "Content"; + std::string FailureReason; + const auto Descriptor = + Axiom::Assets::ResolvePackagedContentDescriptor(ContentRoot, &FailureReason); + if (!Descriptor.has_value()) { + std::cerr << "Invalid package root '" << Options->PackageRoot.string() + << "': " << FailureReason << std::endl; + return 1; + } + if (!std::filesystem::exists(Descriptor->SceneAssetPath)) { + std::cerr << "Packaged scene asset is missing: " + << Descriptor->SceneAssetPath.string() << std::endl; + return 1; + } + if (!std::filesystem::exists(Descriptor->CookManifestPath)) { + std::cerr << "Packaged asset cook manifest is missing: " + << Descriptor->CookManifestPath.string() << std::endl; + return 1; + } + if (!std::filesystem::exists(Descriptor->EngineContentDir)) { + std::cerr << "Packaged engine content directory is missing: " + << Descriptor->EngineContentDir.string() << std::endl; + return 1; + } + + Axiom::PackagedRuntimeHost Host({argv, argc}, Options->Width, Options->Height); + if (!Host.LoadPackagedProject(ContentRoot, &FailureReason)) { + std::cerr << FailureReason << std::endl; + return 1; + } + + Host.Run(); + return 0; +} diff --git a/Headless/CMakeLists.txt b/Headless/CMakeLists.txt index ee28137c..6c2920a6 100644 --- a/Headless/CMakeLists.txt +++ b/Headless/CMakeLists.txt @@ -34,6 +34,12 @@ add_executable(AxiomRemoteViewportServer WebRtcSession.cpp ) +add_executable(AxiomPackagedRuntime + AxiomPackagedRuntime.cpp + PackagedRuntimeHost.cpp + HeadlessSessionLayer.cpp +) + if(APPLE) target_sources(AxiomHeadless PRIVATE MacOSWebRtcSession.mm @@ -51,6 +57,15 @@ target_include_directories(AxiomRemoteViewportServer PRIVATE ) target_link_libraries(AxiomRemoteViewportServer PRIVATE AxiomCore) +target_link_libraries(AxiomPackagedRuntime PRIVATE AxiomCore) + +set_target_properties(AxiomPackagedRuntime PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Headless" +) + +target_compile_definitions(AxiomCore PUBLIC + AXIOM_PACKAGED_RUNTIME_BINARY_PATH="$" +) if(WIN32) target_link_libraries(AxiomRemoteViewportServer PRIVATE ws2_32) diff --git a/Headless/PackagedRuntimeHost.cpp b/Headless/PackagedRuntimeHost.cpp new file mode 100644 index 00000000..5da2d5e4 --- /dev/null +++ b/Headless/PackagedRuntimeHost.cpp @@ -0,0 +1,45 @@ +#include "PackagedRuntimeHost.h" + +#include +#include + +namespace Axiom { + +PackagedRuntimeHost::PackagedRuntimeHost(const ApplicationArgs &Args, + uint32_t Width, uint32_t Height) + : Application({.Title = "Axiom Packaged Runtime", + .Width = Width, + .Height = Height, + .Mode = RuntimeMode::LocalPackagedGame}, + Args) { + m_Layer = new HeadlessSessionLayer(); + m_Layer->SetSharedRendererAdapter(&m_RendererAdapter); + PushLayer(m_Layer); + + m_ScriptHost.Initialize( + AXIOM_CORAL_MANAGED_DIR, + AXIOM_SCRIPTING_TRUST_RESTRICTED ? ScriptTrustProfile::Restricted + : ScriptTrustProfile::Trusted); + m_ScriptHost.LoadEngineAssembly(AXIOM_MANAGED_DIR); + m_ScriptHost.RegisterInternalCalls(m_Layer->GetSession(), SessionId{1}, + m_Layer->GetLocalUserId()); + m_Layer->GetSession().Subscribe(&m_ScriptHost); + m_Layer->SetScriptHost(&m_ScriptHost); +} + +bool PackagedRuntimeHost::LoadPackagedProject(const std::filesystem::path &ContentDir, + std::string *FailureReason) { + m_Layer->GetSession().SetContentDir(ContentDir); + m_Layer->GetSession().SetEngineContentDir(ContentDir / "Engine"); + if (!LoadStartupScene(m_Layer->GetSession())) { + if (FailureReason != nullptr) { + *FailureReason = "Failed to load the packaged startup scene."; + } + return false; + } + + m_Layer->Submit({.Payload = PlaySessionCommand{}}); + return true; +} + +} // namespace Axiom diff --git a/Headless/PackagedRuntimeHost.h b/Headless/PackagedRuntimeHost.h new file mode 100644 index 00000000..86d9bd58 --- /dev/null +++ b/Headless/PackagedRuntimeHost.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include +#include + +#include "HeadlessSessionLayer.h" + +namespace Axiom { + +class PackagedRuntimeHost final : public Application { +public: + PackagedRuntimeHost(const ApplicationArgs &Args, uint32_t Width, + uint32_t Height); + + bool LoadPackagedProject(const std::filesystem::path &ContentDir, + std::string *FailureReason = nullptr); + +private: + HeadlessSessionLayer *m_Layer{nullptr}; + EditorSceneRendererAdapter m_RendererAdapter; + ScriptHost m_ScriptHost; +}; + +} // namespace Axiom diff --git a/Headless/RemoteViewportServer.cpp b/Headless/RemoteViewportServer.cpp index 2d08e799..c565b6bc 100644 --- a/Headless/RemoteViewportServer.cpp +++ b/Headless/RemoteViewportServer.cpp @@ -521,6 +521,8 @@ std::string SerializeProjectJson(const Project::ProjectDescriptor &Project) { << EscapeJsonString(Project.Output.PackagedCookedDir.string()) << "\",\"packagedSceneAssetPath\":\"" << EscapeJsonString(Project.Output.PackagedSceneAssetPath.string()) + << "\",\"stagedRuntimeBinaryPath\":\"" + << EscapeJsonString(Project.Output.StagedRuntimeBinaryPath.string()) << "\",\"packageManifestPath\":\"" << EscapeJsonString(Project.Output.PackageManifestPath.string()) << "\",\"engineContentDir\":\"" @@ -594,8 +596,12 @@ std::string SerializeProjectPackageResult( << (Result.IncludedSceneAsset ? "true" : "false") << ",\"includedEngineContent\":" << (Result.IncludedEngineContent ? "true" : "false") + << ",\"includedRuntimeBinary\":" + << (Result.IncludedRuntimeBinary ? "true" : "false") << ",\"sceneAssetPath\":\"" << EscapeJsonString(Result.SceneAssetPath.string()) + << "\",\"runtimeBinaryPath\":\"" + << EscapeJsonString(Result.RuntimeBinaryPath.string()) << "\"" << ",\"packageDir\":\"" << EscapeJsonString(Result.Cook.Output.PackageDir.string()) diff --git a/Tests/ProjectTests.cpp b/Tests/ProjectTests.cpp index 3b7b9e74..f4256990 100644 --- a/Tests/ProjectTests.cpp +++ b/Tests/ProjectTests.cpp @@ -287,9 +287,13 @@ TEST_F(ProjectSystemTests, PackageProjectContentStagesCookedProjectOutput) { EXPECT_TRUE(std::filesystem::exists(Created->Output.PackagedSceneAssetPath)); EXPECT_TRUE(std::filesystem::exists(Created->Output.PackagedEngineContentDir)); EXPECT_TRUE(std::filesystem::exists(Created->Output.PackageManifestPath)); + EXPECT_TRUE(std::filesystem::exists(Created->Output.StagedRuntimeBinaryPath)); EXPECT_GT(PackageResult->PackagedFileCount, 0u); EXPECT_TRUE(PackageResult->IncludedSceneAsset); + EXPECT_TRUE(PackageResult->IncludedRuntimeBinary); EXPECT_EQ(PackageResult->SceneAssetPath, Created->Output.PackagedSceneAssetPath); + EXPECT_EQ(PackageResult->RuntimeBinaryPath, + Created->Output.StagedRuntimeBinaryPath); EXPECT_FALSE(std::filesystem::exists(Created->Output.PackageDir / "Content" / "scene.json")); @@ -397,6 +401,18 @@ TEST_F(ProjectSystemTests, PackagedProjectLoadsSceneFromCookedAssetsWithoutSourc EXPECT_EQ(*DetailsIt->second.Material->TextureAssetPath, "crate.jpg"); EXPECT_FALSE(std::filesystem::exists(Created->Output.PackageDir / "Content" / "scene.json")); + + Axiom::EditorSession Session(Axiom::SessionId{91}); + Session.SetContentDir(Created->Output.PackagedContentDir); + ASSERT_TRUE(Axiom::LoadStartupScene(Session)); + ASSERT_EQ(Session.GetState().Scene.MeshInstances.size(), 1u); + EXPECT_EQ(Session.GetState().Scene.MeshInstances[0].ObjectId, "crate-1"); + const Axiom::EditorObjectDetails *LoadedDetails = + Session.FindObjectDetails("crate-1"); + ASSERT_NE(LoadedDetails, nullptr); + ASSERT_TRUE(LoadedDetails->Material.has_value()); + ASSERT_TRUE(LoadedDetails->Material->TextureAssetPath.has_value()); + EXPECT_EQ(*LoadedDetails->Material->TextureAssetPath, "crate.jpg"); } TEST_F(ProjectSystemTests, PackagedContentRequiresSceneFileAndWillNotFallback) { From ccc101c4d044c258c068334eaeee06e0a2f57d58 Mon Sep 17 00:00:00 2001 From: Tamely Date: Sat, 23 May 2026 14:35:48 -0500 Subject: [PATCH 3/8] UX and regression coverage --- Axiom/Assets/CookedAssetRuntime.cpp | 146 ++++++++++++++++++++ Axiom/Assets/CookedAssetRuntime.h | 3 + Axiom/Project/ProjectSystem.cpp | 27 ++++ Axiom/Session/StartupScene.cpp | 11 +- Docs/DistributedWraithEngineDesign.md | 5 +- EditorFrontend/components/wraith-engine.tsx | 3 +- Headless/AxiomPackagedRuntime.cpp | 24 ++-- Headless/RemoteViewportServer.cpp | 2 + README.md | 36 +++++ Tests/ProjectTests.cpp | 109 +++++++++++++++ 10 files changed, 342 insertions(+), 24 deletions(-) diff --git a/Axiom/Assets/CookedAssetRuntime.cpp b/Axiom/Assets/CookedAssetRuntime.cpp index c194b158..7caf4580 100644 --- a/Axiom/Assets/CookedAssetRuntime.cpp +++ b/Axiom/Assets/CookedAssetRuntime.cpp @@ -4,8 +4,10 @@ #include "Assets/CookedMeshAsset.h" #include "Assets/CookedTextureAsset.h" #include "Assets/IAssetSource.h" +#include "Assets/SceneFile.h" #include +#include #include namespace Axiom::Assets { @@ -161,6 +163,150 @@ ResolvePackagedContentDescriptor(const std::filesystem::path &Path, }; } +bool ValidatePackagedContentDescriptor(const PackagedContentDescriptor &Descriptor, + std::string *FailureReason) { + if (!std::filesystem::exists(Descriptor.ContentRoot) || + !std::filesystem::is_directory(Descriptor.ContentRoot)) { + if (FailureReason != nullptr) { + *FailureReason = "Expected package root to contain a Content directory at '" + + Descriptor.ContentRoot.string() + "'."; + } + return false; + } + if (!std::filesystem::exists(Descriptor.SceneAssetPath)) { + if (FailureReason != nullptr) { + *FailureReason = "Packaged scene asset is missing at '" + + Descriptor.SceneAssetPath.string() + "'."; + } + return false; + } + if (!std::filesystem::exists(Descriptor.CookManifestPath)) { + if (FailureReason != nullptr) { + *FailureReason = "Packaged asset cook manifest is missing at '" + + Descriptor.CookManifestPath.string() + "'."; + } + return false; + } + if (!std::filesystem::exists(Descriptor.EngineContentDir) || + !std::filesystem::is_directory(Descriptor.EngineContentDir)) { + if (FailureReason != nullptr) { + *FailureReason = "Packaged engine content directory is missing at '" + + Descriptor.EngineContentDir.string() + "'."; + } + return false; + } + + const auto LoadedScene = LoadCookedSceneFromFile(Descriptor.SceneAssetPath); + if (!LoadedScene.has_value()) { + if (FailureReason != nullptr) { + *FailureReason = "Failed to load packaged scene asset '" + + Descriptor.SceneAssetPath.string() + "'."; + } + return false; + } + + const CookedAssetSource CookedSource(Descriptor.ContentRoot); + if (!CookedSource.HasManifest()) { + if (FailureReason != nullptr) { + *FailureReason = "Packaged asset cook manifest could not be loaded from '" + + Descriptor.CookManifestPath.string() + "'."; + } + return false; + } + + auto ValidateResolvedAssetPath = [&](std::string_view RelativePath, + std::string_view Usage) -> bool { + if (RelativePath.empty()) { + return true; + } + + const std::filesystem::path RelativeAssetPath(RelativePath); + if (RelativeAssetPath.is_absolute()) { + if (FailureReason != nullptr) { + *FailureReason = std::string(Usage) + " '" + std::string(RelativePath) + + "' must be content-relative, not absolute."; + } + return false; + } + + const auto Begin = RelativeAssetPath.begin(); + const bool IsEngineRelative = + Begin != RelativeAssetPath.end() && Begin->string() == "Engine"; + if (IsEngineRelative) { + const auto EngineAssetPath = Descriptor.ContentRoot / RelativeAssetPath; + if (!std::filesystem::exists(EngineAssetPath)) { + if (FailureReason != nullptr) { + *FailureReason = std::string(Usage) + " '" + + RelativeAssetPath.generic_string() + + "' does not resolve inside packaged engine content."; + } + return false; + } + return true; + } + + const auto CookedPath = + CookedSource.Resolve(AssetIdFromRelativePath(RelativeAssetPath)); + if (!CookedPath.has_value()) { + if (FailureReason != nullptr) { + *FailureReason = std::string(Usage) + " '" + + RelativeAssetPath.generic_string() + + "' is not present in the packaged asset cook manifest."; + } + return false; + } + if (!std::filesystem::exists(*CookedPath)) { + if (FailureReason != nullptr) { + *FailureReason = std::string(Usage) + " '" + + RelativeAssetPath.generic_string() + + "' maps to missing cooked asset '" + + CookedPath->string() + "'."; + } + return false; + } + return true; + }; + + std::unordered_set MeshPaths; + std::unordered_set TexturePaths; + for (const auto &Instance : LoadedScene->MeshInstances) { + if (!Instance.AssetRelativePath.empty()) { + MeshPaths.insert(Instance.AssetRelativePath); + } + if (Instance.Material != nullptr && + !Instance.Material->TextureAssetPath.empty()) { + TexturePaths.insert(Instance.Material->TextureAssetPath); + } + } + for (const auto &[ObjectId, Details] : LoadedScene->ObjectDetailsById) { + static_cast(ObjectId); + if (!Details.AssetRelativePath.empty()) { + MeshPaths.insert(Details.AssetRelativePath); + } + if (Details.Material.has_value() && + Details.Material->TextureAssetPath.has_value() && + !Details.Material->TextureAssetPath->empty()) { + TexturePaths.insert(*Details.Material->TextureAssetPath); + } + } + if (!LoadedScene->WorldSettings.SkyboxHDRPath.empty()) { + TexturePaths.insert(LoadedScene->WorldSettings.SkyboxHDRPath); + } + + for (const std::string &MeshPath : MeshPaths) { + if (!ValidateResolvedAssetPath(MeshPath, "Mesh asset reference")) { + return false; + } + } + for (const std::string &TexturePath : TexturePaths) { + if (!ValidateResolvedAssetPath(TexturePath, "Texture asset reference")) { + return false; + } + } + + return true; +} + std::optional FindContentRootForPath(const std::filesystem::path &Path) { if (Path.empty()) { diff --git a/Axiom/Assets/CookedAssetRuntime.h b/Axiom/Assets/CookedAssetRuntime.h index cc8d7087..f3eaf3a4 100644 --- a/Axiom/Assets/CookedAssetRuntime.h +++ b/Axiom/Assets/CookedAssetRuntime.h @@ -27,6 +27,9 @@ std::optional ResolvePackagedContentDescriptor(const std::filesystem::path &Path, std::string *FailureReason = nullptr); +bool ValidatePackagedContentDescriptor(const PackagedContentDescriptor &Descriptor, + std::string *FailureReason = nullptr); + std::optional LoadCookedMeshAssetIfAvailable(const std::filesystem::path &Path); diff --git a/Axiom/Project/ProjectSystem.cpp b/Axiom/Project/ProjectSystem.cpp index c2cfb32c..29b9e119 100644 --- a/Axiom/Project/ProjectSystem.cpp +++ b/Axiom/Project/ProjectSystem.cpp @@ -2,6 +2,7 @@ #include "Assets/AssetCookManifest.h" #include "Assets/AssetCooker.h" +#include "Assets/CookedAssetRuntime.h" #include "Assets/SceneFile.h" #include "Core/Log.h" @@ -1042,6 +1043,13 @@ CookProjectContent(const ProjectDescriptor &Project, std::string *FailureReason) const auto Manifest = Assets::LoadAssetCookManifest(Project.Output.CookManifestPath) .value_or(Assets::AssetCookManifest{}); + if (!std::filesystem::exists(Project.Output.CookManifestPath) && + !Assets::SaveAssetCookManifest(Project.Output.CookManifestPath, Manifest)) { + if (FailureReason != nullptr) { + *FailureReason = "Failed to write the cooked asset manifest."; + } + return std::nullopt; + } return ProjectCookResult{ .Output = Project.Output, @@ -1155,6 +1163,25 @@ PackageProjectContent(const ProjectDescriptor &Project, } return std::nullopt; } + std::string ValidationFailureReason; + const auto PackagedDescriptor = + Assets::ResolvePackagedContentDescriptor(Project.Output.PackagedContentDir, + &ValidationFailureReason); + if (!PackagedDescriptor.has_value()) { + if (FailureReason != nullptr) { + *FailureReason = "Packaged content validation failed: " + + ValidationFailureReason; + } + return std::nullopt; + } + if (!Assets::ValidatePackagedContentDescriptor(*PackagedDescriptor, + &ValidationFailureReason)) { + if (FailureReason != nullptr) { + *FailureReason = "Packaged content validation failed: " + + ValidationFailureReason; + } + return std::nullopt; + } Result.PackagedFileCount = CountPackagedFiles(Project.Output.PackageDir); return Result; } diff --git a/Axiom/Session/StartupScene.cpp b/Axiom/Session/StartupScene.cpp index 232290b3..16b0edf1 100644 --- a/Axiom/Session/StartupScene.cpp +++ b/Axiom/Session/StartupScene.cpp @@ -359,14 +359,9 @@ bool LoadStartupScene(EditorSession &Session) { ContentRoot.string(), FailureReason); return false; } - if (!std::filesystem::exists(Descriptor->SceneAssetPath)) { - A_CORE_ERROR("StartupScene: packaged scene asset is missing at '{}'", - Descriptor->SceneAssetPath.string()); - return false; - } - if (!std::filesystem::exists(Descriptor->CookManifestPath)) { - A_CORE_ERROR("StartupScene: packaged asset cook manifest is missing at '{}'", - Descriptor->CookManifestPath.string()); + if (!Assets::ValidatePackagedContentDescriptor(*Descriptor, &FailureReason)) { + A_CORE_ERROR("StartupScene: packaged content validation failed for '{}': {}", + ContentRoot.string(), FailureReason); return false; } auto Loaded = Assets::LoadCookedSceneFromFile(Descriptor->SceneAssetPath); diff --git a/Docs/DistributedWraithEngineDesign.md b/Docs/DistributedWraithEngineDesign.md index c8c236d3..c52b15aa 100644 --- a/Docs/DistributedWraithEngineDesign.md +++ b/Docs/DistributedWraithEngineDesign.md @@ -1351,8 +1351,9 @@ Progress update: - scripting authoring is now project-local: each project gets a generated `Scripts/` workspace plus `.sln`/`.csproj`, the browser has a script editor with file CRUD and syntax highlighting, scripts can be attached to actors, and inspector `Open Script` jumps into the editor - Phase 7 (Asset Pipeline) is complete: `SetMeshAssetCommand` wires any discovered `.glb`/`.gltf`/`.fbx`/`.obj` to mesh objects and actor roots with scene-file persistence; `SetLightPropertiesCommand` drives a Blinn-Phong directional light from `SceneLight` world position; `SetMaterialPropertiesCommand` exposes `BaseColorFactor`/`Metallic`/`Roughness` push constants end-to-end through the inspector; `SetMaterialTextureCommand` assigns PNG/JPG textures to mesh base-color slots with persistence, inspector display, and drag-drop from both the content browser and outliner; FBX/OBJ import is implemented via assimp with embedded and external texture handling; the content browser accepts OS file drag-drop and a file picker Import button that upload to `POST /assets/upload`; texture thumbnail previews are served by the remote viewport server; the content browser navigates folders non-recursively; regression coverage now includes the `CreateObject`→`SetMeshAsset` runtime-creation path and actor mesh assignment - Phase 8 (Binary Asset Formats) and the first packaging foundation are now implemented: `.wmesh`, `.wtex`, and `.wmat` cooked formats exist; `AssetCookManifest` and `CookedAssetSource` resolve cooked content by stable `AssetId`; startup, scene reload, and mesh/texture editing flows all prefer cooked payloads while preserving source fallback during editor use; scene persistence now round-trips cooked material state through `materialAssetPath`; projects now cook into per-project `Content/Cooked/` and stage packaged outputs under per-project `Package/` directories -- packaged runtime cutover is partially implemented: staged packages include cooked project content, scene state, the asset cook manifest, shared engine content, and a package manifest; packaged content roots are now treated as cooked-only at runtime rather than silently recooking or falling back to source files -- the next step is packaging/runtime hardening and editor polish: add a dedicated packaged app entrypoint, tighten packaged-scene/runtime validation, and continue improving build/package UX on top of the finished project system foundation +- packaged runtime cutover is now implemented for the first desktop slice: staged packages include `AxiomPackagedRuntime`, cooked project content, `scene.wscene`, the asset cook manifest, shared engine content, and a package manifest; packaged content roots are treated as cooked-only at runtime and now fail fast on invalid package layout, missing cooked scene/cook manifest, or unresolved cooked asset references +- the next step is editor polish and platform packaging depth: improve package UX further, add richer validation/reporting where needed, and eventually graduate from the plain staged macOS executable layout to more polished distribution targets +- delivery note: large cross-cutting features should keep landing as mergeable reviewed batches with explicit greenlight checkpoints between batches so packaging/runtime changes do not sprawl across unrelated concerns That slice proves the core thesis: diff --git a/EditorFrontend/components/wraith-engine.tsx b/EditorFrontend/components/wraith-engine.tsx index b1523566..f5d7ca5f 100644 --- a/EditorFrontend/components/wraith-engine.tsx +++ b/EditorFrontend/components/wraith-engine.tsx @@ -73,6 +73,7 @@ interface ProjectPackageResponse { includedRuntimeBinary: boolean sceneAssetPath: string runtimeBinaryPath: string + packagedContentPath: string packageDir: string packageManifestPath: string } @@ -313,7 +314,7 @@ export function WraithEngine() { kind: "package", status: "success", title: "Package Complete", - message: `Packaged ${payload.project.name} to ${payload.packageDir}. ${payload.packagedFileCount} files staged${payload.includedRuntimeBinary ? `, including ${payload.runtimeBinaryPath}` : ""}${payload.includedSceneAsset ? ` and ${payload.sceneAssetPath}` : ""}.`, + message: `Packaged ${payload.project.name} to ${payload.packageDir}. Content: ${payload.packagedContentPath}.${payload.includedRuntimeBinary ? ` Runtime: ${payload.runtimeBinaryPath}.` : ""}${payload.includedSceneAsset ? ` Scene: ${payload.sceneAssetPath}.` : ""} ${payload.packagedFileCount} files staged.`, }) setActiveProject(payload.project) await refreshProjects() diff --git a/Headless/AxiomPackagedRuntime.cpp b/Headless/AxiomPackagedRuntime.cpp index fa6673f6..3c05bcde 100644 --- a/Headless/AxiomPackagedRuntime.cpp +++ b/Headless/AxiomPackagedRuntime.cpp @@ -60,6 +60,13 @@ int main(int argc, char **argv) { } const std::filesystem::path ContentRoot = Options->PackageRoot / "Content"; + if (!std::filesystem::exists(ContentRoot) || + !std::filesystem::is_directory(ContentRoot)) { + std::cerr << "Invalid package root '" << Options->PackageRoot.string() + << "': expected a Content directory at '" << ContentRoot.string() + << "'." << std::endl; + return 1; + } std::string FailureReason; const auto Descriptor = Axiom::Assets::ResolvePackagedContentDescriptor(ContentRoot, &FailureReason); @@ -68,19 +75,10 @@ int main(int argc, char **argv) { << "': " << FailureReason << std::endl; return 1; } - if (!std::filesystem::exists(Descriptor->SceneAssetPath)) { - std::cerr << "Packaged scene asset is missing: " - << Descriptor->SceneAssetPath.string() << std::endl; - return 1; - } - if (!std::filesystem::exists(Descriptor->CookManifestPath)) { - std::cerr << "Packaged asset cook manifest is missing: " - << Descriptor->CookManifestPath.string() << std::endl; - return 1; - } - if (!std::filesystem::exists(Descriptor->EngineContentDir)) { - std::cerr << "Packaged engine content directory is missing: " - << Descriptor->EngineContentDir.string() << std::endl; + if (!Axiom::Assets::ValidatePackagedContentDescriptor(*Descriptor, + &FailureReason)) { + std::cerr << "Invalid package root '" << Options->PackageRoot.string() + << "': " << FailureReason << std::endl; return 1; } diff --git a/Headless/RemoteViewportServer.cpp b/Headless/RemoteViewportServer.cpp index c565b6bc..08296f60 100644 --- a/Headless/RemoteViewportServer.cpp +++ b/Headless/RemoteViewportServer.cpp @@ -603,6 +603,8 @@ std::string SerializeProjectPackageResult( << "\",\"runtimeBinaryPath\":\"" << EscapeJsonString(Result.RuntimeBinaryPath.string()) << "\"" + << ",\"packagedContentPath\":\"" + << EscapeJsonString(Result.Cook.Output.PackagedContentDir.string()) << ",\"packageDir\":\"" << EscapeJsonString(Result.Cook.Output.PackageDir.string()) << "\",\"packageManifestPath\":\"" diff --git a/README.md b/README.md index 39d5c159..b701b43e 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,42 @@ pnpm install NEXT_PUBLIC_AXIOM_SERVER_ORIGIN=http://127.0.0.1:8080 pnpm dev ``` +### Packaged runtime + +`Package Project` now produces a cooked-only package under a project's `Package/` +directory. The staged layout is: + +```text +Package/ + AxiomPackagedRuntime + package.wraith.json + Content/ + Cooked/ + scene.wscene + AssetCookManifest.json + ... + Engine/ + ... +``` + +Packages do not ship `Content/scene.json` or project source assets. The packaged +runtime expects `package.wraith.json`, `Content/Cooked/scene.wscene`, the cooked +asset manifest, and all referenced cooked assets to be present. + +To run a staged package directly: + +```bash +./Projects//Package/AxiomPackagedRuntime +``` + +To test the packaged runtime binary built in `build/` against an existing staged +package: + +```bash +./build/debug/Headless/AxiomPackagedRuntime \ + --package-root /absolute/path/to/Projects//Package +``` + Open `http://localhost:3000` in your browser. ### Local native editor (no browser required) diff --git a/Tests/ProjectTests.cpp b/Tests/ProjectTests.cpp index f4256990..16f2fe16 100644 --- a/Tests/ProjectTests.cpp +++ b/Tests/ProjectTests.cpp @@ -1,3 +1,5 @@ +#include +#include #include #include #include @@ -5,6 +7,7 @@ #include +#include #include #include #include @@ -425,3 +428,109 @@ TEST_F(ProjectSystemTests, PackagedContentRequiresSceneFileAndWillNotFallback) { Session.SetContentDir(PackagedRoot / "Content"); EXPECT_FALSE(Axiom::LoadStartupScene(Session)); } + +TEST_F(ProjectSystemTests, PackagedContentValidationRejectsMissingCookedSceneAsset) { + EnsureLogInitialized(); + std::string FailureReason; + const auto Created = + Axiom::Project::CreateProjectScaffold(Root, "Validate Scene", &FailureReason); + ASSERT_TRUE(Created.has_value()) << FailureReason; + + const auto PackageResult = + Axiom::Project::PackageProjectContent(*Created, &FailureReason); + ASSERT_TRUE(PackageResult.has_value()) << FailureReason; + + const auto Descriptor = Axiom::Assets::ResolvePackagedContentDescriptor( + Created->Output.PackagedContentDir, &FailureReason); + ASSERT_TRUE(Descriptor.has_value()) << FailureReason; + + std::filesystem::remove(Descriptor->SceneAssetPath); + EXPECT_FALSE( + Axiom::Assets::ValidatePackagedContentDescriptor(*Descriptor, &FailureReason)); + EXPECT_NE(FailureReason.find("scene asset is missing"), std::string::npos); +} + +TEST_F(ProjectSystemTests, + PackagedContentValidationRejectsManifestEntriesThatDoNotResolve) { + EnsureLogInitialized(); + std::string FailureReason; + const auto Created = + Axiom::Project::CreateProjectScaffold(Root, "Validate Manifest", &FailureReason); + ASSERT_TRUE(Created.has_value()) << FailureReason; + + WriteSingleMeshObj(Created->Root.ContentDir, "singlemesh.obj"); + std::filesystem::copy_file( + std::filesystem::path(AXIOM_CONTENT_DIR) / "Engine" / "tf2 coconut.jpg", + Created->Root.ContentDir / "crate.jpg", + std::filesystem::copy_options::overwrite_existing); + + Axiom::EditorSceneState Scene; + Scene.Items = {{ + .Id = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .Children = {{ + .Id = "crate-1", + .DisplayName = "Crate", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + }}, + }}; + Scene.ObjectDetailsById["world"] = Axiom::EditorObjectDetails{ + .ObjectId = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .SupportsTransform = false, + .TransformReadOnly = true, + }; + Scene.ObjectDetailsById["crate-1"] = Axiom::EditorObjectDetails{ + .ObjectId = "crate-1", + .DisplayName = "Crate", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = Axiom::EditorTransformDetails{}, + .Material = Axiom::EditorMaterialProperties{ + .TextureAssetPath = std::string("crate.jpg"), + }, + .AssetRelativePath = "singlemesh.obj", + }; + auto Material = std::make_shared(); + Material->TextureAssetPath = "crate.jpg"; + Scene.MeshInstances = {{ + .ObjectId = "crate-1", + .Mesh = {}, + .Material = Material, + .RenderPath = Axiom::MeshRenderPath::Graphics, + .Transform = glm::mat4(1.0f), + .AssetRelativePath = "singlemesh.obj", + }}; + ASSERT_TRUE(Axiom::Assets::SaveSceneToFile(Created->Root.SceneFilePath, Scene)); + + const auto PackageResult = + Axiom::Project::PackageProjectContent(*Created, &FailureReason); + ASSERT_TRUE(PackageResult.has_value()) << FailureReason; + + const auto Descriptor = Axiom::Assets::ResolvePackagedContentDescriptor( + Created->Output.PackagedContentDir, &FailureReason); + ASSERT_TRUE(Descriptor.has_value()) << FailureReason; + + const auto Manifest = + Axiom::Assets::LoadAssetCookManifest(Descriptor->CookManifestPath); + ASSERT_TRUE(Manifest.has_value()); + const auto EntryIt = std::find_if( + Manifest->Entries.begin(), Manifest->Entries.end(), + [](const Axiom::Assets::AssetCookManifestEntry &Entry) { + return Entry.RelativePath == "singlemesh.obj"; + }); + ASSERT_NE(EntryIt, Manifest->Entries.end()); + + ASSERT_TRUE(std::filesystem::remove(Descriptor->ContentRoot / EntryIt->CookedPath)); + EXPECT_FALSE( + Axiom::Assets::ValidatePackagedContentDescriptor(*Descriptor, &FailureReason)); + EXPECT_NE(FailureReason.find("singlemesh.obj"), std::string::npos); + EXPECT_NE(FailureReason.find("missing cooked asset"), std::string::npos); +} From 6c4a4000d4405c181012b8b8ba770df010436d3a Mon Sep 17 00:00:00 2001 From: Tamely Date: Sun, 24 May 2026 11:30:53 -0500 Subject: [PATCH 4/8] Add SetWorldSettings to WS path May deprecate websockets as a whole soon --- Headless/RemoteViewportServer.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/Headless/RemoteViewportServer.cpp b/Headless/RemoteViewportServer.cpp index 08296f60..fc6e3a27 100644 --- a/Headless/RemoteViewportServer.cpp +++ b/Headless/RemoteViewportServer.cpp @@ -2876,6 +2876,7 @@ bool RemoteViewportServer::HandleWebSocketMessage(uintptr_t ClientSocketValue, case HeadlessCommandType::SetLightProperties: case HeadlessCommandType::SetMaterialProperties: case HeadlessCommandType::SetMaterialTexture: + case HeadlessCommandType::SetWorldSettings: case HeadlessCommandType::ReloadScripts: case HeadlessCommandType::UpdateViewportCamera: case HeadlessCommandType::GizmoHover: From 462630895ef3171f92a0e98ac0d3748f1fab7e2e Mon Sep 17 00:00:00 2001 From: Tamely Date: Sun, 24 May 2026 11:36:08 -0500 Subject: [PATCH 5/8] Load world settings from scene data --- Axiom/Session/EditorSession.cpp | 46 ++++++++++++++++------- Projects/demo/Content/scene.json | 11 ++++-- Tests/SceneLifecycleTests.cpp | 63 ++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 17 deletions(-) diff --git a/Axiom/Session/EditorSession.cpp b/Axiom/Session/EditorSession.cpp index e48c3f15..a826f169 100644 --- a/Axiom/Session/EditorSession.cpp +++ b/Axiom/Session/EditorSession.cpp @@ -57,6 +57,34 @@ void CookHDRTextureAssetBestEffort(const std::filesystem::path &ContentDir, } } +void HydrateWorldSettingsHDRData(EditorWorldSettings &Settings, + const std::filesystem::path &ContentDir, + std::string_view LogContext) { + if (Settings.SkyboxHDRPath.empty()) { + Settings.SkyboxHDRData = nullptr; + return; + } + if (Settings.SkyboxHDRData) { + return; + } + if (ContentDir.empty()) { + A_CORE_WARN("{}: content directory not configured; cannot load HDR '{}'", + LogContext, Settings.SkyboxHDRPath); + return; + } + + const auto FullPath = ContentDir / Settings.SkyboxHDRPath; + if (std::filesystem::exists(FullPath)) { + CookHDRTextureAssetBestEffort(ContentDir, Settings.SkyboxHDRPath); + } + auto Loaded = Assets::LoadHDRTextureFromFile(FullPath); + if (!Loaded) { + A_CORE_WARN("{}: failed to load HDR '{}'", LogContext, + Settings.SkyboxHDRPath); + } + Settings.SkyboxHDRData = std::move(Loaded); +} + std::string DefaultUserDisplayName(SessionUserId User) { if (User.Value == 1) { return "Host"; @@ -441,6 +469,8 @@ void EditorSession::SetPresenceState(SessionUserId User, void EditorSession::SetSceneState(EditorSceneState SceneState) { m_State.Scene = std::move(SceneState); + HydrateWorldSettingsHDRData(m_State.Scene.WorldSettings, m_ContentDir, + "SetSceneState"); // Populate Material on object details from mesh instances so the inspector // can display and edit material properties for mesh objects. for (const auto &MeshInst : m_State.Scene.MeshInstances) { @@ -2311,21 +2341,9 @@ void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, PreviousHDRData) { // Path unchanged and we already have the data loaded — reuse it. m_State.Scene.WorldSettings.SkyboxHDRData = std::move(PreviousHDRData); - } else if (m_ContentDir.empty()) { - A_CORE_WARN("SetWorldSettings: content directory not configured; cannot " - "load HDR '{}'", - Command.Settings.SkyboxHDRPath); - m_State.Scene.WorldSettings.SkyboxHDRData = nullptr; } else { - CookHDRTextureAssetBestEffort(m_ContentDir, - Command.Settings.SkyboxHDRPath); - const auto FullPath = m_ContentDir / Command.Settings.SkyboxHDRPath; - auto Loaded = Assets::LoadHDRTextureFromFile(FullPath); - if (!Loaded) { - A_CORE_WARN("SetWorldSettings: failed to load HDR '{}'", - Command.Settings.SkyboxHDRPath); - } - m_State.Scene.WorldSettings.SkyboxHDRData = std::move(Loaded); + HydrateWorldSettingsHDRData(m_State.Scene.WorldSettings, m_ContentDir, + "SetWorldSettings"); } } diff --git a/Projects/demo/Content/scene.json b/Projects/demo/Content/scene.json index 8d2bda76..2784df21 100644 --- a/Projects/demo/Content/scene.json +++ b/Projects/demo/Content/scene.json @@ -8,12 +8,17 @@ {"id":"Light","parentId":"world","displayName":"Light","kind":"Light","visible":true} ], "objects": [ - {"id":"Light","displayName":"Light","kind":"Light","visible":true,"isGeneratedAssetChild":false,"supportsTransform":true,"transformReadOnly":false,"location":[0,4,0],"rotationDegrees":[-0,0,0],"scale":[1,1,1],"lightColor":[1,1,1],"lightIntensity":1,"lightDirection":[0.35,0.7,0.2]}, {"id":"Mesh_2","displayName":"Mesh 2","kind":"Mesh","visible":true,"isGeneratedAssetChild":false,"supportsTransform":true,"transformReadOnly":false,"location":[0.710356,0,0.209167],"rotationDegrees":[-0,0,0],"scale":[10,1,10],"assetRelativePath":"basicmesh.glb","physicsBodyType":"static","physicsColliderType":"box","physicsBoxHalfExtents":[1.36719,1,1],"physicsSphereRadius":0.5,"physicsMass":1,"physicsFriction":0.2,"physicsRestitution":1}, - {"id":"world","displayName":"World","kind":"Folder","visible":true,"isGeneratedAssetChild":false,"supportsTransform":false,"transformReadOnly":true}, - {"id":"Mesh","displayName":"Mesh","kind":"Mesh","visible":true,"isGeneratedAssetChild":false,"supportsTransform":true,"transformReadOnly":false,"location":[0.83135,3,0],"rotationDegrees":[-0,0,0],"scale":[1,1,1],"assetRelativePath":"warrior_cats_minecraft_firestar.glb","physicsBodyType":"dynamic","physicsColliderType":"box","physicsBoxHalfExtents":[0.15625,0.75,1.35355],"physicsSphereRadius":0.5,"physicsMass":1,"physicsFriction":0.2,"physicsRestitution":0} + {"id":"Light","displayName":"Light","kind":"Light","visible":true,"isGeneratedAssetChild":false,"supportsTransform":true,"transformReadOnly":false,"location":[0,4,0],"rotationDegrees":[-0,0,0],"scale":[1,1,1],"lightColor":[1,1,1],"lightIntensity":1,"lightDirection":[0.35,0.7,0.2]}, + {"id":"Mesh","displayName":"Mesh","kind":"Mesh","visible":true,"isGeneratedAssetChild":false,"supportsTransform":true,"transformReadOnly":false,"location":[0.83135,3,0],"rotationDegrees":[-0,0,0],"scale":[1,1,1],"assetRelativePath":"warrior_cats_minecraft_firestar.glb","physicsBodyType":"dynamic","physicsColliderType":"box","physicsBoxHalfExtents":[0.15625,0.75,1.35355],"physicsSphereRadius":0.5,"physicsMass":1,"physicsFriction":0.2,"physicsRestitution":0}, + {"id":"world","displayName":"World","kind":"Folder","visible":true,"isGeneratedAssetChild":false,"supportsTransform":false,"transformReadOnly":true} ], "meshNameToObjectId": { + }, + "worldSettings": { + "skyboxColorTop": [0.0784314,0.0901961,0.141176], + "skyboxColorBottom": [0.141176,0.239216,0.380392], + "skyboxHDRPath": "sundowner_overlook_4k.hdr" } } diff --git a/Tests/SceneLifecycleTests.cpp b/Tests/SceneLifecycleTests.cpp index 52691e2a..bd70fd41 100644 --- a/Tests/SceneLifecycleTests.cpp +++ b/Tests/SceneLifecycleTests.cpp @@ -1,6 +1,8 @@ #include #include +#include +#include #include #include #include @@ -2268,6 +2270,67 @@ TEST(SceneLifecycleTests, CookedSceneFile_SaveLoadRoundTripsWorldAndMaterialStat "Engine/tf2 coconut.jpg"); } +TEST(SceneLifecycleTests, SetSceneStateHydratesSkyboxHDRDataFromCookedContent) { + EnsureLogInitialized(); + const auto TempRoot = + std::filesystem::temp_directory_path() / "wraithengine-skybox-reload-test"; + std::error_code RemoveError; + std::filesystem::remove_all(TempRoot, RemoveError); + std::filesystem::create_directories(TempRoot / "Content" / "Cooked"); + + const std::filesystem::path RelativeHDRPath = "Skies/studio.hdr"; + const std::filesystem::path CookedHDRRelativePath = "Cooked/Skies-studio.wtex"; + const auto CookedHDRPath = TempRoot / "Content" / CookedHDRRelativePath; + + Axiom::HDRTextureSourceData HDRTexture; + HDRTexture.Width = 1; + HDRTexture.Height = 1; + HDRTexture.Pixels = {1.0f, 0.5f, 0.25f, 1.0f}; + ASSERT_TRUE(Axiom::Assets::SaveCookedHDRTextureAsset( + CookedHDRPath, HDRTexture, + Axiom::Assets::AssetIdFromRelativePath(RelativeHDRPath))); + + Axiom::Assets::AssetCookManifest Manifest; + Manifest.Entries.push_back(Axiom::Assets::AssetCookManifestEntry{ + .Id = Axiom::Assets::AssetIdFromRelativePath(RelativeHDRPath), + .Kind = Axiom::Assets::AssetKind::Texture, + .RelativePath = RelativeHDRPath.generic_string(), + .CookedPath = CookedHDRRelativePath.generic_string(), + .FormatVersion = Axiom::Assets::kCookedTextureFormatVersion, + .SourceHash = 0, + }); + ASSERT_TRUE(Axiom::Assets::SaveAssetCookManifest( + TempRoot / "Content" / "Cooked" / "AssetCookManifest.json", Manifest)); + + Axiom::EditorSceneState Scene; + Scene.Items = {{ + .Id = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + }}; + Scene.ObjectDetailsById["world"] = Axiom::EditorObjectDetails{ + .ObjectId = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .SupportsTransform = false, + .TransformReadOnly = true, + }; + Scene.WorldSettings.SkyboxHDRPath = RelativeHDRPath.generic_string(); + + Axiom::EditorSession Session(Axiom::SessionId{1}); + Session.SetContentDir(TempRoot / "Content"); + Session.SetSceneState(std::move(Scene)); + + ASSERT_TRUE(Session.GetState().Scene.WorldSettings.SkyboxHDRData != nullptr); + EXPECT_EQ(Session.GetState().Scene.WorldSettings.SkyboxHDRData->Width, 1u); + EXPECT_EQ(Session.GetState().Scene.WorldSettings.SkyboxHDRData->Height, 1u); + ASSERT_EQ(Session.GetState().Scene.WorldSettings.SkyboxHDRData->Pixels.size(), 4u); + EXPECT_FLOAT_EQ(Session.GetState().Scene.WorldSettings.SkyboxHDRData->Pixels[0], + 1.0f); +} + TEST(SceneLifecycleTests, SetPhysicsPropertiesUpdatesAuthoritativeDetails) { Axiom::EditorSession Session = MakeWorldSession(); RecordingSubscriber Subscriber; From cc9f74691eb7a923f6b5dbf29bfa95ad9fddd69f Mon Sep 17 00:00:00 2001 From: Tamely Date: Sun, 24 May 2026 11:50:39 -0500 Subject: [PATCH 6/8] Hydrate HDRs on scene load --- Axiom/Session/EditorSession.cpp | 48 ++++++- .../components/panels/world-details-panel.tsx | 8 +- Tests/SceneLifecycleTests.cpp | 128 ++++++++++++++++++ 3 files changed, 181 insertions(+), 3 deletions(-) diff --git a/Axiom/Session/EditorSession.cpp b/Axiom/Session/EditorSession.cpp index a826f169..d3a139fc 100644 --- a/Axiom/Session/EditorSession.cpp +++ b/Axiom/Session/EditorSession.cpp @@ -1,6 +1,8 @@ #include "Session/EditorSession.h" #include "Assets/AssetCooker.h" +#include "Assets/CookedTextureAsset.h" +#include "Assets/IAssetSource.h" #include "Assets/MeshAsset.h" #include "Physics/PhysicsWorld.h" @@ -59,6 +61,7 @@ void CookHDRTextureAssetBestEffort(const std::filesystem::path &ContentDir, void HydrateWorldSettingsHDRData(EditorWorldSettings &Settings, const std::filesystem::path &ContentDir, + const std::filesystem::path &EngineContentDir, std::string_view LogContext) { if (Settings.SkyboxHDRPath.empty()) { Settings.SkyboxHDRData = nullptr; @@ -73,11 +76,40 @@ void HydrateWorldSettingsHDRData(EditorWorldSettings &Settings, return; } - const auto FullPath = ContentDir / Settings.SkyboxHDRPath; + const std::filesystem::path HDRRelativePath(Settings.SkyboxHDRPath); + const bool IsEngineAsset = + !HDRRelativePath.empty() && *HDRRelativePath.begin() == "Engine"; + std::filesystem::path EffectiveContentDir = ContentDir; + std::filesystem::path EffectiveRelativePath = HDRRelativePath; + if (IsEngineAsset && !EngineContentDir.empty()) { + EffectiveContentDir = EngineContentDir; + auto It = HDRRelativePath.begin(); + ++It; // skip "Engine" + EffectiveRelativePath.clear(); + for (; It != HDRRelativePath.end(); ++It) { + EffectiveRelativePath /= *It; + } + } + + const auto FullPath = EffectiveContentDir / EffectiveRelativePath; if (std::filesystem::exists(FullPath)) { - CookHDRTextureAssetBestEffort(ContentDir, Settings.SkyboxHDRPath); + CookHDRTextureAssetBestEffort(EffectiveContentDir, + EffectiveRelativePath.generic_string()); } auto Loaded = Assets::LoadHDRTextureFromFile(FullPath); + if (!Loaded) { + const Assets::CookedAssetSource CookedSource(EffectiveContentDir); + if (CookedSource.HasManifest()) { + const auto CookedPath = CookedSource.Resolve( + Assets::AssetIdFromRelativePath(EffectiveRelativePath)); + if (CookedPath.has_value()) { + const auto CookedHDR = Assets::LoadCookedHDRTextureAsset(*CookedPath); + if (CookedHDR.has_value()) { + Loaded = std::make_shared(*CookedHDR); + } + } + } + } if (!Loaded) { A_CORE_WARN("{}: failed to load HDR '{}'", LogContext, Settings.SkyboxHDRPath); @@ -470,6 +502,7 @@ void EditorSession::SetPresenceState(SessionUserId User, void EditorSession::SetSceneState(EditorSceneState SceneState) { m_State.Scene = std::move(SceneState); HydrateWorldSettingsHDRData(m_State.Scene.WorldSettings, m_ContentDir, + m_EngineContentDir, "SetSceneState"); // Populate Material on object details from mesh instances so the inspector // can display and edit material properties for mesh objects. @@ -2343,6 +2376,7 @@ void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, m_State.Scene.WorldSettings.SkyboxHDRData = std::move(PreviousHDRData); } else { HydrateWorldSettingsHDRData(m_State.Scene.WorldSettings, m_ContentDir, + m_EngineContentDir, "SetWorldSettings"); } } @@ -2431,10 +2465,20 @@ void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, void EditorSession::SetContentDir(std::filesystem::path ContentDir) { m_ContentDir = std::move(ContentDir); + if (!m_State.Scene.WorldSettings.SkyboxHDRPath.empty()) { + m_State.Scene.WorldSettings.SkyboxHDRData = nullptr; + HydrateWorldSettingsHDRData(m_State.Scene.WorldSettings, m_ContentDir, + m_EngineContentDir, "SetContentDir"); + } } void EditorSession::SetEngineContentDir(std::filesystem::path EngineContentDir) { m_EngineContentDir = std::move(EngineContentDir); + if (!m_State.Scene.WorldSettings.SkyboxHDRPath.empty()) { + m_State.Scene.WorldSettings.SkyboxHDRData = nullptr; + HydrateWorldSettingsHDRData(m_State.Scene.WorldSettings, m_ContentDir, + m_EngineContentDir, "SetEngineContentDir"); + } } void EditorSession::PublishScriptError(const std::string &ObjectId, diff --git a/EditorFrontend/components/panels/world-details-panel.tsx b/EditorFrontend/components/panels/world-details-panel.tsx index b00a3309..090e378e 100644 --- a/EditorFrontend/components/panels/world-details-panel.tsx +++ b/EditorFrontend/components/panels/world-details-panel.tsx @@ -23,7 +23,7 @@ function hexToVec3(hex: string): [number, number, number] { } export function WorldDetailsPanel() { - const { worldSettings, setWorldSettings, assets, listAssets } = useRemoteViewport() + const { worldSettings, setWorldSettings, assets, listAssets, projectionType } = useRemoteViewport() // Derive primitive remote values so effect deps compare by value, not by the // worldSettings object reference (a fresh object on every snapshot refresh). @@ -137,6 +137,12 @@ export function WorldDetailsPanel() { Drop a .hdr from the content browser or pick one with the folder icon. Leave empty to use the gradient above.

+ {hdrActive && projectionType === "orthographic" ? ( +

+ HDR skies only render in Perspective mode right now. In Orthographic, + the viewport falls back to the gradient colors. +

+ ) : null} diff --git a/Tests/SceneLifecycleTests.cpp b/Tests/SceneLifecycleTests.cpp index bd70fd41..1eb3fb9d 100644 --- a/Tests/SceneLifecycleTests.cpp +++ b/Tests/SceneLifecycleTests.cpp @@ -2331,6 +2331,134 @@ TEST(SceneLifecycleTests, SetSceneStateHydratesSkyboxHDRDataFromCookedContent) { 1.0f); } +TEST(SceneLifecycleTests, SetSceneStateHydratesSkyboxHDRDataFromEngineContent) { + EnsureLogInitialized(); + const auto TempRoot = + std::filesystem::temp_directory_path() / "wraithengine-engine-skybox-test"; + std::error_code RemoveError; + std::filesystem::remove_all(TempRoot, RemoveError); + std::filesystem::create_directories(TempRoot / "ProjectContent"); + std::filesystem::create_directories(TempRoot / "Content" / "Engine" / "Cooked"); + + const std::filesystem::path RelativeHDRPath = "Engine/Skies/studio.hdr"; + const std::filesystem::path EngineRelativeHDRPath = "Skies/studio.hdr"; + const std::filesystem::path CookedHDRRelativePath = "Cooked/Skies-studio.wtex"; + const auto CookedHDRPath = + TempRoot / "Content" / "Engine" / CookedHDRRelativePath; + + Axiom::HDRTextureSourceData HDRTexture; + HDRTexture.Width = 1; + HDRTexture.Height = 1; + HDRTexture.Pixels = {0.25f, 0.5f, 1.0f, 1.0f}; + ASSERT_TRUE(Axiom::Assets::SaveCookedHDRTextureAsset( + CookedHDRPath, HDRTexture, + Axiom::Assets::AssetIdFromRelativePath(EngineRelativeHDRPath))); + + Axiom::Assets::AssetCookManifest Manifest; + Manifest.Entries.push_back(Axiom::Assets::AssetCookManifestEntry{ + .Id = Axiom::Assets::AssetIdFromRelativePath(EngineRelativeHDRPath), + .Kind = Axiom::Assets::AssetKind::Texture, + .RelativePath = EngineRelativeHDRPath.generic_string(), + .CookedPath = CookedHDRRelativePath.generic_string(), + .FormatVersion = Axiom::Assets::kCookedTextureFormatVersion, + .SourceHash = 0, + }); + ASSERT_TRUE(Axiom::Assets::SaveAssetCookManifest( + TempRoot / "Content" / "Engine" / "Cooked" / "AssetCookManifest.json", + Manifest)); + + Axiom::EditorSceneState Scene; + Scene.Items = {{ + .Id = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + }}; + Scene.ObjectDetailsById["world"] = Axiom::EditorObjectDetails{ + .ObjectId = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .SupportsTransform = false, + .TransformReadOnly = true, + }; + Scene.WorldSettings.SkyboxHDRPath = RelativeHDRPath.generic_string(); + + Axiom::EditorSession Session(Axiom::SessionId{1}); + Session.SetContentDir(TempRoot / "ProjectContent"); + Session.SetEngineContentDir(TempRoot / "Content" / "Engine"); + Session.SetSceneState(std::move(Scene)); + + ASSERT_TRUE(Session.GetState().Scene.WorldSettings.SkyboxHDRData != nullptr); + EXPECT_EQ(Session.GetState().Scene.WorldSettings.SkyboxHDRData->Width, 1u); + ASSERT_EQ(Session.GetState().Scene.WorldSettings.SkyboxHDRData->Pixels.size(), 4u); + EXPECT_FLOAT_EQ(Session.GetState().Scene.WorldSettings.SkyboxHDRData->Pixels[2], + 1.0f); +} + +TEST(SceneLifecycleTests, + SetContentDirHydratesSkyboxHDRDataForExistingLoadedScene) { + EnsureLogInitialized(); + const auto TempRoot = + std::filesystem::temp_directory_path() / "wraithengine-skybox-order-test"; + std::error_code RemoveError; + std::filesystem::remove_all(TempRoot, RemoveError); + std::filesystem::create_directories(TempRoot / "Content" / "Cooked"); + + const std::filesystem::path RelativeHDRPath = "Skies/studio.hdr"; + const std::filesystem::path CookedHDRRelativePath = "Cooked/Skies-studio.wtex"; + const auto CookedHDRPath = TempRoot / "Content" / CookedHDRRelativePath; + + Axiom::HDRTextureSourceData HDRTexture; + HDRTexture.Width = 1; + HDRTexture.Height = 1; + HDRTexture.Pixels = {0.75f, 0.5f, 0.25f, 1.0f}; + ASSERT_TRUE(Axiom::Assets::SaveCookedHDRTextureAsset( + CookedHDRPath, HDRTexture, + Axiom::Assets::AssetIdFromRelativePath(RelativeHDRPath))); + + Axiom::Assets::AssetCookManifest Manifest; + Manifest.Entries.push_back(Axiom::Assets::AssetCookManifestEntry{ + .Id = Axiom::Assets::AssetIdFromRelativePath(RelativeHDRPath), + .Kind = Axiom::Assets::AssetKind::Texture, + .RelativePath = RelativeHDRPath.generic_string(), + .CookedPath = CookedHDRRelativePath.generic_string(), + .FormatVersion = Axiom::Assets::kCookedTextureFormatVersion, + .SourceHash = 0, + }); + ASSERT_TRUE(Axiom::Assets::SaveAssetCookManifest( + TempRoot / "Content" / "Cooked" / "AssetCookManifest.json", Manifest)); + + Axiom::EditorSceneState Scene; + Scene.Items = {{ + .Id = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + }}; + Scene.ObjectDetailsById["world"] = Axiom::EditorObjectDetails{ + .ObjectId = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .SupportsTransform = false, + .TransformReadOnly = true, + }; + Scene.WorldSettings.SkyboxHDRPath = RelativeHDRPath.generic_string(); + + Axiom::EditorSession Session(Axiom::SessionId{1}); + Session.SetSceneState(std::move(Scene)); + ASSERT_EQ(Session.GetState().Scene.WorldSettings.SkyboxHDRData, nullptr); + + Session.SetContentDir(TempRoot / "Content"); + + ASSERT_TRUE(Session.GetState().Scene.WorldSettings.SkyboxHDRData != nullptr); + EXPECT_EQ(Session.GetState().Scene.WorldSettings.SkyboxHDRData->Width, 1u); + ASSERT_EQ(Session.GetState().Scene.WorldSettings.SkyboxHDRData->Pixels.size(), 4u); + EXPECT_FLOAT_EQ(Session.GetState().Scene.WorldSettings.SkyboxHDRData->Pixels[0], + 0.75f); +} + TEST(SceneLifecycleTests, SetPhysicsPropertiesUpdatesAuthoritativeDetails) { Axiom::EditorSession Session = MakeWorldSession(); RecordingSubscriber Subscriber; From 17bd18de9c27323cb48ecea2306cae93e4455873 Mon Sep 17 00:00:00 2001 From: Tamely Date: Sun, 24 May 2026 11:53:03 -0500 Subject: [PATCH 7/8] Fix JSON response on successful packaging --- Headless/RemoteViewportServer.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/Headless/RemoteViewportServer.cpp b/Headless/RemoteViewportServer.cpp index fc6e3a27..f076f0fa 100644 --- a/Headless/RemoteViewportServer.cpp +++ b/Headless/RemoteViewportServer.cpp @@ -605,6 +605,7 @@ std::string SerializeProjectPackageResult( << "\"" << ",\"packagedContentPath\":\"" << EscapeJsonString(Result.Cook.Output.PackagedContentDir.string()) + << "\"" << ",\"packageDir\":\"" << EscapeJsonString(Result.Cook.Output.PackageDir.string()) << "\",\"packageManifestPath\":\"" From d3cd24c5f66b9f91b2e5aa2763e4a5f232d7fb4b Mon Sep 17 00:00:00 2001 From: Tamely Date: Sun, 24 May 2026 11:56:03 -0500 Subject: [PATCH 8/8] Update docs with a clearer forward path --- Docs/DistributedWraithEngineDesign.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Docs/DistributedWraithEngineDesign.md b/Docs/DistributedWraithEngineDesign.md index c52b15aa..ecea6481 100644 --- a/Docs/DistributedWraithEngineDesign.md +++ b/Docs/DistributedWraithEngineDesign.md @@ -1352,7 +1352,10 @@ Progress update: - Phase 7 (Asset Pipeline) is complete: `SetMeshAssetCommand` wires any discovered `.glb`/`.gltf`/`.fbx`/`.obj` to mesh objects and actor roots with scene-file persistence; `SetLightPropertiesCommand` drives a Blinn-Phong directional light from `SceneLight` world position; `SetMaterialPropertiesCommand` exposes `BaseColorFactor`/`Metallic`/`Roughness` push constants end-to-end through the inspector; `SetMaterialTextureCommand` assigns PNG/JPG textures to mesh base-color slots with persistence, inspector display, and drag-drop from both the content browser and outliner; FBX/OBJ import is implemented via assimp with embedded and external texture handling; the content browser accepts OS file drag-drop and a file picker Import button that upload to `POST /assets/upload`; texture thumbnail previews are served by the remote viewport server; the content browser navigates folders non-recursively; regression coverage now includes the `CreateObject`→`SetMeshAsset` runtime-creation path and actor mesh assignment - Phase 8 (Binary Asset Formats) and the first packaging foundation are now implemented: `.wmesh`, `.wtex`, and `.wmat` cooked formats exist; `AssetCookManifest` and `CookedAssetSource` resolve cooked content by stable `AssetId`; startup, scene reload, and mesh/texture editing flows all prefer cooked payloads while preserving source fallback during editor use; scene persistence now round-trips cooked material state through `materialAssetPath`; projects now cook into per-project `Content/Cooked/` and stage packaged outputs under per-project `Package/` directories - packaged runtime cutover is now implemented for the first desktop slice: staged packages include `AxiomPackagedRuntime`, cooked project content, `scene.wscene`, the asset cook manifest, shared engine content, and a package manifest; packaged content roots are treated as cooked-only at runtime and now fail fast on invalid package layout, missing cooked scene/cook manifest, or unresolved cooked asset references -- the next step is editor polish and platform packaging depth: improve package UX further, add richer validation/reporting where needed, and eventually graduate from the plain staged macOS executable layout to more polished distribution targets +- the next step is now a targeted runtime/editor architecture refactor before further large feature work: the current codebase has enough cross-cutting packaging/editor/runtime/session state that cameras, gameplay input, and possession should not be layered on top of the existing seams without first tightening the boundaries +- that refactor should stay narrow and gameplay-motivated rather than turning into general cleanup; the goal is to separate editor authoring state from runtime gameplay state, editor viewport controls from in-game input, and editor camera movement from runtime camera ownership/activation +- after that refactor, the next gameplay foundation slice should be cameras, input handling, and possession so WraithEngine can support a real playable runtime loop instead of only editor-driven scene simulation +- editor polish and deeper platform packaging should then resume on top of the cleaner architecture: improve package UX further, add richer validation/reporting where needed, and eventually graduate from the plain staged macOS executable layout to more polished distribution targets - delivery note: large cross-cutting features should keep landing as mergeable reviewed batches with explicit greenlight checkpoints between batches so packaging/runtime changes do not sprawl across unrelated concerns That slice proves the core thesis: