From 72f454b1dde79b0cd5ddf900564c503d9536aeba Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 22 Oct 2026 12:42:58 -0500 Subject: [PATCH 1/2] feat(ariadne): headless avatar predict/reconcile/interpolate (H1 slice 2) The one genuinely new mechanism H1 needs on top of the clock_driver + data() replication it already has: a participant's avatar is server-authoritative, so a client must move its own avatar without a round-trip (predict), correct when the authoritative state arrives (reconcile), and show peers lag-free without guessing ahead (interpolate). New cvc::ariadne (headless, engine-free, no GL / state tree / transport -- that plumbing is the holon, slice 3): - avatar_sim: a deterministic linear kinematic integrator over a fixed dt (position += velocity*dt, pure double arithmetic, no trig), so the same intents reproduce bit-for-bit -- what makes replay sound. A stub for the physics::world save/restore stepping slice 3 swaps in behind the same shape. - predicted_avatar: client prediction + server reconciliation. Applies local intents immediately, logs the unacknowledged ones; on an authoritative state for a past tick, keeps the prediction when it already matches within tolerance (no correction), else snaps to the authoritative state and REPLAYS the inputs issued after that tick, so in-flight player input is corrected-on-top, not dropped. - interpolate_peer: a non-local peer between its last two authoritative samples, alpha clamped to [0,1) so it never extrapolates past newest. avatar_sim_test (6 cases, all green): bit-reproducible stepping; a perfect prediction reconciles with no correction; prediction ahead of the server keeps inputs pending; a server override snaps + replays in-flight ticks (and really moves the avatar); a matching ack after a correction does not re-snap or jump backward; peer interpolation never overshoots (and clamps negative/NaN alpha to the previous sample). All comparisons are lib-step vs lib-step, so bit-equality holds without -ffp-contract=off. Branches off master (has #766 clock_driver + #767 H0). Independent of #768 (the physics codec); the MVP pose rides scalar keys, so this does not need the typed-object codec. Next: S3 wires this into a cvc::ariadne::holon on its own clock_driver. --- inc/cvc/ariadne/avatar_sim.h | 124 +++++++++++++++++++++ src/cvc/CMakeLists.txt | 3 +- src/cvc/ariadne/avatar_sim.cpp | 97 ++++++++++++++++ src/cvc/tests/CMakeLists.txt | 11 ++ src/cvc/tests/avatar_sim_test.cpp | 179 ++++++++++++++++++++++++++++++ 5 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 inc/cvc/ariadne/avatar_sim.h create mode 100644 src/cvc/ariadne/avatar_sim.cpp create mode 100644 src/cvc/tests/avatar_sim_test.cpp diff --git a/inc/cvc/ariadne/avatar_sim.h b/inc/cvc/ariadne/avatar_sim.h new file mode 100644 index 000000000..db66e335e --- /dev/null +++ b/inc/cvc/ariadne/avatar_sim.h @@ -0,0 +1,124 @@ +/* + 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_ARIADNE_AVATAR_SIM_H__ +#define __CVC_ARIADNE_AVATAR_SIM_H__ + +#include +#include +#include // cvc::physics::body_state +#include // cvc::physics::pose / interpolate +#include // cvc::physics::vec3 +#include + +namespace cvc { +namespace ariadne { + +// ---------------- +// cvc::ariadne — headless avatar prediction / reconciliation (H1 slice 2) +// ---------------- +// The one genuinely new mechanism H1 needs on top of the clock + replication it +// already has: a participant's avatar is server-authoritative, but a client must +// move its OWN avatar without waiting a round-trip, so it PREDICTS locally and +// RECONCILES when the server's authoritative state arrives; peers it never +// predicts, only interpolates between their last two authoritative samples. +// +// This is deliberately headless and engine-free: pure double arithmetic over a +// fixed timestep, no cvc::physics::world (Jolt), no GL, no state tree, no +// transport. That isolates the loop's correctness -- which is what is subtle -- +// from the plumbing (the holon owns the clock_driver and the state wiring; that is +// slice 3+). The integrator is a linear stub; slice 3 swaps it for +// physics::world save/restore stepping behind the same predict/reconcile shape. + +// The per-tick input that drives an avatar: a desired world-space linear velocity. +// Purely linear (no orientation integration) so the stub needs no trig and is +// trivially bit-reproducible across platforms. +struct avatar_intent { + cvc::physics::vec3 move; // desired linear velocity, world metres/second +}; + +// A deterministic, headless kinematic avatar: integrate an avatar_intent over +// fixed-dt quanta into a cvc::physics::body_state. position += velocity*dt and +// velocity = move; rotation is carried unchanged. Pure double arithmetic, so the +// same intents from the same start at the same dt reproduce BIT-FOR-BIT -- the +// property prediction and reconciliation both rely on. +class avatar_sim { +public: + avatar_sim() = default; + explicit avatar_sim(const cvc::physics::body_state &start) : _state(start) {} + + const cvc::physics::body_state &state() const { return _state; } + void set_state(const cvc::physics::body_state &s) { _state = s; } + + // Advance exactly one fixed quantum from `in`. + void step(const avatar_intent &in, double dt); + +private: + cvc::physics::body_state _state; +}; + +// Client-side prediction + server reconciliation for ONE locally-controlled +// avatar on a fixed-dt clock. The client applies its own intents immediately +// (predict), keeping a log of inputs the server has not yet acknowledged; when an +// authoritative state for a past tick arrives it reconciles: if the state the +// client predicted at that tick already matches the authoritative one within `tol` +// metres, it keeps its prediction (no visible correction); otherwise it snaps to +// the authoritative state and REPLAYS the inputs issued after that tick, so the +// current prediction is corrected without discarding the player's in-flight input. +// +// Deterministic: with matching inputs and dt the replay reproduces the prediction +// exactly, so a correct prediction reconciles to a bit-identical state and costs +// nothing; only a genuine divergence (a server override, a dropped step) snaps. +class predicted_avatar { +public: + predicted_avatar() = default; + explicit predicted_avatar(const cvc::physics::body_state &start) : _sim(start) {} + + // Apply the local player's intent for `tick` and advance the prediction. Ticks + // are expected monotonically increasing (the clock's quanta). + void predict(std::uint64_t tick, const avatar_intent &in, double dt); + + // Reconcile against an authoritative state valid as of `server_tick` + // (expected monotonically non-decreasing across calls). Returns true if a hard + // correction (snap + replay) was applied, false if the prediction already held. + bool reconcile(const cvc::physics::body_state &authoritative, std::uint64_t server_tick, + double dt, double tol); + + const cvc::physics::body_state &state() const { return _sim.state(); } + // Inputs issued but not yet acknowledged by the server (awaiting replay). + std::size_t pending() const { return _log.size(); } + +private: + struct logged { + std::uint64_t tick = 0; + avatar_intent in; + cvc::physics::body_state predicted; // state AFTER applying `in` at `tick` + }; + avatar_sim _sim; + std::deque _log; +}; + +// Interpolate a NON-local peer avatar between its last two authoritative samples, +// NEVER extrapolating: alpha is clamped to [0, 1), so the result never passes the +// newest sample (a one-step display lag is correct; extrapolation would overshoot +// on a late sample). Thin wrapper over cvc::physics::interpolate on the pose +// (position + rotation) of each body_state. +cvc::physics::pose interpolate_peer(const cvc::physics::body_state &prev, + const cvc::physics::body_state &newest, double alpha); + +} // namespace ariadne +} // namespace cvc + +#endif // __CVC_ARIADNE_AVATAR_SIM_H__ diff --git a/src/cvc/CMakeLists.txt b/src/cvc/CMakeLists.txt index bf23c1a10..b295b3466 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -1306,11 +1306,12 @@ list(APPEND INCLUDE_FILES ../../inc/cvc/net/http_client.h) # the retained Widget tree, the Runtime + reconcile boundary, and direct # cvc::state binding, walking a pluggable Backend. It touches NO VTK/ImGui/GL — # the ImGui-over-VTK backend lives in cvcGL (src/cvcGL/ariadne). Roadmap §16.1. -list(APPEND SOURCE_FILES ariadne/ariadne.cpp ariadne/loader.cpp ariadne/uri.cpp ariadne/uri_state.cpp ariadne/uri_http.cpp ariadne/uri_http_cache.cpp ariadne/net_intrinsics.cpp ariadne/stream_intrinsics.cpp ariadne/conference/room_intrinsics.cpp ariadne/state_io.cpp ariadne/ftxui_backend.cpp ariadne/term_graphics.cpp ariadne/tvision_backend.cpp ariadne/app_runtime.cpp ariadne/doc_args.cpp ariadne/host_cli.cpp ariadne/host_signals.cpp ariadne/requires.cpp ariadne/host_info.cpp) +list(APPEND SOURCE_FILES ariadne/ariadne.cpp ariadne/loader.cpp ariadne/uri.cpp ariadne/uri_state.cpp ariadne/uri_http.cpp ariadne/uri_http_cache.cpp ariadne/net_intrinsics.cpp ariadne/stream_intrinsics.cpp ariadne/conference/room_intrinsics.cpp ariadne/state_io.cpp ariadne/ftxui_backend.cpp ariadne/term_graphics.cpp ariadne/tvision_backend.cpp ariadne/app_runtime.cpp ariadne/doc_args.cpp ariadne/host_cli.cpp ariadne/host_signals.cpp ariadne/requires.cpp ariadne/host_info.cpp ariadne/avatar_sim.cpp) list(APPEND INCLUDE_FILES ../../inc/cvc/ariadne/widget.h ../../inc/cvc/ariadne/backend.h ../../inc/cvc/ariadne/ariadne.h + ../../inc/cvc/ariadne/avatar_sim.h ../../inc/cvc/ariadne/app_runtime.h ../../inc/cvc/ariadne/doc_args.h ../../inc/cvc/ariadne/host_cli.h diff --git a/src/cvc/ariadne/avatar_sim.cpp b/src/cvc/ariadne/avatar_sim.cpp new file mode 100644 index 000000000..938106d7e --- /dev/null +++ b/src/cvc/ariadne/avatar_sim.cpp @@ -0,0 +1,97 @@ +/* + 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 + +namespace cvc { +namespace ariadne { + +void avatar_sim::step(const avatar_intent &in, double dt) { + // Linear kinematic integration: the body moves at the requested velocity. Pure + // double arithmetic (vec3 + and *), no trig, so it is bit-reproducible. + _state.linear_velocity = in.move; + _state.position = _state.position + in.move * dt; + _state.active = true; +} + +void predicted_avatar::predict(std::uint64_t tick, const avatar_intent &in, double dt) { + _sim.step(in, dt); + logged e; + e.tick = tick; + e.in = in; + e.predicted = _sim.state(); + _log.push_back(e); +} + +bool predicted_avatar::reconcile(const cvc::physics::body_state &authoritative, + std::uint64_t server_tick, double dt, double tol) { + // What did we predict AT server_tick? (null if we never predicted that tick -- + // e.g. the server is ahead of us, or that input was already acknowledged.) + const logged *at = nullptr; + for (const auto &e : _log) + if (e.tick == server_tick) { + at = &e; + break; + } + + const double tol2 = tol * tol; + const auto within_tol = [&](const cvc::physics::body_state &a, + const cvc::physics::body_state &b) { + const cvc::physics::vec3 d = a.position - b.position; + return cvc::physics::dot(d, d) <= tol2; + }; + + if (at != nullptr && within_tol(at->predicted, authoritative)) { + // Prediction held: the server agrees with what we had at server_tick, so the + // inputs after it (which we already applied) still stand. Just drop the inputs + // the server has now acknowledged; keep predicting forward from our state. + while (!_log.empty() && _log.front().tick <= server_tick) + _log.pop_front(); + return false; + } + + // Diverged (a server override, a dropped/extra step, or we have no record of + // server_tick): adopt the authoritative state, then re-apply the inputs we + // issued AFTER server_tick so the player's in-flight motion is preserved on top + // of the correction rather than lost. + std::deque replay; + for (const auto &e : _log) + if (e.tick > server_tick) + replay.push_back(e); + + _sim.set_state(authoritative); + _log.clear(); + for (logged &e : replay) { + _sim.step(e.in, dt); + e.predicted = _sim.state(); + _log.push_back(e); + } + return true; +} + +cvc::physics::pose interpolate_peer(const cvc::physics::body_state &prev, + const cvc::physics::body_state &newest, double alpha) { + if (!(alpha >= 0.0)) // also catches NaN + alpha = 0.0; + if (alpha >= 1.0) + alpha = std::nextafter(1.0, 0.0); // never reach or pass the newest sample + const cvc::physics::pose a{prev.position, prev.rotation}; + const cvc::physics::pose b{newest.position, newest.rotation}; + return cvc::physics::interpolate(a, b, alpha); +} + +} // namespace ariadne +} // namespace cvc diff --git a/src/cvc/tests/CMakeLists.txt b/src/cvc/tests/CMakeLists.txt index 3d563ae9a..efb4348f7 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(avatar_sim_test avatar_sim_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 + avatar_sim_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(avatar_sim_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(avatar_sim_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(avatar_sim_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/avatar_sim_test.cpp b/src/cvc/tests/avatar_sim_test.cpp new file mode 100644 index 000000000..626bd7adf --- /dev/null +++ b/src/cvc/tests/avatar_sim_test.cpp @@ -0,0 +1,179 @@ +/* + 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::ariadne avatar prediction / reconciliation (H1 slice 2). The +// properties worth pinning are the ones a real netcode loop lives or dies on: +// deterministic integration (so replay reproduces prediction), a perfect +// prediction that reconciles with NO correction, a server override that snaps the +// client and REPLAYS its in-flight inputs rather than dropping them, and a peer +// interpolation that never extrapolates past the newest sample. +// +// Every comparison here is lib-step vs lib-step (both the server reference and the +// client go through cvc::ariadne::avatar_sim::step), so bit-equality holds without +// -ffp-contract=off: there is no test-side recomputation of the lib's inline math. + +#include +#include +#include +#include +#include +#include +#include +#include + +using cvc::ariadne::avatar_intent; +using cvc::ariadne::avatar_sim; +using cvc::ariadne::interpolate_peer; +using cvc::ariadne::predicted_avatar; +using cvc::physics::body_state; +using cvc::physics::vec3; + +namespace { +std::uint64_t bits(double d) { + std::uint64_t u = 0; + std::memcpy(&u, &d, sizeof(u)); + return u; +} +// Bit-for-bit equality of two positions (exact; catches a 1-ulp replay drift). +void expect_pos_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)); +} +constexpr double k120 = 1.0 / 120.0; + +// A varied, non-trivial intent for tick t (distinct components, not all equal). +avatar_intent intent_at(std::uint64_t t) { + const double f = static_cast(t); + return avatar_intent{vec3{0.1 * f, -0.05 * f, 0.2}}; +} +} // namespace + +// ── determinism ───────────────────────────────────────────────────────────── + +TEST(AvatarSimTest, StepIsBitReproducible) { + avatar_sim a, b; + for (std::uint64_t t = 1; t <= 200; ++t) { + const avatar_intent in = intent_at(t); + a.step(in, k120); + b.step(in, k120); + } + expect_pos_bit_eq(a.state().position, b.state().position); + expect_pos_bit_eq(a.state().linear_velocity, b.state().linear_velocity); +} + +// ── perfect prediction costs nothing ──────────────────────────────────────── + +TEST(AvatarSimTest, PerfectPredictionReconcilesWithNoCorrection) { + avatar_sim server; // authoritative + predicted_avatar client; // predicts the same inputs + for (std::uint64_t t = 1; t <= 50; ++t) { + const avatar_intent in = intent_at(t); + client.predict(t, in, k120); + server.step(in, k120); + const bool corrected = client.reconcile(server.state(), t, k120, 1e-9); + EXPECT_FALSE(corrected) << "a matching prediction must not snap (t=" << t << ")"; + expect_pos_bit_eq(client.state().position, server.state().position); + } + EXPECT_EQ(client.pending(), 0u) << "all inputs acknowledged"; +} + +// Client runs AHEAD of the server (prediction under latency): it predicts several +// ticks, the server acknowledges older ticks with matching inputs. No correction, +// and the unacknowledged inputs stay pending. +TEST(AvatarSimTest, PredictionAheadOfServerKeepsPendingInputs) { + avatar_sim server; + predicted_avatar client; + std::vector issued; + for (std::uint64_t t = 1; t <= 5; ++t) { + const avatar_intent in = intent_at(t); + issued.push_back(in); + client.predict(t, in, k120); // client is now 5 ticks ahead + } + // Server has only integrated up to tick 2 so far. + for (std::uint64_t t = 1; t <= 2; ++t) + server.step(issued[t - 1], k120); + const bool corrected = client.reconcile(server.state(), 2, k120, 1e-9); + EXPECT_FALSE(corrected); + EXPECT_EQ(client.pending(), 3u) << "inputs for ticks 3,4,5 remain unacknowledged"; +} + +// ── divergence snaps + replays, preserving in-flight input ────────────────── + +TEST(AvatarSimTest, ServerOverrideSnapsAndReplaysInFlightInputs) { + body_state start; // origin + predicted_avatar client(start); + const avatar_intent mv{vec3{1.0, 0.0, 0.0}}; // moving +x at 1 m/s + for (std::uint64_t t = 1; t <= 5; ++t) + client.predict(t, mv, k120); // client predicts x = 5*k120 + + // The server overrides the avatar at tick 3 (a teleport the client could not + // predict), with the same ongoing velocity. + body_state auth; + auth.position = vec3{100.0, 0.0, 0.0}; + auth.linear_velocity = mv.move; + const bool corrected = client.reconcile(auth, /*server_tick=*/3, k120, 0.01); + EXPECT_TRUE(corrected) << "a divergent authoritative state must snap"; + + // Expected: adopt auth@3, then REPLAY the in-flight inputs for ticks 4 and 5. + avatar_sim ref(auth); + ref.step(mv, k120); + ref.step(mv, k120); + expect_pos_bit_eq(client.state().position, ref.state().position); + EXPECT_EQ(client.pending(), 2u) << "ticks 4,5 are still in flight (not yet acked)"; + // The correction is real: the client moved from ~0.04 m to ~100 m. + EXPECT_GT(client.state().position.x, 99.0); +} + +// A later matching ack after a correction settles cleanly (no second snap). +TEST(AvatarSimTest, AckAfterCorrectionDoesNotReSnap) { + predicted_avatar client; + const avatar_intent mv{vec3{2.0, 0.0, 0.0}}; + for (std::uint64_t t = 1; t <= 4; ++t) + client.predict(t, mv, k120); + body_state auth; + auth.position = vec3{50.0, 0.0, 0.0}; + auth.linear_velocity = mv.move; + ASSERT_TRUE(client.reconcile(auth, 2, k120, 0.01)); // snap at 2, replay 3,4 + const body_state after_snap = client.state(); + + // Now the server, having adopted the same post-snap trajectory, acks tick 3 + // with the state the client now holds for tick 3 (auth@2 + one replay step). + avatar_sim server(auth); + server.step(mv, k120); // server's tick-3 state + const bool corrected = client.reconcile(server.state(), 3, k120, 0.01); + EXPECT_FALSE(corrected) << "a matching ack after a correction must not re-snap"; + EXPECT_EQ(client.pending(), 1u) << "only tick 4 remains"; + // State advanced forward, never jumped backward. + EXPECT_GE(client.state().position.x, after_snap.position.x); +} + +// ── peer interpolation never extrapolates ─────────────────────────────────── + +TEST(AvatarSimTest, PeerInterpolationNeverOvershoots) { + body_state prev, newest; + prev.position = vec3{0.0, 0.0, 0.0}; + prev.rotation = cvc::physics::quat{1.0, 0.0, 0.0, 0.0}; + newest.position = vec3{10.0, 0.0, 0.0}; + newest.rotation = cvc::physics::quat{1.0, 0.0, 0.0, 0.0}; + + EXPECT_DOUBLE_EQ(interpolate_peer(prev, newest, 0.0).position.x, 0.0); // alpha 0 = prev exactly + EXPECT_NEAR(interpolate_peer(prev, newest, 0.5).position.x, 5.0, 1e-12); + + // alpha >= 1 is clamped below 1: the result approaches but never reaches or + // passes the newest sample (no extrapolation / overshoot). + EXPECT_LT(interpolate_peer(prev, newest, 1.0).position.x, 10.0); + EXPECT_LT(interpolate_peer(prev, newest, 99.0).position.x, 10.0); + EXPECT_GT(interpolate_peer(prev, newest, 99.0).position.x, 9.0); + + // Negative / NaN alpha clamps to prev (no behind-the-start extrapolation). + EXPECT_DOUBLE_EQ(interpolate_peer(prev, newest, -5.0).position.x, 0.0); + EXPECT_DOUBLE_EQ(interpolate_peer(prev, newest, std::nan("")).position.x, 0.0); +} From 2de2bb2e7c991a4fa7f8dda2fe743319dec5d1f4 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 22 Oct 2026 12:56:19 -0500 Subject: [PATCH 2/2] fix(ariadne): guard reconcile against stale/duplicate acks; pin tol + integrator tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the predict/reconcile mechanism surfaced three items, all addressed here: - [med] predicted_avatar::reconcile had no guard against a non-monotonic server_tick. A reordered or duplicate ack (tick <= one already reconciled) failed the lookup, fell into the diverged branch, and snapped the avatar back to a stale authoritative state while dropping the inputs between the two acks (a visible backward rubber-band). Real datagram transports reorder/duplicate, so this was a live latent hazard for S3's caller. Fix: track the highest reconciled server_tick and no-op (return false, touch nothing) on any ack at or below it. Test: StaleOrDuplicateAckIsIgnored. - [med] the held-vs-snap tolerance was never exercised at a nonzero distance — every "no correction" test had a bit-identical prediction (d==0), so a tol/tol2 or threshold-direction regression would ship green. Test: ReconcileHoldsWithinToleranceAndSnapsBeyond (0.5*tol held, 2*tol snaps). - [low] the determinism test was tautological (two in-process runs of a pure function). Replaced with IntegratesConstantVelocityToClosedForm: a closed-form n*v*dt check (EXPECT_NEAR, not a bit-exact golden, so FMA contraction across toolchains can't flip it) that catches a formula regression, plus a retained purity assertion. Noted for S3 (dismissed as out-of-scope for the headless stub): the tolerance is position-only (fine while the stub integrates no rotation; revisit when physics::world adds orientation), and _log is unbounded if a caller never acks (a real server acks every tick). avatar_sim_test now 8 cases, all green. --- inc/cvc/ariadne/avatar_sim.h | 12 +++- src/cvc/ariadne/avatar_sim.cpp | 10 ++++ src/cvc/tests/avatar_sim_test.cpp | 92 ++++++++++++++++++++++++++++--- 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/inc/cvc/ariadne/avatar_sim.h b/inc/cvc/ariadne/avatar_sim.h index db66e335e..a35947323 100644 --- a/inc/cvc/ariadne/avatar_sim.h +++ b/inc/cvc/ariadne/avatar_sim.h @@ -90,9 +90,13 @@ class predicted_avatar { // are expected monotonically increasing (the clock's quanta). void predict(std::uint64_t tick, const avatar_intent &in, double dt); - // Reconcile against an authoritative state valid as of `server_tick` - // (expected monotonically non-decreasing across calls). Returns true if a hard - // correction (snap + replay) was applied, false if the prediction already held. + // Reconcile against an authoritative state valid as of `server_tick`. Returns + // true if a hard correction (snap + replay) was applied, false if the prediction + // already held OR the ack was ignored. A `server_tick` at or below the highest + // one already reconciled is a no-op (returns false, touches nothing): a + // duplicate or reordered/stale ack -- which real datagram transports produce -- + // must not snap the avatar back to an old authoritative state and discard + // progress, so only a strictly newer ack is acted on. bool reconcile(const cvc::physics::body_state &authoritative, std::uint64_t server_tick, double dt, double tol); @@ -108,6 +112,8 @@ class predicted_avatar { }; avatar_sim _sim; std::deque _log; + std::uint64_t _last_acked = 0; // highest server_tick reconciled; guards non-monotonic acks + bool _acked = false; // whether _last_acked is meaningful yet }; // Interpolate a NON-local peer avatar between its last two authoritative samples, diff --git a/src/cvc/ariadne/avatar_sim.cpp b/src/cvc/ariadne/avatar_sim.cpp index 938106d7e..0478d6f4a 100644 --- a/src/cvc/ariadne/avatar_sim.cpp +++ b/src/cvc/ariadne/avatar_sim.cpp @@ -38,6 +38,16 @@ void predicted_avatar::predict(std::uint64_t tick, const avatar_intent &in, doub bool predicted_avatar::reconcile(const cvc::physics::body_state &authoritative, std::uint64_t server_tick, double dt, double tol) { + // A stale, duplicate or reordered ack (<= the highest tick we have already + // reconciled) is a no-op: acting on it would fail the lookup below (that tick + // was pruned), fall into the diverged branch, and snap the avatar back to an old + // authoritative state while discarding the inputs between the two acks. Only a + // strictly newer ack advances the reconciliation. + if (_acked && server_tick <= _last_acked) + return false; + _last_acked = server_tick; + _acked = true; + // What did we predict AT server_tick? (null if we never predicted that tick -- // e.g. the server is ahead of us, or that input was already acknowledged.) const logged *at = nullptr; diff --git a/src/cvc/tests/avatar_sim_test.cpp b/src/cvc/tests/avatar_sim_test.cpp index 626bd7adf..a5b1a28b1 100644 --- a/src/cvc/tests/avatar_sim_test.cpp +++ b/src/cvc/tests/avatar_sim_test.cpp @@ -56,17 +56,35 @@ avatar_intent intent_at(std::uint64_t t) { } } // namespace -// ── determinism ───────────────────────────────────────────────────────────── - -TEST(AvatarSimTest, StepIsBitReproducible) { +// ── integration correctness + determinism ────────────────────────────────── + +TEST(AvatarSimTest, IntegratesConstantVelocityToClosedForm) { + // Closed form: after n fixed steps at constant velocity v the position is n*v*dt + // (to the rounding of the iterated sum) and the velocity equals the last intent. + // This catches a formula regression (sign, scale, a missing dt) that a + // "same function twice -> same answer" comparison never could. EXPECT_NEAR, not a + // bit-exact golden, so an FMA-contraction difference across toolchains (the + // #747/#750 class) can't flip it red. + avatar_sim sim; + const avatar_intent v{vec3{3.0, -2.0, 0.5}}; + const int n = 10; + for (int i = 0; i < n; ++i) + sim.step(v, k120); + EXPECT_NEAR(sim.state().position.x, n * 3.0 * k120, 1e-12); + EXPECT_NEAR(sim.state().position.y, n * -2.0 * k120, 1e-12); + EXPECT_NEAR(sim.state().position.z, n * 0.5 * k120, 1e-12); + EXPECT_DOUBLE_EQ(sim.state().linear_velocity.x, 3.0); + EXPECT_TRUE(sim.state().active); + + // And it is a pure function of its inputs (no hidden/global state): a second + // instance fed the same varied intents reaches a bit-identical state -- the + // purity replay depends on. avatar_sim a, b; - for (std::uint64_t t = 1; t <= 200; ++t) { - const avatar_intent in = intent_at(t); - a.step(in, k120); - b.step(in, k120); + for (std::uint64_t t = 1; t <= 64; ++t) { + a.step(intent_at(t), k120); + b.step(intent_at(t), k120); } expect_pos_bit_eq(a.state().position, b.state().position); - expect_pos_bit_eq(a.state().linear_velocity, b.state().linear_velocity); } // ── perfect prediction costs nothing ──────────────────────────────────────── @@ -155,6 +173,64 @@ TEST(AvatarSimTest, AckAfterCorrectionDoesNotReSnap) { EXPECT_GE(client.state().position.x, after_snap.position.x); } +// The tolerance decides held-vs-snap: a sub-tolerance divergence is held (keep the +// prediction), a super-tolerance one snaps. Pins the squared-distance comparison +// (and its direction) at the boundary -- the d==0 "no correction" tests above +// exercise it only at zero distance. +TEST(AvatarSimTest, ReconcileHoldsWithinToleranceAndSnapsBeyond) { + const double tol = 0.1; + const avatar_intent still{vec3{0.0, 0.0, 0.0}}; // client predicts staying at origin + { + predicted_avatar c; + c.predict(1, still, k120); + body_state auth; + auth.position = vec3{0.5 * tol, 0.0, 0.0}; // within tolerance + EXPECT_FALSE(c.reconcile(auth, 1, k120, tol)) << "sub-tolerance divergence must be held"; + EXPECT_NEAR(c.state().position.x, 0.0, 1e-12) << "held keeps the prediction, not the nudge"; + } + { + predicted_avatar c; + c.predict(1, still, k120); + body_state auth; + auth.position = vec3{2.0 * tol, 0.0, 0.0}; // beyond tolerance + EXPECT_TRUE(c.reconcile(auth, 1, k120, tol)) << "super-tolerance divergence must snap"; + EXPECT_NEAR(c.state().position.x, 2.0 * tol, 1e-12) << "snapped to authoritative"; + } +} + +// A stale / duplicate / reordered ack (server_tick at or below one already +// reconciled) is a no-op: it must not snap the avatar back to an old authoritative +// state nor drop progress. Guards the non-monotonic-ack hazard. +TEST(AvatarSimTest, StaleOrDuplicateAckIsIgnored) { + predicted_avatar client; + const avatar_intent mv{vec3{1.0, 0.0, 0.0}}; + for (std::uint64_t t = 1; t <= 10; ++t) + client.predict(t, mv, k120); + + // An accepted, matching ack at tick 7 (built by the identical step path, so it is + // held, not snapped): prunes ticks 1..7, leaving 8,9,10 pending. + avatar_sim ref; + for (int i = 0; i < 7; ++i) + ref.step(mv, k120); + ASSERT_FALSE(client.reconcile(ref.state(), 7, k120, 1e-9)); + const body_state after7 = client.state(); + ASSERT_EQ(client.pending(), 3u); + + // A reordered ack for an older tick 5 (far from the current state) must be + // ignored -- no snap backward, no loss. + body_state stale; + stale.position = vec3{5.0 * k120, 0.0, 0.0}; + stale.linear_velocity = mv.move; + EXPECT_FALSE(client.reconcile(stale, 5, k120, 1e-9)) << "stale/reordered ack must be a no-op"; + expect_pos_bit_eq(client.state().position, after7.position); + EXPECT_EQ(client.pending(), 3u); + + // A duplicate of the most recent ack (tick 7) is likewise ignored. + EXPECT_FALSE(client.reconcile(stale, 7, k120, 1e-9)) << "duplicate ack must be a no-op"; + expect_pos_bit_eq(client.state().position, after7.position); + EXPECT_EQ(client.pending(), 3u); +} + // ── peer interpolation never extrapolates ─────────────────────────────────── TEST(AvatarSimTest, PeerInterpolationNeverOvershoots) {