#include #include #include #include #include #include #include #include #include #include namespace faset::assets { namespace fs = std::filesystem; namespace { void valid_id(const std::string& id) { if (id.empty() || id.size() > 128 || !std::all_of(id.begin(), id.end(), [](unsigned char c) { return std::isalnum(c) || c == '-' || c == '_'; })) throw std::runtime_error("Invalid AssetId"); } std::vector read_bytes(const fs::path& path) { std::ifstream file(faset::native_io_path(path), std::ios::binary | std::ios::ate); if (!file) throw std::runtime_error("Cannot read cooked file: " + faset::path_to_utf8(path)); auto length = file.tellg(); if (length < 0 || static_cast(length) > 1024ull * 1024 * 1024) throw std::runtime_error("Cooked file exceeds 1 GiB limit"); std::vector bytes(static_cast(length)); file.seekg(0); if (!bytes.empty() && !file.read(reinterpret_cast(bytes.data()), static_cast(bytes.size()))) throw std::runtime_error("Truncated cooked file"); return bytes; } Json read_json(const fs::path& path) { auto bytes = read_bytes(path); if (bytes.empty()) throw std::runtime_error("Empty cooked JSON manifest"); return Json::parse(reinterpret_cast(bytes.data()), reinterpret_cast(bytes.data() + bytes.size())); } fs::path cooked_path(const fs::path& directory, const std::string& name) { if (name.empty()) throw std::runtime_error("Empty cooked path"); return faset::project_path(directory, faset::path_from_utf8(name)); } void validate_generation(const fs::path& directory, const Json& manifest) { if (manifest.at("schema_version") != 1) throw std::runtime_error("Unsupported asset manifest version"); std::set files; for (const auto& file : manifest.at("files")) { auto name = file.at("path").get(); if (!files.insert(name).second) throw std::runtime_error("Duplicate cooked file path"); auto bytes = read_bytes(cooked_path(directory, name)); if (bytes.size() != file.at("size").get() || faset::sha256(std::span(bytes)) != file.at("sha256").get()) throw std::runtime_error("Corrupt cooked file: " + name); } for (const auto& mesh : manifest.at("meshes")) for (const auto& primitive : mesh.at("primitives")) if (!files.contains(primitive.at("path").get())) throw std::runtime_error("Mesh payload is missing from its manifest"); for (const auto& texture : manifest.at("textures")) if (!files.contains(texture.at("path").get())) throw std::runtime_error("Texture payload is missing from its manifest"); } struct BinaryReader { const std::vector& bytes; std::size_t cursor = 0; std::uint32_t u32() { if (bytes.size() - cursor < 4) throw std::runtime_error("Truncated cooked mesh"); std::uint32_t v = 0; for (int i = 0; i < 4; ++i) v |= std::to_integer(bytes[cursor++]) << (8 * i); return v; } float number() { auto v = std::bit_cast(u32()); if (!std::isfinite(v)) throw std::runtime_error("Invalid cooked float"); return v; } }; Primitive decode_primitive(const std::vector& bytes, int material) { BinaryReader in{bytes}; if (in.u32() != 0x48534d46 || in.u32() != 1) throw std::runtime_error("Unsupported cooked mesh format"); const auto nv = in.u32(), ni = in.u32(); if (static_cast(nv) * 32 + static_cast(ni) * 4 + 16 != bytes.size()) throw std::runtime_error("Invalid cooked mesh size"); Primitive p; p.material = material; p.vertices.resize(nv); p.indices.resize(ni); for (auto& v : p.vertices) { for (auto& x : v.position) x = in.number(); for (auto& x : v.normal) x = in.number(); for (auto& x : v.uv) x = in.number(); } for (auto& i : p.indices) { i = in.u32(); if (i >= nv) throw std::runtime_error("Cooked mesh index out of range"); } return p; } } // namespace AssetStore::AssetStore(fs::path root) : cache_root_(fs::absolute(std::move(root)).lexically_normal()) {} fs::path AssetStore::generation_directory(const std::string& id) const { valid_id(id); const auto root = faset::project_path(cache_root_, fs::path("assets") / id); const auto pointer = read_json(root / "current.json"); const auto generation = pointer.at("generation").get(); valid_id(generation); return faset::project_path(root, fs::path("generations") / generation); } Json AssetStore::current_manifest(const std::string& id) const { valid_id(id); const auto root = faset::project_path(cache_root_, fs::path("assets") / id); const auto pointer = read_json(root / "current.json"); const auto generation = pointer.at("generation").get(); valid_id(generation); auto manifest = read_json( faset::project_path(root, fs::path("generations") / generation / "manifest.json")); if (manifest.at("asset_id").get() != id || manifest.at("generation").get() != generation) throw std::runtime_error("Cooked manifest identity does not match its generation"); // Locations belong to the active pointer, not the immutable content recipe: // moving sources can reuse an existing generation whose paths are historical. // One pointer snapshot keeps logical source and payload in the same publication. if (pointer.contains("payload_source")) manifest["payload_source"] = pointer.at("payload_source"); else { // Earlier pointers only stored source. Ordinary files are their own // payload; a bundle payload keeps its path relative to the manifest. const auto previous_source = faset::path_from_utf8(manifest.at("source").get()); const auto previous_payload = faset::path_from_utf8( manifest.value("payload_source", faset::path_to_utf8(previous_source))); const auto current_source = faset::path_from_utf8(pointer.at("source").get()); const auto current_payload = previous_payload == previous_source ? current_source : (current_source.parent_path() / previous_payload.lexically_relative(previous_source.parent_path())) .lexically_normal(); manifest["payload_source"] = faset::path_to_utf8(current_payload); } manifest["source"] = pointer.at("source"); return manifest; } CookedAsset AssetStore::load_asset(const std::string& id) const { const auto directory = generation_directory(id); const auto m = read_json(directory / "manifest.json"); validate_generation(directory, m); if (m.at("asset_id").get() != id || m.at("generation").get() != faset::path_to_utf8(directory.filename())) throw std::runtime_error("Cooked asset identity does not match its generation"); CookedAsset asset; asset.asset_id = m.at("asset_id"); asset.generation = m.at("generation"); for (const auto& n : m.at("nodes")) { Node node; node.id = n.at("id"); node.name = n.at("name"); node.parent_id = n.at("parent_id"); node.mesh = n.at("mesh"); node.local_transform = n.at("local_transform").get>(); node.stable_source_id = n.at("stable_source_id"); asset.nodes.push_back(std::move(node)); } for (const auto& j : m.at("meshes")) { Mesh mesh; mesh.id = j.at("id"); mesh.name = j.at("name"); for (const auto& primitive : j.at("primitives")) mesh.primitives.push_back(decode_primitive( read_bytes(cooked_path(directory, primitive.at("path").get())), primitive.at("material"))); asset.meshes.push_back(std::move(mesh)); } for (const auto& j : m.at("materials")) { // Early development manifests embedded v1 materials without a tag. // Preserve readability, but never interpret an explicitly unknown version. if (j.value("format", "faset.material") != "faset.material" || j.value("version", 1) != 1) throw std::runtime_error("Unsupported cooked material format or version"); Material material; material.id = j.at("id"); material.name = j.at("name"); material.base_color = j.at("base_color").get>(); material.emissive = j.at("emissive").get>(); material.metallic = j.at("metallic"); material.roughness = j.at("roughness"); material.alpha_mode = j.at("alpha_mode"); material.alpha_cutoff = j.at("alpha_cutoff"); material.double_sided = j.at("double_sided"); material.unlit = j.at("unlit"); material.base_color_texture = j.at("base_color_texture"); material.metallic_roughness_texture = j.at("metallic_roughness_texture"); material.normal_texture = j.at("normal_texture"); material.occlusion_texture = j.at("occlusion_texture"); material.emissive_texture = j.at("emissive_texture"); asset.materials.push_back(std::move(material)); } for (const auto& j : m.at("textures")) { Texture texture; texture.id = j.at("id"); texture.name = j.at("name"); texture.mime_type = j.at("mime_type"); texture.bytes = read_bytes(cooked_path(directory, j.at("path").get())); texture.wrap_s = j.at("wrap_s"); texture.wrap_t = j.at("wrap_t"); texture.min_filter = j.at("min_filter"); texture.mag_filter = j.at("mag_filter"); asset.textures.push_back(std::move(texture)); } return asset; } } // namespace faset::assets