diff --git a/inc/cvc/physics/physics_codec.h b/inc/cvc/physics/physics_codec.h new file mode 100644 index 000000000..6fe2185ed --- /dev/null +++ b/inc/cvc/physics/physics_codec.h @@ -0,0 +1,76 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. +*/ + +#ifndef __CVC_PHYSICS_PHYSICS_CODEC_H__ +#define __CVC_PHYSICS_PHYSICS_CODEC_H__ + +#include + +namespace cvc { + +class app; +class state_codec_registry; + +namespace physics { + +// ---------------- +// cvc::physics — pose / body_state replication codec +// ---------------- +// A holon replicates a participant's avatar as a cvc::physics::body_state (or a +// bare cvc::physics::pose) carried in a state node's data(). For that payload to +// cross the cluster, the distributed-state pipeline needs the type to have both a +// registered data-type name and a (de)serialization codec in the shard's codec +// registry -- the same contract cvc::ariadne::stream::format_desc uses. +// +// The wire is little-endian, fixed-width, FIELD-BY-FIELD (never a struct memcpy, +// so trailing-bool padding and ABI differences never leak onto the wire): each +// double is its IEEE-754 binary64 bit pattern as 8 LE bytes (matching the +// built-in "cvc.f64.v1" double codec), and `active` is one byte. So the encoding +// is byte-identical across the (little-endian) fleet, which content-addressed +// blob offload and live/snapshot digest agreement both require. +// +// These helpers bridge cvc::physics types to the cvc::state codec registry; like +// the stream codec they are registered INTO a shard's (mutable) registry rather +// than baked into cvc::state, which sits below physics. + +// The canonical, cross-ABI-stable data-type names, used BOTH as the app's +// registerDataType name and the codec-registry key, so dataTypeName() resolves +// to this exact string rather than the compiler's mangled typeid name (an +// unregistered type would fall back to the mangled name, which differs across +// toolchains and would silently fail to replicate). +// +// Returned by an inline accessor (a plain string literal) rather than held in a +// namespace-scope constant: no static global to initialize or to export across a +// shared-library boundary (a `const char* const` data symbol is not exported by +// CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS, which covers functions only). +inline const char *body_state_type_name() { return "cvc::physics::body_state"; } +inline const char *pose_type_name() { return "cvc::physics::pose"; } + +// Register body_state + pose as named data types on `ctx`, so a node holding one +// in data() reports the stable name from dataTypeName(). Idempotent. +void register_physics_data_types(app &ctx); + +// Register the body_state + pose (de)serialization codecs into a shard's codec +// registry so a node's physics data() replicates cross-node. Call on every +// participating node's shard registry (alongside the built-in / volume-geometry +// codecs the shard installs itself). The registry key is body_state_type_name() / +// pose_type_name() -- identical to the registerDataType name above, so the key the +// emit path looks up (dataTypeName()) always matches a registered codec. +void register_physics_codecs(state_codec_registry ®istry); + +} // namespace physics +} // namespace cvc + +#endif // __CVC_PHYSICS_PHYSICS_CODEC_H__ diff --git a/src/cvc/CMakeLists.txt b/src/cvc/CMakeLists.txt index bf23c1a10..50ddc12b4 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -70,6 +70,7 @@ set(INCLUDE_FILES ../../inc/cvc/physics/world_geometry.h ../../inc/cvc/physics/plant_host.h ../../inc/cvc/physics/pose_tracker.h + ../../inc/cvc/physics/physics_codec.h ../../inc/cvc/physics/nav_plant.h ../../inc/cvc/physics/nav_eval.h ../../inc/cvc/physics/plant_log.h @@ -268,6 +269,7 @@ set(SOURCE_FILES physics/world_geometry.cpp physics/plant_host.cpp physics/pose_tracker.cpp + physics/physics_codec.cpp physics/binding_meta.cpp physics/nav_plant.cpp physics/nav_eval.cpp diff --git a/src/cvc/physics/physics_codec.cpp b/src/cvc/physics/physics_codec.cpp new file mode 100644 index 000000000..495caecc9 --- /dev/null +++ b/src/cvc/physics/physics_codec.cpp @@ -0,0 +1,164 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. +*/ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cvc { +namespace physics { + +namespace { + +// Little-endian, fixed-width framing (mirrors cvc::ariadne::stream::stream_codec) +// so the content is byte-identical across the little-endian fleet. +void put_u8(std::vector &o, std::uint8_t v) { o.push_back(v); } +void put_u64(std::vector &o, std::uint64_t v) { + for (int i = 0; i < 8; ++i) + o.push_back(static_cast((v >> (8 * i)) & 0xff)); +} +// A double as its IEEE-754 binary64 bit pattern, 8 LE bytes. memcpy (not a +// reinterpret or std::bit_cast) keeps it well-defined and C++17-clean; encode and +// decode are exact inverses on any single host and byte-identical across LE hosts, +// matching the built-in "cvc.f64.v1" double codec. +void put_f64(std::vector &o, double d) { + std::uint64_t bits = 0; + std::memcpy(&bits, &d, sizeof(bits)); + put_u64(o, bits); +} + +struct reader { + const unsigned char *p; + const unsigned char *e; + bool ok = true; + explicit reader(const std::vector &b) : p(b.data()), e(b.data() + b.size()) {} + std::uint8_t u8() { + if (p + 1 > e) { + ok = false; + return 0; + } + return *p++; + } + std::uint64_t u64() { + if (p + 8 > e) { + ok = false; + return 0; + } + std::uint64_t v = 0; + for (int i = 0; i < 8; ++i) + v |= static_cast(p[i]) << (8 * i); + p += 8; + return v; + } + double f64() { + std::uint64_t bits = u64(); + double d = 0.0; + std::memcpy(&d, &bits, sizeof(d)); + return d; + } +}; + +void put_vec3(std::vector &o, const vec3 &v) { + put_f64(o, v.x); + put_f64(o, v.y); + put_f64(o, v.z); +} +void put_quat(std::vector &o, const quat &q) { + put_f64(o, q.w); + put_f64(o, q.x); + put_f64(o, q.y); + put_f64(o, q.z); +} +vec3 read_vec3(reader &r) { + vec3 v; + v.x = r.f64(); + v.y = r.f64(); + v.z = r.f64(); + return v; +} +quat read_quat(reader &r) { + quat q; + q.w = r.f64(); + q.x = r.f64(); + q.y = r.f64(); + q.z = r.f64(); + return q; +} + +std::vector encode_body_state(const boost::any &v) { + const body_state s = boost::any_cast(v); + std::vector o; + put_vec3(o, s.position); + put_quat(o, s.rotation); + put_vec3(o, s.linear_velocity); + put_vec3(o, s.angular_velocity); + put_u8(o, s.active ? 1u : 0u); + return o; +} + +boost::any decode_body_state(const std::vector &b) { + reader r(b); + body_state s; + s.position = read_vec3(r); + s.rotation = read_quat(r); + s.linear_velocity = read_vec3(r); + s.angular_velocity = read_vec3(r); + s.active = r.u8() != 0; + if (!r.ok) + throw std::runtime_error("cvc::physics::body_state decode: truncated payload"); + return boost::any(s); +} + +std::vector encode_pose(const boost::any &v) { + const pose p = boost::any_cast(v); + std::vector o; + put_vec3(o, p.position); + put_quat(o, p.rotation); + return o; +} + +boost::any decode_pose(const std::vector &b) { + reader r(b); + pose p; + p.position = read_vec3(r); + p.rotation = read_quat(r); + if (!r.ok) + throw std::runtime_error("cvc::physics::pose decode: truncated payload"); + return boost::any(p); +} + +} // namespace + +void register_physics_data_types(app &ctx) { + ctx.registerDataType(body_state_type_name()); + ctx.registerDataType(pose_type_name()); +} + +void register_physics_codecs(state_codec_registry ®istry) { + registry.register_codec(body_state_type_name(), encode_body_state, decode_body_state, + "cvc.physics.body_state.v1"); + registry.register_codec(pose_type_name(), encode_pose, decode_pose, "cvc.physics.pose.v1"); +} + +} // namespace physics +} // namespace cvc diff --git a/src/cvc/tests/CMakeLists.txt b/src/cvc/tests/CMakeLists.txt index 3d563ae9a..ef000d3bc 100644 --- a/src/cvc/tests/CMakeLists.txt +++ b/src/cvc/tests/CMakeLists.txt @@ -107,6 +107,7 @@ add_executable(world_clock_test world_clock_test.cpp) add_executable(clock_driver_test clock_driver_test.cpp) add_executable(world_units_test world_units_test.cpp) add_executable(physics_world_test physics_world_test.cpp) +add_executable(physics_codec_test physics_codec_test.cpp) add_executable(physics_field_test physics_field_test.cpp) add_executable(physics_vehicle_test physics_vehicle_test.cpp) add_executable(physics_energy_test physics_energy_test.cpp) @@ -303,6 +304,7 @@ set(TEST_TARGETS clock_driver_test world_units_test physics_world_test + physics_codec_test physics_field_test physics_vehicle_test physics_energy_test @@ -1640,6 +1642,13 @@ target_link_libraries(physics_world_test GTest::gtest_main ) +target_link_libraries(physics_codec_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main +) + target_link_libraries(physics_vehicle_test PRIVATE cvc @@ -2038,6 +2047,7 @@ target_compile_features(world_clock_test PRIVATE cxx_std_17) target_compile_features(clock_driver_test PRIVATE cxx_std_17) target_compile_features(world_units_test PRIVATE cxx_std_17) target_compile_features(physics_world_test PRIVATE cxx_std_17) +target_compile_features(physics_codec_test PRIVATE cxx_std_17) target_compile_features(physics_field_test PRIVATE cxx_std_17) target_compile_features(physics_vehicle_test PRIVATE cxx_std_17) target_compile_features(physics_energy_test PRIVATE cxx_std_17) @@ -2493,6 +2503,7 @@ cvc_discover_tests(world_clock_test) cvc_discover_tests(clock_driver_test) cvc_discover_tests(world_units_test) cvc_discover_tests(physics_world_test) +cvc_discover_tests(physics_codec_test) cvc_discover_tests(physics_field_test) cvc_discover_tests(physics_vehicle_test) cvc_discover_tests(physics_energy_test) diff --git a/src/cvc/tests/physics_codec_test.cpp b/src/cvc/tests/physics_codec_test.cpp new file mode 100644 index 000000000..059b1780d --- /dev/null +++ b/src/cvc/tests/physics_codec_test.cpp @@ -0,0 +1,167 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. +*/ + +// Tests for cvc::physics::physics_codec -- the body_state / pose wire codec that +// lets a holon replicate a participant's avatar pose as a node's data(). The +// properties worth pinning: an exact (bit-for-bit, including NaN / -0.0 / +// denormals) round-trip, a truncated payload that throws rather than reading out +// of bounds, a byte-identical little-endian wire, and -- guarding the #767 S1 +// class of bug -- that the codec-registry key equals dataTypeName() so the +// emit path's lookup actually finds the codec. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using cvc::physics::body_state; +using cvc::physics::pose; +using cvc::physics::quat; +using cvc::physics::vec3; + +namespace { +// The type names come from the public inline accessors +// cvc::physics::body_state_type_name() / pose_type_name() -- being inline (not an +// exported data symbol) they link across a shared-library boundary on every +// platform, and there is no static global to carry them. + +// Bit pattern of a double, so NaN / -0.0 / denormals compare exactly. +std::uint64_t bits(double d) { + std::uint64_t u = 0; + std::memcpy(&u, &d, sizeof(u)); + return u; +} +void expect_vec3_bit_eq(const vec3 &a, const vec3 &b) { + EXPECT_EQ(bits(a.x), bits(b.x)); + EXPECT_EQ(bits(a.y), bits(b.y)); + EXPECT_EQ(bits(a.z), bits(b.z)); +} +void expect_quat_bit_eq(const quat &a, const quat &b) { + EXPECT_EQ(bits(a.w), bits(b.w)); + EXPECT_EQ(bits(a.x), bits(b.x)); + EXPECT_EQ(bits(a.y), bits(b.y)); + EXPECT_EQ(bits(a.z), bits(b.z)); +} + +// A body_state with every field distinct and several nasty doubles. +body_state sample_body_state() { + body_state s; + s.position = {1.5, -2.25, 3.0e300}; + s.rotation = {0.5, -0.5, 0.5, -0.5}; + s.linear_velocity = {std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity(), -0.0}; + s.angular_velocity = {std::numeric_limits::denorm_min(), -1e-9, 42.0}; + s.active = true; + return s; +} +} // namespace + +// ── a shard-registry round-trip (the real usage) ──────────────────────────── + +class PhysicsCodecTest : public ::testing::Test { +protected: + cvc::app ctx; + cvc::state_codec_registry reg; + void SetUp() override { + cvc::physics::register_physics_data_types(ctx); + cvc::physics::register_physics_codecs(reg); + } +}; + +TEST_F(PhysicsCodecTest, BodyStateRoundTripsBitForBit) { + const body_state in = sample_body_state(); + const std::vector wire = + reg.encode(cvc::physics::body_state_type_name(), boost::any(in)); + const boost::any back = reg.decode(cvc::physics::body_state_type_name(), wire); + ASSERT_FALSE(back.empty()); + const body_state out = boost::any_cast(back); + expect_vec3_bit_eq(in.position, out.position); + expect_quat_bit_eq(in.rotation, out.rotation); + expect_vec3_bit_eq(in.linear_velocity, out.linear_velocity); // NaN / Inf / -0.0 survive + expect_vec3_bit_eq(in.angular_velocity, out.angular_velocity); + EXPECT_EQ(in.active, out.active); +} + +TEST_F(PhysicsCodecTest, PoseRoundTripsBitForBit) { + pose in; + in.position = {-7.0, 0.0, 12345.678}; + in.rotation = {1.0, 0.0, 0.0, 0.0}; + const std::vector wire = + reg.encode(cvc::physics::pose_type_name(), boost::any(in)); + const pose out = boost::any_cast(reg.decode(cvc::physics::pose_type_name(), wire)); + expect_vec3_bit_eq(in.position, out.position); + expect_quat_bit_eq(in.rotation, out.rotation); +} + +TEST_F(PhysicsCodecTest, RegistryHasBothCodecs) { + EXPECT_TRUE(reg.has(cvc::physics::body_state_type_name())); + EXPECT_TRUE(reg.has(cvc::physics::pose_type_name())); +} + +// The #767 S1 class of bug: if the registry key differed from what dataTypeName() +// returns, the emit path's _codecs->encode(dataTypeName(), ...) would miss and the +// object would silently fail to replicate. Pin that they are identical. +TEST_F(PhysicsCodecTest, RegistryKeyEqualsDataTypeName) { + EXPECT_EQ(ctx.dataTypeName(), std::string(cvc::physics::body_state_type_name())); + EXPECT_EQ(ctx.dataTypeName(), std::string(cvc::physics::pose_type_name())); +} + +// ── wire shape: fixed-width little-endian, field-by-field ─────────────────── + +TEST_F(PhysicsCodecTest, WireIsFixedWidthLittleEndian) { + body_state s; // all zero, then set position.x = 1.0 and active = true + s.position.x = 1.0; + s.active = true; + const std::vector wire = + reg.encode(cvc::physics::body_state_type_name(), boost::any(s)); + // 13 doubles (pos.xyz, rot.wxyz, linvel.xyz, angvel.xyz) * 8 + 1 active byte. + ASSERT_EQ(wire.size(), 13u * 8u + 1u); + // position.x == 1.0 -> IEEE-754 0x3FF0000000000000, little-endian first field. + const unsigned char one_le[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x3F}; + for (int i = 0; i < 8; ++i) + EXPECT_EQ(wire[i], one_le[i]) << "byte " << i; + // the very next field (position.y == 0.0) is eight zero bytes. + for (int i = 8; i < 16; ++i) + EXPECT_EQ(wire[i], 0x00) << "byte " << i; + EXPECT_EQ(wire.back(), 0x01); // active = true, the trailing byte +} + +TEST_F(PhysicsCodecTest, PoseWireIsSevenDoubles) { + const std::vector wire = + reg.encode(cvc::physics::pose_type_name(), boost::any(pose{})); + EXPECT_EQ(wire.size(), 7u * 8u); +} + +// ── truncation is rejected, not read out of bounds ────────────────────────── + +TEST_F(PhysicsCodecTest, TruncatedBodyStateThrows) { + const std::vector full = + reg.encode(cvc::physics::body_state_type_name(), boost::any(sample_body_state())); + for (std::size_t n : {std::size_t{0}, std::size_t{1}, full.size() - 1, full.size() - 8}) { + const std::vector short_wire(full.begin(), full.begin() + n); + EXPECT_THROW(reg.decode(cvc::physics::body_state_type_name(), short_wire), std::exception) + << "n=" << n; + } +} + +TEST_F(PhysicsCodecTest, TruncatedPoseThrows) { + const std::vector full = + reg.encode(cvc::physics::pose_type_name(), boost::any(pose{vec3{1, 2, 3}, quat{}})); + const std::vector short_wire(full.begin(), full.end() - 1); + EXPECT_THROW(reg.decode(cvc::physics::pose_type_name(), short_wire), std::exception); +}