677 lines
31 KiB
C++
677 lines
31 KiB
C++
#include <cstring>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <memory>
|
|
#include <string>
|
|
|
|
#include <glm/gtc/packing.hpp>
|
|
#include <destrum/Assets/AssetDatabase.h>
|
|
#include <destrum/Assets/AssetManager.h>
|
|
#include <destrum/Assets/AssetTypes.h>
|
|
#include <destrum/Assets/AssetReference.h>
|
|
#include <destrum/Assets/ModelFile.h>
|
|
#include <destrum/Assets/PackFile.h>
|
|
#include <destrum/Assets/TextureFile.h>
|
|
#include <destrum/FS/AssetFS.h>
|
|
#include <destrum/ObjectModel/Component.h>
|
|
#include <destrum/ObjectModel/GameObject.h>
|
|
#include <destrum/Serialization/GlmJson.h>
|
|
#include <destrum/Components/Animator.h>
|
|
#include <destrum/Graphics/Material.h>
|
|
#include <destrum/Graphics/imageLoader.h>
|
|
#include <destrum/Serialization/MaterialSerialization.h>
|
|
#include <destrum/Serialization/ObjectSerialization.h>
|
|
|
|
#include "TestUtils.h"
|
|
|
|
using namespace test;
|
|
|
|
namespace {
|
|
void testAssetPathValidation()
|
|
{
|
|
const auto root = std::filesystem::temp_directory_path() / "destrum_asset_test";
|
|
std::filesystem::create_directories(root / "assets" / "engine");
|
|
std::filesystem::create_directories(root / "assets" / "game");
|
|
std::ofstream{root / "assets" / "engine" / "asset.txt"} << "asset";
|
|
std::ofstream{root / "assets" / "engine" / "cooked.asset"} << "cooked";
|
|
std::ofstream{root / "assets" / "engine" / "manifest.json"}
|
|
<< R"({"version":1,"assets":[{"src":"asset.txt","out":"cooked.asset","type":"raw","mtime_epoch_ns":-5020137004799419216,"size_bytes":355972}]})";
|
|
|
|
auto& assetFs = AssetFS::GetInstance();
|
|
assetFs.Init(root);
|
|
check(std::filesystem::exists(assetFs.GetFullPath("engine://asset.txt")),
|
|
"valid virtual asset paths must resolve");
|
|
check(assetFs.GetCookedPathForFile("engine://asset.txt").filename() == "cooked.asset",
|
|
"manifest output paths must resolve to cooked assets");
|
|
|
|
bool rejected = false;
|
|
try {
|
|
(void)assetFs.GetFullPath("engine://../outside.txt");
|
|
} catch (const std::exception&) {
|
|
rejected = true;
|
|
}
|
|
check(rejected, "asset traversal must be rejected");
|
|
|
|
const auto outside = root / "outside";
|
|
const auto link = root / "assets" / "engine" / "link";
|
|
std::filesystem::create_directories(outside);
|
|
std::error_code symlinkError;
|
|
std::filesystem::create_directory_symlink(outside, link, symlinkError);
|
|
if (!symlinkError) {
|
|
bool symlinkEscapeRejected = false;
|
|
try {
|
|
(void)assetFs.GetFullPath("engine://link/missing.txt");
|
|
} catch (const std::exception&) {
|
|
symlinkEscapeRejected = true;
|
|
}
|
|
check(symlinkEscapeRejected,
|
|
"symlink traversal must be rejected for missing targets");
|
|
}
|
|
|
|
std::filesystem::remove_all(root);
|
|
assetFs.Reset();
|
|
}
|
|
|
|
void testAnimatorAssetReferences()
|
|
{
|
|
GameObject object{"animated"};
|
|
auto* animator = object.AddComponent<Animator>();
|
|
|
|
Skeleton skeleton;
|
|
Joint joint;
|
|
joint.id = 0;
|
|
joint.localTranslation = glm::vec3{0.0f};
|
|
joint.localRotation = glm::identity<glm::quat>();
|
|
joint.localScale = glm::vec3{1.0f};
|
|
skeleton.joints.push_back(joint);
|
|
skeleton.inverseBindMatrices.push_back(glm::mat4{1.0f});
|
|
skeleton.jointNames.push_back("root");
|
|
skeleton.assetReference.path = "engine://character.fbx";
|
|
buildParentIndex(skeleton);
|
|
animator->setSkeleton(std::move(skeleton));
|
|
|
|
auto clip = std::make_shared<SkeletalAnimation>();
|
|
clip->name = "walk";
|
|
clip->assetReference.path = "engine://walk.fbx";
|
|
clip->assetReference.subresource = "animation:0";
|
|
clip->duration = 1.0f;
|
|
SkeletalAnimation::Keyframe keyframe;
|
|
keyframe.time = 0.0f;
|
|
keyframe.translation = glm::vec3{0.0f};
|
|
keyframe.rotation = glm::identity<glm::quat>();
|
|
keyframe.scale = glm::vec3{1.0f};
|
|
SkeletalAnimation::Track track;
|
|
track.jointIndex = 0;
|
|
track.keyframes.push_back(keyframe);
|
|
clip->tracks.push_back(std::move(track));
|
|
animator->addClip(std::move(clip));
|
|
animator->play("walk");
|
|
|
|
const auto data = animator->Serialize();
|
|
check(data.contains("assetReferences"),
|
|
"Animator scenes must store asset references");
|
|
check(data.at("assetReferences").at("skeleton").at("path") == "engine://character.fbx",
|
|
"Animator serialization must store the skeleton source");
|
|
check(data.at("assetReferences").at("animations").at(0).at("asset").at("subresource") ==
|
|
"animation:0",
|
|
"Animator serialization must store the animation subresource");
|
|
check(data.dump().find("keyframes") == std::string::npos,
|
|
"Animator scene data must not embed animation keyframes");
|
|
}
|
|
|
|
ModelDoc::ModelAsset makeTestModelAsset()
|
|
{
|
|
Skeleton skeleton;
|
|
skeleton.assetReference = {.path = "engine://character.fbx", .subresource = "skeleton"};
|
|
skeleton.hierarchy = {{.id = 0, .children = {1}}, {.id = 1, .children = {}}};
|
|
skeleton.inverseBindMatrices = {glm::mat4{1.0f}, glm::mat4{2.0f}};
|
|
skeleton.joints = {
|
|
{.id = 0, .localTranslation = glm::vec3{0.0f, 1.0f, 0.0f}},
|
|
{.id = 1, .localScale = glm::vec3{2.0f}},
|
|
};
|
|
skeleton.jointNames = {"root", "spine"};
|
|
buildParentIndex(skeleton);
|
|
|
|
CPUMesh mesh;
|
|
mesh.indices = {0, 1, 2};
|
|
mesh.vertices = {
|
|
{.position = glm::vec3{0.0f}, .uv_x = 0.25f, .normal = glm::vec3{0.0f, 1.0f, 0.0f}},
|
|
{.position = glm::vec3{1.0f, 0.0f, 0.0f}, .tangent = glm::vec4{1.0f, 0.0f, 0.0f, -1.0f}},
|
|
{.position = glm::vec3{0.0f, 0.0f, 1.0f}, .uv_y = 0.75f},
|
|
};
|
|
mesh.skinningData = {
|
|
{.jointIds = glm::uvec4{0, 1, 0, 0}, .weights = glm::vec4{0.5f, 0.5f, 0.0f, 0.0f}},
|
|
{.jointIds = glm::uvec4{1, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}},
|
|
{.jointIds = glm::uvec4{0, 0, 0, 0}, .weights = glm::vec4{1.0f, 0.0f, 0.0f, 0.0f}},
|
|
};
|
|
mesh.name = "body";
|
|
mesh.assetReference = {.path = "engine://character.fbx", .subresource = "mesh:0"};
|
|
mesh.minPos = glm::vec3{0.0f};
|
|
mesh.maxPos = glm::vec3{1.0f};
|
|
mesh.skeleton = skeleton;
|
|
|
|
ModelDoc::MeshPrimitive primitive;
|
|
primitive.mesh = mesh;
|
|
primitive.materialIndex = 0;
|
|
primitive.nodeName = "Body";
|
|
primitive.sourceMeshIndex = 3;
|
|
primitive.skinned = true;
|
|
primitive.nodeWorldTransform = glm::mat4{3.0f};
|
|
|
|
ModelDoc::MaterialInfo material;
|
|
material.name = "skin";
|
|
material.assetReference = {.path = "engine://character.fbx", .subresource = "material:0"};
|
|
material.baseColor = glm::vec4{0.8f, 0.6f, 0.4f, 1.0f};
|
|
material.emissiveColor = glm::vec3{0.1f};
|
|
material.metallicFactor = 0.2f;
|
|
material.roughnessFactor = 0.9f;
|
|
material.opacity = 0.5f;
|
|
material.textures = {{
|
|
.semantic = ModelDoc::TextureSemantic::BaseColor,
|
|
.rawPath = "skin.png",
|
|
.path = "textures/skin.png",
|
|
.assimpType = aiTextureType_DIFFUSE,
|
|
.slot = 1,
|
|
.embedded = false,
|
|
}};
|
|
|
|
SkeletalAnimation animation;
|
|
animation.assetReference = {.path = "engine://character.fbx", .subresource = "animation:0"};
|
|
animation.tracks = {{
|
|
.jointIndex = 1,
|
|
.keyframes = {
|
|
{.time = 0.0f, .translation = glm::vec3{0.0f}, .rotation = glm::identity<glm::quat>(), .scale = glm::vec3{1.0f}},
|
|
{.time = 0.5f, .translation = glm::vec3{0.0f, 2.0f, 0.0f}, .rotation = glm::quat{0.0f, 1.0f, 0.0f, 0.0f}, .scale = glm::vec3{1.0f}},
|
|
},
|
|
}};
|
|
animation.duration = 0.5f;
|
|
animation.looped = false;
|
|
animation.name = "wave";
|
|
animation.startFrame = 4;
|
|
animation.events = {{2, {"footstep", "dust"}}};
|
|
|
|
ModelDoc::ModelAsset model;
|
|
model.sourcePath = "engine://character.fbx";
|
|
model.primitives = {primitive};
|
|
model.materials = {material};
|
|
model.skeleton = skeleton;
|
|
model.animations = {animation};
|
|
return model;
|
|
}
|
|
|
|
bool modelFileReadThrows(const std::vector<std::uint8_t>& bytes)
|
|
{
|
|
try {
|
|
(void)ModelFile::Read(bytes);
|
|
} catch (const std::exception&) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void testModelFileRoundTrip()
|
|
{
|
|
const auto bytes = ModelFile::Write(makeTestModelAsset());
|
|
const auto loaded = ModelFile::Read(bytes);
|
|
|
|
check(ModelFile::Write(loaded) == bytes,
|
|
"a cooked model must write back to exactly the same bytes");
|
|
|
|
check(loaded.sourcePath == "engine://character.fbx",
|
|
"cooked models must keep their source path");
|
|
|
|
check(loaded.primitives.size() == 1, "cooked models must keep their primitives");
|
|
const auto& primitive = loaded.primitives.front();
|
|
check(primitive.nodeName == "Body" && primitive.sourceMeshIndex == 3 && primitive.skinned,
|
|
"cooked primitives must keep their import metadata");
|
|
check(primitive.mesh.indices == std::vector<std::uint32_t>{0, 1, 2},
|
|
"cooked meshes must keep their indices");
|
|
check(primitive.mesh.vertices.size() == 3 &&
|
|
primitive.mesh.vertices[0].uv_x == 0.25f &&
|
|
primitive.mesh.vertices[1].tangent.w == -1.0f,
|
|
"cooked meshes must keep their vertices");
|
|
check(primitive.mesh.skinningData[0].jointIds.y == 1 &&
|
|
primitive.mesh.skinningData[0].weights.x == 0.5f,
|
|
"cooked meshes must keep their skinning data");
|
|
check(primitive.mesh.skeleton.has_value() &&
|
|
primitive.mesh.skeleton->jointNames.size() == 2,
|
|
"cooked skinned meshes must keep their skeleton");
|
|
|
|
check(loaded.materials.size() == 1 && loaded.materials.front().opacity == 0.5f,
|
|
"cooked models must keep their materials");
|
|
const auto& texture = loaded.materials.front().textures.front();
|
|
check(texture.semantic == ModelDoc::TextureSemantic::BaseColor &&
|
|
texture.path == "textures/skin.png" &&
|
|
texture.assimpType == aiTextureType_DIFFUSE &&
|
|
texture.slot == 1 &&
|
|
!texture.embedded,
|
|
"cooked materials must keep their textures");
|
|
|
|
check(loaded.skeleton.jointNames == std::vector<std::string>{"root", "spine"} &&
|
|
loaded.skeleton.parentIndex == std::vector<int>{-1, 0} &&
|
|
loaded.skeleton.joints[1].localScale == glm::vec3{2.0f},
|
|
"cooked models must keep their skeleton");
|
|
|
|
check(loaded.animations.size() == 1, "cooked models must keep their animations");
|
|
const auto& animation = loaded.animations.front();
|
|
check(animation.name == "wave" && !animation.looped && animation.startFrame == 4,
|
|
"cooked animations must keep their settings");
|
|
check(animation.tracks.front().keyframes[1].translation.y == 2.0f &&
|
|
animation.tracks.front().keyframes[1].rotation.x == 1.0f,
|
|
"cooked animations must keep their keyframes");
|
|
check(animation.events.at(2) == std::vector<std::string>{"footstep", "dust"},
|
|
"cooked animations must keep their events");
|
|
}
|
|
|
|
void testModelFileRejectsBadData()
|
|
{
|
|
const auto bytes = ModelFile::Write(makeTestModelAsset());
|
|
|
|
auto truncated = bytes;
|
|
truncated.resize(bytes.size() / 2);
|
|
check(modelFileReadThrows(truncated), "truncated cooked models must be rejected");
|
|
|
|
auto wrongMagic = bytes;
|
|
wrongMagic[0] = 'X';
|
|
check(modelFileReadThrows(wrongMagic), "files that aren't cooked models must be rejected");
|
|
|
|
auto wrongVersion = bytes;
|
|
wrongVersion[4] = static_cast<std::uint8_t>(wrongVersion[4] + 1);
|
|
check(modelFileReadThrows(wrongVersion), "cooked models from another version must be rejected");
|
|
|
|
auto trailingData = bytes;
|
|
trailingData.push_back(0);
|
|
check(modelFileReadThrows(trailingData), "cooked models with trailing data must be rejected");
|
|
}
|
|
|
|
bool loadModelThrows(std::string_view reference, const ModelDoc::LoadOptions& options)
|
|
{
|
|
try {
|
|
(void)AssetManager::GetInstance().LoadModel(reference, options);
|
|
} catch (const std::exception&) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void testModelFileFromImportedModel()
|
|
{
|
|
const auto source = std::filesystem::path{DESTRUM_ENGINE_ASSETS_SRC_DIR} / "sphere.fbx";
|
|
const auto imported = ModelDoc::LoadModel(source.generic_string());
|
|
|
|
const auto path = std::filesystem::temp_directory_path() / "destrum_model_test.dmodel";
|
|
ModelFile::WriteToFile(imported, path);
|
|
const auto loaded = ModelFile::ReadFromFile(path);
|
|
std::filesystem::remove(path);
|
|
|
|
check(!imported.primitives.empty(), "the test model must import with at least one primitive");
|
|
check(loaded.primitives.size() == imported.primitives.size() &&
|
|
loaded.primitives.front().mesh.vertices.size() == imported.primitives.front().mesh.vertices.size() &&
|
|
loaded.primitives.front().mesh.indices == imported.primitives.front().mesh.indices,
|
|
"an imported model must survive being cooked");
|
|
check(ModelFile::Write(loaded) == ModelFile::Write(imported),
|
|
"a cooked imported model must match the import exactly");
|
|
}
|
|
|
|
void testAssetManagerLoadsCookedModels()
|
|
{
|
|
const auto root = std::filesystem::temp_directory_path() / "destrum_cooked_model_test";
|
|
const auto engineDir = root / "assets" / "engine";
|
|
std::filesystem::remove_all(root);
|
|
std::filesystem::create_directories(engineDir);
|
|
std::filesystem::create_directories(root / "assets" / "game");
|
|
|
|
// The source can't be imported, so a successful load has to come from the cooked file
|
|
std::ofstream{engineDir / "model.fbx"} << "not a model";
|
|
ModelFile::WriteToFile(makeTestModelAsset(), engineDir / "model.fbx.dmodel");
|
|
std::ofstream{engineDir / "manifest.json"}
|
|
<< R"({"version":1,"assets":[{"src":"model.fbx","out":"model.fbx.dmodel","type":"model","mtime_epoch_ns":0,"size_bytes":0}]})";
|
|
|
|
auto& assetFs = AssetFS::GetInstance();
|
|
assetFs.Init(root);
|
|
|
|
const auto model = AssetManager::GetInstance().LoadModel("engine://model.fbx");
|
|
check(model.primitives.size() == 1 && model.animations.size() == 1,
|
|
"cooked models must be loaded instead of importing the source");
|
|
check(model.sourcePath == "engine://model.fbx" &&
|
|
model.primitives.front().mesh.assetReference.path == "engine://model.fbx" &&
|
|
model.primitives.front().mesh.skeleton->assetReference.path == "engine://model.fbx",
|
|
"cooked models must be referenced by their virtual path");
|
|
check(model.materials.front().textures.front().path ==
|
|
std::filesystem::weakly_canonical(engineDir / "textures/skin.png").generic_string(),
|
|
"cooked texture paths must be resolved next to the cooked model");
|
|
|
|
ModelDoc::LoadOptions withoutExtras{};
|
|
withoutExtras.loadMaterials = false;
|
|
withoutExtras.loadAnimations = false;
|
|
const auto stripped = AssetManager::GetInstance().LoadModel("engine://model.fbx", withoutExtras);
|
|
check(stripped.materials.empty() &&
|
|
stripped.animations.empty() &&
|
|
stripped.primitives.front().materialIndex == ModelDoc::InvalidIndex,
|
|
"cooked models must drop what the load options didn't ask for");
|
|
|
|
ModelDoc::LoadOptions withoutSkeleton{};
|
|
withoutSkeleton.loadSkeleton = false;
|
|
check(loadModelThrows("engine://model.fbx", withoutSkeleton),
|
|
"skinned cooked models must be imported when loaded without a skeleton");
|
|
|
|
ModelDoc::LoadOptions otherImport{};
|
|
otherImport.flipUVs = false;
|
|
check(loadModelThrows("engine://model.fbx", otherImport),
|
|
"cooked models must be imported when the import options differ from the cook options");
|
|
|
|
assetFs.Reset();
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
TextureFile::Texture makeTestTexture()
|
|
{
|
|
const std::vector<float> rgba{
|
|
0.0f, 0.25f, 0.5f, 1.0f,
|
|
2.0f, 16.0f, 100.0f, 1.0f,
|
|
};
|
|
return TextureFile::FromRGBA32Float(2, 1, rgba);
|
|
}
|
|
|
|
bool textureFileReadThrows(const std::vector<std::uint8_t>& bytes)
|
|
{
|
|
try {
|
|
(void)TextureFile::Read(bytes);
|
|
} catch (const std::exception&) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void testTextureFileRoundTrip()
|
|
{
|
|
const auto texture = makeTestTexture();
|
|
check(texture.pixels.size() == 2 * TextureFile::BytesPerPixel(TextureFile::PixelFormat::RGBA16Float),
|
|
"cooked textures must store every pixel");
|
|
|
|
std::uint64_t secondPixel = 0;
|
|
std::memcpy(&secondPixel, texture.pixels.data() + 8, sizeof(secondPixel));
|
|
const glm::vec4 unpacked = glm::unpackHalf4x16(secondPixel);
|
|
check(unpacked == glm::vec4{2.0f, 16.0f, 100.0f, 1.0f},
|
|
"half float conversion must keep HDR values above 1");
|
|
|
|
const auto bytes = TextureFile::Write(texture);
|
|
const auto loaded = TextureFile::Read(bytes);
|
|
check(loaded.width == 2 && loaded.height == 1 &&
|
|
loaded.format == TextureFile::PixelFormat::RGBA16Float &&
|
|
loaded.pixels == texture.pixels,
|
|
"cooked textures must round-trip");
|
|
|
|
auto truncated = bytes;
|
|
truncated.resize(bytes.size() - 1);
|
|
check(textureFileReadThrows(truncated), "truncated cooked textures must be rejected");
|
|
|
|
auto wrongMagic = bytes;
|
|
wrongMagic[0] = 'X';
|
|
check(textureFileReadThrows(wrongMagic), "files that aren't cooked textures must be rejected");
|
|
|
|
auto wrongSize = bytes;
|
|
wrongSize[8] = 3;
|
|
check(textureFileReadThrows(wrongSize), "cooked textures whose size doesn't match their pixels must be rejected");
|
|
}
|
|
|
|
void testImageLoaderReadsCookedTextures()
|
|
{
|
|
const auto path = std::filesystem::temp_directory_path() / "destrum_texture_test.dtex";
|
|
const auto texture = makeTestTexture();
|
|
TextureFile::WriteToFile(texture, path);
|
|
|
|
const auto image = util::loadImage(path, TextureIntent::ColorSrgb);
|
|
std::filesystem::remove(path);
|
|
|
|
check(image.vkFormat == VK_FORMAT_R16G16B16A16_SFLOAT &&
|
|
image.width == 2 && image.height == 1 &&
|
|
image.byteSize == texture.pixels.size(),
|
|
"cooked textures must load as half float images");
|
|
check(image.pixels != nullptr &&
|
|
std::memcmp(image.pixels, texture.pixels.data(), texture.pixels.size()) == 0,
|
|
"cooked texture pixels must be uploaded as stored");
|
|
}
|
|
|
|
void testLdrTextureFile()
|
|
{
|
|
const std::vector<std::uint8_t> rgba{255, 0, 0, 255, 0, 128, 255, 64};
|
|
const auto texture = TextureFile::FromRGBA8(2, 1, rgba);
|
|
const auto loaded = TextureFile::Read(TextureFile::Write(texture));
|
|
check(loaded.format == TextureFile::PixelFormat::RGBA8 && loaded.pixels == rgba,
|
|
"8 bit cooked textures must round-trip");
|
|
|
|
const auto dir = std::filesystem::temp_directory_path() / "destrum_ldr_texture_test";
|
|
std::filesystem::create_directories(dir);
|
|
const auto source = dir / "missing_source.png";
|
|
auto cooked = source;
|
|
cooked += TextureFile::Extension;
|
|
TextureFile::WriteToFile(texture, cooked);
|
|
|
|
const auto color = util::loadImage(source, TextureIntent::ColorSrgb);
|
|
const auto data = util::loadImage(source, TextureIntent::DataLinear);
|
|
std::filesystem::remove_all(dir);
|
|
|
|
check(color.vkFormat == VK_FORMAT_R8G8B8A8_SRGB && data.vkFormat == VK_FORMAT_R8G8B8A8_UNORM,
|
|
"8 bit cooked textures must pick their color space from the intent");
|
|
check(color.width == 2 && color.height == 1 && color.pixels != nullptr &&
|
|
std::memcmp(color.pixels, rgba.data(), rgba.size()) == 0,
|
|
"loading a source image must use its cooked sibling");
|
|
}
|
|
|
|
bool packOpenThrows(const std::filesystem::path& path)
|
|
{
|
|
try {
|
|
(void)PackFile::Reader{path};
|
|
} catch (const std::exception&) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void testPackFileRoundTrip()
|
|
{
|
|
const auto dir = std::filesystem::temp_directory_path() / "destrum_pack_file_test";
|
|
std::filesystem::remove_all(dir);
|
|
std::filesystem::create_directories(dir);
|
|
std::ofstream{dir / "a.txt", std::ios::binary} << "hello";
|
|
std::ofstream{dir / "empty.txt", std::ios::binary};
|
|
|
|
const auto pack = dir / "test.dpak";
|
|
PackFile::Write({{"engine/a.txt", dir / "a.txt"}, {"game/empty.txt", dir / "empty.txt"}}, pack);
|
|
|
|
const PackFile::Reader reader{pack};
|
|
const PackFile::Entry* text = reader.Find("engine/a.txt");
|
|
const PackFile::Entry* empty = reader.Find("game/empty.txt");
|
|
check(reader.Entries().size() == 2 && text != nullptr && empty != nullptr && !reader.Find("a.txt"),
|
|
"packs must list every file by its full path");
|
|
const auto bytes = reader.Read(*text);
|
|
check(std::string(bytes.begin(), bytes.end()) == "hello" && reader.Read(*empty).empty(),
|
|
"packed files must read back as written");
|
|
|
|
bool duplicateRejected = false;
|
|
try {
|
|
PackFile::Write({{"engine/a.txt", dir / "a.txt"}, {"engine/a.txt", dir / "a.txt"}}, dir / "dupe.dpak");
|
|
} catch (const std::exception&) {
|
|
duplicateRejected = true;
|
|
}
|
|
check(duplicateRejected, "packs can't hold two files at one path");
|
|
|
|
std::ofstream{dir / "bad.dpak", std::ios::binary} << "DTEX not a pack";
|
|
check(packOpenThrows(dir / "bad.dpak"), "files that aren't packs must be rejected");
|
|
|
|
const auto size = std::filesystem::file_size(pack);
|
|
std::filesystem::resize_file(pack, size - 4);
|
|
check(packOpenThrows(pack), "truncated packs must be rejected");
|
|
|
|
std::filesystem::remove_all(dir);
|
|
}
|
|
|
|
void testAssetFSMountsPacks()
|
|
{
|
|
const auto root = std::filesystem::temp_directory_path() / "destrum_packed_assets_test";
|
|
const auto staging = root / "staging";
|
|
std::filesystem::remove_all(root);
|
|
std::filesystem::create_directories(staging);
|
|
|
|
std::ofstream{staging / "notes.txt", std::ios::binary} << "packed";
|
|
std::ofstream{staging / "notes.txt.meta", std::ios::binary} << R"({"guid":"0123456789abcdef0123456789abcdef"})";
|
|
std::ofstream{staging / "model.fbx", std::ios::binary} << "not a model";
|
|
ModelFile::WriteToFile(makeTestModelAsset(), staging / "model.fbx.dmodel");
|
|
const std::vector<std::uint8_t> rgba{255, 0, 0, 255};
|
|
TextureFile::WriteToFile(TextureFile::FromRGBA8(1, 1, rgba), staging / "tex.png.dtex");
|
|
std::ofstream{staging / "manifest.json", std::ios::binary}
|
|
<< R"({"version":2,"assets":[{"src":"model.fbx","out":"model.fbx.dmodel","type":"model"}]})";
|
|
|
|
PackFile::Write({
|
|
{"engine/manifest.json", staging / "manifest.json"},
|
|
{"engine/model.fbx", staging / "model.fbx"},
|
|
{"engine/model.fbx.dmodel", staging / "model.fbx.dmodel"},
|
|
{"engine/tex.png.dtex", staging / "tex.png.dtex"},
|
|
{"game/notes.txt", staging / "notes.txt"},
|
|
{"game/notes.txt.meta", staging / "notes.txt.meta"},
|
|
}, root / AssetFS::PackName);
|
|
|
|
// A loose copy next to the pack must not be picked up
|
|
std::filesystem::create_directories(root / "assets" / "game");
|
|
std::ofstream{root / "assets" / "game" / "notes.txt", std::ios::binary} << "loose";
|
|
std::ofstream{root / "assets" / "game" / "loose.txt", std::ios::binary} << "loose";
|
|
|
|
auto& assetFs = AssetFS::GetInstance();
|
|
assetFs.Init(root);
|
|
check(assetFs.IsPacked() && assetFs.GetMounts().size() == 2, "a pack next to the exe must mount every scheme in it");
|
|
|
|
const auto notes = assetFs.ReadBytes("game://notes.txt");
|
|
check(std::string(notes.begin(), notes.end()) == "packed", "packed mounts must read from the pack");
|
|
check(assetFs.GetFullPath("game://notes.txt") == (root / "assets" / "game" / "notes.txt").lexically_normal(),
|
|
"packed files must keep the full path they have unpacked");
|
|
check(assetFs.Exists(assetFs.GetFullPath("game://notes.txt")) &&
|
|
!assetFs.Exists(assetFs.GetFullPath("game://loose.txt")),
|
|
"only packed files exist under a packed mount");
|
|
check(assetFs.ToVirtualPath(assetFs.GetFullPath("engine://tex.png")) == "engine://tex.png",
|
|
"packed full paths must map back to virtual paths");
|
|
check(!assetFs.WriteFile("game://notes.txt", "changed"), "packed mounts are read only");
|
|
|
|
const auto model = AssetManager::GetInstance().LoadModel("engine://model.fbx");
|
|
check(model.primitives.size() == 1, "cooked models must load from the pack");
|
|
|
|
const auto image = util::loadImage(assetFs.GetFullPath("engine://tex.png"), TextureIntent::ColorSrgb);
|
|
check(image.width == 1 && image.pixels != nullptr && std::memcmp(image.pixels, rgba.data(), rgba.size()) == 0,
|
|
"images must find their cooked sibling in the pack");
|
|
|
|
auto& database = AssetDatabase::GetInstance();
|
|
database.Refresh();
|
|
check(database.FindPath(*AssetGuid::Parse("0123456789abcdef0123456789abcdef")) == "game://notes.txt",
|
|
"asset ids must be read from packed meta files");
|
|
database.Clear();
|
|
|
|
assetFs.Reset();
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
void testAssetReferenceCacheKeys()
|
|
{
|
|
const AssetReference reference{
|
|
.path = "game://CharacterMedium.fbx",
|
|
.subresource = "mesh:0",
|
|
};
|
|
|
|
const auto key = reference.cacheKey();
|
|
check(key == "game://CharacterMedium.fbx#mesh:0",
|
|
"asset references must produce stable cache keys");
|
|
|
|
const auto parsed = AssetReference::fromCacheKey(key);
|
|
check(parsed.has_value() &&
|
|
parsed->path == reference.path &&
|
|
parsed->subresource == reference.subresource,
|
|
"asset cache keys must recover their source reference");
|
|
check(!AssetReference::fromCacheKey("CharacterMedium.fbx").has_value(),
|
|
"plain cache names must not be treated as file-backed assets");
|
|
}
|
|
|
|
void testAssetGuids()
|
|
{
|
|
const AssetGuid first = AssetGuid::Generate();
|
|
const AssetGuid second = AssetGuid::Generate();
|
|
check(first.IsValid() && second.IsValid() && first != second, "generated asset ids must be valid and unique");
|
|
|
|
const std::string text = first.ToString();
|
|
check(text.size() == 32 && AssetGuid::Parse(text) == first, "asset ids must round-trip through their text form");
|
|
check(!AssetGuid::Parse("1234").has_value() && !AssetGuid::Parse(std::string(32, 'z')).has_value() &&
|
|
!AssetGuid::Parse(std::string(32, '0')).has_value(),
|
|
"malformed or empty asset ids must be rejected");
|
|
}
|
|
|
|
void testAssetDatabaseFollowsMovedAssets()
|
|
{
|
|
auto& database = AssetDatabase::GetInstance();
|
|
database.Clear();
|
|
|
|
const AssetGuid guid = AssetGuid::Generate();
|
|
database.Register("game://models/crate.glb", guid);
|
|
check(database.FindGuid("game://models/crate.glb") == guid && database.FindPath(guid) == "game://models/crate.glb",
|
|
"registered assets must be found by path and by id");
|
|
|
|
const nlohmann::json reference = database.WriteReference("game://models/crate.glb", "mesh:0");
|
|
check(reference.at("guid") == guid.ToString() && reference.at("sub") == "mesh:0",
|
|
"references to known assets must carry the id and subresource");
|
|
check(!database.WriteReference("game://unknown.png").contains("guid"),
|
|
"assets without a meta file must not get an id outside a save");
|
|
|
|
database.Clear();
|
|
database.Register("game://props/crate.glb", guid);
|
|
const auto moved = database.ReadReference(reference);
|
|
check(moved.has_value() && moved->path == "game://props/crate.glb" && moved->subresource == "mesh:0",
|
|
"references must follow their id when the asset moved");
|
|
|
|
const auto byPath = database.ReadReference(nlohmann::json{{"path", "game://other.png"}});
|
|
check(byPath.has_value() && byPath->path == "game://other.png", "references without a known id must keep their path");
|
|
check(!database.ReadReference(nlohmann::json{{"guid", guid.ToString()}}).has_value(),
|
|
"a reference needs a path to fall back on");
|
|
database.Clear();
|
|
}
|
|
|
|
void testAssetTypes()
|
|
{
|
|
check(AssetTypes::IsModel("game://a/B.GLB") && AssetTypes::IsImage("x.Png") && AssetTypes::IsMaterial("m.dmat") &&
|
|
AssetTypes::IsScene("game://scenes/main.scene") && AssetTypes::IsPrefab("p.prefab") && AssetTypes::IsMeta("a.glb.meta"),
|
|
"asset types must be recognised by extension, ignoring case");
|
|
check(!AssetTypes::IsModel("model.glb.meta") && !AssetTypes::IsScene("scene.json"),
|
|
"only the last extension decides the asset type");
|
|
check(MaterialSerialization::IsFileKey("game://materials/a.dmat") && !MaterialSerialization::IsFileKey("local:3:1") &&
|
|
!MaterialSerialization::IsFileKey("game://a.glb#material:0"),
|
|
"only .dmat paths are material files");
|
|
}
|
|
|
|
void testMainSceneFileIsValid()
|
|
{
|
|
std::ifstream file{std::filesystem::path{LIGHTKEEPER_ASSETS_SRC_DIR} / "scenes" / "main.scene"};
|
|
const auto root = nlohmann::json::parse(file, nullptr, false);
|
|
check(!root.is_discarded() && root.at("version") == ObjectSerialization::CurrentObjectVersion &&
|
|
root.at("objects").size() > 100,
|
|
"the main scene must be saved in the current format with the whole level");
|
|
check(!MaterialSerialization::Validate(root.at("materials")).has_value(),
|
|
"the main scene's local materials must be valid");
|
|
}
|
|
}
|
|
|
|
void RunAssetsTests()
|
|
{
|
|
testAssetPathValidation();
|
|
testAnimatorAssetReferences();
|
|
testModelFileRoundTrip();
|
|
testModelFileRejectsBadData();
|
|
testModelFileFromImportedModel();
|
|
testAssetManagerLoadsCookedModels();
|
|
testTextureFileRoundTrip();
|
|
testImageLoaderReadsCookedTextures();
|
|
testLdrTextureFile();
|
|
testPackFileRoundTrip();
|
|
testAssetFSMountsPacks();
|
|
testAssetReferenceCacheKeys();
|
|
testAssetGuids();
|
|
testAssetDatabaseFollowsMovedAssets();
|
|
testAssetTypes();
|
|
testMainSceneFileIsValid();
|
|
}
|