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;
}
}
+166 -46
View File
@@ -1,56 +1,176 @@
#include <faset/authoring/service.hpp>
#include <faset/authoring/templates.hpp>
#include <faset/authoring/transforms.hpp>
#include <faset/core/io.hpp>
#include <iostream>
#define CHECK(x) do {if(!(x))throw std::runtime_error("Check failed at line "+std::to_string(__LINE__)+": " #x);}while(false)
template<class Fn> void fails(Fn fn,const std::string& code) {try{fn();}catch(const faset::Error& error){CHECK(error.code()==code);return;}throw std::runtime_error("Expected error "+code);}
int main() {
using namespace faset;using namespace faset::authoring;
const auto root=std::filesystem::temp_directory_path()/("faset-authoring-"+new_id());
#define CHECK(x) \
do { \
if (!(x)) \
throw std::runtime_error("Check failed at line " + std::to_string(__LINE__) + \
": " #x); \
} while (false)
template <class Fn> void fails(Fn fn, const std::string& code) {
try {
auto schemas=builtin_schemas();AuthoringService service(root,schemas);
auto created=service.create("Courtyard",3);const std::string id=created["id"];
auto first=make_entity(schemas,"Door");const std::string entity_id=first["id"],transform_id=first["components"][0]["id"];
Json commands=Json::array({{{"op","entity.create"},{"entity",first}}});
const auto after=service.transact(id,0,commands,"request-1");CHECK(after["revision"]==1);CHECK(after["scene"]["entities"].size()==1);
CHECK(service.transact(id,0,commands,"request-1")==after);
fails([&]{service.transact(id,0,commands);},"revision.conflict");
fails([&]{service.transact(id,1,commands,"request-1");},"idempotency.conflict");
auto bad=Json::array({{{"op","entity.rename"},{"entity",entity_id},{"name","Should not survive"}},{{"op","component.set"},{"entity",entity_id},{"component",transform_id},{"field","position"},{"value","not a vector"}}});
fails([&]{service.transact(id,1,bad);},"validation.field_type");CHECK(service.query(id)==after);
auto edited=service.transact(id,1,Json::array({{{"op","component.set"},{"entity",entity_id},{"component",transform_id},{"field","position"},{"value",{4,0,2}}}}));
CHECK(edited["revision"]==2);CHECK(service.undo(id,2)["scene"]==after["scene"]);CHECK(service.redo(id,3)["scene"]==edited["scene"]);
CHECK(service.save(id,"Scenes/courtyard.scene.json")["dirty"]==false);
service.transact(id,4,Json::array({{{"op","entity.rename"},{"entity",entity_id},{"name","Дверь 世界"}}}));
AuthoringService restarted(root,schemas);const auto recovered=restarted.open("Scenes/courtyard.scene.json",true);CHECK(recovered["scene"]["entities"][0]["name"]=="Дверь 世界");CHECK(recovered["dirty"]==true);
atomic_write(root/"Scenes/courtyard.scene.json",read_text(root/"Scenes/courtyard.scene.json")+"\n");
fails([&]{restarted.save(id);},"save.disk_conflict");
fn();
} catch (const faset::Error& error) {
CHECK(error.code() == code);
return;
}
throw std::runtime_error("Expected error " + code);
}
int main() {
using namespace faset;
using namespace faset::authoring;
const auto root = std::filesystem::temp_directory_path() / ("faset-authoring-" + new_id());
try {
auto schemas = builtin_schemas();
AuthoringService service(root, schemas);
auto created = service.create("Courtyard", 3);
const std::string id = created["id"];
auto first = make_entity(schemas, "Door");
const std::string entity_id = first["id"], transform_id = first["components"][0]["id"];
Json commands = Json::array({{{"op", "entity.create"}, {"entity", first}}});
const auto after = service.transact(id, 0, commands, "request-1");
CHECK(after["revision"] == 1);
CHECK(after["scene"]["entities"].size() == 1);
CHECK(service.transact(id, 0, commands, "request-1") == after);
fails([&] { service.transact(id, 0, commands); }, "revision.conflict");
fails([&] { service.transact(id, 1, commands, "request-1"); }, "idempotency.conflict");
auto bad = Json::array(
{{{"op", "entity.rename"}, {"entity", entity_id}, {"name", "Should not survive"}},
{{"op", "component.set"},
{"entity", entity_id},
{"component", transform_id},
{"field", "position"},
{"value", "not a vector"}}});
fails([&] { service.transact(id, 1, bad); }, "validation.field_type");
CHECK(service.query(id) == after);
auto edited = service.transact(id, 1,
Json::array({{{"op", "component.set"},
{"entity", entity_id},
{"component", transform_id},
{"field", "position"},
{"value", {4, 0, 2}}}}));
CHECK(edited["revision"] == 2);
CHECK(service.undo(id, 2)["scene"] == after["scene"]);
CHECK(service.redo(id, 3)["scene"] == edited["scene"]);
CHECK(service.save(id, "Scenes/courtyard.scene.json")["dirty"] == false);
service.transact(
id, 4,
Json::array(
{{{"op", "entity.rename"}, {"entity", entity_id}, {"name", "Дверь 世界"}}}));
AuthoringService restarted(root, schemas);
const auto recovered = restarted.open("Scenes/courtyard.scene.json", true);
CHECK(recovered["scene"]["entities"][0]["name"] == "Дверь 世界");
CHECK(recovered["dirty"] == true);
AuthoringService opened_then_recovered(root, schemas);
opened_then_recovered.open("Scenes/courtyard.scene.json");
fails([&] { opened_then_recovered.recover(id); }, "recovery.revision_required");
CHECK(opened_then_recovered.recover(id, 0)["scene"]["entities"][0]["name"] == "Дверь 世界");
auto fresh = service.create("Never saved", 2);
const std::string fresh_id = fresh.at("id");
service.transact(fresh_id, 0,
Json::array({{{"op", "entity.create"}, {"name", "Recovered sprite"}}}));
AuthoringService recover_new(root, schemas);
const auto restored_new = recover_new.recover(fresh_id);
CHECK(restored_new["dirty"] == true && restored_new["path"] == "");
CHECK(restored_new["scene"]["entities"].size() == 1);
fails([&] { recover_new.recover("../../outside"); }, "id.invalid");
atomic_write(root / "Scenes/courtyard.scene.json",
read_text(root / "Scenes/courtyard.scene.json") + "\n");
fails([&] { restarted.save(id); }, "save.disk_conflict");
// Parent cycles are rejected atomically; names never provide identity.
fails([&]{service.transact(id,5,Json::array({{{"op","entity.reparent"},{"entity",entity_id},{"parent",entity_id}}}));},"entity.cycle");
CHECK(service.query(id)["revision"]==5);
fails(
[&] {
service.transact(id, 5,
Json::array({{{"op", "entity.reparent"},
{"entity", entity_id},
{"parent", entity_id}}}));
},
"entity.cycle");
CHECK(service.query(id)["revision"] == 5);
// Unavailable extension data is retained, including fields unknown to this SDK.
auto unknown=make_entity(schemas,"Plugin object");unknown["components"].push_back({{"id",new_id()},{"type","plugin.future"},{"version",5},{"fields",{{"unknown",Json::array({1,2,3})}}}});
service.transact(id,5,Json::array({{{"op","entity.create"},{"entity",unknown}}}));CHECK(service.query(id)["scene"]["entities"][1]==unknown);
auto unknown = make_entity(schemas, "Plugin object");
unknown["components"].push_back({{"id", new_id()},
{"type", "plugin.future"},
{"version", 5},
{"fields", {{"unknown", Json::array({1, 2, 3})}}}});
service.transact(id, 5, Json::array({{{"op", "entity.create"}, {"entity", unknown}}}));
CHECK(service.query(id)["scene"]["entities"][1] == unknown);
// Nested template addresses remain valid after source rename and source reparent.
auto source=make_scene("Door template");source["entities"].push_back(first);
auto middle=make_scene("Nested");middle["instances"].push_back({{"id","nested"},{"source","door"}});
auto outer=make_scene("Level");
Json address={{"path",Json::array({"nested"})},{"object",entity_id},{"component",transform_id},{"field","position"}};
outer["instances"].push_back({{"id","one"},{"source","middle"},{"overrides",Json::array({{{"address",address},{"value",{8,0,0}}}})}});
outer["instances"].push_back({{"id","two"},{"source","middle"}});
auto loader=[&](const std::string& name){return name=="door"?source:middle;};
auto resolved=resolve_templates(outer,schemas,loader);CHECK(resolved.conflicts.empty());CHECK(resolved.scene["entities"].size()==2);
CHECK(resolved.scene["entities"][0]["components"][0]["fields"]["position"]==Json::array({8,0,0}));
CHECK(resolved.scene["entities"][1]["components"][0]["fields"]["position"]==Json::array({0,0,0}));
const auto stable=resolved.scene["entities"][0]["id"];source["entities"][0]["name"]="Renamed";
CHECK(resolve_templates(outer,schemas,loader).scene["entities"][0]["id"]==stable);
source["entities"]=Json::array();CHECK(resolve_templates(outer,schemas,loader).conflicts.size()==1);CHECK(outer["instances"][0]["overrides"].size()==1);
// Stable FieldId survives a label rename; incompatible migrations require an explicit decision.
SchemaRegistry newer;newer.register_schema({{"id","sample.type"},{"name","Sample"},{"version",2},{"fields",{{"speed",{{"id","speed"},{"name","Movement speed"},{"type","number"},{"default",2}}},{"enabled",{{"type","boolean"},{"default",true}}}}}});
newer.add_migration("sample.type",1,{{"speed",{{"scale",0.01}}}});
const auto migrated=newer.migrate_component({{"id",new_id()},{"type","sample.type"},{"version",1},{"fields",{{"speed",300},{"unrecognized","preserve"}}}});
CHECK(migrated["fields"]["speed"]==3.0);CHECK(migrated["fields"]["enabled"]==true);CHECK(migrated["fields"]["unrecognized"]=="preserve");
std::filesystem::remove_all(root);std::cout<<"Authoring transactions, conflict/retry, Undo, recovery, unknown fields, nested IDs and migrations passed\n";return 0;
} catch(const std::exception& error) {std::filesystem::remove_all(root);std::cerr<<error.what()<<'\n';return 1;}
auto source = make_scene("Door template");
source["entities"].push_back(first);
auto middle = make_scene("Nested");
middle["instances"].push_back({{"id", "nested"}, {"source", "door"}});
auto outer = make_scene("Level");
Json address = {{"path", Json::array({"nested"})},
{"object", entity_id},
{"component", transform_id},
{"field", "position"}};
outer["instances"].push_back(
{{"id", "one"},
{"source", "middle"},
{"overrides", Json::array({{{"address", address}, {"value", {8, 0, 0}}}})}});
outer["instances"].push_back({{"id", "two"}, {"source", "middle"}});
auto loader = [&](const std::string& name) { return name == "door" ? source : middle; };
auto resolved = resolve_templates(outer, schemas, loader);
CHECK(resolved.conflicts.empty());
CHECK(resolved.scene["entities"].size() == 2);
CHECK(resolved.scene["entities"][0]["components"][0]["fields"]["position"] ==
Json::array({8, 0, 0}));
CHECK(resolved.scene["entities"][1]["components"][0]["fields"]["position"] ==
Json::array({0, 0, 0}));
const auto stable = resolved.scene["entities"][0]["id"];
source["entities"][0]["name"] = "Renamed";
CHECK(resolve_templates(outer, schemas, loader).scene["entities"][0]["id"] == stable);
source["entities"] = Json::array();
CHECK(resolve_templates(outer, schemas, loader).conflicts.size() == 1);
CHECK(outer["instances"][0]["overrides"].size() == 1);
// Stable FieldId survives a label rename; incompatible migrations require an explicit
// decision.
SchemaRegistry newer;
newer.register_schema(
{{"id", "sample.type"},
{"name", "Sample"},
{"version", 2},
{"fields",
{{"speed",
{{"id", "speed"}, {"name", "Movement speed"}, {"type", "number"}, {"default", 2}}},
{"enabled", {{"type", "boolean"}, {"default", true}}}}}});
newer.add_migration("sample.type", 1, {{"speed", {{"scale", 0.01}}}});
const auto migrated =
newer.migrate_component({{"id", new_id()},
{"type", "sample.type"},
{"version", 1},
{"fields", {{"speed", 300}, {"unrecognized", "preserve"}}}});
CHECK(migrated["fields"]["speed"] == 3.0);
CHECK(migrated["fields"]["enabled"] == true);
CHECK(migrated["fields"]["unrecognized"] == "preserve");
// Full TRS reparent, including parent rotation/scale, preserves world placement.
auto hierarchy = make_scene("Transforms");
auto parent = make_entity(schemas, "Parent");
auto child = make_entity(schemas, "Child");
parent["components"][0]["fields"]["position"] = Json::array({10, 0, 0});
parent["components"][0]["fields"]["rotation"] = Json::array({0, 0, 1.5707963267948966});
parent["components"][0]["fields"]["scale"] = Json::array({2, 2, 2});
child["components"][0]["fields"]["position"] = Json::array({10, 4, 0});
hierarchy["entities"] = Json::array({parent, child});
reparent_entity(hierarchy, child["id"], parent["id"], true);
auto position = hierarchy["entities"][1]["components"][0]["fields"]["position"];
CHECK(std::abs(position[0].get<double>() - 2.0) < 1e-6);
CHECK(std::abs(position[1].get<double>()) < 1e-6);
reparent_entity(hierarchy, child["id"], nullptr, true);
position = hierarchy["entities"][1]["components"][0]["fields"]["position"];
CHECK(std::abs(position[0].get<double>() - 10) < 1e-6);
CHECK(std::abs(position[1].get<double>() - 4) < 1e-6);
std::filesystem::remove_all(root);
std::cout << "Authoring transactions, conflict/retry, Undo, recovery, unknown fields, "
"nested IDs and migrations passed\n";
return 0;
} catch (const std::exception& error) {
std::filesystem::remove_all(root);
std::cerr << error.what() << '\n';
return 1;
}
}
+28
View File
@@ -0,0 +1,28 @@
import bpy, sys, pathlib, shutil, json
root=pathlib.Path(sys.argv[sys.argv.index('--')+1])
output=pathlib.Path(sys.argv[sys.argv.index('--')+2])
sys.path.insert(0,str(root/'tools'))
import blender_addon
blender_addon.register()
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
bpy.ops.mesh.primitive_cube_add(location=(0,0,1))
obj=bpy.context.object;obj.name='Door panel'
obj['faset_id']='1c4b69fb-1219-4430-b046-8185eb265a0f'
obj.data['faset_id']='2072eb21-2558-41ee-a843-82e676e6a44e'
mat=bpy.data.materials.new('Brass');mat.diffuse_color=(0.8,0.5,0.12,1);mat['faset_id']='31dc2a5b-3d1c-41b8-b4ef-ddc626087458';obj.data.materials.append(mat)
bpy.context.scene['faset_asset_id']='54c039e4-c19f-4365-a9f8-a38416ec6e3f'
bpy.context.scene.frame_set(17,subframe=.25)
for stage in ('initial','renamed','removed'):
directory=output/stage;directory.mkdir(parents=True,exist_ok=True)
if stage=='renamed':
obj.name='Renamed Door';obj.data.vertices[0].co.x-=.2
if stage=='removed':
bpy.data.objects.remove(obj,do_unlink=True)
result=bpy.ops.export_scene.faset_bundle(filepath=str(directory/'manifest.json'))
assert 'FINISHED' in result,result
assert bpy.context.scene.frame_current==17 and bpy.context.scene.frame_subframe==.25
if stage!='removed':
assert bpy.context.view_layer.objects.active==obj and obj.select_get()
bpy.ops.wm.save_as_mainfile(filepath=str(directory/'source.blend'))
print('FASET_BLENDER_ROUNDTRIP_EXPORT_OK')
+289
View File
@@ -0,0 +1,289 @@
#include <bit>
#include <chrono>
#include <cstdlib>
#include <faset/assets/asset_pipeline.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/core/process.hpp>
#include <faset/editor/build_service.hpp>
#include <iostream>
#include <thread>
#ifndef _WIN32
#include <csignal>
#endif
using namespace faset;
namespace fs = std::filesystem;
void require(bool value, const char* message) {
if (!value)
throw std::runtime_error(message);
}
std::string collect(Process& process) {
std::string text;
while (true) {
auto poll = process.poll();
text += poll.output;
if (!poll.running) {
require(poll.exit_code == 0, "Child process failed");
return text;
}
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
}
Json scene(int dimension) {
return {{"format", "faset.scene"}, {"version", 1}, {"id", "scene-test"},
{"name", "Build test"}, {"dimension", dimension}, {"entities", Json::array()},
{"instances", Json::array()}};
}
int integration(const fs::path& root) {
fs::create_directories(root);
editor::BuildConfig config;
config.project_root = root / "project";
config.engine_root = FASET_ENGINE_SOURCE;
config.build_directory = root / "native-build";
config.cache_root = root / "project" / ".faset" / "cache";
editor::BuildService service(config);
service.scaffold("Export integration", 3);
// This dedicated integration fixture is reset before testing incremental user edits.
atomic_write(config.project_root / "Scripts" / "Gameplay.cpp",
read_text(config.engine_root / "tools" / "project_templates" / "Gameplay.cpp"));
auto wait = [&](const std::string& id) {
std::string stage;
while (true) {
auto job = service.job(id);
if (job.stage != stage) {
stage = job.stage;
std::cout << job.stage << std::endl;
}
if (job.finished()) {
if (job.state != "succeeded") {
std::cerr << job.error << '\n' << job.log;
throw std::runtime_error("Integration job failed");
}
return job;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
};
const auto assets = config.project_root / "Assets";
std::string binary;
for (float value : {-1.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 2.f, 0.f}) {
auto bits = std::bit_cast<std::uint32_t>(value);
for (int i = 0; i < 4; ++i)
binary.push_back(static_cast<char>(bits >> (8 * i)));
}
atomic_write(assets / "triangle.bin", binary);
Json gltf = {
{"asset", {{"version", "2.0"}}},
{"scene", 0},
{"scenes", Json::array({{{"nodes", {0}}}})},
{"nodes", Json::array({{{"mesh", 0}, {"extras", {{"faset_id", "triangle-node"}}}}})},
{"buffers", Json::array({{{"uri", "triangle.bin"}, {"byteLength", 36}}})},
{"bufferViews", Json::array({{{"buffer", 0}, {"byteLength", 36}}})},
{"accessors", Json::array({{{"bufferView", 0},
{"componentType", 5126},
{"count", 3},
{"type", "VEC3"},
{"min", {-1, 0, 0}},
{"max", {1, 2, 0}}}})},
{"meshes",
Json::array({{{"primitives", Json::array({{{"attributes", {{"POSITION", 0}}}}})}}})}};
atomic_write_json(assets / "triangle.gltf", gltf);
assets::AssetPipeline importer(config.cache_root);
auto imported = importer.import_asset({assets / "triangle.gltf"});
require(imported.ok(), "Integration asset import");
for (int dimension : {2, 3}) {
auto document = scene(dimension);
auto component = [](std::string type, Json fields) {
return Json{
{"id", type}, {"type", type}, {"version", 1}, {"fields", std::move(fields)}};
};
Json components = Json::array(
{component("faset.transform",
{{"position", {0, 0, 0}}, {"rotation", {0, 0, 0}}, {"scale", {1, 1, 1}}})});
if (dimension == 2)
components.push_back(
component("faset.sprite", {{"size", {2, 2}}, {"color", {.2, .7, .9, 1}}}));
else
components.push_back(component(
"faset.mesh", {{"asset", imported.asset_id + "#" +
importer.load_asset(imported.asset_id).nodes.at(0).id},
{"color", {.3, .8, .5, 1}}}));
document["entities"].push_back({{"id", "object"},
{"name", "Object"},
{"parent", nullptr},
{"components", components}});
auto result =
wait(service.start_export(document, root / ("export-" + std::to_string(dimension))));
const auto directory = fs::path(result.result.at("directory").get<std::string>());
require(result.result.at("configuration") == "Release",
"Exports default to the Release profile");
require(fs::path(result.result.at("build_directory").get<std::string>()) ==
config.build_directory / "Release",
"Export has a separate CMake directory");
require(read_json(directory / "manifest.json").at("configuration") == "Release",
"Export manifest records the actual profile");
Process player({{result.result.at("executable").get<std::string>(), "--headless",
"--frames", "3", "--capture", (directory / "verification.ppm").string()},
directory,
{}});
std::cout << collect(player);
require(fs::file_size(directory / "verification.ppm") > 1000,
"Exported game rendered a frame");
atomic_write_json(root / ("result-" + std::to_string(dimension) + ".json"), result.result);
}
auto source = read_text(config.project_root / "Scripts" / "Gameplay.cpp");
auto position = source.find("Character");
require(position != std::string::npos, "Template schema fixture");
source.replace(position, 9, "Custom Character");
atomic_write(config.project_root / "Scripts" / "Gameplay.cpp", source);
auto release_cache = read_text(config.build_directory / "Release" / "CMakeCache.txt");
auto rebuilt = wait(service.start_build());
require(rebuilt.result.at("configuration") == "Debug", "Development builds remain Debug");
require(fs::path(rebuilt.result.at("build_directory").get<std::string>()) ==
config.build_directory / "Debug",
"Development CMake directory is isolated");
require(read_text(config.build_directory / "Release" / "CMakeCache.txt") == release_cache,
"Development build preserves the Release cache");
auto schema = read_json(rebuilt.result.at("schema").get<std::string>());
bool updated{};
for (const auto& type : schema.at("types"))
if (type.value("name", "") == "Custom Character")
updated = true;
require(updated, "Incremental C++ build produced new schema");
auto last = read_text(config.cache_root / "last_build.json");
atomic_write(config.project_root / "Scripts" / "Gameplay.cpp",
source + "\n#error intentional_build_failure\n");
auto failed = service.wait(service.start_build());
require(failed.state == "failed", "Invalid user C++ must fail build");
require(read_text(config.cache_root / "last_build.json") == last,
"Failed compile preserved last good build");
atomic_write(config.project_root / "Scripts" / "Gameplay.cpp", source);
std::cout << "Real 2D/3D exports, imported mesh packaging, native launches, incremental C++ "
"schema and failed-build recovery passed\n";
return 0;
}
int main(int argc, char** argv) {
if (argc > 1 && std::string(argv[1]) == "--child") {
Json args = Json::array();
for (int i = 2; i < argc; ++i)
args.push_back(argv[i]);
std::cout << Json{{"args", args},
{"cwd", fs::current_path().string()},
{"env", std::getenv("FASET_PROCESS_TEST")
? std::getenv("FASET_PROCESS_TEST")
: ""}}
.dump()
<< std::endl;
std::cerr << "stderr-sentinel\n";
std::cout << std::string(100000, 'x') << std::endl;
return 0;
}
if (argc > 1 && std::string(argv[1]) == "--sleep") {
#ifndef _WIN32
std::signal(SIGTERM, SIG_IGN);
#endif
std::cout << "ready\n" << std::flush;
std::this_thread::sleep_for(std::chrono::seconds(30));
return 0;
}
if (argc == 3 && std::string(argv[1]) == "--integration") {
try {
return integration(fs::absolute(argv[2]));
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
fs::path temporary = fs::temp_directory_path() / ("Faset build test " + new_id());
try {
fs::create_directories(temporary);
auto executable = fs::absolute(argv[0]);
Process child({{executable.string(), "--child", "space argument", "quote\"backslash\\",
"$(touch not-executed); & |", ""},
temporary,
{{"FASET_PROCESS_TEST", "value with spaces"}}});
auto text = collect(child);
auto result = Json::parse(text.substr(0, text.find('\n')));
require(result["args"] == Json::array({"space argument", "quote\"backslash\\",
"$(touch not-executed); & |", ""}),
"Arguments must remain literal");
require(result["env"] == "value with spaces", "Child environment override");
require(fs::equivalent(result["cwd"].get<std::string>(), temporary),
"Child working directory");
require(text.find("stderr-sentinel") != std::string::npos && text.size() > 100000,
"Combined pipe output drained fully");
require(!fs::exists(temporary / "not-executed"), "No shell execution");
Process sleeper({{executable.string(), "--sleep"}, temporary, {}});
bool ready{};
while (!ready) {
auto p = sleeper.poll();
ready = p.output.find("ready") != std::string::npos;
require(p.running, "Sleeper exited early");
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
auto start = std::chrono::steady_clock::now();
sleeper.cancel();
require(!sleeper.poll().running, "Cancellation must reap the process");
require(std::chrono::steady_clock::now() - start < std::chrono::seconds(3),
"Cancellation must finish promptly");
editor::BuildConfig config;
config.project_root = temporary / "project";
config.engine_root = FASET_ENGINE_SOURCE;
editor::BuildService service(config);
service.scaffold("Test project", 2);
atomic_write(config.project_root / "Scripts" / "Gameplay.cpp", "// User code\n");
service.scaffold("Another name", 3);
require(read_text(config.project_root / "Scripts" / "Gameplay.cpp") == "// User code\n",
"Scaffold preserves existing source");
auto id = service.start_cook(scene(2));
auto cooked = service.wait(id);
require(cooked.state == "succeeded", "Cook job succeeds");
auto bytes = read_text(cooked.result.at("scene").get<std::string>());
require(bytes.substr(0, 8) == "FASETSCN" && bytes.size() > 20, "Cooked envelope magic");
std::uint32_t version{};
std::uint64_t size{};
for (unsigned i = 0; i < 4; ++i)
version |= std::uint32_t(static_cast<unsigned char>(bytes[8 + i])) << (8 * i);
for (unsigned i = 0; i < 8; ++i)
size |= std::uint64_t(static_cast<unsigned char>(bytes[12 + i])) << (8 * i);
require(version == 1 && size == bytes.size() - 20, "Cooked envelope version and size");
require(Json::from_cbor(bytes.begin() + 20, bytes.end()) == scene(2),
"Cooked scene preserves all values");
auto previous = read_text(service.config().cache_root / "last_cook.json");
auto broken = scene(3);
broken["version"] = 999;
auto bad = service.wait(service.start_cook(broken));
require(bad.state == "failed", "Unsupported scene version rejected");
require(read_text(service.config().cache_root / "last_cook.json") == previous,
"Failed cook preserves last good generation");
broken = scene(3);
broken["entities"].push_back(
{{"id", "entity"},
{"components",
Json::array({{{"type", "faset.mesh"}, {"fields", {{"asset", "missing-asset"}}}}})}});
auto missing = service.wait(service.start_cook(broken));
require(missing.state == "failed", "Missing asset blocks publication");
require(read_text(service.config().cache_root / "last_cook.json") == previous,
"Missing asset preserves last good generation");
broken = scene(3);
broken["entities"].push_back({{"id", "entity"},
{"components", Json::array({{{"type", "faset.unknown"},
{"version", 1},
{"fields", Json::object()}}})}});
auto unknown = service.wait(service.start_cook(broken));
require(unknown.state == "failed" &&
unknown.error.find("unresolved component") != std::string::npos,
"Unknown component type blocks cooking");
require(read_text(service.config().cache_root / "last_cook.json") == previous,
"Unknown schema preserves last good generation");
std::cout << "Literal process arguments, pipes, cancellation, scaffold and atomic cook "
"contracts passed\n";
fs::remove_all(temporary);
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
std::error_code ignored;
fs::remove_all(temporary, ignored);
return 1;
}
}
+48 -16
View File
@@ -1,27 +1,59 @@
#include <faset/core/error.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/core/error.hpp>
#include <iostream>
#include <set>
#define CHECK(x) do { if(!(x)) throw std::runtime_error("Check failed: " #x); } while(false)
#define CHECK(x) \
do { \
if (!(x)) \
throw std::runtime_error("Check failed: " #x); \
} while (false)
int main() {
const auto directory=std::filesystem::temp_directory_path()/("faset-core-"+faset::new_id());
const auto directory =
std::filesystem::temp_directory_path() / ("faset-core-" + faset::new_id());
try {
CHECK(faset::sha256("")=="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
CHECK(faset::sha256("abc")=="ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
CHECK(faset::sha256(std::string(1000000,'a'))=="cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0");
CHECK(faset::sha256("") ==
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
CHECK(faset::sha256("abc") ==
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
CHECK(faset::sha256(std::string(1000000, 'a')) ==
"cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0");
std::set<std::string> ids;
for(int i=0;i<1000;++i) { const auto id=faset::new_id();CHECK(id.size()==36);CHECK(id[14]=='4');CHECK(ids.insert(id).second); }
faset::atomic_write(directory/"state.json","{\"value\":1}");
faset::atomic_write_json(directory/"state.json",{{"value",2},{"text","Привет 世界"}});
CHECK(faset::read_json(directory/"state.json").at("value")==2);
CHECK(faset::sha256_file(directory/"state.json")==faset::sha256(faset::read_text(directory/"state.json")));
CHECK(std::filesystem::equivalent(faset::project_path(directory,"assets/../state.json"),directory/"state.json"));
bool rejected=false;try { faset::project_path(directory,"../escape"); } catch(const faset::Error&) {rejected=true;} CHECK(rejected);
rejected=false;try { faset::project_path(directory,directory/"state.json"); } catch(const faset::Error&) {rejected=true;} CHECK(rejected);
for (int i = 0; i < 1000; ++i) {
const auto id = faset::new_id();
CHECK(id.size() == 36);
CHECK(id[14] == '4');
CHECK(ids.insert(id).second);
}
faset::atomic_write(directory / "state.json", "{\"value\":1}");
faset::atomic_write_json(directory / "state.json", {{"value", 2}, {"text", "Привет 世界"}});
CHECK(faset::read_json(directory / "state.json").at("value") == 2);
CHECK(faset::sha256_file(directory / "state.json") ==
faset::sha256(faset::read_text(directory / "state.json")));
CHECK(std::filesystem::equivalent(faset::project_path(directory, "assets/../state.json"),
directory / "state.json"));
bool rejected = false;
try {
faset::project_path(directory, "../escape");
} catch (const faset::Error&) {
rejected = true;
}
CHECK(rejected);
rejected = false;
try {
faset::project_path(directory, directory / "state.json");
} catch (const faset::Error&) {
rejected = true;
}
CHECK(rejected);
std::filesystem::remove_all(directory);
std::cout<<"Core: SHA-256 vectors, persistent IDs, durable replace, Unicode, path boundaries passed\n";
std::cout << "Core: SHA-256 vectors, persistent IDs, durable replace, Unicode, path "
"boundaries passed\n";
return 0;
} catch(const std::exception& error) {std::filesystem::remove_all(directory);std::cerr<<error.what()<<'\n';return 1;}
} catch (const std::exception& error) {
std::filesystem::remove_all(directory);
std::cerr << error.what() << '\n';
return 1;
}
}
+190
View File
@@ -0,0 +1,190 @@
#include <cmath>
#include <faset/core/io.hpp>
#include <faset/editor/editor_ui.hpp>
#include <iostream>
using namespace faset;
void check(bool value, const char* message) {
if (!value)
throw std::runtime_error(message);
}
render::Event key(std::string value, bool control = false, bool shift = false) {
render::Event e;
e.type = render::Event::Type::KeyDown;
e.key = std::move(value);
e.control = control;
e.shift = shift;
return e;
}
void click(editor::EditorUI& ui, const std::string& id) {
const auto* widget = ui.widgets().find(id);
check(widget != nullptr, "Missing widget");
const auto rect = widget->rect.intersection(widget->clip);
check(rect.width > 0 && rect.height > 0, "Widget clipped");
render::Event down;
down.type = render::Event::Type::MouseDown;
down.button = 1;
down.x = rect.x + rect.width * .5f;
down.y = rect.y + rect.height * .5f;
auto up = down;
up.type = render::Event::Type::MouseUp;
ui.frame({down, up});
}
void text(editor::EditorUI& ui, const std::string& id, const std::string& value,
bool commit = true) {
click(ui, id);
render::Event e;
e.type = render::Event::Type::TextInput;
e.text = value;
std::vector<render::Event> events{key("A", true), e};
if (commit)
events.push_back(key("Return"));
ui.frame(events);
}
int main() {
auto root = std::filesystem::temp_directory_path() / ("faset-ui-authoring-" + new_id());
try {
std::filesystem::create_directories(root);
atomic_write_json(root / "project.faset.json", {{"format", "faset.project"},
{"version", 1},
{"name", "UI integration test"},
{"dimension", 3}});
#if defined(FASET_TEST_PLUGIN_DIRECTORY)
std::filesystem::create_directories(root / "Plugins");
for (const auto& file : std::filesystem::directory_iterator(FASET_TEST_PLUGIN_DIRECTORY))
if (file.is_regular_file())
std::filesystem::copy_file(file.path(), root / "Plugins" / file.path().filename());
#endif
editor::Session session({root, FASET_TEST_ENGINE, root});
render::Renderer renderer({1280, 800, "Faset editor test", true, true});
editor::EditorUI ui(session, renderer,
std::filesystem::path(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/dark.json");
ui.frame({});
click(ui, "add-cube");
auto state = session.authoring().query(ui.current_document());
check(state["scene"]["entities"].size() == 1, "Add cube button must author entity");
const auto eid = state["scene"]["entities"][0]["id"].get<std::string>();
check(ui.selected_entity() == eid, "New entity selected");
text(ui, "object-name", "Дверь");
state = session.authoring().query(ui.current_document());
check(state["scene"]["entities"][0]["name"] == "Дверь", "Inspector Unicode rename");
const auto cid = state["scene"]["entities"][0]["components"][0]["id"].get<std::string>();
const auto position = "field-" + cid + "-position-0";
text(ui, position, "3.5");
state = session.authoring().query(ui.current_document());
check(state["scene"]["entities"][0]["components"][0]["fields"]["position"][0] == 3.5,
"Inspector typed field transaction");
click(ui, "undo");
state = session.authoring().query(ui.current_document());
check(state["scene"]["entities"][0]["components"][0]["fields"]["position"][0] == 0,
"Toolbar Undo uses authoring history");
click(ui, "redo");
state = session.authoring().query(ui.current_document());
check(state["scene"]["entities"][0]["components"][0]["fields"]["position"][0] == 3.5,
"Toolbar Redo");
// A concurrent MCP edit cannot be overwritten by an unfinished inspector
// edit.
text(ui, "object-name", "Unsaved local name", false);
state = session.authoring().query(ui.current_document());
session.authoring().transact(
ui.current_document(), state.at("revision"),
Json::array({{{"op", "entity.rename"}, {"entity", eid}, {"name", "External rename"}}}));
ui.frame({key("Return")});
state = session.authoring().query(ui.current_document());
check(state["scene"]["entities"][0]["name"] == "External rename",
"Revision conflict preserves external edit");
click(ui, "scene-root");
check(ui.selected_entity().empty(), "Scene root deselects object");
click(ui, "entity-" + eid);
check(ui.selected_entity() == eid, "Scene tree selection");
const auto initial_revision = state.at("revision").get<std::uint64_t>();
auto* field = ui.widgets().find(position);
const auto r = field->rect;
render::Event down;
down.type = render::Event::Type::MouseDown;
down.button = 1;
down.x = r.x + 20;
down.y = r.y + 12;
auto move = down;
move.type = render::Event::Type::MouseMove;
move.x += 20;
auto move2 = move;
move2.x += 20;
auto up = move2;
up.type = render::Event::Type::MouseUp;
ui.frame({down, move, move2, up});
state = session.authoring().query(ui.current_document());
check(state["revision"] == initial_revision + 1,
"Numeric drag commits exactly one transaction");
// Selecting and manipulating the actual rendered geometry uses viewport
// events.
auto screen = [&](render::Vec3 position) {
const auto& snap = ui.snapshot();
const auto& m = snap.view_projection;
const auto w = m[3] * position[0] + m[7] * position[1] + m[11] * position[2] + m[15];
const auto nx =
(m[0] * position[0] + m[4] * position[1] + m[8] * position[2] + m[12]) / w;
const auto ny =
(m[1] * position[0] + m[5] * position[1] + m[9] * position[2] + m[13]) / w;
return render::Vec2{snap.scene_rect[0] + (nx + 1) * snap.scene_rect[2] * .5f,
snap.scene_rect[1] + (ny + 1) * snap.scene_rect[3] * .5f};
};
const auto x =
state["scene"]["entities"][0]["components"][0]["fields"]["position"][0].get<float>();
ui.select_entity("");
ui.frame({});
auto center = screen({x, 0, 0});
down.x = center[0];
down.y = center[1];
up = down;
up.type = render::Event::Type::MouseUp;
ui.frame({down, up});
check(ui.selected_entity() == eid, "Viewport ray must select visible cooked geometry");
auto tip = screen({x + 1.44f, 0, 0});
down.x = (center[0] + tip[0]) * .5f;
down.y = (center[1] + tip[1]) * .5f;
move = down;
move.type = render::Event::Type::MouseMove;
move.x += 20;
up = move;
up.type = render::Event::Type::MouseUp;
const auto before_gizmo = state.at("revision").get<std::uint64_t>();
ui.frame({down});
ui.frame({move});
check(session.authoring().query(ui.current_document())["revision"] == before_gizmo,
"Gizmo preview must not mutate authoring");
ui.frame({up});
state = session.authoring().query(ui.current_document());
check(state["revision"] == before_gizmo + 1, "Gizmo release commits one transaction");
click(ui, "undo");
state = session.authoring().query(ui.current_document());
check(std::abs(state["scene"]["entities"][0]["components"][0]["fields"]["position"][0]
.get<float>() -
x) < .001f,
"Gizmo undo restores transform");
#if defined(FASET_TEST_PLUGIN_DIRECTORY)
session.authoring().register_schemas(Json::parse(
R"([{"id":"example.beacon","name":"Beacon","version":1,"fields":{"speed":{"id":"speed","name":"Rotation speed","type":"number","default":1.0}}}])"));
ui.frame({});
check(!session.plugin_panels().empty(), "Actual native plugin panel must load");
const auto before_plugin = state["scene"]["entities"].size();
click(ui, "tab-plugin-example.beacon.tools");
click(ui, "plugin-action-example.beacon.tools");
state = session.authoring().query(ui.current_document());
check(state["scene"]["entities"].size() == before_plugin + 1,
"Plugin panel action must author Beacon through Commands");
click(ui, "tab-assets");
#endif
renderer.render(ui.snapshot());
renderer.capture(root / "editor-ui.ppm");
check(renderer.stats().validation_errors == 0, "Vulkan validation errors");
std::cout << "Editor UI: actual events create/select/rename/typed "
"fields/Undo/Redo/conflict/one drag transaction passed. "
"Screenshot: "
<< (root / "editor-ui.ppm") << '\n';
return 0;
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
return 1;
}
}
+84
View File
@@ -0,0 +1,84 @@
"""Exercise the real headless Editor over newline-delimited MCP, without a GPU."""
import json
import pathlib
import queue
import subprocess
import sys
import tempfile
import threading
def run(executable, project):
process = subprocess.Popen([executable, "--project", str(project), "--mcp"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, encoding="utf-8")
output = queue.Queue()
def reader():
for line in process.stdout:
output.put(json.loads(line)) # Any non-JSON stdout fails the test.
output.put(None)
threading.Thread(target=reader, daemon=True).start()
sequence = 0
def request(method, params=None):
nonlocal sequence
sequence += 1
message = {"jsonrpc": "2.0", "id": sequence, "method": method}
if params is not None:
message["params"] = params
process.stdin.write(json.dumps(message) + "\n")
process.stdin.flush()
result = output.get(timeout=20)
assert result and result["id"] == sequence, result
return result
def call(name, arguments=None, error=False):
response = request("tools/call", {"name": name, "arguments": arguments or {}})["result"]
assert response["isError"] == error, response
return response["structuredContent"]
try:
initialized = request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "faset-integration-test", "version": "1"}})
assert initialized["result"]["serverInfo"]["name"] == "faset-editor"
process.stdin.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n')
process.stdin.flush()
names = {tool["name"] for tool in request("tools/list")["result"]["tools"]}
assert {"faset_scene_edit", "faset_export", "faset_job_cancel", "faset_plugins"} <= names
assert "faset_runtime_query" not in names and "faset_editor_capture" not in names
if (project / "Scenes/main.scene.json").exists():
opened = call("faset_document_open", {"path": "Scenes/main.scene.json"})
assert opened["scene"]["entities"][0]["name"] == "Door 世界"
return
scene = call("faset_document_create", {"name": "MCP integration", "dimension": 3})
identity = scene["id"]
edit = {"document": identity, "revision": 0,
"operations": [{"op": "entity.create", "name": "Door 世界"}],
"idempotency_key": "create-door"}
changed = call("faset_scene_edit", edit)
assert call("faset_scene_edit", edit) == changed
conflict = dict(edit)
del conflict["idempotency_key"]
assert call("faset_scene_edit", conflict, True)["error"]["code"] == "revision.conflict"
undone = call("faset_undo", {"document": identity, "revision": 1})
assert undone["scene"]["entities"] == []
redone = call("faset_redo", {"document": identity, "revision": 2})
assert redone["scene"] == changed["scene"]
saved = call("faset_document_save", {"document": identity, "path": "Scenes/main.scene.json"})
assert not saved["dirty"]
call("faset_document_open", {"path": "../outside.json"}, True)
assert request("resources/read", {"uri": "faset://documents"})["result"]["contents"]
assert request("faset_runtime_query")["error"]["code"] == -32601
finally:
process.stdin.close()
try:
code = process.wait(timeout=20)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
raise
errors = process.stderr.read()
assert code == 0, errors
with tempfile.TemporaryDirectory(prefix="faset-mcp-stdio-") as temporary:
run(sys.argv[1], pathlib.Path(temporary))
run(sys.argv[1], pathlib.Path(temporary))
print("Real MCP stdio lifecycle, clean stdout, revision conflict, retry, Undo/Redo, disk reopen and process shutdown passed")
+76
View File
@@ -0,0 +1,76 @@
#include <faset/core/io.hpp>
#include <faset/editor/mcp.hpp>
#include <iostream>
#define CHECK(x) \
do { \
if (!(x)) \
throw std::runtime_error("Check failed at " + std::to_string(__LINE__) + ": " #x); \
} while (false)
int main() {
using namespace faset;
using namespace faset::editor;
const auto root = std::filesystem::temp_directory_path() / ("faset-mcp-" + new_id());
try {
authoring::AuthoringService service(root);
Commands commands(service);
McpServer server(commands);
auto request = [&](std::string method, Json params = Json::object()) {
auto result = server.handle(
{{"jsonrpc", "2.0"}, {"id", 1}, {"method", method}, {"params", params}});
CHECK(result.has_value());
return *result;
};
CHECK(request("tools/list")["error"]["code"] == -32002);
CHECK(request("initialize",
{{"protocolVersion", "2025-06-18"},
{"capabilities", Json::object()},
{"clientInfo",
{{"name", "test"}, {"version", "1"}}}})["result"]["protocolVersion"] ==
"2025-06-18");
CHECK(!server.handle({{"jsonrpc", "2.0"}, {"method", "notifications/initialized"}}));
const auto listed = request("tools/list")["result"]["tools"];
CHECK(listed.size() >= 10);
for (const auto& tool : listed) {
const std::string name = tool["name"];
CHECK(name.find("runtime") == std::string::npos);
CHECK(tool["inputSchema"]["additionalProperties"] == false);
}
auto call = [&](std::string name, Json arguments = Json::object()) {
return request("tools/call", {{"name", name}, {"arguments", arguments}})["result"];
};
const auto created =
call("faset_document_create", {{"name", "MCP scene"}, {"dimension", 2}});
CHECK(created["isError"] == false);
const std::string id = created["structuredContent"]["id"];
Json args = {{"document", id},
{"revision", 0},
{"operations", Json::array({{{"op", "entity.create"}, {"name", "Player"}}})},
{"idempotency_key", "first"}};
const auto first = call("faset_scene_edit", args);
CHECK(first["isError"] == false);
CHECK(call("faset_scene_edit", args) == first);
args.erase("idempotency_key");
CHECK(call("faset_scene_edit", args)["structuredContent"]["error"]["code"] ==
"revision.conflict");
CHECK(service.query(id)["scene"] == first["structuredContent"]["scene"]);
CHECK(call("faset_scene_edit", {{"document", id},
{"revision", -1},
{"operations", args["operations"]}})["isError"] == true);
CHECK(call("faset_document_open",
{{"path", "../outside.json"}})["structuredContent"]["error"]["code"] ==
"path.outside_project");
CHECK(call("faset_runtime_query")["isError"] == true);
CHECK(request("resources/read", {{"uri", "faset://schema"}}).contains("result"));
CHECK(request("not/a/method")["error"]["code"] == -32601);
CHECK(server.handle(Json::array())->at("error").at("code") == -32600);
std::filesystem::remove_all(root);
std::cout << "MCP lifecycle, shared authoring, retries/conflicts, tool schemas and "
"editor-only boundary passed\n";
return 0;
} catch (const std::exception& error) {
std::filesystem::remove_all(root);
std::cerr << error.what() << '\n';
return 1;
}
}
+87
View File
@@ -0,0 +1,87 @@
#include "../examples/extensions/beacon/Beacon.hpp"
#include <faset/core/io.hpp>
#include <faset/editor/plugins.hpp>
#include <iostream>
#define CHECK(x) \
do { \
if (!(x)) \
throw std::runtime_error("Check failed: " #x); \
} while (false)
int main() {
using namespace faset;
using namespace faset::editor;
const auto root = std::filesystem::temp_directory_path() / ("faset-plugins-" + new_id());
try {
std::filesystem::create_directories(root);
const auto folder = root / "Plugins";
std::filesystem::copy(FASET_TEST_PLUGIN_DIRECTORY, folder,
std::filesystem::copy_options::recursive);
authoring::AuthoringService authoring(root);
authoring.register_schemas(Json::array({beacon::schema()}));
Commands commands(authoring);
const auto document = authoring.create("Extension test");
const auto id = document.at("id");
{
PluginManager plugins(commands, [](auto) {});
plugins.load(folder);
CHECK(plugins.status().size() == 1);
CHECK(plugins.status()[0]["state"] == "loaded");
CHECK(plugins.panels().size() == 1);
const auto edited = commands.call("plugin_example_beacon_create", {{"document", id}});
CHECK(edited["revision"] == 1);
CHECK(edited["scene"]["entities"][0]["components"][2]["type"] == "example.beacon");
authoring.undo(id, 1);
CHECK(authoring.query(id)["scene"]["entities"].empty());
authoring.redo(id, 2);
authoring.save(id, "Scenes/plugin.scene.json");
// The extension's runtime component runs without loading its Editor DLL/SO.
runtime::Runtime world;
beacon::register_behavior(world);
world.load(edited.at("scene"));
world.advance(1.0 / 60);
CHECK(world.transform(world.find(edited["scene"]["entities"][0]["id"])).rotation[1] >
0);
}
for (const auto& command : commands.list())
CHECK(command["name"] != "plugin_example_beacon_create");
authoring::AuthoringService absent(root);
CHECK(absent.open("Scenes/plugin.scene.json")["scene"]["entities"][0]["components"][2]
["fields"]["speed"] == 1.0);
const auto file = folder / "beacon.faset-plugin.json";
const auto original = read_json(file);
auto wrong = original;
wrong["build_fingerprint"] = "wrong-build";
atomic_write_json(file, wrong);
{
PluginManager plugins(commands, [](auto) {});
plugins.load(folder);
CHECK(plugins.status()[0]["state"] == "failed");
CHECK(plugins.panels().empty());
}
auto cycle = original;
cycle["dependencies"] = Json::array({{{"id", "example.beacon"}, {"version", "1.0.0"}}});
atomic_write_json(file, cycle);
{
PluginManager plugins(commands, [](auto) {});
plugins.load(folder);
CHECK(plugins.status()[0]["state"] == "failed");
CHECK(plugins.panels().empty());
}
auto missing = original;
missing["dependencies"] = Json::array({{{"id", "missing"}, {"version", "1.0.0"}}});
atomic_write_json(file, missing);
{
PluginManager plugins(commands, [](auto) {});
plugins.load(folder);
CHECK(plugins.status()[0]["state"] == "failed");
}
std::filesystem::remove_all(root);
std::cout << "Native plugin ABI, ownership, commands, runtime component, missing package "
"preservation and dependency validation passed\n";
return 0;
} catch (const std::exception& error) {
std::filesystem::remove_all(root);
std::cerr << error.what() << '\n';
return 1;
}
}
+127 -21
View File
@@ -1,26 +1,132 @@
#include <faset/render/renderer.hpp>
#include <faset/render/render_graph.hpp>
#include <cmath>
#include <faset/render/render_graph.hpp>
#include <faset/render/renderer.hpp>
#include <iostream>
#include <stdexcept>
using namespace faset::render;
void require(bool test,const char* message){if(!test)throw std::runtime_error(message);}
int main(int argc,char** argv){try{
if(argc>1&&std::string(argv[1])=="--unit"){
int count{};RenderGraph invalid;invalid.add("consumer",{"missing"},{},[&]{++count;});bool caught{};try{invalid.execute();}catch(const std::runtime_error&){caught=true;}require(caught&&count==0,"Graph must validate before side effects");RenderGraph graph;graph.import("external");graph.add("first",{"external"},{"color"},[&]{require(count==0,"Pass order");++count;});graph.add("second",{"color"},{},[&]{++count;});graph.execute();require(count==2,"Pass execution count");auto t=transform({2,3,4},{},{2,3,4});require(t[12]==2&&t[13]==3&&t[14]==4,"Transform translation");auto m=multiply(identity,t);require(m==t,"Matrix multiplication identity");require(cube_mesh()->indices.size()==36,"Cube triangle topology");std::cout<<"Render graph and math contracts passed\n";return 0;
void require(bool test, const char* message) {
if (!test)
throw std::runtime_error(message);
}
int main(int argc, char** argv) {
try {
if (argc > 1 && std::string(argv[1]) == "--unit") {
int count{};
RenderGraph invalid;
invalid.add("consumer", {"missing"}, {}, [&] { ++count; });
bool caught{};
try {
invalid.execute();
} catch (const std::runtime_error&) {
caught = true;
}
require(caught && count == 0, "Graph must validate before side effects");
RenderGraph graph;
graph.import("external");
graph.add("first", {"external"}, {"color"}, [&] {
require(count == 0, "Pass order");
++count;
});
graph.add("second", {"color"}, {}, [&] { ++count; });
graph.execute();
require(count == 2, "Pass execution count");
auto t = transform({2, 3, 4}, {}, {2, 3, 4});
require(t[12] == 2 && t[13] == 3 && t[14] == 4, "Transform translation");
auto m = multiply(identity, t);
require(m == t, "Matrix multiplication identity");
require(cube_mesh()->indices.size() == 36, "Cube triangle topology");
std::cout << "Render graph and math contracts passed\n";
return 0;
}
bool visible = argc > 1 && std::string(argv[1]) == "--visible";
Renderer renderer({320, 240, "Faset render validation", !visible, true});
Snapshot scene;
scene.eye = {4, 3, 5};
scene.view_projection =
multiply(perspective(.85f, 320.f / 240.f, .1f, 100), look_at(scene.eye, {0, 0, 0}));
scene.draws.push_back(
{cube_mesh(), transform({0, 0, 0}), {.2f, .65f, .95f, 1}, .4f, .15f, true});
scene.draws.push_back({cube_mesh(),
transform({0, -1, 0}, {}, {10, 1, 10}),
{.45f, .48f, .5f, 1},
.8f,
0,
true});
scene.ui_quads.push_back({8, 8, 70, 16, {.8f, .1f, .15f, 1}});
auto texture = std::make_shared<Texture>();
texture->width = texture->height = 1;
texture->rgba = {20, 220, 40, 255};
scene.ui_quads.push_back({260, 8, 40, 20, {1, 1, 1, 1}, texture});
renderer.render(scene);
require(renderer.stats().validation_errors == 0, "Vulkan validation reported an error");
auto pixels = renderer.pixels();
require(pixels.size() == 320 * 240 * 4, "Readback dimensions");
auto index = (10 * 320 + 10) * 4;
require(pixels[index] > 190 && pixels[index + 1] < 50, "Colored UI pixel");
index = (10 * 320 + 270) * 4;
require(pixels[index] < 30 && pixels[index + 1] > 200, "Textured UI pixel");
texture->srgb = true;
++texture->revision;
renderer.render(scene);
auto srgb_pixels = renderer.pixels();
require(std::abs(int(srgb_pixels[index + 1]) - 220) <= 1,
"Unlit sRGB texture retains its display-space color");
texture->srgb = false;
++texture->revision;
auto baked_scene = scene;
auto baked_mesh = std::make_shared<Mesh>(*cube_mesh());
for (auto& vertex : baked_mesh->vertices) {
vertex.position[0] *= 10;
vertex.position[2] *= 10;
}
baked_scene.draws[1].mesh = baked_mesh;
baked_scene.draws[1].model = transform({0, -1, 0});
renderer.render(baked_scene);
auto baked_pixels = renderer.pixels();
std::size_t normal_difference{};
for (std::size_t i = 0; i < pixels.size(); ++i)
if (std::abs(int(pixels[i]) - int(baked_pixels[i])) > 2)
++normal_difference;
require(normal_difference < 10,
"Nonuniformly scaled normals must match baked geometry lighting");
auto shadowed = pixels;
if (argc > 2)
renderer.capture(std::string(argv[2]) + ".shadowed.ppm");
for (auto& draw : scene.draws)
draw.cast_shadow = false;
renderer.render(scene);
pixels = renderer.pixels();
std::size_t shadow_difference{};
for (std::size_t i = 0; i < pixels.size(); i += 4)
if (pixels[i] > shadowed[i] + 8)
++shadow_difference;
if (shadow_difference <= 20) {
std::cerr << "Shadow difference pixels: " << shadow_difference << "\n";
if (argc > 2)
renderer.capture(std::string(argv[2]) + ".unshadowed.ppm");
}
require(shadow_difference > 20, "Directional shadow must darken rendered surface pixels");
for (auto& draw : scene.draws)
draw.cast_shadow = true;
std::string reload_error;
require(renderer.reload_shaders(reload_error), "Compatible shader pipeline reload");
texture->rgba = {40, 30, 230, 255};
++texture->revision;
renderer.render(scene);
pixels = renderer.pixels();
require(pixels[index + 2] > 220, "Texture revision upload");
if (argc > 2)
renderer.capture(argv[2]);
renderer.resize(400, 300);
renderer.poll_events();
renderer.render(scene);
require(renderer.width() == 400 && renderer.height() == 300, "Render target resize");
require(renderer.stats().validation_errors == 0, "Resize validation error");
std::cout << "Vulkan frame, shadow/PBR, atlas upload, readback and resize passed on "
<< renderer.stats().device << '\n';
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
return 1;
}
bool visible=argc>1&&std::string(argv[1])=="--visible";
Renderer renderer({320,240,"Faset render validation",!visible,true});
Snapshot scene;scene.eye={4,3,5};scene.view_projection=multiply(perspective(.85f,320.f/240.f,.1f,100),look_at(scene.eye,{0,0,0}));scene.draws.push_back({cube_mesh(),transform({0,0,0}),{.2f,.65f,.95f,1},.4f,.15f,true});scene.draws.push_back({cube_mesh(),transform({0,-1,0},{},{8,.2f,8}),{.45f,.48f,.5f,1},.8f,0,true});scene.ui_quads.push_back({8,8,70,16,{.8f,.1f,.15f,1}});
auto texture=std::make_shared<Texture>();texture->width=texture->height=1;texture->rgba={20,220,40,255};scene.ui_quads.push_back({260,8,40,20,{1,1,1,1},texture});
renderer.render(scene);require(renderer.stats().validation_errors==0,"Vulkan validation reported an error");auto pixels=renderer.pixels();require(pixels.size()==320*240*4,"Readback dimensions");auto index=(10*320+10)*4;require(pixels[index]>190&&pixels[index+1]<50,"Colored UI pixel");index=(10*320+270)*4;require(pixels[index]<30&&pixels[index+1]>200,"Textured UI pixel");
auto shadowed=pixels;
if(argc>2)renderer.capture(std::string(argv[2])+".shadowed.ppm");
for(auto& draw:scene.draws)draw.cast_shadow=false;
renderer.render(scene);pixels=renderer.pixels();std::size_t shadow_difference{};for(std::size_t i=0;i<pixels.size();i+=4)if(pixels[i]>shadowed[i]+8)++shadow_difference;if(shadow_difference<=20){std::cerr<<"Shadow difference pixels: "<<shadow_difference<<"\n";if(argc>2)renderer.capture(std::string(argv[2])+".unshadowed.ppm");}require(shadow_difference>20,"Directional shadow must darken rendered surface pixels");
for(auto& draw:scene.draws)draw.cast_shadow=true;
std::string reload_error;require(renderer.reload_shaders(reload_error),"Compatible shader pipeline reload");
texture->rgba={40,30,230,255};++texture->revision;renderer.render(scene);pixels=renderer.pixels();require(pixels[index+2]>220,"Texture revision upload");
if(argc>2)renderer.capture(argv[2]);renderer.resize(400,300);renderer.poll_events();renderer.render(scene);require(renderer.width()==400&&renderer.height()==300,"Render target resize");require(renderer.stats().validation_errors==0,"Resize validation error");
std::cout<<"Vulkan frame, shadow/PBR, atlas upload, readback and resize passed on "<<renderer.stats().device<<'\n';
}catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}return 0;}
return 0;
}
+184
View File
@@ -0,0 +1,184 @@
#include <bit>
#include <cmath>
#include <faset/assets/asset_pipeline.hpp>
#include <faset/core/io.hpp>
#include <faset/player/SceneView.hpp>
#include <faset/runtime/Runtime.hpp>
#include <iostream>
#include <stdexcept>
using Json = nlohmann::json;
namespace {
void check(bool value, const char* message) {
if (!value)
throw std::runtime_error(message);
}
template <class F> void rejects(F&& function, const char* message) {
bool caught = false;
try {
function();
} catch (const std::exception&) {
caught = true;
}
check(caught, message);
}
Json component(std::string type, Json fields) {
return {{"id", type}, {"type", type}, {"version", 1}, {"fields", fields}};
}
Json entity(std::string id, Json parent, Json components) {
return {{"id", id}, {"name", id}, {"parent", parent}, {"components", components}};
}
void run() {
const auto folder =
std::filesystem::temp_directory_path() / ("faset-player-test-" + faset::new_id());
std::filesystem::create_directories(folder);
struct Cleanup {
std::filesystem::path path;
~Cleanup() {
std::error_code error;
std::filesystem::remove_all(path, error);
}
} cleanup{folder};
Json scene{
{"format", "faset.scene"},
{"version", 1},
{"id", "test"},
{"name", "Test"},
{"dimension", 3},
{"instances", Json::array()},
{"entities",
Json::array(
{entity("parent", nullptr,
Json::array({component("faset.transform", {{"position", {1, 2, 3}}})})),
entity("child", "parent",
Json::array({component("faset.transform", {{"position", {2, 0, 0}}}),
component("faset.mesh", {{"asset", "builtin:cube"}})}))})}};
faset::atomic_write_json(folder / "scene.json", scene);
check(faset::player::readScene(folder / "scene.json") == scene, "JSON scene roundtrip");
const auto cbor = Json::to_cbor(scene);
std::string cooked = "FASETSCN";
for (int i = 0; i < 4; ++i)
cooked.push_back(static_cast<char>(std::uint32_t{1} >> (8 * i)));
for (int i = 0; i < 8; ++i)
cooked.push_back(static_cast<char>(std::uint64_t(cbor.size()) >> (8 * i)));
cooked.append(reinterpret_cast<const char*>(cbor.data()), cbor.size());
faset::atomic_write(folder / "scene.fscene", cooked);
check(faset::player::readScene(folder / "scene.fscene") == scene,
"CBOR cooked scene roundtrip");
auto truncated = cooked.substr(0, cooked.size() - 1);
faset::atomic_write(folder / "truncated.fscene", truncated);
rejects([&] { faset::player::readScene(folder / "truncated.fscene"); },
"reject cooked size mismatch");
auto version = cooked;
version[8] = 2;
faset::atomic_write(folder / "version.fscene", version);
rejects([&] { faset::player::readScene(folder / "version.fscene"); }, "reject cooked version");
faset::player::SceneView view(folder);
auto snapshot = view.build(scene, 16.f / 9.f);
check(snapshot.draws.size() == 1, "SceneView builtin mesh");
check(snapshot.draws[0].model[12] == 3 && snapshot.draws[0].model[13] == 2 &&
snapshot.draws[0].model[14] == 3,
"hierarchy local transforms composed");
faset::runtime::Runtime world;
world.load(scene);
auto pose = world.transform(world.find("child"));
pose.position[0] = 5;
world.setTransform(world.find("child"), pose);
snapshot = view.build(world.snapshotJson(), 1);
check(snapshot.draws[0].model[12] == 6,
"runtime presentation overrides original authoring pose");
auto bad = scene;
bad["entities"][0]["parent"] = "child";
rejects([&] { view.build(bad, 1); }, "view rejects hierarchy cycle");
bad = scene;
bad["entities"][1]["components"][1]["fields"]["asset"] = "missing-asset";
view.build(bad, 1);
check(!view.diagnostics().empty() && view.diagnostics()[0].starts_with("error:"),
"missing asset is diagnostic, not silent success");
auto camera = faset::player::CameraSettings{};
camera.eye = camera.target;
rejects([&] { view.build(scene, 1, camera); }, "reject degenerate camera");
auto two = scene;
two["dimension"] = 2;
two["entities"] = Json::array(
{entity("high", nullptr,
Json::array({component("faset.sprite", {{"layer", 10}, {"color", {1, 0, 0, 1}}})})),
entity(
"low", nullptr,
Json::array({component("faset.sprite", {{"layer", 0}, {"color", {0, 1, 0, 1}}})}))});
snapshot = view.build(two, 1);
check(snapshot.sprites.size() == 2 && snapshot.sprites[0].color[1] == 1,
"2D sprites sorted by layer");
// Import a real textured glTF fixture, then consume only its cooked generation.
std::string geometry;
auto word = [&](std::uint32_t v) {
for (int i = 0; i < 4; ++i)
geometry.push_back(static_cast<char>(v >> (8 * i)));
};
for (float value : {0.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 1.f, 0.f})
word(std::bit_cast<std::uint32_t>(value));
for (char value : {0, 0, 1, 0, 2, 0})
geometry.push_back(value);
faset::atomic_write(folder / "geometry.bin", geometry);
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,
13, 73, 68, 65, 84, 120, 156, 99, 248, 16, 32, 242, 31, 0, 5, 220, 2, 84,
184, 210, 98, 74, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130};
faset::atomic_write(folder / "pixel.png",
std::string_view(reinterpret_cast<const char*>(png.data()), png.size()));
Json gltf = {
{"asset", {{"version", "2.0"}}},
{"scene", 0},
{"scenes", Json::array({{{"nodes", Json::array({0})}}})},
{"nodes", Json::array({{{"mesh", 0}, {"translation", {2, 0, 0}}}})},
{"buffers", Json::array({{{"uri", "geometry.bin"}, {"byteLength", 42}}})},
{"bufferViews", Json::array({{{"buffer", 0}, {"byteOffset", 0}, {"byteLength", 36}},
{{"buffer", 0}, {"byteOffset", 36}, {"byteLength", 6}}})},
{"accessors",
Json::array(
{{{"bufferView", 0},
{"componentType", 5126},
{"count", 3},
{"type", "VEC3"},
{"min", {0, 0, 0}},
{"max", {1, 1, 0}}},
{{"bufferView", 1}, {"componentType", 5123}, {"count", 3}, {"type", "SCALAR"}}})},
{"meshes", Json::array({{{"primitives", Json::array({{{"attributes", {{"POSITION", 0}}},
{"indices", 1},
{"material", 0}}})}}})},
{"materials", Json::array({{{"pbrMetallicRoughness",
{{"baseColorFactor", {1, 1, 1, 1}},
{"baseColorTexture", {{"index", 0}}},
{"metallicFactor", 0},
{"roughnessFactor", 0.5}}}}})},
{"images", Json::array({{{"uri", "pixel.png"}}})},
{"textures", Json::array({{{"source", 0}}})}};
faset::atomic_write_json(folder / "triangle.gltf", gltf);
faset::assets::AssetPipeline pipeline(folder);
auto imported = pipeline.import_asset({folder / "triangle.gltf"});
check(imported.ok(), "real textured glTF fixture import");
auto importedScene = scene;
importedScene["entities"][1]["components"][1]["fields"]["asset"] = imported.asset_id;
snapshot = view.build(importedScene, 1);
check(view.diagnostics().empty(), "valid cooked texture/material produces no error");
check(snapshot.draws.size() == 1 && snapshot.draws[0].mesh->vertices.size() == 3,
"cooked mesh reaches render snapshot");
check(snapshot.draws[0].model[12] == 5, "asset node transform composed with scene hierarchy");
check(snapshot.draws[0].texture && snapshot.draws[0].texture->srgb &&
snapshot.draws[0].texture->rgba == std::vector<std::uint8_t>({240, 80, 20, 255}),
"PNG decoded into sRGB base-color texture");
}
} // namespace
int main() {
try {
run();
std::cout << "Player JSON/CBOR, hierarchy, runtime snapshot, camera, sprite ordering and "
"asset error contracts passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
+309 -70
View File
@@ -1,6 +1,6 @@
#include <faset/runtime/Runtime.hpp>
#include "Gameplay.hpp"
#include <cmath>
#include <faset/runtime/Runtime.hpp>
#include <iostream>
#include <limits>
#include <stdexcept>
@@ -8,82 +8,321 @@
#include <vector>
using namespace faset::runtime;
using Json=nlohmann::json;
using Json = nlohmann::json;
namespace {
void check(bool result,const char* text){if(!result)throw std::runtime_error(text);}
void near(float actual,float expected,float tolerance,const char* text){check(std::abs(actual-expected)<tolerance,text);}
template<class F>void rejects(F&& fn,const char* message){bool caught=false;try{fn();}catch(const std::exception&){caught=true;}check(caught,message);}
Json component(std::string type,Json fields=Json::object()){return {{"id",type+"-id"},{"type",type},{"version",1},{"fields",fields}};}
Json entity(std::string id,float y=0){return {{"id",id},{"name",id},{"parent",nullptr},{"components",Json::array({component("faset.transform",{{"position",{0,y,0}}})})}};}
Json scene(int dim=2){return {{"format","faset.scene"},{"version",1},{"id","test-scene"},{"name","Test"},{"dimension",dim},{"entities",Json::array()},{"instances",Json::array()}};}
void physics(int dimension){
Runtime world;auto doc=scene(dimension);auto floor=entity("ground",-0.5f);auto falling=entity("falling",4);
auto type=dimension==2?"faset.rigid_body_2d":"faset.rigid_body_3d";
Json extents=dimension==2?Json{10,0.5}:Json{10,0.5,10};
floor["components"].push_back(component(type,{{"body_type","static"},{"half_extents",extents}}));
falling["components"].push_back(component(type));doc["entities"]=Json::array({floor,falling});world.load(doc);auto h=world.find("falling");
bool contact=false;
for(int i=0;i<240;++i){world.advance(1.0/60);for(const auto& event:world.collisions())contact=contact||event.began;}
near(world.transform(h).position[1],0.5f,0.09f,"body must fall and settle on actual solver floor");check(contact,"native contact event must be delivered");
auto pose=world.transform(h);pose.position[1]=6;world.teleport(h,pose);
near(world.presentation(h).position[1],6,0.0001f,"teleport resets interpolation");
rejects([&]{world.setTransform(h,pose);},"physics transform cannot be casually overwritten");
world.applyImpulse(h,{0,2,0});check(world.velocity(h)[1]>0,"impulse changes solver velocity");
void check(bool result, const char* text) {
if (!result)
throw std::runtime_error(text);
}
void lifecycle(){
Runtime world;std::vector<std::string> events;bool spawned=false;float presented=-1;int pressedTicks=0;
void near(float actual, float expected, float tolerance, const char* text) {
check(std::abs(actual - expected) < tolerance, text);
}
template <class F> void rejects(F&& fn, const char* message) {
bool caught = false;
try {
fn();
} catch (const std::exception&) {
caught = true;
}
check(caught, message);
}
Json component(std::string type, Json fields = Json::object()) {
return {{"id", type + "-id"}, {"type", type}, {"version", 1}, {"fields", fields}};
}
Json entity(std::string id, float y = 0) {
return {{"id", id},
{"name", id},
{"parent", nullptr},
{"components", Json::array({component("faset.transform", {{"position", {0, y, 0}}})})}};
}
Json scene(int dim = 2) {
return {{"format", "faset.scene"}, {"version", 1},
{"id", "test-scene"}, {"name", "Test"},
{"dimension", dim}, {"entities", Json::array()},
{"instances", Json::array()}};
}
void physics(int dimension) {
Runtime world;
auto doc = scene(dimension);
auto floor = entity("ground", -0.5f);
auto falling = entity("falling", 4);
auto type = dimension == 2 ? "faset.rigid_body_2d" : "faset.rigid_body_3d";
Json extents = dimension == 2 ? Json{10, 0.5} : Json{10, 0.5, 10};
floor["components"].push_back(
component(type, {{"body_type", "static"}, {"half_extents", extents}}));
falling["components"].push_back(component(type));
doc["entities"] = Json::array({floor, falling});
world.load(doc);
auto h = world.find("falling");
bool contact = false;
for (int i = 0; i < 240; ++i) {
world.advance(1.0 / 60);
for (const auto& event : world.collisions())
contact = contact || event.began;
}
near(world.transform(h).position[1], 0.5f, 0.09f,
"body must fall and settle on actual solver floor");
check(contact, "native contact event must be delivered");
check(world.grounded(h), "settled body must have a supporting native contact");
auto pose = world.transform(h);
pose.position[1] = 6;
world.teleport(h, pose);
near(world.presentation(h).position[1], 6, 0.0001f, "teleport resets interpolation");
check(!world.grounded(h), "teleport off the floor removes grounded state");
rejects([&] { world.setTransform(h, pose); },
"physics transform cannot be casually overwritten");
world.applyImpulse(h, {0, 2, 0});
check(world.velocity(h)[1] > 0, "impulse changes solver velocity");
}
void lifecycle() {
Runtime world;
std::vector<std::string> events;
bool spawned = false;
float presented = -1;
int pressedTicks = 0;
Behavior behavior;
behavior.onStart=[&](Runtime&,EntityHandle,double){events.push_back("start");};
behavior.fixedUpdate=[&](Runtime& r,EntityHandle h,double){events.push_back("fixed");if(r.input().jumpPressed)++pressedTicks;auto t=r.transform(h);t.position[0]+=1;r.setTransform(h,t);if(!spawned){r.spawn(entity("spawned"));spawned=true;}};
behavior.update=[&](Runtime&,EntityHandle,double){events.push_back("update");};
behavior.lateUpdate=[&](Runtime& r,EntityHandle h,double){events.push_back("late");presented=r.presentation(h).position[0];};
behavior.onDestroy=[&](Runtime& r,EntityHandle h,double){check(r.valid(h),"OnDestroy still sees a valid handle");events.push_back("destroy");};
world.registerBehavior("test.behavior",behavior);auto doc=scene();auto object=entity("main");object["components"].push_back(component("test.behavior"));doc["entities"].push_back(object);world.load(doc);
check(events==std::vector<std::string>{"start"},"load runs OnStart once");
world.advance(1.0/120,{0,0,true,false});check(!world.find("spawned"),"zero-tick frame applies no structural commands");
world.advance(1.0/60);check(!world.find("spawned"),"FixedUpdate spawn must wait until next tick");near(presented,0.5f,0.001f,"LateUpdate receives interpolated transform");check(pressedTicks==1,"input edge preserved across zero-tick frame");
world.advance(3.0/60);check(bool(world.find("spawned")),"spawn appears next tick");check(pressedTicks==1,"edge not repeated in catchup ticks");
auto old=world.find("main");world.destroy(old);check(world.valid(old),"destroy deferred");world.singleStep();check(!world.valid(old),"handle invalid after removal");
check(events.back()=="destroy","destroy lifecycle runs exactly at barrier");
world.spawn(entity("replacement"));world.singleStep();check(!world.valid(old),"reused slot never revives a stale handle");
auto replacement=world.find("replacement");world.load(doc);check(!world.valid(replacement),"load creates a new session");
behavior.onStart = [&](Runtime&, EntityHandle, double) { events.push_back("start"); };
behavior.fixedUpdate = [&](Runtime& r, EntityHandle h, double) {
events.push_back("fixed");
if (r.input().jumpPressed)
++pressedTicks;
auto t = r.transform(h);
t.position[0] += 1;
r.setTransform(h, t);
if (!spawned) {
r.spawn(entity("spawned"));
spawned = true;
}
};
behavior.update = [&](Runtime&, EntityHandle, double) { events.push_back("update"); };
behavior.lateUpdate = [&](Runtime& r, EntityHandle h, double) {
events.push_back("late");
presented = r.presentation(h).position[0];
};
behavior.onDestroy = [&](Runtime& r, EntityHandle h, double) {
check(r.valid(h), "OnDestroy still sees a valid handle");
events.push_back("destroy");
};
world.registerBehavior("test.behavior", behavior);
auto doc = scene();
auto object = entity("main");
object["components"].push_back(component("test.behavior"));
doc["entities"].push_back(object);
world.load(doc);
check(events == std::vector<std::string>{"start"}, "load runs OnStart once");
world.advance(1.0 / 120, {0, 0, true, false});
check(!world.find("spawned"), "zero-tick frame applies no structural commands");
world.advance(1.0 / 60);
check(!world.find("spawned"), "FixedUpdate spawn must wait until next tick");
near(presented, 0.5f, 0.001f, "LateUpdate receives interpolated transform");
check(pressedTicks == 1, "input edge preserved across zero-tick frame");
world.advance(3.0 / 60);
check(bool(world.find("spawned")), "spawn appears next tick");
check(pressedTicks == 1, "edge not repeated in catchup ticks");
auto old = world.find("main");
world.destroy(old);
check(world.valid(old), "destroy deferred");
world.singleStep();
check(!world.valid(old), "handle invalid after removal");
check(events.back() == "destroy", "destroy lifecycle runs exactly at barrier");
world.spawn(entity("replacement"));
world.singleStep();
check(!world.valid(old), "reused slot never revives a stale handle");
auto replacement = world.find("replacement");
world.load(doc);
check(!world.valid(replacement), "load creates a new session");
// Ensure captured state remains alive while Runtime's destructor calls OnDestroy.
world.clear();
}
void clockAndValidation(){
Runtime world;auto doc=scene();doc["entities"].push_back(entity("object"));world.load(doc);
auto stats=world.advance(1.0);check(stats.fixedTicks==4,"catchup bounded to four ticks");check(stats.droppedTime>0.9,"excess time reported");check(stats.interpolationAlpha>=0&&stats.interpolationAlpha<1,"interpolation fraction bounded");
auto tick=stats.tick;world.setPaused(true);world.advance(100);check(world.snapshot().tick==tick,"pause does not accumulate");world.singleStep();check(world.snapshot().tick==tick+1,"single-step advances exactly once");world.setPaused(false);check(world.advance(0).fixedTicks==0,"resume does not catch up pause");
auto old=world.find("object");auto invalid=doc;invalid["entities"][0]["parent"]="object";rejects([&]{world.load(invalid);},"reject hierarchy cycle");check(world.valid(old),"invalid load preserves old world");
invalid=doc;invalid["entities"][0]["components"].push_back(component("faset.rigid_body_3d"));rejects([&]{world.load(invalid);},"reject physics dimension mismatch");
rejects([&]{world.advance(-1);},"reject negative time");
world.addComponent(old,component("faset.sprite"));check(!world.snapshot().entities[0].sprite,"component addition deferred");world.singleStep();check(world.snapshot().entities[0].sprite.has_value(),"component added at barrier");world.removeComponent(old,"faset.sprite");world.singleStep();check(!world.snapshot().entities[0].sprite,"component removed at barrier");
Runtime other;other.load(doc);check(!other.valid(old),"handle cannot cross worlds");
auto schema=faset::gameplay::schema();check(schema.size()==2,"sample has explicit metadata without world");
void clockAndValidation() {
Runtime world;
auto doc = scene();
doc["entities"].push_back(entity("object"));
world.load(doc);
auto stats = world.advance(1.0);
check(stats.fixedTicks == 4, "catchup bounded to four ticks");
check(stats.droppedTime > 0.9, "excess time reported");
check(stats.interpolationAlpha >= 0 && stats.interpolationAlpha < 1,
"interpolation fraction bounded");
auto tick = stats.tick;
world.setPaused(true);
world.advance(100);
check(world.snapshot().tick == tick, "pause does not accumulate");
world.singleStep();
check(world.snapshot().tick == tick + 1, "single-step advances exactly once");
world.setPaused(false);
check(world.advance(0).fixedTicks == 0, "resume does not catch up pause");
auto old = world.find("object");
auto invalid = doc;
invalid["entities"][0]["parent"] = "object";
rejects([&] { world.load(invalid); }, "reject hierarchy cycle");
check(world.valid(old), "invalid load preserves old world");
invalid = doc;
invalid["entities"][0]["components"].push_back(component("faset.rigid_body_3d"));
rejects([&] { world.load(invalid); }, "reject physics dimension mismatch");
rejects([&] { world.advance(-1); }, "reject negative time");
world.addComponent(old, component("faset.sprite"));
check(!world.snapshot().entities[0].sprite, "component addition deferred");
world.singleStep();
check(world.snapshot().entities[0].sprite.has_value(), "component added at barrier");
world.removeComponent(old, "faset.sprite");
world.singleStep();
check(!world.snapshot().entities[0].sprite, "component removed at barrier");
Runtime other;
other.load(doc);
check(!other.valid(old), "handle cannot cross worlds");
auto schema = faset::gameplay::schema();
check(schema.size() == 2, "sample has explicit metadata without world");
}
void structuralFailuresAndCallbacks(){
Runtime world;auto doc=scene();doc["entities"].push_back(entity("object"));world.load(doc);auto h=world.find("object");
world.addComponent(h,component("faset.sprite",{{"size",{-1,2}}}));world.singleStep();check(!world.snapshot().entities[0].sprite,"invalid deferred component leaves entity unchanged");check(!world.diagnostics().empty(),"invalid deferred command reports diagnostic");
auto zero=world.transform(h);zero.scale[0]=0;world.setTransform(h,zero);world.addComponent(h,component("faset.rigid_body_2d"));world.singleStep();rejects([&]{world.fields(h,"faset.rigid_body_2d");},"invalid runtime collider scale must not half-add component");
zero.scale[0]=1;world.setTransform(h,zero);world.addComponent(h,component("faset.rigid_body_2d"));world.singleStep();check(world.velocity(h)[1]<0,"deferred body runs actual physics");
world.removeComponent(h,"faset.rigid_body_2d");world.singleStep();rejects([&]{world.velocity(h);},"removed physics adapter no longer accessible");
world.destroy(h);world.destroy(h);world.singleStep();check(!world.valid(h),"repeated deferred destroy is safe");
rejects([&]{world.advance(std::numeric_limits<double>::quiet_NaN());},"nonfinite time rejected");
rejects([&]{world.advance(0,{std::numeric_limits<float>::infinity(),0,false,false});},"nonfinite input rejected");
Runtime callbacks;std::vector<std::string> order;
Behavior b;b.onStart=[&](Runtime& r,EntityHandle,double){order.push_back("start");rejects([&]{r.singleStep();},"OnStart cannot recursively advance");};
b.fixedUpdate=[&](Runtime&,EntityHandle,double){order.push_back("fixed");throw std::runtime_error("intentional callback failure");};
b.update=[&](Runtime&,EntityHandle,double){order.push_back("update");};
b.lateUpdate=[&](Runtime& r,EntityHandle h,double){order.push_back("late");auto p=r.presentation(h);p.position[2]=9;r.setPresentation(h,p);};
callbacks.registerBehavior("test",b);auto object=entity("callbacks");object["components"].push_back(component("test"));doc["entities"]=Json::array({object});callbacks.load(doc);callbacks.singleStep();
check(order==std::vector<std::string>{"start","fixed","update","late"},"callback failure does not skip remaining phases");check(callbacks.diagnostics().size()==1,"callback exception diagnostic");near(callbacks.snapshot().entities[0].transform.position[2],9,0.001f,"LateUpdate changes final presentation only");near(callbacks.transform(callbacks.find("callbacks")).position[2],0,0.001f,"presentation does not overwrite simulation");callbacks.clear();
void structuralFailuresAndCallbacks() {
Runtime world;
auto doc = scene();
doc["entities"].push_back(entity("object"));
world.load(doc);
auto h = world.find("object");
world.addComponent(h, component("faset.sprite", {{"size", {-1, 2}}}));
world.singleStep();
check(!world.snapshot().entities[0].sprite,
"invalid deferred component leaves entity unchanged");
check(!world.diagnostics().empty(), "invalid deferred command reports diagnostic");
auto zero = world.transform(h);
zero.scale[0] = 0;
world.setTransform(h, zero);
world.addComponent(h, component("faset.rigid_body_2d"));
world.singleStep();
rejects([&] { world.fields(h, "faset.rigid_body_2d"); },
"invalid runtime collider scale must not half-add component");
zero.scale[0] = 1;
world.setTransform(h, zero);
world.addComponent(h, component("faset.rigid_body_2d"));
world.singleStep();
check(world.velocity(h)[1] < 0, "deferred body runs actual physics");
world.removeComponent(h, "faset.rigid_body_2d");
world.singleStep();
rejects([&] { world.velocity(h); }, "removed physics adapter no longer accessible");
world.destroy(h);
world.destroy(h);
world.singleStep();
check(!world.valid(h), "repeated deferred destroy is safe");
rejects([&] { world.advance(std::numeric_limits<double>::quiet_NaN()); },
"nonfinite time rejected");
rejects([&] { world.advance(0, {std::numeric_limits<float>::infinity(), 0, false, false}); },
"nonfinite input rejected");
Runtime callbacks;
std::vector<std::string> order;
Behavior b;
b.onStart = [&](Runtime& r, EntityHandle, double) {
order.push_back("start");
rejects([&] { r.singleStep(); }, "OnStart cannot recursively advance");
};
b.fixedUpdate = [&](Runtime&, EntityHandle, double) {
order.push_back("fixed");
throw std::runtime_error("intentional callback failure");
};
b.update = [&](Runtime&, EntityHandle, double) { order.push_back("update"); };
b.lateUpdate = [&](Runtime& r, EntityHandle h, double) {
order.push_back("late");
auto p = r.presentation(h);
p.position[2] = 9;
r.setPresentation(h, p);
};
callbacks.registerBehavior("test", b);
auto object = entity("callbacks");
object["components"].push_back(component("test"));
doc["entities"] = Json::array({object});
callbacks.load(doc);
callbacks.singleStep();
check(order == std::vector<std::string>{"start", "fixed", "update", "late"},
"callback failure does not skip remaining phases");
check(callbacks.diagnostics().size() == 1, "callback exception diagnostic");
near(callbacks.snapshot().entities[0].transform.position[2], 9, 0.001f,
"LateUpdate changes final presentation only");
near(callbacks.transform(callbacks.find("callbacks")).position[2], 0, 0.001f,
"presentation does not overwrite simulation");
callbacks.clear();
}
void sampleGameplay(){
Runtime world;faset::gameplay::registerGameplay(world);auto doc=scene(3);auto door=entity("door");door["components"].push_back(component("gameplay.door",{{"speed",2.0}}));doc["entities"]=Json::array({door});world.load(doc);
world.advance(1.0/60,{0,0,false,true});for(int i=0;i<59;++i)world.advance(1.0/60);
near(world.transform(world.find("door")).rotation[1],1.5707963f,0.001f,"sample door opens through real static gameplay callback");
world.advance(1.0/60,{0,0,false,true});for(int i=0;i<59;++i)world.advance(1.0/60);
near(world.transform(world.find("door")).rotation[1],0,0.001f,"sample door toggles closed");
void sampleGameplay() {
Runtime world;
faset::gameplay::registerGameplay(world);
auto doc = scene(3);
auto door = entity("door");
door["components"].push_back(component("gameplay.door", {{"speed", 2.0}}));
doc["entities"] = Json::array({door});
world.load(doc);
world.advance(1.0 / 60, {0, 0, false, true});
for (int i = 0; i < 59; ++i)
world.advance(1.0 / 60);
near(world.transform(world.find("door")).rotation[1], 1.5707963f, 0.001f,
"sample door opens through real static gameplay callback");
world.advance(1.0 / 60, {0, 0, false, true});
for (int i = 0; i < 59; ++i)
world.advance(1.0 / 60);
near(world.transform(world.find("door")).rotation[1], 0, 0.001f, "sample door toggles closed");
}
void groundedJump() {
Runtime world;
faset::gameplay::registerGameplay(world);
auto doc = scene(2);
auto ground = entity("ground", -0.5f);
ground["components"].push_back(
component("faset.rigid_body_2d", {{"body_type", "static"}, {"half_extents", {10, .5}}}));
auto character = entity("character", 0.5f);
character["components"].push_back(component("faset.rigid_body_2d"));
character["components"].push_back(component("gameplay.character"));
doc["entities"] = Json::array({ground, character});
world.load(doc);
auto h = world.find("character");
for (int i = 0; i < 10; ++i)
world.advance(1.0 / 60);
check(world.grounded(h), "character starts supported");
world.advance(1.0 / 60, {0, 0, true, false});
check(world.velocity(h)[1] > 4, "grounded jump sets upward velocity");
for (int i = 0; i < 90 && world.velocity(h)[1] > 0.1f; ++i)
world.advance(1.0 / 60);
check(!world.grounded(h), "apex is not grounded");
auto before = world.velocity(h)[1];
world.advance(1.0 / 60, {0, 0, true, false});
check(world.velocity(h)[1] < before, "jump at apex must not create a second impulse");
for (int i = 0; i < 180; ++i)
world.advance(1.0 / 60);
check(world.grounded(h), "character regains ground after landing");
Runtime wall;
auto wallScene = scene(2);
auto obstacle = entity("wall");
obstacle["components"].push_back(
component("faset.rigid_body_2d", {{"body_type", "static"}, {"half_extents", {.5, 5}}}));
auto body = entity("side");
body["components"][0]["fields"]["position"] = {1, 0, 0};
body["components"].push_back(component("faset.rigid_body_2d", {{"gravity_scale", 0}}));
wallScene["entities"] = Json::array({obstacle, body});
wall.load(wallScene);
for (int i = 0; i < 5; ++i)
wall.advance(1.0 / 60);
check(!wall.grounded(wall.find("side")), "wall contact is not a supporting floor contact");
}
} // namespace
int main() {
try {
auto run = [](const char* name, auto fn) {
try {
fn();
std::cout << name << " passed\n";
} catch (const std::exception& error) {
throw std::runtime_error(std::string(name) + ": " + error.what());
}
};
run("Box2D", [] { physics(2); });
run("Box3D", [] { physics(3); });
run("Lifecycle", lifecycle);
run("Clock/validation", clockAndValidation);
run("Structural failures", structuralFailuresAndCallbacks);
run("Sample gameplay", sampleGameplay);
run("Grounded jump", groundedJump);
std::cout << "runtime contracts passed: actual Box2D/Box3D collisions, lifecycle, handles, "
"interpolation, deferred mutation, catchup, pause, validation, gameplay\n";
return 0;
} catch (const std::exception& ex) {
std::cerr << ex.what() << '\n';
return 1;
}
}
int main(){try{auto run=[](const char* name,auto fn){try{fn();std::cout<<name<<" passed\n";}catch(const std::exception& error){throw std::runtime_error(std::string(name)+": "+error.what());}};run("Box2D",[]{physics(2);});run("Box3D",[]{physics(3);});run("Lifecycle",lifecycle);run("Clock/validation",clockAndValidation);run("Structural failures",structuralFailuresAndCallbacks);run("Sample gameplay",sampleGameplay);std::cout<<"runtime contracts passed: actual Box2D/Box3D collisions, lifecycle, handles, interpolation, deferred mutation, catchup, pause, validation, gameplay\n";return 0;}catch(const std::exception& ex){std::cerr<<ex.what()<<'\n';return 1;}}
+103
View File
@@ -0,0 +1,103 @@
#include "Gameplay.hpp"
#include <cmath>
#include <fstream>
#include <iostream>
#include <set>
#include <stdexcept>
#include <string>
namespace {
void check(bool value, const char* message) {
if (!value)
throw std::runtime_error(message);
}
void near(float a, float b, const char* message) {
check(std::abs(a - b) < 0.002f, message);
}
} // namespace
int main() {
try {
nlohmann::json scene;
std::ifstream input(FASET_TUTORIAL_SCENE);
input >> scene;
std::set<std::string> stableIds;
for (const auto& entity : scene.at("entities")) {
check(stableIds.insert(entity.at("id").get<std::string>()).second,
"entity stable ID must be globally unique");
for (const auto& component : entity.at("components"))
check(stableIds.insert(component.at("id").get<std::string>()).second,
"component stable ID must be globally unique for Editor authoring");
}
const auto types = faset::gameplay::schema();
check(types.is_array() && !types.empty(), "tutorial must export real component schemas");
for (const auto& type : types)
for (const auto& [id, field] : type.at("fields").items())
check(field.at("id") == id && field.contains("default"),
"schema FieldId/default contract");
faset::runtime::Runtime world;
faset::gameplay::registerGameplay(world);
world.load(scene);
const std::string tutorial = FASET_TUTORIAL_NAME;
if (tutorial == "moving") {
for (int i = 0; i < 60; ++i)
world.advance(1.0 / 60);
near(world.transform(world.find("actor")).position[0], 2,
"60 Hz motion covers two metres per second");
world.load(scene);
for (int i = 0; i < 30; ++i)
world.advance(1.0 / 30);
near(world.transform(world.find("actor")).position[0], 2,
"30 Hz motion covers the same distance");
} else if (tutorial == "following") {
world.advance(1.5 / 60);
near(world.presentation(world.find("actor")).position[0], 1.0f / 60,
"presentation halfway between completed fixed poses");
near(world.presentation(world.find("camera")).position[0],
world.presentation(world.find("actor")).position[0],
"LateUpdate follows interpolated target");
near(world.transform(world.find("camera")).position[0], 0,
"following does not change simulation transform");
const auto target = world.find("actor");
world.destroy(target);
world.singleStep();
check(!world.valid(target), "target handle invalid after destruction");
} else if (tutorial == "spawning") {
check(!world.find("temporary-box"), "OnStart spawn deferred until first tick");
world.singleStep();
auto spawned = world.find("temporary-box");
check(world.valid(spawned), "child created at first barrier");
for (int i = 0; i < 90; ++i)
world.singleStep();
check(!world.valid(spawned) && !world.find("temporary-box"),
"lifetime removes temporary entity");
world.load(scene);
world.singleStep();
check(world.valid(world.find("temporary-box")) && !world.valid(spawned),
"module state and handles work across scene restart");
} else if (tutorial == "physics") {
auto self = world.find("actor");
for (int i = 0; i < 10; ++i)
world.advance(1.0 / 60);
check(world.grounded(self), "controller starts on floor");
world.advance(1.0 / 60, {1, 0, true, false});
check(world.velocity(self)[0] > 3.5f && world.velocity(self)[1] > 4,
"input applies velocity and grounded jump");
for (int i = 0; i < 90 && world.velocity(self)[1] > 0.1f; ++i)
world.advance(1.0 / 60);
check(!world.grounded(self), "apex has no ground contact");
const float before = world.velocity(self)[1];
world.advance(1.0 / 60, {0, 0, true, false});
check(world.velocity(self)[1] < before, "controller refuses air jump");
for (int i = 0; i < 180; ++i)
world.advance(1.0 / 60);
check(world.grounded(self), "controller lands again");
} else
throw std::runtime_error("Unknown compiled tutorial");
check(world.diagnostics().empty(), "tutorial callbacks must not silently report errors");
std::cout << tutorial << " tutorial compiled and passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
+115
View File
@@ -0,0 +1,115 @@
#include <faset/core/io.hpp>
#include <faset/ui/ui.hpp>
#include <iostream>
using namespace faset;
int main(int argc, char** argv) {
try {
render::Renderer renderer({1280, 800, "Faset UI reference implementation", true, true});
ui::Context ui(FASET_TEST_FONT);
ui.set_theme(ui::Theme::load(FASET_UI_THEME));
ui.apply_layout(
read_json(std::filesystem::path(FASET_UI_THEME).parent_path() / "editor-layout.json"));
auto& menu = ui.find("menubar")->add(ui::Kind::Row, "menuitems");
for (const auto& name : {"Faset", "File", "Edit", "Scene", "View", "Help"})
menu.add(ui::Kind::Button, "menu-" + std::string(name), name).layout.width = 65;
auto& tools = ui.find("toolbar")->add(ui::Kind::Row, "tools");
for (const auto& name :
{"Workshop", "Courtyard", "Save", "Undo", "Redo", "Play", "Stop", "Build"})
tools.add(ui::Kind::Button, "tool-" + std::string(name), name).layout.width = 80;
auto& scene = *ui.find("scene_panel");
scene.add(ui::Kind::Tab, "scene-tab", "Scene").selected = true;
auto& tree = scene.add(ui::Kind::Column, "scene-tree");
tree.layout.flex = 1;
tree.layout.scroll = true;
tree.layout.gap = 0;
for (const auto& name :
{"Courtyard", "Camera", "Sun", "Ground", "Player", "Door", "Crates"}) {
auto& row = tree.add(ui::Kind::TreeRow, "object-" + std::string(name), name);
row.indent = std::string(name) == "Courtyard" ? 0 : 1;
row.selected = std::string(name) == "Door";
}
auto& inspector = *ui.find("inspector_panel");
inspector.add(ui::Kind::Tab, "inspector-tab", "Inspector").selected = true;
auto& body = inspector.add(ui::Kind::Column, "properties");
body.layout.padding = 10;
body.layout.scroll = true;
body.layout.flex = 1;
body.layout.gap = 8;
body.add(ui::Kind::TextField, "object-name", "Door");
body.add(ui::Kind::Label, "transform-title", "Transform");
for (const auto& name : {"Position", "Rotation", "Scale"}) {
auto& row = body.add(ui::Kind::Row, "property-" + std::string(name));
row.layout.height = 30;
row.add(ui::Kind::Label, "label-" + std::string(name), name).layout.width = 64;
for (int axis = 0; axis < 3; ++axis) {
auto& field =
row.add(ui::Kind::NumberField, std::string(name) + std::to_string(axis));
field.layout.flex = 1;
field.layout.min_width = 40;
field.value = std::string(name) == "Scale" ? 1 : 0;
}
}
body.add(ui::Kind::Label, "mesh-title", "Mesh");
body.add(ui::Kind::Button, "mesh-asset", "Door.glb");
body.add(ui::Kind::Label, "body-title", "Rigid Body");
auto& check = body.add(ui::Kind::Checkbox, "static", "Static");
check.checked = true;
body.add(ui::Kind::Label, "controller-title", "Door Controller (C++)");
auto& speed = body.add(ui::Kind::NumberField, "speed");
speed.value = 2;
body.add(ui::Kind::Button, "add-component", "Add Component");
body.add(ui::Kind::Label, "unicode-check", "Cyrillic: Дверь, сцена");
auto& bottom = *ui.find("bottom_panel");
auto& tabs = bottom.add(ui::Kind::Row, "asset-tabs");
tabs.layout.height = 30;
tabs.add(ui::Kind::Tab, "assets-tab", "Assets").selected = true;
tabs.add(ui::Kind::Tab, "console-tab", "Console");
auto& search = bottom.add(ui::Kind::Row, "asset-search");
search.layout.height = 30;
search.add(ui::Kind::Label, "breadcrumb", "Assets / Models").layout.flex = 1;
search.add(ui::Kind::TextField, "search", "Search assets...").layout.width = 260;
for (const auto& name : {"Door.glb", "Crate.glb", "Ground.material", "Courtyard.scene"}) {
auto& row = bottom.add(ui::Kind::TreeRow, "asset-" + std::string(name), name);
row.layout.height = 26;
row.indent = 1;
}
ui.find("statusbar")
->add(ui::Kind::Label, "status",
"Ready Vulkan 1.3 | C++ "
"gameplay | Local project");
ui.layout(1280, 800);
render::Snapshot snapshot;
const auto rect = ui.find("viewport")->rect;
snapshot.scene_rect = {rect.x, rect.y, rect.width, rect.height};
snapshot.eye = {5, 4, 7};
snapshot.view_projection =
render::multiply(render::perspective(.75f, rect.width / rect.height, .1f, 100),
render::look_at(snapshot.eye, {0, 1, 0}));
render::DrawItem ground;
ground.mesh = render::cube_mesh();
ground.model = render::transform({0, -.25f, 0}, {}, {8, .5f, 8});
ground.color = {.25f, .29f, .32f, 1};
snapshot.draws.push_back(ground);
render::DrawItem door;
door.mesh = render::cube_mesh();
door.model = render::transform({0, 1.25f, 0}, {}, {1.6f, 2.5f, .3f});
door.color = {.46f, .28f, .13f, 1};
snapshot.draws.push_back(door);
render::DrawItem crate;
crate.mesh = render::cube_mesh();
crate.model = render::transform({-2, .6f, 1}, {0, .2f, 0}, {1.2f, 1.2f, 1.2f});
crate.color = {.38f, .25f, .14f, 1};
snapshot.draws.push_back(crate);
ui.draw(snapshot);
renderer.render(snapshot);
renderer.capture(argc > 1 ? argv[1] : "ui-test.ppm");
if (renderer.stats().validation_errors)
throw std::runtime_error("Vulkan validation reported UI rendering errors");
std::cout << "UI glyph atlas and retained panels rendered on " << renderer.stats().device
<< '\n';
return 0;
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
return 1;
}
}
+239
View File
@@ -0,0 +1,239 @@
#include <chrono>
#include <faset/ui/ui.hpp>
#include <iostream>
#include <stdexcept>
using namespace faset;
namespace {
void check(bool value, const char* message) {
if (!value)
throw std::runtime_error(message);
}
render::Event key(std::string name, bool control = false, bool shift = false) {
render::Event e;
e.type = render::Event::Type::KeyDown;
e.key = std::move(name);
e.control = control;
e.shift = shift;
return e;
}
render::Event text(std::string value) {
render::Event e;
e.type = render::Event::Type::TextInput;
e.text = std::move(value);
return e;
}
render::Event mouse(render::Event::Type type, float x, float y) {
render::Event e;
e.type = type;
e.x = x;
e.y = y;
e.button = 1;
return e;
}
void click(ui::Context& context, const ui::Widget& widget) {
const auto r = widget.rect;
context.handle(mouse(render::Event::Type::MouseDown, r.x + 5, r.y + 5));
context.handle(mouse(render::Event::Type::MouseUp, r.x + 5, r.y + 5));
}
} // namespace
int main() {
try {
ui::TextBuffer buffer("Привет");
check(buffer.backspace() && buffer.text() == "Приве", "UTF-8 backspace split codepoint");
check(buffer.undo() && buffer.text() == "Привет", "text undo");
buffer.select_all();
buffer.insert("Дверь");
buffer.left(true);
check(buffer.selected_text() == "ь", "UTF-8 selection");
buffer.insert("ца");
check(buffer.text() == "Дверца", "selection replacement");
check(!buffer.insert(std::string("\xc0\x80", 2)), "overlong UTF-8 accepted");
buffer.home();
buffer.delete_forward();
check(buffer.text() == "верца", "UTF-8 delete");
buffer.undo();
check(buffer.text() == "Дверца", "undo delete");
ui::Context context(FASET_TEST_FONT);
auto& root = context.root();
root.layout.gap = 4;
auto& name = root.add(ui::Kind::TextField, "name", "Door");
name.layout.height = 32;
int commits = 0;
name.on_commit = [&](ui::Widget&) { ++commits; };
auto& number = root.add(ui::Kind::NumberField, "number");
number.value = 4;
number.step = .5;
number.layout.height = 32;
int number_commits = 0, previews = 0;
number.on_commit = [&](ui::Widget&) { ++number_commits; };
number.on_preview = [&](ui::Widget&) { ++previews; };
auto& checkbox = root.add(ui::Kind::Checkbox, "enabled", "Enabled");
int checks = 0;
checkbox.on_commit = [&](ui::Widget&) { ++checks; };
auto& button = root.add(ui::Kind::Button, "save", "Save");
int clicks = 0;
button.on_click = [&](ui::Widget&) { ++clicks; };
context.layout(320, 240);
std::string clipboard;
context.set_clipboard([&] { return clipboard; },
[&](const std::string& s) { clipboard = s; });
bool ime = false;
int ime_rectangles = 0;
context.set_ime([&](bool enabled) { ime = enabled; },
[&](ui::Rect r) {
check(r.width > 0, "IME area");
++ime_rectangles;
});
check(context.focus("name") && ime, "field focus enables IME");
context.handle(key("A", true));
context.handle(text("Привет"));
check(!context.update_text("name", "External"), "document refresh erased dirty edit");
context.handle(key("C", true));
context.handle(key("A", true));
context.handle(key("C", true));
check(clipboard == "Привет", "clipboard copied wrong selection");
clipboard = "Дверь";
context.handle(key("V", true));
check(name.text == "Дверь", "UTF-8 paste");
context.handle(key("Z", true));
check(name.text == "Привет", "local Ctrl-Z");
context.handle(key("Return"));
check(commits == 1 && name.text == "Привет", "text commits once");
context.handle(key("Return"));
check(commits == 1, "unchanged text recommitted");
context.handle(key("End"));
render::Event composition;
composition.type = render::Event::Type::TextEditing;
composition.text = "й";
composition.edit_length = 1;
context.handle(composition);
check(name.text == "Привет", "IME preedit changed document field");
context.handle(text("й"));
context.handle(key("Return"));
check(name.text == "Приветй" && commits == 2, "IME commit");
context.focus("number");
const auto r = number.rect;
context.handle(mouse(render::Event::Type::MouseDown, r.x + 20, r.y + 12));
context.handle(mouse(render::Event::Type::MouseMove, r.x + 40, r.y + 12));
context.handle(mouse(render::Event::Type::MouseMove, r.x + 50, r.y + 12));
context.handle(mouse(render::Event::Type::MouseUp, r.x + 50, r.y + 12));
check(number.value == 19 && number_commits == 1 && previews == 2,
"numeric drag must commit once at release");
context.focus("save");
check(number_commits == 1, "blur duplicated drag commit");
context.focus("number");
context.handle(key("A", true));
context.handle(text("NaN"));
context.handle(key("Return"));
check(number_commits == 1 && !number.error.empty(), "invalid numeric input committed");
context.handle(key("Escape"));
check(number.value == 19, "cancel numeric input changed value");
click(context, checkbox);
check(checkbox.checked && checks == 1, "checkbox commit");
click(context, button);
check(clicks == 1, "button click");
context.handle(key("Return"));
check(clicks == 2, "keyboard button activation");
context.handle(key("Tab"));
check(context.focused_id() == "name", "focus wraps predictably");
check(ime_rectangles > 0, "IME rectangle never sent");
ui::Context split(FASET_TEST_FONT);
auto& row = split.root().add(ui::Kind::Row, "row");
row.layout.flex = 1;
row.layout.gap = 0;
auto& left = row.add(ui::Kind::Panel, "left");
left.layout.width = 100;
left.layout.min_width = 50;
auto& divider = row.add(ui::Kind::Divider, "divider");
divider.layout.width = 5;
auto& right = row.add(ui::Kind::Panel, "right");
right.layout.flex = 1;
right.layout.min_width = 50;
int resize_commit = 0;
divider.on_commit = [&](ui::Widget&) { ++resize_commit; };
split.layout(300, 100);
const auto d = divider.rect;
split.handle(mouse(render::Event::Type::MouseDown, d.x + 2, 20));
split.handle(mouse(render::Event::Type::MouseMove, d.x + 32, 20));
split.handle(mouse(render::Event::Type::MouseUp, d.x + 32, 20));
check(left.layout.width == 130 && right.rect.width == 165 && resize_commit == 1,
"divider resize");
const auto moved_divider = divider.rect;
split.handle(mouse(render::Event::Type::MouseDown, moved_divider.x + 2, 20));
split.handle(mouse(render::Event::Type::MouseMove, moved_divider.x + 42, 20));
split.handle(key("Escape"));
check(left.layout.width == 130 && resize_commit == 1,
"Escape must restore divider without committing");
int cancellations = 0;
number.on_cancel = [&](ui::Widget&) { ++cancellations; };
context.handle(mouse(render::Event::Type::MouseDown, r.x + 10, r.y + 12));
context.handle(mouse(render::Event::Type::MouseMove, r.x + 50, r.y + 12));
context.handle(key("Escape"));
check(number.value == 19 && number_commits == 1 && cancellations == 1,
"Escape must cancel numeric drag");
ui::Context scrolling(FASET_TEST_FONT);
auto& list = scrolling.root().add(ui::Kind::Panel, "list");
list.layout.height = 80;
list.layout.scroll = true;
list.layout.gap = 0;
for (int i = 0; i < 10; ++i)
list.add(ui::Kind::Label, "row" + std::to_string(i), "Объект " + std::to_string(i));
scrolling.layout(300, 200);
scrolling.handle(mouse(render::Event::Type::MouseMove, 30, 30));
render::Event wheel;
wheel.type = render::Event::Type::Wheel;
wheel.y = -2;
check(scrolling.handle(wheel) && list.scroll_y > 0, "scroll event");
render::Snapshot frame;
scrolling.draw(frame);
check(!frame.ui_quads.empty(), "retained UI emitted no quads");
for (const auto& q : frame.ui_quads)
check(q.x >= 0 && q.y >= 0 && q.x + q.width <= 300.01f && q.y + q.height <= 80.01f,
"CPU clipping escaped scroll panel");
check(scrolling.font().measure("Привет", 14) > 20, "Cyrillic shaping failed");
ui::DockLayout docks;
docks.move("Scene", "left", 0);
docks.move("Assets", "left", 1);
docks.move("Assets", "left", 0);
docks.set_size("Scene", 224);
check(docks.panels("left") == std::vector<std::string>({"Assets", "Scene"}),
"dock reordering");
const auto state = docks.to_json();
ui::DockLayout restored;
restored.from_json(state);
check(restored.to_json() == state, "dock serialization");
auto invalid = state;
invalid["areas"]["right"] = {"Scene"};
bool rejected = false;
try {
restored.from_json(invalid);
} catch (...) {
rejected = true;
}
check(rejected && restored.to_json() == state, "invalid dock load mutated existing layout");
ui::Context declarative(FASET_TEST_FONT);
declarative.apply_layout({{"id", "root"},
{"kind", "column"},
{"children", ui::Json::array({{{"id", "run"},
{"kind", "button"},
{"text", "Play"},
{"layout", {{"height", 30}}}}})}});
int action = 0;
declarative.find("run")->on_click = [&](ui::Widget&) { ++action; };
declarative.apply_layout({{"id", "root"},
{"kind", "column"},
{"children", ui::Json::array({{{"id", "run"},
{"kind", "button"},
{"text", "Run"},
{"layout", {{"height", 34}}}}})}});
declarative.layout(200, 100);
click(declarative, *declarative.find("run"));
check(action == 1 && declarative.find("run")->text == "Run", "layout reload lost callback");
std::cout << "UI: UTF-8, shaping, text/IME/clipboard, focus, transactions, "
"layout, clipping, docking OK\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}