Single-vehicle sync improvements#220
Conversation
- Add pose divergence ring buffer tracking position/orientation delta - Implement hinge angle measurement for articulated vehicles - Add live statistics computation with p50/p95/p99 percentiles - Provide CSV export for external analysis (Python, MATLAB) - Include 10 unit tests validating quaternion math and buffer operations - Update ClusterSyncRoadmap.md marking Phase 1a as complete All exit criteria met: tooling produces sensible numbers, CSV export works, no console spam, 30s buffer at 60Hz implemented and tested.
- Register mp_measurement_* commands in BeamNG console - Provide CLI interface for pose divergence and hinge angle export - Commands: enable, disable, export_pose, export_hinge, export_report, stats, clear, status - Integration point for Phase 1a measurement primitives
… tracking
- Replace joint-angle tracking with per-body root state sync
- Remove forward kinematics from replay path (direct state setting)
- Update state representation: S(t) = {(x_i, q_i, v_i, ω_i)} for all bodies
- Hitch angles now implicit in relative transforms, not wire state
- Add authority handover protocol for cross-owner coupling
- Add multi-body sync atomicity risk to known issues
- Fix MathJax delimiter errors (remove \left/\right commands)
- Clean up duplicate clauses and simplify equations
- Add explicit O (owner) and P (peer) notation definitions
Model revision ensures crashes and deformation match authority exactly
by syncing the outcome of BeamNG physics, not re-simulating joints.
… blending Complete implementation of dead-reckoning prediction and smooth state replay for multiplayer vehicle synchronization in ForkedKISS. Rust implementation (shared crate): - New sync module with prediction.rs and replay.rs - Dead-reckoning: position extrapolation x(t+Δt) = x(t) + v(t)·Δt - Quaternion integration: q(t+Δt) = q(t) ⊗ exp(ω·Δt/2) - Spherical linear interpolation (slerp) for rotation blending - Default 150ms blend duration for smooth snapshot transitions - component_id field added to VehicleUpdate (Phase 2 ready) - 10 new unit tests, all passing (20 total in shared crate) Lua implementation (BeamNG side): - New kiss_sync.lua module with prediction and blending logic - kiss_transforms.lua updated to use direct state setting - Replaces old force-based physics interpolation approach - Direct position/rotation setting eliminates drift and oscillation - Maintains coherence with ClusterSyncFoundation.md invariant: T_i^P(t) = T_i^wire(t) Integration: - kissmp-server broadcasts VehicleUpdate with component_id field - vehiclemanager.lua → kisstransform.lua → kiss_transforms.lua flow intact - No breaking changes to network protocol (component_id = vehicle_id in Phase 1) Testing: - All 20 Rust unit tests passing (prediction math, blending, multi-body) - Lua syntax validated with luac - Ready for live validation testing with measurement tools from Phase 1a Exit criteria status: - Wire format complete ✓ - Prediction between snapshots ✓ - Blending on arrival (no teleports) ✓ - Multi-body ready (component_id reserved) ✓ - Live validation: ready for testing References: - ClusterSyncRoadmap.md#L147-169 (Phase 1b specification) - ClusterSyncFoundation.md (mathematical model) - Phase1b_Implementation.md (detailed implementation guide)
The sync and measurement modules were never used by production code. Quaternion math belongs on the client (Lua) side, not in shared types. Server correctly treats rotations as opaque [f32; 4] data only.
|
I wanted to let you know our current thoughts on your work: Looking at some of your changes we are mainly concerned that it strays away from the relative simplicity and maintainability of the current codebase (i.e. KISS principle). In the current state of these mass changes, you still mention limitations like drift/error accumulation and potentially needing vehicle-specific presets. Also, you mention some other complex "Out of Scope" aspects, would you also be planning on having them implemented too? As this continues to raise the total complexity further. As I said before, if you can break it down into modular changes it would be nicer to review, test and consider. You'd probably have a better idea on how to split this. Next, we can look into merging any stable and simple changes first, proven and consistent bugfixes. Cluster-sync and other crazy stuff should be kept on a separate branch. I'm wondering if it would be sensible to perhaps have an experimental toggle for this model, even, if it proves to be reliable eventually. Ideally this should be kept up to sync with the master branch. As you said correctly, this may be a bit of a faff but hopefully we can agree on a way to make it easier for you, especially as we are not currently working on mass changes to this logic (apart from refactoring from @DaddelZeit, which is sensible given that the codebase is years old). Ultimately, the rest of us are currently in a state of maintenance, bugfixing and gradual improvement. The other concerns are impact on performance and updates to the game itself. The performance concern is both for local resources and effect of internet latency (which this game suffers from greatly, regardless of how great the sync implementation is). I'm not sure myself how realistic the latter is, so please correct me if I'm wrong, but your current changes seem very sensitive to parameters and increase reliance on game engine events which, when updated or tweaked, I assume would result in more maintenance being required from us. I also notice a lot of AI agentic work being used. While it's powerful, it does seem to be doing some fairly large rewrites and bloating of the code which is daunting for us reviewers. We appreciate this pretty significant effort, but hopefully you can understand our points as we're hoping to coordinate and experiment with these changes. And hopefully this is not read as some wacky corporate email as I wanted to be logical and polite :)) Also relevant to #188. |
|
Hi there! Thanks for the feedback.
This is the inherent limitation of the previous vehicle-syncing approach you've seen in all the videos I've posted with trailers. Different vehicle configurations, say, an eSBR 500 with a small tilt bed has its center point somewhere else than just the car, or a capsule bus. You can tune some parameters in the vehicle clusters (or vehicle groups), and get it working quite well for an articulated bus, and it will pretty much fix the drift there - however, the car and trailer drift will now diverge from the true state again. You just simply cannot fix this with PD/PID sync. If this even in a modularized state, for now just a single car, etc... is still too much (doesn't fit your definition of simple), I would drop the work on trailers. |
The entire point of this rather dry mathematical model is to avoid that. It's a single branch of logic for everything. The opposite of the other PR that I will close. Since you seem fine with going forward in a stepped and modular fashion ... How about we simply start with cars turning correctly and closing a lot of the latency drift gap, thus position offset. As far as I have tested master, the car position gets up to a meter off, diverging with acceleration and converging with deceleration, also, the car turns way too late, not because of angular prediction, but because the car turns around its centerpoint - and not where it's actually articulated (the front steered wheels). This would be simplest case of node cluster sync (single vehicle, single cluster), pose hopefully no large risk of regression and would make the rendering of other cars visibly smoother and more realistic. In principle this should even work for an articulated bus as all of the simulated positions and velocities are sent over the wire exactly as simulated on the owner's side. What do you think? |
|
The mathematical model in the PR description is mostly about the node velocity-sync that allows car movement to replay exactly for others how it did on the owner's instance. Also, only one BeamNG instance should ever have the authority to simulate a cluster where ownership is fragmented (truck may be Person A and trailer Person B) but that's a corner case. |
Add direct state replay for vehicle deformation via node positions. Rust: - Add Deformation struct with node_positions field - Add deformation: Option<Deformation> to VehicleUpdate Lua: - Rewrite kiss_nodes.lua: capture_nodes() and apply_nodes() API - Integrate deformation into vehiclemanager.send_vehicle_update() - Apply nodes in kiss_transforms.set_target_transform() - Pass deformation through kisstransform.update_vehicle_transform() Docs: - Update ClusterSyncRoadmap.md Phase 2 for explicit per-body transforms Architecture: - Authority captures node positions every tick - Receivers apply positions directly (no velocity prediction) - BeamNG physics handles intermediate dynamics locally - Handles all vehicle types uniformly Bandwidth: ~144 KB/s per vehicle at 60Hz (200 nodes) Future: quantization + delta for 10-15x reduction
Server relays deformation data from Lua clients, doesn't generate it.
|
I think the term "cluster" here is a bit confusing because in BeamNG the term cluster refers to a part of the node/beam structure |
The old force-match reconstructed per-node velocities from (v_COM, ω) on the receiver, which assumes a single rigid body. That assumption breaks on every BeamNG vehicle — wheels spin around hubs, rotors around masts, articulated buses have multiple bodies — so the remote's nodes got pushed toward targets inconsistent with the jbeam's actual state. Symptoms stacked: systemic speed deficit, yaw-coupled heading drift, try_rude teleports, at-speed curve crashes. Replace the estimator with direct replay of per-node position and velocity. The wire now carries both for every node; the receiver applies position via setNodePosition and velocity via a one-physics-step impulse. No rigid-body assumption, no COM/ref frame concerns, no sign conventions to get wrong. Every DOF the authority has — chassis flex, wheel spin, rotor rpm, suspension, panel deformation, articulation — is implicitly synced because it's the same node state. Wire format changes: - Deformation -> ClusterNodes; gains node_velocities alongside node_positions - VehicleUpdate.deformation -> VehicleUpdate.cluster_nodes Server (previously dropped deformation on receive and relayed None): - Vehicle stores cluster_nodes - Incoming VehicleUpdate writes to it - Tick relay forwards it to other clients Sender: - kiss_nodes.capture_nodes returns (positions, velocities) - kiss_vehicle.update_transform_info captures both, pushes via GE bridge - vehiclemanager assembles cluster_nodes on the outgoing packet Receiver: - kisstransform pipes cluster_nodes to the vehicle - kiss_transforms.set_target_transform applies via kiss_nodes.apply_nodes - kiss_transforms.update no longer calls the removed force-match formula Removed: - kiss_vehicle.apply_linear_velocity_ang_torque - The COM-offset cache and sign-flip fixes scaffolded around it
Server rejected incoming packets with "invalid type: map, expected a sequence" because Lua's capture_nodes returns tables keyed by node CID, which jsonEncode serializes as JSON objects. The Rust side expected a JSON array via Vec<[f32; 3]>. Switch node_positions and node_velocities to HashMap<u32, [f32; 3]> so the wire shape matches the Lua table shape directly. Also swap obj:getNodeVelocity -> obj:getNodeVelocityVector; current BeamNG expects a second argument on getNodeVelocity (reference node), which we don't want for world-frame velocity.
The receive path is JSON end-to-end (client -> server -> bridge -> client), and JSON object keys are always strings. jsonDecode surfaces the cluster_nodes table with string keys like "0", "12", which BeamNG's setNodePosition / getNodeVelocityVector / applyForceVector reject because they expect numeric CIDs. Wrap with tonumber before calling into the C++ bindings.
The previous design carried per-node state inside VehicleUpdate, which made packets vastly exceed the QUIC datagram MTU (~1200 bytes) on anything bigger than a Pigeon. send_datagram errored, killing the send task, and ping climbed to the second range as side effects piled up. Split the message model: - VehicleUpdate now carries only cluster-level state (transform, electrics, gearbox, ids). Small and MTU-fit. - ClusterNodesFragment is a new message type carrying a subset of the per-node state for one tick, with vehicle_id + tick_id + fragment_index + total_fragments. Each fragment is independently applicable on the receiver; dropped fragments just mean those nodes don't update that tick. Server passes fragments through as they arrive (no storage, no reassembly) on the unreliable channel. VehicleUpdate keeps its tick-based relay for cluster state. Receiver tracks highest-applied tick_id per vehicle (wrap-aware comparison), drops older fragments, and queues kiss_nodes.apply_nodes for fresh ones. Sender emits up to CLUSTER_NODES_PER_FRAGMENT (30) nodes per fragment — sized so a fragment fits one datagram even before quantization. Note: the bridge also deserializes the ServerCommand enum, so it needs a rebuild against the new shared crate to route the new ClusterNodesFragment variant. Unknown variants are silently dropped by the existing if-let-Ok pattern.
…ams" This reverts commit 38be996.
…gram size limitations VehicleUpdate now carries per-node position + velocity for every node, which for anything larger than a Pigeon exceeds the QUIC datagram size cap (~1200 bytes). send_datagram returned errors, killed the drive_send task via the ?-propagation, and ping climbed as pongs stopped flowing. Route VehicleUpdate on the ordered stream instead. Streams have no size cap, so oversize packets go through intact. Tradeoff: head-of-line blocking under packet loss (packet N+1 waits for N's retransmit before delivery). Acceptable while we figure out bandwidth reduction — correctness first, optimize second.
…o i16 Reduces per-tick cluster_nodes payload from ~70 KB to ~25 KB for a 1000-node vehicle during normal driving, which pulls it under the CHUNK_SIZE threshold and eliminates the chunking that was breaking big-vehicle sync. Two changes: 1. Positions are transmitted as body-frame offsets from rest pose, not world positions. Chassis nodes stay within submm of rest while driving, so their offsets quantize to zero and get omitted from the map entirely. Only nodes with meaningful deformation or independent motion (wheels, suspension flex, panels under load) appear in node_positions. Sender subtracts cached rest-body-pose; receiver reconstructs rest + offset and rotates back to world via current body rotation. 2. Wire format is i16 for both positions and velocities: - Positions: mm precision (scale 1000), range ±32.767 m - Velocities: cm/s precision (scale 100), range ±327.67 m/s Both comfortably cover any realistic value while halving JSON byte cost versus the previous f32 representation. Rest-pose cache built lazily on first capture/apply from v.data.nodes. The receiver iterates all nodes regardless of whether they're in the received map; omitted = at rest. Velocities stay per-node as before (chassis nodes have nonzero velocity while driving, so skipping isn't beneficial there without reintroducing the body-twist reconstruction we avoid). No change to how kiss_transforms / kisstransform / server code routes the messages — only the ClusterNodes wire shape and the kiss_nodes encode/decode change.
…ting
Rebuilds Layer 2 sync as per-node DEVIATIONS from the rigid-body prediction
Layer 1 (Transform) already describes. Orthogonal by construction — receiver
can't double-count cluster motion because cluster motion was factored out at
capture time.
Capture: each node transmits body-frame pos_deviation from jbeam rest and
world-frame vel_deviation from rigid prediction. Sender-side epsilon
thresholds skip entries below noise. Chassis nodes on a cruising vehicle
deviate zero → omitted → bandwidth drops to only the nodes actually doing
something non-rigid (wheels, suspension, deformation).
Apply has three modes:
- Initial-sync: first packet or |Δp|>2m → hard-set via setNodePosition with
no competing applyForceVector on same tick. Avoids solver conflict.
- Steady-state blended correction: impulse toward v_desired = v_target +
POSITION_PULL_GAIN · Δp, with two receiver-side gates (see below).
- Large-delta re-init: authority teleport, long disconnect, severe desync
self-heal by re-entering initial-sync.
Two receiver-side gates make sync agnostic across vehicles and props:
- Gate 1 (dead-band): skip applyForceVector entirely when both |Δp| and
|Δv| are within tolerance of target. Stationary rigid props have every
node in this regime → zero impulses → no ground-contact fight → no
flyaway. Moving parts (spinning wheels, active suspension, deformation)
have Δv outside the band and get corrected as before.
- Gate 2 (structural force cap): clamp |Δv| per tick to MAX_DELTA_V_PER_TICK.
No single bad packet / pathological reconstruction can detonate the jbeam.
Lever-arm convention fix: reconstruct rigid_vel using the node's current
position as the lever arm, matching the sender's capture convention. Using
p_target here produces an ω × Δp bias that was the cause of the earlier
~20cm stationary-Pigeon helmet bob.
Owner-choppiness fix already present: only owned vehicles run the per-node
capture workload via the we_own_this_vehicle flag threaded through
update_transform_info — remote vehicles skip the ~1000 getNode calls per tick.
Live-tunable via new imgui Tuning tab: position_scale, velocity_scale,
position/velocity epsilon (sender-side), position_pull_gain,
position/velocity dead-band, max Δv per tick (receiver-side). All persist
via kissconfig save/load and push to every vehicle on slider change and on
spawn.
No Rust wire format changes — only updated doc comments.
|
It seems the only way to preserve both positions and velocities in tandem for a soft-body physics multiplayer mod like this is a trajectory sync with a specific set of selected actuating nodes. More specifically, a node triangle near the center of the vehicle chassis, and below 10 outer points as lever arms if necessary as the actuation of the center triangle is relatively weak. So-called "support nodes" along with a "support gain". Already simulates bus movement at the most oscillation-sensitive seat of an articulated bus (the back) quite realistically. https://www.youtube.com/watch?v=7zzush2qU4E I'm currently still working on readying and testing the new trajectory sync approach.
|
Replace layered per-node / Layer1 cluster sync with a single motion-first path: sender publishes COG pose+twist plus send_timer/ping_ms/send_dt; receiver runs second-order dead-reckoning in kiss_sync and a PD correction loop in kiss_transforms with overshoot dampening. SessionTuningUpdate and the tuning sliders are removed; cluster_nodes payload kept only for optional deformation.
Add optional acceleration / angular_acceleration to Transform so receivers can skip the (v_new - v_prev)/remote_dt path that amplifies sample noise by ~1/remote_dt. Sender lowpasses its own derived COG-frame acceleration at 30 Hz and ships it on the wire; receivers prefer it over their own differential, falling back to the legacy path when absent. Adds Tuning tab sliders for the receiver-side vel / accel smoothing rates (local-only, per-client) and renames acc/racc shorthands to linear_accel / angular_accel for readability. Also includes a short rate-limited [bob] diagnostic print in kiss_transforms left in for the next test session.
…ccel The receiver-side REMOTE_ACCEL_SMOOTH_RATE lowpass on linear/angular accel was added to suppress the (v_new - v_prev)/remote_dt amplifier on the legacy fallback path. With the sender now shipping pre-smoothed acceleration, the receiver lerp stacked a second ~167ms time constant on top of the sender's ~33ms, leaving smooth_linear_accel non-zero long after the sender had settled and producing forward position drift through the 0.5*a*t^2 prediction term. Use the raw sender value (or one-shot legacy differential) directly. Velocity lerp stays since velocity still arrives unfiltered from the wire's perspective.
Model
Cluster topology
A cluster is a collection of$\displaystyle N$ rigid bodies. A truck with trailer is two bodies. An articulated bus is two bodies. Each body $\displaystyle B_i$ has a complete world-space transform that is synchronized independently.
State representation
The full cluster state at time$\displaystyle t$ is a set of body transforms:
Where for each body$\displaystyle B_i$ :
Key principle: All body transforms are transmitted explicitly. Joint articulation is implicit in the relative transforms between bodies — joint angles are not part of wire state:
The wire carries only the outcome of the physics simulation, not the internal joint state.
Authority distribution
Physics ownership
The cluster owner simulates physics for all bodies using BeamNG's native couplers and solver. Non-owner peers perform direct state replay: they receive$\mathbf{S}(t)$ snapshots and set body transforms directly. Non-owner peers do not integrate forces or compute forward kinematics.
Formally, for any peer$\displaystyle P$ and any body $\displaystyle B_i$ in a cluster owned by $\displaystyle O \neq P$ :
All body transforms come from the wire, never from local simulation.
Invariant: replay peers execute direct state application only, no dynamics, no forward kinematics.
Rear steering
Bidirectional physics coupling occurs within the owner's BeamNG solver. The wire carries only the resulting body states. Rear-steer input is control state, not cluster state.
Invariant: control inputs and cluster state are separate message types. Steering angles are consumed by the owner to produce$\displaystyle \mathbf{S}(t+\Delta t)$ . Replay peers receive the result — rear-steer angle need not be transmitted.
Invariants
If these four hold, drift is impossible by construction:
Single integrator. For each cluster, exactly one peer (the owner) integrates forces through BeamNG's native solver. All others replay.
Explicit body states. Wire state is $\displaystyle {(\mathbf{x}_i, \mathbf{q}_i, \mathbf{v}_i, \boldsymbol{\omega}i)}{i=0}^{N-1}$ — all body transforms explicit. No derived poses, no joint DOFs.
Direct replay. Replay peers set body transforms directly from wire data. No forward kinematics, no joint reconstruction, no independent physics.
Control/state separation. Control inputs flow peer → owner. State flows owner → peers. These are different message types with different guarantees.
The only drift sources are numerical (float precision) and latency (peer sees old state). Both are bounded by how recently the owner's last$\displaystyle \mathbf{S}(t)$ arrived.
Implementation Scope
This PR implements:
component_idfield to distinguish bodies within a cluster group (reserved in Phase 1b, activated in Phase 2)Out of Scope
Review Guidance
Wire schema
Verify all body transforms are explicit with no joint DOFs. Confirm
component_idis reserved for cluster extension.Replay path
Confirm direct state application is the sole source of body poses. Verify no residual forward kinematics or independent trailer physics simulation.
Authority boundary
Confirm owner simulates full cluster through BeamNG's native solver before broadcasting$\displaystyle \mathbf{S}(t)$ . Verify control input handling is separated from state transmission.
Testing
Known Risks
Two places where the model meets BeamNG's API and might need adjustment:
applyClusterLinearAngularAccelcompatibility. The foundation assumes the owner can integrate the cluster through its solver without the API imposing sync assumptions that fight the "owner simulates everything" invariant. This needs verification on Phase 1/2 work.Coupling handshake latency. The two-phase protocol adds at least one round-trip between detection and commitment. If this is perceptible to the player pulling up to a hitch, we may need speculative commit with server rollback rather than strict two-phase commit. That's a UX tradeoff to evaluate later.
References
ClusterSyncFoundation.mdClusterSyncRoadmap.mdHotReloadingDebuggerRoadmap.md