Checkpoint 2: integrate native Editor, MCP, gameplay builds and standalone export

This commit is contained in:
Emil
2026-09-18 03:40:15 +03:00
parent 5c6b24d34d
commit 999686a896
125 changed files with 16086 additions and 1714 deletions
+259 -55
View File
@@ -1,73 +1,277 @@
#include <faset/assets/asset_pipeline.hpp>
#include <faset/core/hash.hpp>
#include <bit>
#include <chrono>
#include <faset/assets/asset_pipeline.hpp>
#include <faset/core/hash.hpp>
#include <fstream>
#include <iostream>
#include <stdexcept>
using namespace faset::assets;
namespace fs=std::filesystem;
namespace fs = std::filesystem;
namespace {
void require(bool value,const std::string& message){if(!value)throw std::runtime_error(message);}
void u32(std::vector<unsigned char>& data,std::uint32_t value){for(int i=0;i<4;++i)data.push_back(static_cast<unsigned char>(value>>(8*i)));}
void save(const fs::path& file,const std::vector<unsigned char>& data){std::ofstream out(file,std::ios::binary);out.write(reinterpret_cast<const char*>(data.data()),static_cast<std::streamsize>(data.size()));}
void save(const fs::path& file,const std::string& text){std::ofstream(file,std::ios::binary)<<text;}
std::vector<unsigned char> geometry(float x){std::vector<unsigned char> bin;for(float f:{x,0.f,0.f,1.f,0.f,0.f,0.f,1.f,0.f})u32(bin,std::bit_cast<std::uint32_t>(f));for(unsigned char c:{0,0,1,0,2,0})bin.push_back(c);return bin;}
Json document(const std::string& name,bool stable,bool second,float x=0) {
Json node{{"name",name},{"mesh",0}};if(stable)node["extras"]={{"faset_id","node-door"}};
Json j={{"asset",{{"version","2.0"}}},{"scene",0},{"scenes",Json::array({{{"nodes",Json::array({0})}}})},{"nodes",Json::array({node})},{"buffers",Json::array({{{"byteLength",42}}})},{"bufferViews",Json::array({{{"buffer",0},{"byteOffset",0},{"byteLength",36},{"target",34962}},{{"buffer",0},{"byteOffset",36},{"byteLength",6},{"target",34963}}})},{"accessors",Json::array({{{"bufferView",0},{"componentType",5126},{"count",3},{"type","VEC3"},{"min",{std::min(0.f,x),0,0}},{"max",{std::max(1.f,x),1,0}}},{{"bufferView",1},{"componentType",5123},{"count",3},{"type","SCALAR"}}})},{"meshes",Json::array({{{"name","Triangle"},{"extras",{{"faset_id","mesh-triangle"}}},{"primitives",Json::array({{{"attributes",{{"POSITION",0}}},{"indices",1},{"material",0}}})}}})},{"materials",Json::array({{{"name","Red"},{"pbrMetallicRoughness",{{"baseColorFactor",{1.0,0.2,0.1,1.0}},{"metallicFactor",0.2},{"roughnessFactor",0.6}}}}})}};
if(second){j["nodes"].push_back({{"name","Handle"},{"mesh",0},{"extras",{{"faset_id","node-handle"}}}});j["scenes"][0]["nodes"].push_back(1);}
void require(bool value, const std::string& message) {
if (!value)
throw std::runtime_error(message);
}
void u32(std::vector<unsigned char>& data, std::uint32_t value) {
for (int i = 0; i < 4; ++i)
data.push_back(static_cast<unsigned char>(value >> (8 * i)));
}
void save(const fs::path& file, const std::vector<unsigned char>& data) {
std::ofstream out(file, std::ios::binary);
out.write(reinterpret_cast<const char*>(data.data()),
static_cast<std::streamsize>(data.size()));
}
void save(const fs::path& file, const std::string& text) {
std::ofstream(file, std::ios::binary) << text;
}
std::vector<unsigned char> geometry(float x) {
std::vector<unsigned char> bin;
for (float f : {x, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 1.f, 0.f})
u32(bin, std::bit_cast<std::uint32_t>(f));
for (unsigned char c : {0, 0, 1, 0, 2, 0})
bin.push_back(c);
return bin;
}
Json document(const std::string& name, bool stable, bool second, float x = 0) {
Json node{{"name", name}, {"mesh", 0}};
if (stable)
node["extras"] = {{"faset_id", "node-door"}};
Json j = {
{"asset", {{"version", "2.0"}}},
{"scene", 0},
{"scenes", Json::array({{{"nodes", Json::array({0})}}})},
{"nodes", Json::array({node})},
{"buffers", Json::array({{{"byteLength", 42}}})},
{"bufferViews",
Json::array({{{"buffer", 0}, {"byteOffset", 0}, {"byteLength", 36}, {"target", 34962}},
{{"buffer", 0}, {"byteOffset", 36}, {"byteLength", 6}, {"target", 34963}}})},
{"accessors",
Json::array(
{{{"bufferView", 0},
{"componentType", 5126},
{"count", 3},
{"type", "VEC3"},
{"min", {std::min(0.f, x), 0, 0}},
{"max", {std::max(1.f, x), 1, 0}}},
{{"bufferView", 1}, {"componentType", 5123}, {"count", 3}, {"type", "SCALAR"}}})},
{"meshes", Json::array({{{"name", "Triangle"},
{"extras", {{"faset_id", "mesh-triangle"}}},
{"primitives", Json::array({{{"attributes", {{"POSITION", 0}}},
{"indices", 1},
{"material", 0}}})}}})},
{"materials", Json::array({{{"name", "Red"},
{"pbrMetallicRoughness",
{{"baseColorFactor", {1.0, 0.2, 0.1, 1.0}},
{"metallicFactor", 0.2},
{"roughnessFactor", 0.6}}}}})}};
if (second) {
j["nodes"].push_back(
{{"name", "Handle"}, {"mesh", 0}, {"extras", {{"faset_id", "node-handle"}}}});
j["scenes"][0]["nodes"].push_back(1);
}
return j;
}
void glb(const fs::path& path,const std::string& name="Door",bool stable=true,bool second=false,float x=0) {
auto json=document(name,stable,second,x).dump();while(json.size()%4)json+=' ';
auto binary=geometry(x);while(binary.size()%4)binary.push_back(0);
std::vector<unsigned char> result;u32(result,0x46546c67);u32(result,2);u32(result,static_cast<std::uint32_t>(12+8+json.size()+8+binary.size()));u32(result,static_cast<std::uint32_t>(json.size()));u32(result,0x4e4f534a);result.insert(result.end(),json.begin(),json.end());u32(result,static_cast<std::uint32_t>(binary.size()));u32(result,0x004e4942);result.insert(result.end(),binary.begin(),binary.end());save(path,result);
void glb(const fs::path& path, const std::string& name = "Door", bool stable = true,
bool second = false, float x = 0) {
auto json = document(name, stable, second, x).dump();
while (json.size() % 4)
json += ' ';
auto binary = geometry(x);
while (binary.size() % 4)
binary.push_back(0);
std::vector<unsigned char> result;
u32(result, 0x46546c67);
u32(result, 2);
u32(result, static_cast<std::uint32_t>(12 + 8 + json.size() + 8 + binary.size()));
u32(result, static_cast<std::uint32_t>(json.size()));
u32(result, 0x4e4f534a);
result.insert(result.end(), json.begin(), json.end());
u32(result, static_cast<std::uint32_t>(binary.size()));
u32(result, 0x004e4942);
result.insert(result.end(), binary.begin(), binary.end());
save(path, result);
}
void success(const ImportResult& result){if(!result.ok()){std::string text="Import failed: ";for(const auto& d:result.diagnostics)text+=d+"; ";throw std::runtime_error(text);}}
void success(const ImportResult& result) {
if (!result.ok()) {
std::string text = "Import failed: ";
for (const auto& d : result.diagnostics)
text += d + "; ";
throw std::runtime_error(text);
}
}
} // namespace
int main() {
const auto root=fs::temp_directory_path()/("faset-assets-test-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
const auto root = fs::temp_directory_path() /
("faset-assets-test-" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
fs::create_directories(root);
try {
AssetPipeline pipeline(root/"cache");const auto source=root/"door.glb";glb(source);
auto first=pipeline.import_asset({source});success(first);require(!first.asset_id.empty(),"persistent identity missing");
auto asset=pipeline.load_asset(first.asset_id);require(asset.meshes.size()==1&&asset.nodes.size()==1,"mesh/node extraction");require(asset.meshes[0].primitives[0].indices==std::vector<std::uint32_t>({0,1,2}),"index extraction");require(asset.meshes[0].primitives[0].vertices[0].normal[2]==1,"generated normal");require(asset.materials[0].base_color[1]>.19f&&asset.materials[0].metallic==.2f,"PBR extraction");
const auto node_id=asset.nodes[0].id;
const Json custom{{node_id,{{"gameplay",{{"locked",true}}},{"physics",{{"mass",12}}},{"material","custom-brass"}}}};
pipeline.set_overrides(first.asset_id,custom);
auto unchanged=pipeline.import_asset({source});success(unchanged);require(unchanged.cache_hit&&unchanged.generation==first.generation,"content cache hit");
glb(source,"Renamed panel",true,false,.25f);auto modified=pipeline.import_asset({source});success(modified);require(modified.asset_id==first.asset_id&&modified.generation!=first.generation,"stable asset identity and changed generation");
asset=pipeline.load_asset(first.asset_id);require(asset.nodes[0].id==node_id&&asset.nodes[0].name=="Renamed panel","faset_id survives rename");require(asset.meshes[0].primitives[0].vertices[0].position[0]==.25f,"new binary geometry loaded");require(pipeline.overrides(first.asset_id)==custom,"reimport erased authoring overrides");
glb(source,"Renamed panel",true,true,.25f);auto added=pipeline.import_asset({source});success(added);
glb(source,"Renamed panel",true,false,.25f);auto removed=pipeline.import_asset({source});require(removed.status==ImportStatus::conflict&&!removed.removed_output_ids.empty(),"deletion must conflict");require(pipeline.load_asset(first.asset_id).generation==added.generation,"conflict replaced active generation");require(pipeline.overrides(first.asset_id)==custom,"conflict erased overrides");
ImportRequest resolve{source};resolve.allow_removed_outputs=true;auto resolved=pipeline.import_asset(resolve);success(resolved);require(pipeline.load_asset(first.asset_id).nodes.size()==1,"explicit deletion resolution");
const auto active=resolved.generation;
save(source,std::string("not a GLB"));auto failed=pipeline.import_asset({source});require(failed.status==ImportStatus::failed,"invalid source accepted");require(pipeline.load_asset(first.asset_id).generation==active,"failed import replaced active");
glb(source,"Renamed panel",true,false,.5f);ImportJob* job_ptr=nullptr;ImportJob job([&](const ImportProgress& p){if(p.fraction>=.9f)job_ptr->cancel();});job_ptr=&job;
auto cancelled=pipeline.import_asset({source},job);require(cancelled.status==ImportStatus::cancelled,"cancel before commit failed");require(pipeline.load_asset(first.asset_id).generation==active,"cancel replaced active");
ImportRequest changed_settings{source};changed_settings.settings={{"target","test-profile"}};auto settings=pipeline.import_asset(changed_settings);success(settings);require(settings.generation!=active,"recipe omitted from cache key");
const auto plain=root/"ordinary.glb";glb(plain,"Ordinary",false);auto standard=pipeline.import_asset({plain});success(standard);require(!pipeline.load_asset(standard.asset_id).nodes[0].stable_source_id,"ordinary GLB wrongly marked stable source");
glb(plain,"Renamed without ID",false);require(pipeline.import_asset({plain}).status==ImportStatus::conflict,"ambiguous rename silently matched");
auto duplicate=document("Duplicate",true,true);duplicate["nodes"][1]["extras"]["faset_id"]="node-door";duplicate["buffers"][0]["uri"]="mesh.bin";save(root/"mesh.bin",geometry(0));save(root/"duplicate.gltf",duplicate.dump());require(pipeline.import_asset({root/"duplicate.gltf"}).status==ImportStatus::failed,"duplicate source ID accepted");
auto external=document("External",true,false);external["buffers"][0]["uri"]="mesh.bin";external["images"]=Json::array({{{"uri","pixel.png"},{"mimeType","image/png"}}});external["textures"]=Json::array({{{"source",0}}});external["materials"][0]["pbrMetallicRoughness"]["baseColorTexture"]={{"index",0}};
AssetPipeline pipeline(root / "cache");
const auto source = root / "door.glb";
glb(source);
auto first = pipeline.import_asset({source});
success(first);
require(!first.asset_id.empty(), "persistent identity missing");
auto asset = pipeline.load_asset(first.asset_id);
require(asset.meshes.size() == 1 && asset.nodes.size() == 1, "mesh/node extraction");
require(asset.meshes[0].primitives[0].indices == std::vector<std::uint32_t>({0, 1, 2}),
"index extraction");
require(asset.meshes[0].primitives[0].vertices[0].normal[2] == 1, "generated normal");
require(asset.materials[0].base_color[1] > .19f && asset.materials[0].metallic == .2f,
"PBR extraction");
const auto node_id = asset.nodes[0].id;
const Json custom{{node_id,
{{"gameplay", {{"locked", true}}},
{"physics", {{"mass", 12}}},
{"material", "custom-brass"}}}};
pipeline.set_overrides(first.asset_id, custom);
auto unchanged = pipeline.import_asset({source});
success(unchanged);
require(unchanged.cache_hit && unchanged.generation == first.generation,
"content cache hit");
glb(source, "Renamed panel", true, false, .25f);
auto modified = pipeline.import_asset({source});
success(modified);
require(modified.asset_id == first.asset_id && modified.generation != first.generation,
"stable asset identity and changed generation");
asset = pipeline.load_asset(first.asset_id);
require(asset.nodes[0].id == node_id && asset.nodes[0].name == "Renamed panel",
"faset_id survives rename");
require(asset.meshes[0].primitives[0].vertices[0].position[0] == .25f,
"new binary geometry loaded");
require(pipeline.overrides(first.asset_id) == custom,
"reimport erased authoring overrides");
glb(source, "Renamed panel", true, true, .25f);
auto added = pipeline.import_asset({source});
success(added);
glb(source, "Renamed panel", true, false, .25f);
auto removed = pipeline.import_asset({source});
require(removed.status == ImportStatus::conflict && !removed.removed_output_ids.empty(),
"deletion must conflict");
require(pipeline.load_asset(first.asset_id).generation == added.generation,
"conflict replaced active generation");
require(pipeline.overrides(first.asset_id) == custom, "conflict erased overrides");
ImportRequest resolve{source};
resolve.allow_removed_outputs = true;
auto resolved = pipeline.import_asset(resolve);
success(resolved);
require(pipeline.load_asset(first.asset_id).nodes.size() == 1,
"explicit deletion resolution");
const auto active = resolved.generation;
save(source, std::string("not a GLB"));
auto failed = pipeline.import_asset({source});
require(failed.status == ImportStatus::failed, "invalid source accepted");
require(pipeline.load_asset(first.asset_id).generation == active,
"failed import replaced active");
glb(source, "Renamed panel", true, false, .5f);
ImportJob* job_ptr = nullptr;
ImportJob job([&](const ImportProgress& p) {
if (p.fraction >= .9f)
job_ptr->cancel();
});
job_ptr = &job;
auto cancelled = pipeline.import_asset({source}, job);
require(cancelled.status == ImportStatus::cancelled, "cancel before commit failed");
require(pipeline.load_asset(first.asset_id).generation == active, "cancel replaced active");
ImportRequest changed_settings{source};
changed_settings.settings = {{"target", "test-profile"}};
auto settings = pipeline.import_asset(changed_settings);
success(settings);
require(settings.generation != active, "recipe omitted from cache key");
const auto plain = root / "ordinary.glb";
glb(plain, "Ordinary", false);
auto standard = pipeline.import_asset({plain});
success(standard);
require(!pipeline.load_asset(standard.asset_id).nodes[0].stable_source_id,
"ordinary GLB wrongly marked stable source");
glb(plain, "Renamed without ID", false);
require(pipeline.import_asset({plain}).status == ImportStatus::conflict,
"ambiguous rename silently matched");
auto duplicate = document("Duplicate", true, true);
duplicate["nodes"][1]["extras"]["faset_id"] = "node-door";
duplicate["buffers"][0]["uri"] = "mesh.bin";
save(root / "mesh.bin", geometry(0));
save(root / "duplicate.gltf", duplicate.dump());
require(pipeline.import_asset({root / "duplicate.gltf"}).status == ImportStatus::failed,
"duplicate source ID accepted");
auto external = document("External", true, false);
external["buffers"][0]["uri"] = "mesh.bin";
external["images"] = Json::array({{{"uri", "pixel.png"}, {"mimeType", "image/png"}}});
external["textures"] = Json::array({{{"source", 0}}});
external["materials"][0]["pbrMetallicRoughness"]["baseColorTexture"] = {{"index", 0}};
// Real 1x1 PNG payload, transparent pixel; importer owns encoded bytes.
const std::vector<unsigned char> png={137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,11,73,68,65,84,120,156,99,96,0,2,0,0,5,0,1,165,246,69,64,0,0,0,0,73,69,78,68,174,66,96,130};
save(root/"pixel.png",png);save(root/"external.gltf",external.dump());auto ext=pipeline.import_asset({root/"external.gltf"});success(ext);auto ext_asset=pipeline.load_asset(ext.asset_id);require(ext_asset.textures.size()==1&&ext_asset.textures[0].bytes.size()==png.size(),"external image not extracted");require(ext_asset.materials[0].base_color_texture==0,"material texture reference lost");
save(root/"mesh.bin",geometry(.3f));auto dependent=pipeline.import_asset({root/"external.gltf"});success(dependent);require(dependent.generation!=ext.generation,"buffer dependency not invalidated");
const std::vector<unsigned char> png = {
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0,
0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0,
0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0,
1, 165, 246, 69, 64, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130};
save(root / "pixel.png", png);
save(root / "external.gltf", external.dump());
auto ext = pipeline.import_asset({root / "external.gltf"});
success(ext);
auto ext_asset = pipeline.load_asset(ext.asset_id);
require(ext_asset.textures.size() == 1 && ext_asset.textures[0].bytes.size() == png.size(),
"external image not extracted");
require(ext_asset.materials[0].base_color_texture == 0, "material texture reference lost");
save(root / "mesh.bin", geometry(.3f));
auto dependent = pipeline.import_asset({root / "external.gltf"});
success(dependent);
require(dependent.generation != ext.generation, "buffer dependency not invalidated");
// A Blender bundle keeps its logical source stable while immutable payload paths change.
const auto bundle_dir=root/"bundle";fs::create_directories(bundle_dir/"payload");
glb(bundle_dir/"payload"/"first.glb","Bundle",true,false);
Json bundle{{"schema_version",1},{"asset_id","bundle-asset"},{"files",Json::array({{{"path","payload/first.glb"},{"sha256",faset::sha256_file(bundle_dir/"payload"/"first.glb")}}})}};
save(bundle_dir/"manifest.json",bundle.dump());auto bundle_first=pipeline.import_asset({bundle_dir/"manifest.json"});success(bundle_first);require(bundle_first.asset_id=="bundle-asset","bundle identity lost");
glb(bundle_dir/"payload"/"second.glb","Bundle renamed",true,false,.4f);bundle["files"][0]={{"path","payload/second.glb"},{"sha256",faset::sha256_file(bundle_dir/"payload"/"second.glb")}};
save(bundle_dir/"manifest.json",bundle.dump());auto bundle_second=pipeline.import_asset({bundle_dir/"manifest.json"});success(bundle_second);require(bundle_second.asset_id==bundle_first.asset_id&&bundle_second.generation!=bundle_first.generation,"bundle reimport identity/generation");
bundle["files"][0]["sha256"]=std::string(64,'0');save(bundle_dir/"manifest.json",bundle.dump());require(pipeline.import_asset({bundle_dir/"manifest.json"}).status==ImportStatus::failed,"bundle checksum ignored");require(pipeline.load_asset("bundle-asset").generation==bundle_second.generation,"bad bundle replaced active");
const auto bundle_dir = root / "bundle";
fs::create_directories(bundle_dir / "payload");
glb(bundle_dir / "payload" / "first.glb", "Bundle", true, false);
Json bundle{{"schema_version", 1},
{"asset_id", "bundle-asset"},
{"files", Json::array({{{"path", "payload/first.glb"},
{"sha256", faset::sha256_file(bundle_dir / "payload" /
"first.glb")}}})}};
save(bundle_dir / "manifest.json", bundle.dump());
auto bundle_first = pipeline.import_asset({bundle_dir / "manifest.json"});
success(bundle_first);
require(bundle_first.asset_id == "bundle-asset", "bundle identity lost");
glb(bundle_dir / "payload" / "second.glb", "Bundle renamed", true, false, .4f);
bundle["files"][0] = {
{"path", "payload/second.glb"},
{"sha256", faset::sha256_file(bundle_dir / "payload" / "second.glb")}};
save(bundle_dir / "manifest.json", bundle.dump());
auto bundle_second = pipeline.import_asset({bundle_dir / "manifest.json"});
success(bundle_second);
require(bundle_second.asset_id == bundle_first.asset_id &&
bundle_second.generation != bundle_first.generation,
"bundle reimport identity/generation");
bundle["files"][0]["sha256"] = std::string(64, '0');
save(bundle_dir / "manifest.json", bundle.dump());
require(pipeline.import_asset({bundle_dir / "manifest.json"}).status ==
ImportStatus::failed,
"bundle checksum ignored");
require(pipeline.load_asset("bundle-asset").generation == bundle_second.generation,
"bad bundle replaced active");
// Changing a dependency after it was snapshotted cannot publish mixed content.
const auto dependency_active=pipeline.load_asset(ext.asset_id).generation;
ImportJob mutate([&](const ImportProgress& progress){if(progress.fraction==.5f)save(root/"mesh.bin",geometry(.7f));});
require(pipeline.import_asset({root/"external.gltf"},mutate).status==ImportStatus::failed,"concurrent dependency edit accepted");require(pipeline.load_asset(ext.asset_id).generation==dependency_active,"concurrent edit changed active");
fs::remove_all(root/"cache");auto restored=pipeline.import_asset({source});success(restored);require(restored.asset_id==first.asset_id,"cleared cache changed AssetId");require(restored.manifest["settings"]==changed_settings.settings,"persisted recipe lost");require(pipeline.overrides(first.asset_id)==custom,"cleared cache lost authoring overrides");
fs::remove_all(root);std::cout<<"assets: geometry/PBR/texture, GLB/glTF, cache, rename, deletion, overrides, failure, cancellation OK\n";return 0;
} catch(const std::exception& e){std::cerr<<e.what()<<"\nFixtures retained: "<<root<<'\n';return 1;}
const auto dependency_active = pipeline.load_asset(ext.asset_id).generation;
ImportJob mutate([&](const ImportProgress& progress) {
if (progress.fraction == .5f)
save(root / "mesh.bin", geometry(.7f));
});
require(pipeline.import_asset({root / "external.gltf"}, mutate).status ==
ImportStatus::failed,
"concurrent dependency edit accepted");
require(pipeline.load_asset(ext.asset_id).generation == dependency_active,
"concurrent edit changed active");
fs::remove_all(root / "cache");
auto restored = pipeline.import_asset({source});
success(restored);
require(restored.asset_id == first.asset_id, "cleared cache changed AssetId");
require(restored.manifest["settings"] == changed_settings.settings,
"persisted recipe lost");
require(pipeline.overrides(first.asset_id) == custom,
"cleared cache lost authoring overrides");
fs::remove_all(root);
std::cout << "assets: geometry/PBR/texture, GLB/glTF, cache, rename, deletion, overrides, "
"failure, cancellation OK\n";
return 0;
} catch (const std::exception& e) {
std::cerr << e.what() << "\nFixtures retained: " << root << '\n';
return 1;
}
}