diff --git a/Axiom/Assets/CookedAssetRuntime.cpp b/Axiom/Assets/CookedAssetRuntime.cpp
index e44fb761..7caf4580 100644
--- a/Axiom/Assets/CookedAssetRuntime.cpp
+++ b/Axiom/Assets/CookedAssetRuntime.cpp
@@ -4,8 +4,99 @@
#include "Assets/CookedMeshAsset.h"
#include "Assets/CookedTextureAsset.h"
#include "Assets/IAssetSource.h"
+#include "Assets/SceneFile.h"
+
+#include
+#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 +108,205 @@ 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,
+ };
+}
+
+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 4eeb3846..f3eaf3a4 100644
--- a/Axiom/Assets/CookedAssetRuntime.h
+++ b/Axiom/Assets/CookedAssetRuntime.h
@@ -6,14 +6,30 @@
#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);
+
+bool ValidatePackagedContentDescriptor(const PackagedContentDescriptor &Descriptor,
+ std::string *FailureReason = nullptr);
+
std::optional
LoadCookedMeshAssetIfAvailable(const std::filesystem::path &Path);
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/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 1ec3c5ea..29b9e119 100644
--- a/Axiom/Project/ProjectSystem.cpp
+++ b/Axiom/Project/ProjectSystem.cpp
@@ -2,12 +2,15 @@
#include "Assets/AssetCookManifest.h"
#include "Assets/AssetCooker.h"
+#include "Assets/CookedAssetRuntime.h"
+#include "Assets/SceneFile.h"
#include "Core/Log.h"
#include
#include
#include
#include
+#include
#include
#include
#include
@@ -25,6 +28,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";
@@ -572,8 +579,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,10 +646,14 @@ 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",
+ .StagedRuntimeBinaryPath =
+ Root.RootPath / "Package" /
+ std::filesystem::path(AXIOM_PACKAGED_RUNTIME_BINARY_PATH).filename(),
};
}
@@ -1032,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,
@@ -1066,16 +1084,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 =
@@ -1087,12 +1115,47 @@ 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),
- .IncludedSceneFile = std::filesystem::exists(Project.Output.PackagedSceneFilePath),
+ .IncludedSceneAsset =
+ 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) {
@@ -1100,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/Project/ProjectSystem.h b/Axiom/Project/ProjectSystem.h
index de6d640c..1e4a22e8 100644
--- a/Axiom/Project/ProjectSystem.h
+++ b/Axiom/Project/ProjectSystem.h
@@ -44,9 +44,10 @@ 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;
+ std::filesystem::path StagedRuntimeBinaryPath;
};
struct ProjectCookResult {
@@ -58,8 +59,11 @@ struct ProjectCookResult {
struct ProjectPackageResult {
ProjectCookResult Cook;
std::size_t PackagedFileCount{0};
- bool IncludedSceneFile{false};
+ bool IncludedSceneAsset{false};
bool IncludedEngineContent{false};
+ bool IncludedRuntimeBinary{false};
+ std::filesystem::path SceneAssetPath;
+ std::filesystem::path RuntimeBinaryPath;
};
struct ProjectDescriptor {
diff --git a/Axiom/Session/EditorSession.cpp b/Axiom/Session/EditorSession.cpp
index e48c3f15..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"
@@ -57,6 +59,64 @@ 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;
+ return;
+ }
+ if (Settings.SkyboxHDRData) {
+ return;
+ }
+ if (ContentDir.empty()) {
+ A_CORE_WARN("{}: content directory not configured; cannot load HDR '{}'",
+ LogContext, Settings.SkyboxHDRPath);
+ return;
+ }
+
+ 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(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);
+ }
+ Settings.SkyboxHDRData = std::move(Loaded);
+}
+
std::string DefaultUserDisplayName(SessionUserId User) {
if (User.Value == 1) {
return "Host";
@@ -441,6 +501,9 @@ 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.
for (const auto &MeshInst : m_State.Scene.MeshInstances) {
@@ -2311,21 +2374,10 @@ 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,
+ m_EngineContentDir,
+ "SetWorldSettings");
}
}
@@ -2413,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/Axiom/Session/StartupScene.cpp b/Axiom/Session/StartupScene.cpp
index 2db604c4..16b0edf1 100644
--- a/Axiom/Session/StartupScene.cpp
+++ b/Axiom/Session/StartupScene.cpp
@@ -350,6 +350,33 @@ 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 (!Assets::ValidatePackagedContentDescriptor(*Descriptor, &FailureReason)) {
+ A_CORE_ERROR("StartupScene: packaged content validation failed for '{}': {}",
+ ContentRoot.string(), FailureReason);
+ 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 +393,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/Docs/DistributedWraithEngineDesign.md b/Docs/DistributedWraithEngineDesign.md
index c8c236d3..ecea6481 100644
--- a/Docs/DistributedWraithEngineDesign.md
+++ b/Docs/DistributedWraithEngineDesign.md
@@ -1351,8 +1351,12 @@ 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 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:
diff --git a/EditorFrontend/components/engine/project-browser.tsx b/EditorFrontend/components/engine/project-browser.tsx
index 3b77f504..bf45b492 100644
--- a/EditorFrontend/components/engine/project-browser.tsx
+++ b/EditorFrontend/components/engine/project-browser.tsx
@@ -23,7 +23,8 @@ export interface ProjectDescriptor {
packageDir: string
packagedContentDir: string
packagedCookedDir: string
- packagedSceneFilePath: string
+ packagedSceneAssetPath: string
+ stagedRuntimeBinaryPath: string
packageManifestPath: string
engineContentDir: string
sceneFilePath: string
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/EditorFrontend/components/wraith-engine.tsx b/EditorFrontend/components/wraith-engine.tsx
index d983cb30..f5d7ca5f 100644
--- a/EditorFrontend/components/wraith-engine.tsx
+++ b/EditorFrontend/components/wraith-engine.tsx
@@ -68,8 +68,12 @@ interface ProjectPackageResponse {
cookedSourceAssetCount: number
manifestEntryCount: number
packagedFileCount: number
- includedSceneFile: boolean
+ includedSceneAsset: boolean
includedEngineContent: boolean
+ includedRuntimeBinary: boolean
+ sceneAssetPath: string
+ runtimeBinaryPath: string
+ packagedContentPath: string
packageDir: string
packageManifestPath: string
}
@@ -310,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 with cooked assets${payload.includedSceneFile ? " and scene state" : ""}.`,
+ 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
new file mode 100644
index 00000000..3c05bcde
--- /dev/null
+++ b/Headless/AxiomPackagedRuntime.cpp
@@ -0,0 +1,93 @@
+#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";
+ 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);
+ if (!Descriptor.has_value()) {
+ std::cerr << "Invalid package root '" << Options->PackageRoot.string()
+ << "': " << FailureReason << std::endl;
+ return 1;
+ }
+ if (!Axiom::Assets::ValidatePackagedContentDescriptor(*Descriptor,
+ &FailureReason)) {
+ std::cerr << "Invalid package root '" << Options->PackageRoot.string()
+ << "': " << FailureReason << 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 7cea7163..f076f0fa 100644
--- a/Headless/RemoteViewportServer.cpp
+++ b/Headless/RemoteViewportServer.cpp
@@ -519,8 +519,10 @@ 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())
+ << "\",\"stagedRuntimeBinaryPath\":\""
+ << EscapeJsonString(Project.Output.StagedRuntimeBinaryPath.string())
<< "\",\"packageManifestPath\":\""
<< EscapeJsonString(Project.Output.PackageManifestPath.string())
<< "\",\"engineContentDir\":\""
@@ -590,10 +592,20 @@ 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")
+ << ",\"includedRuntimeBinary\":"
+ << (Result.IncludedRuntimeBinary ? "true" : "false")
+ << ",\"sceneAssetPath\":\""
+ << EscapeJsonString(Result.SceneAssetPath.string())
+ << "\",\"runtimeBinaryPath\":\""
+ << EscapeJsonString(Result.RuntimeBinaryPath.string())
+ << "\""
+ << ",\"packagedContentPath\":\""
+ << EscapeJsonString(Result.Cook.Output.PackagedContentDir.string())
+ << "\""
<< ",\"packageDir\":\""
<< EscapeJsonString(Result.Cook.Output.PackageDir.string())
<< "\",\"packageManifestPath\":\""
@@ -2865,6 +2877,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:
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/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 baa67c36..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
@@ -284,10 +287,18 @@ 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_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"));
std::ifstream PackageManifestFile(Created->Output.PackageManifestPath);
ASSERT_TRUE(PackageManifestFile.is_open());
@@ -295,11 +306,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 +390,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 +402,20 @@ 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"));
+
+ 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) {
@@ -400,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);
+}
diff --git a/Tests/SceneLifecycleTests.cpp b/Tests/SceneLifecycleTests.cpp
index e55a78a9..1eb3fb9d 100644
--- a/Tests/SceneLifecycleTests.cpp
+++ b/Tests/SceneLifecycleTests.cpp
@@ -1,6 +1,8 @@
#include
#include
+#include
+#include
#include
#include
#include
@@ -2178,6 +2180,285 @@ 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, 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, 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;