Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions inc/cvc/ariadne/avatar_sim.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
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 <cstdint>
#include <cvc/core/namespace.h>
#include <cvc/physics/body.h> // cvc::physics::body_state
#include <cvc/physics/pose_tracker.h> // cvc::physics::pose / interpolate
#include <cvc/physics/types.h> // cvc::physics::vec3
#include <deque>

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`. 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);

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<logged> _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,
// 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__
3 changes: 2 additions & 1 deletion src/cvc/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1308,11 +1308,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
Expand Down
107 changes: 107 additions & 0 deletions src/cvc/ariadne/avatar_sim.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
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 <cmath>
#include <cvc/ariadne/avatar_sim.h>

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) {
// 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;
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<logged> 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
11 changes: 11 additions & 0 deletions src/cvc/tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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_codec_test physics_codec_test.cpp)
add_executable(physics_field_test physics_field_test.cpp)
add_executable(physics_vehicle_test physics_vehicle_test.cpp)
Expand Down Expand Up @@ -304,6 +305,7 @@ set(TEST_TARGETS
clock_driver_test
world_units_test
physics_world_test
avatar_sim_test
physics_codec_test
physics_field_test
physics_vehicle_test
Expand Down Expand Up @@ -1642,6 +1644,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_codec_test
PRIVATE
cvc
Expand Down Expand Up @@ -2047,6 +2056,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_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)
Expand Down Expand Up @@ -2503,6 +2513,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_codec_test)
cvc_discover_tests(physics_field_test)
cvc_discover_tests(physics_vehicle_test)
Expand Down
Loading
Loading