#include #include #include #include #include #include #include #include #include using Json = nlohmann::json; namespace { void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); } template 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 physicsDebug(faset::player::SceneView& view) { for (int dimension : {2, 3}) { const std::string bodyName = dimension == 2 ? "rigid_body_2d" : "rigid_body_3d"; Json body{{"body_type", "dynamic"}, {"half_extents", dimension == 2 ? Json{.75, .5} : Json{.75, .5, .25}}, {"linear_velocity", dimension == 2 ? Json{2, 0} : Json{2, 0, 0}}}; const Json authoredPose{{"position", {1, 2, 3}}, {"rotation", {0, 0, std::numbers::pi_v / 2}}, {"scale", {-2, 3, -4}}}; Json scene{{"format", "faset.scene"}, {"version", 1}, {"id", "physics"}, {"name", "physics"}, {"dimension", dimension}, {"instances", Json::array()}, {"entities", Json::array({entity("body", nullptr, Json::array({component("faset.transform", authoredPose), component("faset." + bodyName, body)}))})}}; faset::runtime::RuntimeConfig config; config.gravity = {0, 0, 0}; faset::runtime::Runtime runtime(config); runtime.load(scene); runtime.advance(config.fixedDelta * 1.5); const auto handle = runtime.find("body"); const auto physical = runtime.transform(handle); const auto displayed = runtime.presentation(handle); check(physical.position[0] > displayed.position[0] + .01f, "Fixture separates actual body pose from interpolated presentation"); // This is the same explicit current-pose contract used by the Player; // snapshotJson's presentation pose is intentionally unsuitable here. Json physics{ {"dimension", dimension}, {"entities", Json::array({{{"parent", nullptr}, {bodyName, runtime.fields(handle, "faset." + bodyName)}, {"transform", {{"position", physical.position}, {"rotation", physical.rotation}, {"scale", physical.scale}}}}})}}; faset::render::Snapshot debug; const auto originalCamera = debug.view_projection; view.appendPhysicsDebug(debug, physics, .02f); check(debug.draws.size() == (dimension == 2 ? 4 : 12), "Box outline edge count"); check(debug.view_projection == originalCamera, "Debug overlay leaves camera unchanged"); faset::render::Vec3 center{}; for (const auto& edge : debug.draws) { check(!edge.cast_shadow && edge.mesh, "Debug edges are unshadowed meshes"); for (const auto& vertex : edge.mesh->vertices) check(vertex.normal == faset::render::Vec3{0, 0, 0}, "Debug edge color is unlit"); for (int axis = 0; axis < 3; ++axis) center[axis] += edge.model[12 + axis] / float(debug.draws.size()); } for (int axis = 0; axis < 3; ++axis) check(std::abs(center[axis] - physical.position[axis]) < .0001f, "Outlines center on current Box2D/Box3D pose"); // Rz(pi/2) turns the first X edge into world Y. Abs(scale) gives a // three-unit X edge and 1.5-unit local Y half extent in both adapters. const auto& first = debug.draws.front().model; check(std::abs(first[0]) < .0001f && std::abs(first[1] - 3) < .0001f && std::abs(first[12] - physical.position[0] - 1.5f) < .0001f, "Negative nonuniform scale and box rotation match physics shape policy"); if (dimension == 3) check(std::abs(first[14] - physical.position[2] + 1) < .0001f, "Box3D Z half extent includes absolute Z scale"); auto invalid = physics; invalid["entities"][0]["parent"] = "another-body"; rejects([&] { view.appendPhysicsDebug(debug, invalid); }, "Debug respects runtime root-only physical bodies"); if (dimension == 2) { invalid = physics; invalid["entities"][0]["transform"]["rotation"][0] = .5; rejects([&] { view.appendPhysicsDebug(debug, invalid); }, "Box2D debug rejects rotation outside Z"); } rejects([&] { view.appendPhysicsDebug(debug, physics, 0); }, "Debug thickness must be positive"); auto opaque = scene; opaque["entities"][0]["components"][1]["version"] = 2; opaque["entities"][0]["components"][1]["fields"] = "future representation"; faset::render::Snapshot skipped; view.appendPhysicsDebug(skipped, opaque); check(skipped.draws.empty(), "Debug does not interpret unknown body schema versions"); } } 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(std::uint32_t{1} >> (8 * i))); for (int i = 0; i < 8; ++i) cooked.push_back(static_cast(std::uint64_t(cbor.size()) >> (8 * i))); cooked.append(reinterpret_cast(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); physicsDebug(view); 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 opaque = scene; opaque["entities"][1]["components"][1]["version"] = 2; opaque["entities"][1]["components"][1]["fields"] = {{"asset", 42}, {"color", "future-format"}}; const auto unchanged = opaque; check(view.build(opaque, 1).draws.empty(), "Future mesh fields are opaque in editor preview"); check(!view.diagnostics().empty() && view.diagnostics()[0].find("unsupported faset.mesh") != std::string::npos, "Opaque preview component has an actionable diagnostic"); check(opaque == unchanged, "Preview preserves unknown component bytes"); rejects([&] { world.load(opaque); }, "Player runtime rejects unknown component versions"); opaque = scene; opaque["entities"][1]["components"][0]["version"] = 2; opaque["entities"][1]["components"][0]["fields"] = {{"position", "future-format"}}; snapshot = view.build(opaque, 1); check(snapshot.draws[0].model[12] == 1 && snapshot.draws[0].model[13] == 2, "Future transform fields are not interpreted as v1 coordinates"); 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(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(value)); for (char value : {0, 0, 1, 0, 2, 0}) geometry.push_back(value); faset::atomic_write(folder / "geometry.bin", geometry); const std::vector 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(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({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; } }