Files
skate-3-rust-engine/crates/skate-game/src/modding/replication.rs
T

430 lines
25 KiB
Rust

//! Generic, package-verified body/scene/node replication. No remote Lua runs,
//! no platform-specific path exists, and keys are scoped by authenticated peer.
//! APPLICATION is a live snapshot: pose, collider fragments, and scenes stay
//! published until the local object is removed. Sequencing, ACK/retry and late
//! join are provided by the existing transport.
mod wire;
use super::{Mods,graphics};
use bevy::prelude::*;
use serde::{Deserialize,Serialize};
use skate_mods::scene::{GraphicsDefinition,NodeState,TransformState,valid_quaternion,valid_vector};
use std::{collections::{BTreeMap,BTreeSet},time::{Duration,Instant,SystemTime,UNIX_EPOCH}};
use wire::*;
type Slot=(u64,String,String);
const RECORD_BUDGET:usize=224; // leave room for ordinary sdk.net state
const STALE_BODY_GRACE:Duration=Duration::from_millis(300);
const MAX_REMOTE_BODIES:usize=32;
const MAX_REMOTE_GRAPHICS:usize=64;
const MAX_REMOTE_NODES:usize=128;
const BLEND:f32=0.06;
const EXTRAPOLATION:f32=0.10;
pub(super) struct State {
epoch:u64, connection:u64, started:Instant, last_send:Instant,
definitions:BTreeMap<u64,CachedDefinition>,
bodies:BTreeMap<Slot,Replica>,
pending_poses:BTreeMap<(String,String),(BodyPose,Instant)>,
graphics:BTreeMap<Slot,(u64,u64)>,
node_sequences:BTreeMap<(String,String,String),(u64,u64,u32)>,
attachments:BTreeMap<u64,Attachment>,
warnings:BTreeSet<String>, last_problem:String,
pub status:String,
}
impl Default for State {
fn default() -> Self {
let now=Instant::now();
Self { epoch:(SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() as u64).max(1),
connection:0,started:now,last_send:now-Duration::from_secs(1),definitions:BTreeMap::new(),
bodies:BTreeMap::new(),pending_poses:BTreeMap::new(),graphics:BTreeMap::new(),
node_sequences:BTreeMap::new(),attachments:BTreeMap::new(),
warnings:BTreeSet::new(),last_problem:String::new(),status:String::new() }
}
}
struct CachedDefinition { revision:usize,hash:u64,chunks:Vec<Vec<u8>> }
#[derive(Clone,Debug,Serialize,Deserialize)]
#[serde(deny_unknown_fields)]
struct BodyPose { p:[f32;3],q:[f32;4],v:[f32;3],w:[f32;3],com:[f32;3],stamp:u64 }
impl BodyPose {
fn valid(&self) -> bool {
valid_vector(&self.p,100_000.) && valid_quaternion(&self.q)
&& valid_vector(&self.v,1000.) && valid_vector(&self.w,10_000.) && valid_vector(&self.com,2000.)
}
}
struct Replica {
id:u64,epoch:u64,instance:u64,revision:u64,seq:u32,
pose:BodyPose,received:Instant,error_position:Vec3,error_axis:Vec3,
}
impl Replica {
fn sample(&self) -> (Transform,Vec3,Vec3) {
let age=self.received.elapsed().as_secs_f32().min(EXTRAPOLATION);
let base_q=Quat::from_array(self.pose.q).normalize();
let base_v=Vec3::from_array(self.pose.v);let base_w=Vec3::from_array(self.pose.w);
let com=Vec3::from_array(self.pose.com);
let rotation=(Quat::from_scaled_axis(base_w*age)*base_q).normalize();
let position=Vec3::from_array(self.pose.p)+base_q*com+base_v*age-rotation*com;
let weight=(1.-age/BLEND).clamp(0.,1.);
let correction=Quat::from_scaled_axis(self.error_axis*weight);
let q=(correction*rotation).normalize();
let t=Transform::from_translation(position+self.error_position*weight).with_rotation(q);
if age>=EXTRAPOLATION { return (t,Vec3::ZERO,Vec3::ZERO); }
let w=if weight>0. { correction*base_w-self.error_axis/BLEND } else { base_w };
// COM velocity includes the derivative of the bounded origin/orientation
// correction, so contact velocity agrees with the displayed motion.
let v=if weight>0. {
base_v-base_w.cross(rotation*com)+w.cross(q*com)-self.error_position/BLEND
} else { base_v };
(t,v,w)
}
fn receive(&mut self,pose:BodyPose,seq:u32) {
if seq==self.seq {return;}
let old=self.sample().0;
let new_p=Vec3::from_array(pose.p);let new_q=Quat::from_array(pose.q).normalize();
let error=old.translation-new_p;
// Reset/large discontinuities are not interpolated through the level.
if error.length_squared()>25. { self.error_position=Vec3::ZERO;self.error_axis=Vec3::ZERO; }
else {
self.error_position=error;
let mut dq=(old.rotation*new_q.conjugate()).normalize();if dq.w<0. {dq=-dq;}
self.error_axis=dq.to_scaled_axis();
}
self.pose=pose;self.seq=seq;self.received=Instant::now();
}
}
#[derive(Serialize,Deserialize)]
#[serde(deny_unknown_fields)]
struct GraphicRecord {
definition:GraphicsDefinition,transform:TransformState,visible:bool,body_instance:Option<u64>,
}
#[derive(Clone,Serialize,Deserialize)]
#[serde(deny_unknown_fields)]
struct AttachmentRecord { offset:[f32;3],hidden:bool }
struct Attachment { owner:String,key:String,epoch:u64,instance:u64,record:AttachmentRecord }
fn owner(peer:u64,package:&str) -> String { format!("@{peer}:{package}") }
fn issue(state:&mut State,message:String) {
state.last_problem=message.clone();
if state.warnings.len()<128 && state.warnings.insert(message.clone()) {warn!("Mod replication: {message}");}
}
fn make(kind:u8,package:&str,key:&str,fp:u64,epoch:u64,instance:u64,payload:Vec<u8>) -> Envelope {
Envelope {kind,owner:package.into(),key:key.into(),node:String::new(),fingerprint:fp,epoch,instance,
revision:0,index:0,count:0,payload}
}
fn push_group(out:&mut BTreeMap<String,Vec<u8>>,packets:Vec<Envelope>,state:&mut State,label:&str) {
let mut encoded=Vec::new();
for packet in packets {
let Some(bytes)=packet.encode() else {issue(state,format!("{label}: record exceeds protocol bounds"));return;};
encoded.push((packet.key(),bytes));
}
if out.len()+encoded.len()>RECORD_BUDGET {
issue(state,format!("{label}: {RECORD_BUDGET}-record mod replication budget exceeded"));return;
}
out.extend(encoded);
}
fn outgoing(mods:&Mods,state:&mut State) -> BTreeMap<String,Vec<u8>> {
let mut out=BTreeMap::new();
let session=Envelope::session(state.epoch);out.insert(session.key(),session.encode().unwrap());
let packages:BTreeMap<_,_>=mods.manager.packages.iter().filter(|(_,p)|p.running())
.map(|(id,p)|(id.clone(),p.content_fingerprint())).collect();
let local_ids:BTreeSet<_>=mods.bodies.iter().filter(|((o,_),_)|!o.starts_with('@')).map(|(_,id)|*id).collect();
state.definitions.retain(|id,_|local_ids.contains(id));
let stamp=state.started.elapsed().as_millis() as u64;
for ((package,key),&id) in mods.bodies.iter().filter(|((o,_),_)|!o.starts_with('@')) {
let Some(&fp)=packages.get(package) else {continue;};
let Some(revision)=mods.world.definition_revision(id) else {continue;};
if state.definitions.get(&id).is_none_or(|d|d.revision!=revision) {
let Some(definition)=mods.world.body_definition(id) else {continue;};
let bytes=match skate_dynamics::definition_codec::encode(&definition) {
Ok(bytes)=>bytes,
Err(error)=>{
issue(state,format!("{package}/{key}: collider definition: {error}"));
state.definitions.insert(id,CachedDefinition {revision,hash:0,chunks:Vec::new()});
continue;
}
};
let chunks=if bytes.len()<=MAX_BLOB {bytes.chunks(CHUNK).map(|c|c.to_vec()).collect()} else {
issue(state,format!("{package}/{key}: collider definition exceeds {MAX_BLOB} bytes"));Vec::new()
};
state.definitions.insert(id,CachedDefinition {revision,hash:skate_net::hash(&bytes),chunks});
}
let Some(definition)=state.definitions.get(&id) else {continue;};
if definition.chunks.is_empty() {continue;}
let definition_hash=definition.hash;
let definition_chunks=definition.chunks.clone();
let Some(body)=mods.world.read(id) else {continue;};
let pose=BodyPose {p:body.position,q:body.rotation,v:body.linvel,w:body.angvel,
com:mods.world.local_center(id).unwrap_or([0.;3]),stamp};
if !pose.valid() {issue(state,format!("{package}/{key}: non-finite or out-of-range body pose"));continue;}
let mut header=make(BODY,package,key,fp,state.epoch,id,serde_json::to_vec(&pose).unwrap());
header.revision=definition_hash;header.count=definition_chunks.len() as u16;
// Pose updates are tiny and must win the record budget every tick.
push_group(&mut out,vec![header.clone()],state,&format!("{package}/{key} pose"));
// Geometry is durable APPLICATION state, same as pose. Keys omitted from
// this map are tombstoned, and remotes assemble only from the live snapshot,
// so a one-shot announce cannot survive loss, credit windows, or late join.
let mut fragments=Vec::new();
for (index,chunk) in definition_chunks.iter().enumerate() {
let mut part=header.clone();part.kind=DEFINITION;part.index=index as u16;part.payload=chunk.clone();fragments.push(part);
}
push_group(&mut out,fragments,state,&format!("{package}/{key} definition"));
}
for ((package,key),g) in mods.graphics.iter().filter(|((o,_),_)|!o.starts_with('@')) {
let Some(&fp)=packages.get(package) else {continue;};
let frame=GraphicRecord {definition:g.definition.clone(),transform:g.transform.clone(),visible:g.visible,
body_instance:g.body.as_ref().and_then(|body|mods.bodies.get(&(package.clone(),body.clone())).copied())};
let Ok(data)=serde_json::to_vec(&frame) else {
issue(state,format!("{package}/{key}: graphics record is not serializable"));continue;
};
let mut packets=vec![make(GRAPHIC,package,key,fp,state.epoch,g.serial,data)];
for (node,frame) in &g.nodes {
let Ok(data)=serde_json::to_vec(&frame.state) else {
issue(state,format!("{package}/{key}: node '{node}' is not serializable"));continue;
};
let mut packet=make(NODE,package,key,fp,state.epoch,g.serial,data);packet.node=node.clone();packets.push(packet);
}
push_group(&mut out,packets,state,&format!("{package}/{key} graphics"));
}
if let Some(a)=&mods.attach {
if let (Some(&fp),Some(&id))=(packages.get(&a.owner),mods.bodies.get(&(a.owner.clone(),a.body.clone()))) {
let record=AttachmentRecord {offset:a.offset.to_array(),hidden:true};
let p=make(ATTACH,&a.owner,&a.body,fp,state.epoch,id,serde_json::to_vec(&record).unwrap());
push_group(&mut out,vec![p],state,"actor attachment");
}
}
out
}
fn remove_body(mods:&mut Mods,state:&mut State,slot:&Slot) {
if let Some(replica)=state.bodies.remove(slot) {
let key=(owner(slot.0,&slot.1),slot.2.clone());
// Never delete a dynamics id that was reused by a different owner/key.
if mods.bodies.get(&key)==Some(&replica.id) {
mods.bodies.remove(&key);
mods.world.remove(replica.id);
}
state.pending_poses.remove(&key);
}
}
fn clear_remote(world:&mut World,mods:&mut Mods,state:&mut State) {
for slot in state.bodies.keys().cloned().collect::<Vec<_>>() {remove_body(mods,state,&slot);}
for slot in state.graphics.keys() {super::retire_graphics(world,mods,&(owner(slot.0,&slot.1),slot.2.clone()));}
state.graphics.clear();state.node_sequences.clear();state.attachments.clear();state.pending_poses.clear();
}
fn incoming(world:&mut World,mods:&mut Mods,state:&mut State,records:Vec<(u64,String,u32,Vec<u8>)>) {
let packets:Vec<_>=records.iter().filter(|(_,key,_,value)|key.starts_with("m3:") && !value.is_empty())
.filter_map(|(peer,key,seq,value)|Envelope::decode(key,value).map(|e|(*peer,*seq,e))).collect();
let sessions:BTreeMap<_,_>=packets.iter().filter(|(_,_,e)|e.kind==SESSION).map(|(peer,_,e)|(*peer,e.epoch)).collect();
if records.iter().any(|(_,key,_,data)|(key.starts_with("dyn:") || key.starts_with("m2:")) && !data.is_empty()) {
issue(state,"A peer uses an older mod-object protocol; all players need the Model Collision Repair engine update".into());
}
let packages:BTreeMap<_,_>=mods.manager.packages.iter().filter(|(_,p)|p.running())
.map(|(id,p)|(id.clone(),p.content_fingerprint())).collect();
let accepted:Vec<_>=packets.iter().filter(|(peer,_,e)| {
if e.kind==SESSION || sessions.get(peer)!=Some(&e.epoch) {return false;}
if packages.get(&e.owner)!=Some(&e.fingerprint) {
issue(state,format!("peer {peer}: package '{}' missing, disabled, or fingerprint differs",e.owner));return false;
}
true
}).collect();
let mut live_bodies=BTreeSet::new();let mut body_count=BTreeMap::<u64,usize>::new();
for &&(peer,seq,ref packet) in &accepted {
if packet.kind!=BODY {continue;}
let count=body_count.entry(peer).or_default();if *count>=MAX_REMOTE_BODIES {continue;}*count+=1;
let Ok(pose)=serde_json::from_slice::<BodyPose>(&packet.payload) else {continue;};
if !pose.valid() {continue;}
let slot=(peer,packet.owner.clone(),packet.key.clone());live_bodies.insert(slot.clone());
state.pending_poses.insert((owner(peer,&packet.owner),packet.key.clone()),(pose.clone(),Instant::now()));
let changed=state.bodies.get(&slot).is_none_or(|r|r.epoch!=packet.epoch || r.instance!=packet.instance || r.revision!=packet.revision);
if changed {
// Geometry may span multiple packets. Continue moving the previous
// valid solid while assembling, rather than freezing/removing it.
if let Some(replica)=state.bodies.get_mut(&slot).filter(|r|r.epoch==packet.epoch && r.instance==packet.instance) {
replica.receive(pose.clone(),seq);
}
let Some(bytes)=assemble(packet,accepted.iter().filter(|r|r.0==peer).map(|r|&r.2)) else {continue;};
let definition=match skate_dynamics::definition_codec::decode(&bytes) {
Ok(definition)=>definition,
Err(error)=>{issue(state,format!("{}: remote collider definition: {error}",packet.owner));continue;}
};
if !(0.01..=10_000_000.).contains(&definition.body.mass) {continue;}
if let Some(replica)=state.bodies.get_mut(&slot).filter(|r|r.epoch==packet.epoch && r.instance==packet.instance) {
match mods.world.update_replica_deformation(replica.id,&definition) {
Ok(true)=>{replica.revision=packet.revision;continue;},
Ok(false)=>{},
Err(error)=>{issue(state,format!("remote deformation: {error}"));continue;},
}
}
match mods.world.spawn_replica(&definition) {
Ok(id) => {
remove_body(mods,state,&slot);
mods.world.set_pose(id,pose.p,pose.q);
mods.world.set_kinematic_motion(id,pose.v,pose.w);
mods.bodies.insert((owner(peer,&packet.owner),packet.key.clone()),id);
state.bodies.insert(slot.clone(),Replica {id,epoch:packet.epoch,instance:packet.instance,revision:packet.revision,
seq,pose,received:Instant::now(),error_position:Vec3::ZERO,error_axis:Vec3::ZERO});
},
Err(error) => issue(state,format!("peer {peer} body {}/{}: {error}",packet.owner,packet.key)),
}
} else if let Some(replica)=state.bodies.get_mut(&slot) {replica.receive(pose,seq);}
}
let stale:Vec<_>=state.bodies.keys().filter(|slot| {
!live_bodies.contains(*slot)
&& state.bodies.get(slot).is_some_and(|r| r.received.elapsed()>=STALE_BODY_GRACE)
}).cloned().collect();
for slot in stale {remove_body(mods,state,&slot);}
let live_pose:BTreeSet<_>=live_bodies.iter().map(|s|(owner(s.0,&s.1),s.2.clone())).collect();
state.pending_poses.retain(|key,(_,at)| live_pose.contains(key) || at.elapsed()<STALE_BODY_GRACE);
let mut live_graphics=BTreeSet::new();let mut graphics_count=BTreeMap::<u64,usize>::new();
for &&(peer,_,ref packet) in &accepted {
if packet.kind!=GRAPHIC {continue;}
let count=graphics_count.entry(peer).or_default();if *count>=MAX_REMOTE_GRAPHICS {continue;}*count+=1;
let Ok(record)=serde_json::from_slice::<GraphicRecord>(&packet.payload) else {
issue(state,format!("peer {peer} scene {}/{}: unreadable graphics record",packet.owner,packet.key));continue;
};
if !record.definition.validate() || !record.transform.validate() {
issue(state,format!("peer {peer} scene {}/{}: invalid graphics descriptor",packet.owner,packet.key));continue;
}
let slot=(peer,packet.owner.clone(),packet.key.clone());
let o=owner(peer,&packet.owner);let key=(o.clone(),packet.key.clone());
let bound=record.definition.body.as_ref().is_none_or(|body| {
state.bodies.get(&(peer,packet.owner.clone(),body.clone())).is_some_and(|r|r.epoch==packet.epoch && Some(r.instance)==record.body_instance)
|| state.pending_poses.contains_key(&(o.clone(),body.clone()))
});
let visible=record.visible && bound;
if state.graphics.get(&slot)!=Some(&(packet.epoch,packet.instance))
|| mods.graphics.get(&key).is_none_or(|g|g.definition!=record.definition) {
if let Err(error)=graphics::spawn(world,mods,&o,&packet.owner,packet.key.clone(),record.definition,record.transform,visible,Some(packet.instance)) {
issue(state,format!("peer {peer} scene {}/{}: {error}",packet.owner,packet.key));continue;
}
state.graphics.insert(slot.clone(),(packet.epoch,packet.instance));
} else if let Some(g)=mods.graphics.get_mut(&key) {g.transform=record.transform;g.visible=visible;}
live_graphics.insert(slot);
}
for slot in state.graphics.keys().filter(|s|!live_graphics.contains(*s)).cloned().collect::<Vec<_>>() {
super::retire_graphics(world,mods,&(owner(slot.0,&slot.1),slot.2.clone()));state.graphics.remove(&slot);
}
let mut live_nodes=BTreeSet::new();let mut node_count=BTreeMap::<u64,usize>::new();
for &&(peer,seq,ref packet) in &accepted {
if packet.kind!=NODE {continue;}
let count=node_count.entry(peer).or_default();if *count>=MAX_REMOTE_NODES {continue;}*count+=1;
if state.graphics.get(&(peer,packet.owner.clone(),packet.key.clone()))!=Some(&(packet.epoch,packet.instance)) {continue;}
let Ok(node)=serde_json::from_slice::<NodeState>(&packet.payload) else {continue;};if !node.validate() {continue;}
let o=owner(peer,&packet.owner);let slot=(o.clone(),packet.key.clone(),packet.node.clone());
live_nodes.insert(slot.clone());
let Some(g)=mods.graphics.get_mut(&(o,packet.key.clone())) else {continue;};
if state.node_sequences.get(&slot)!=Some(&(packet.epoch,packet.instance,seq)) || !g.nodes.contains_key(&packet.node) {
g.nodes.insert(packet.node.clone(),graphics::TimedNode {state:node,received:Instant::now()});
state.node_sequences.insert(slot,(packet.epoch,packet.instance,seq));
}
}
for (o,key,node) in state.node_sequences.keys().filter(|s|!live_nodes.contains(*s)).cloned().collect::<Vec<_>>() {
graphics::reset_node(world,mods,&o,&key,&node);state.node_sequences.remove(&(o,key,node));
}
state.attachments.clear();
for &&(peer,_,ref packet) in &accepted {
if packet.kind!=ATTACH {continue;}
let Ok(record)=serde_json::from_slice::<AttachmentRecord>(&packet.payload) else {continue;};
if !valid_vector(&record.offset,100.) {continue;}
if state.bodies.get(&(peer,packet.owner.clone(),packet.key.clone())).is_some_and(|r|r.epoch==packet.epoch && r.instance==packet.instance) {
state.attachments.insert(peer,Attachment {owner:packet.owner.clone(),key:packet.key.clone(),epoch:packet.epoch,instance:packet.instance,record});
}
}
}
fn sample(mods:&mut Mods,state:&State) {
for replica in state.bodies.values() {
let (t,v,w)=replica.sample();
mods.world.set_pose(replica.id,t.translation.to_array(),t.rotation.to_array());
mods.world.set_kinematic_motion(replica.id,v.to_array(),w.to_array());
}
}
pub(crate) fn sample_fixed(world:&mut World) {
world.resource_scope(|_world,mut mods:Mut<Mods>| {
let state=std::mem::take(&mut mods.replication);sample(&mut mods,&state);mods.replication=state;
});
}
pub(crate) fn sync(world:&mut World) {
let (active,local,records)=world.get_resource::<crate::multiplayer::Multiplayer>()
.map(|n|(n.active(),n.mod_identity().1,n.application_records())).unwrap_or((false,0,Vec::new()));
world.resource_scope(|world,mut mods:Mut<Mods>| {
let mut state=std::mem::take(&mut mods.replication);
if !active {
clear_remote(world,&mut mods,&mut state);state.connection=0;mods.dyn_published.clear();
state.status="Solid replication offline".into();mods.replication=state;return;
}
if state.connection!=local {
clear_remote(world,&mut mods,&mut state);state=State::default();state.connection=local;mods.dyn_published.clear();
}
let definition_pending=mods.bodies.iter().filter(|((owner,_),_)|!owner.starts_with('@')).any(|(_, &id)| {
mods.world.definition_revision(id).is_some_and(|revision| {
state.definitions.get(&id).is_none_or(|d|d.revision!=revision)
})
});
if definition_pending || state.last_send.elapsed()>=Duration::from_millis(49) {
let desired=outgoing(&mods,&mut state);
let desired_keys: BTreeSet<String> = desired.keys().cloned().collect();
let mut net=world.resource_mut::<crate::multiplayer::Multiplayer>();
for key in mods.dyn_published.difference(&desired_keys) {
net.publish_application(key,Vec::new());
}
for (key,value) in desired {
if !net.publish_application(&key,value) {
issue(&mut state,format!("transport rejected '{key}': application key/value budget reached"));
}
}
mods.dyn_published=desired_keys;state.last_send=Instant::now();
}
incoming(world,&mut mods,&mut state,records);
sample(&mut mods,&state);
graphics::sync(world,&mut mods);
let local_bodies=mods.bodies.iter().filter(|((owner,_),_)|!owner.starts_with('@')).count();
let collider_records=state.definitions.values().map(|d|d.chunks.len()).sum::<usize>();
state.status=format!("Solid bridge: {} remote / {} scenes / {} local publishing / {} collider records{}",
state.bodies.len(),state.graphics.len(),local_bodies,collider_records,
if state.last_problem.is_empty() {String::new()} else {format!(" | last issue: {}",state.last_problem)});
mods.replication=state;
});
}
pub(super) fn reset(world:&mut World,mods:&mut Mods) {
if let Some(mut net)=world.get_resource_mut::<crate::multiplayer::Multiplayer>() {
for key in &mods.dyn_published {net.publish_application(key,Vec::new());}
}
let mut old=std::mem::take(&mut mods.replication);clear_remote(world,mods,&mut old);
mods.dyn_published.clear();
}
impl State {
pub(super) fn pending_root(&self, owner:&str, body:&str) -> Option<(Vec3,Quat)> {
let (pose,_)=self.pending_poses.get(&(owner.to_owned(),body.to_owned()))?;
Some((Vec3::from_array(pose.p),Quat::from_array(pose.q).normalize()))
}
}
pub(crate) fn attached_root(mods:&Mods,peer:u64) -> Option<(Transform,bool)> {
let attachment=mods.replication.attachments.get(&peer)?;
let replica=mods.replication.bodies.get(&(peer,attachment.owner.clone(),attachment.key.clone()))?;
if replica.epoch!=attachment.epoch || replica.instance!=attachment.instance {return None;}
let snap=mods.world.read(replica.id)?;
let q=Quat::from_array(snap.rotation).normalize();
Some((Transform::from_translation(Vec3::from_array(snap.position)+q*Vec3::from_array(attachment.record.offset)).with_rotation(q),attachment.record.hidden))
}
#[cfg(test)]
mod tests {
use super::*;
#[test] fn rotating_offset_com_preserves_center_motion() {
let pose=BodyPose {p:[1.,2.,3.],q:[0.,0.,0.,1.],v:[0.;3],w:[0.,2.,0.],com:[1.,0.,0.],stamp:0};
let replica=Replica {id:1,epoch:1,instance:1,revision:0,seq:1,pose,received:Instant::now()-Duration::from_millis(50),error_position:Vec3::ZERO,error_axis:Vec3::ZERO};
let (t,_,_)=replica.sample();let com=t.translation+t.rotation*Vec3::X;
assert!(com.distance(Vec3::new(2.,2.,3.))<1e-4);
}
#[test] fn stale_owner_pose_does_not_extrapolate_forever() {
let pose=BodyPose {p:[0.;3],q:[0.,0.,0.,1.],v:[100.,0.,0.],w:[0.;3],com:[0.;3],stamp:0};
let replica=Replica {id:1,epoch:1,instance:1,revision:0,seq:1,pose,received:Instant::now()-Duration::from_secs(1),error_position:Vec3::ZERO,error_axis:Vec3::ZERO};
let (t,v,w)=replica.sample();assert!((t.translation.x-10.).abs()<1e-5);assert_eq!(v,Vec3::ZERO);assert_eq!(w,Vec3::ZERO);
}
#[test] fn one_max_size_body_and_scene_fit_the_record_budget() {
assert!(1 + 1 + MAX_CHUNKS + 1 + 4 <= RECORD_BUDGET);
}
}