From 94e5f613f3633e2474cd6c2379f545f895d30c98 Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 16:38:49 +0100 Subject: [PATCH] Cache delivered online models and stop completed transfers with fair chunk scheduling --- .../skate-game/src/multiplayer/appearance.rs | 30 +++- .../src/multiplayer/appearance_transfer.rs | 133 +++++++++++++++++- crates/skate-game/src/multiplayer/render.rs | 48 +++++++ crates/skate-net/src/lobby.rs | 12 +- docs/multiplayer-test.md | 8 +- 5 files changed, 220 insertions(+), 11 deletions(-) diff --git a/crates/skate-game/src/multiplayer/appearance.rs b/crates/skate-game/src/multiplayer/appearance.rs index 69785c81..9d60cb4c 100644 --- a/crates/skate-game/src/multiplayer/appearance.rs +++ b/crates/skate-game/src/multiplayer/appearance.rs @@ -27,6 +27,7 @@ pub(super) struct Appearances { local: String, pub looks: BTreeMap, seen: BTreeMap, + cached: BTreeMap<[u8; 32], Look>, pub status: String, pub progress: String, } @@ -61,7 +62,11 @@ pub(super) fn sync( }; } let selection = models.online_selection(); - let signature = format!("{:?}:{}", models.active, parts.applied); + let signature = models + .active + .as_ref() + .map(|id| format!("model:{id}")) + .unwrap_or_else(|| format!("outfit:{}", parts.applied)); if state.local != signature { let payload = (|| -> Result, String> { if let Some((native, path)) = selection { @@ -90,6 +95,10 @@ pub(super) fn sync( })(); match payload { Ok(bytes) => { + info!( + "ONLINE_CHARACTER_PUBLISH bytes={} selection={signature}", + bytes.len() + ); state.exchange.publish(bytes); state.status.clear(); } @@ -119,6 +128,13 @@ pub(super) fn sync( let bytes = std::mem::take(&mut state.exchange.ready.get_mut(&id).unwrap().1); state.seen.insert(id, hash); let result = (|| -> Result { + if bytes.is_empty() { + return state + .cached + .get(&hash) + .cloned() + .ok_or("Cached appearance missing".into()); + } if bytes.first() == Some(&0) { validate_glb(&bytes[1..])?; let name = format!("{}.glb", blake3::Hash::from_bytes(hash).to_hex()); @@ -147,6 +163,18 @@ pub(super) fn sync( })(); match result { Ok(look) => { + let identity = state.exchange.ready[&id].0.clone(); + state.exchange.remember(identity); + state.cached.insert(hash, look.clone()); + info!( + "ONLINE_CHARACTER_READY peer={id} kind={} hash={}", + if matches!(look, Look::Imported(_)) { + "import" + } else { + "retail" + }, + blake3::Hash::from_bytes(hash) + ); state.looks.insert(id, (hash, look)); } Err(e) => warn!("Remote appearance {id}: {e}"), diff --git a/crates/skate-game/src/multiplayer/appearance_transfer.rs b/crates/skate-game/src/multiplayer/appearance_transfer.rs index 8b1f5317..e64f0b00 100644 --- a/crates/skate-game/src/multiplayer/appearance_transfer.rs +++ b/crates/skate-game/src/multiplayer/appearance_transfer.rs @@ -40,10 +40,15 @@ pub struct Exchange { incoming: BTreeMap, pub ready: BTreeMap)>, spent: usize, + known: BTreeMap<[u8; 32], Identity>, round: usize, next: u64, + serving: Option<([u8; 32], usize, u64)>, } impl Exchange { + pub fn remember(&mut self, id: Identity) { + self.known.insert(id.hash, id); + } pub fn progress(&self) -> String { let total: usize = self.incoming.values().map(|p| p.received.len()).sum(); let done: usize = self @@ -77,7 +82,7 @@ impl Exchange { if now < self.next { return; } - self.next = now + 150; + self.next = now + 25; self.incoming .retain(|id, _| session.actors.contains_key(id)); self.ready.retain(|id, _| session.actors.contains_key(id)); @@ -115,6 +120,12 @@ impl Exchange { continue; }; if self.ready.get(&actor).is_some_and(|(old, _)| old == &id) { + self.incoming.remove(&actor); + continue; + } + if self.known.get(&id.hash) == Some(&id) { + self.incoming.remove(&actor); + self.ready.insert(actor, (id, vec![])); continue; } if self.incoming.get(&actor).is_none_or(|p| p.id != id) { @@ -164,11 +175,33 @@ impl Exchange { records.push((format!("@look/r/{slot}"), vec![])); } } + // Requests occupy stable bounded slots, but actor ordering can change. + // Explicit tombstones stop transfers after completion/cache hits/departures. + for slot in 0..skate_net::lobby::MAX_PLAYERS { + let key = format!("@look/r/{slot}"); + if !records.iter().any(|(k, _)| k == &key) { + records.push((key, vec![])); + } + } + if requests.is_empty() { + for slot in 0..WINDOW { + records.push((format!("@look/c/{slot}"), vec![])); + } + } if !requests.is_empty() { requests.sort_unstable(); requests.dedup(); - let page = requests[self.round % requests.len()]; - self.round = self.round.wrapping_add(1); + let page = if let Some((hash, page, at)) = self.serving.filter(|(hash, page, at)| { + *hash == local.hash && requests.contains(page) && now.saturating_sub(*at) < 150 + }) { + let _ = (hash, at); + page + } else { + let page = requests[self.round % requests.len()]; + self.round = self.round.wrapping_add(1); + self.serving = Some((local.hash, page, now)); + page + }; for slot in 0..WINDOW { let offset = (page * WINDOW + slot) * CHUNK; if offset >= bytes.len() { @@ -307,3 +340,97 @@ mod tests { assert!(!e.publish(vec![])); } } +#[cfg(test)] +mod delivery_tests { + use super::*; + #[test] + fn online_appearance_large_model_delivered_once_cached_and_quiet_after_swap_back() { + let mut sessions: Vec<_> = (0..2) + .map(|i| { + Session::new( + 44, + skate_net::lobby::Info { + id: 10 + i, + map: 1, + rig: 1, + physics: 1, + appearance: 1, + }, + if i == 0 { None } else { Some(1) }, + ) + }) + .collect(); + for s in &mut sessions { + s.set_loopback(true); + } + let mut exchanges = [Exchange::default(), Exchange::default()]; + let model: Vec = (0..32_067_793).map(|i| (i * 17) as u8).collect(); + exchanges[0].publish(model.clone()); + exchanges[1].publish(vec![4]); + let mut phase = 0; + let mut finished_at = 0; + let mut last_model_packet = 0; + let mut reused = false; + for step in 0..18000 { + let now = step * 10; + let mut wire = vec![]; + for i in 0..2 { + exchanges[i].tick(&mut sessions[i], now); + for p in sessions[i].service(now) { + if p.data.get(24) == Some(&skate_net::lobby::APPLICATION) && p.data.len() > 100 + { + last_model_packet = now; + assert!(phase < 3, "completed model was retransmitted"); + } + if step % 31 != 0 { + wire.push((i + 1, p)); + } + } + } + for (from, p) in wire { + sessions[p.peer as usize - 1].receive(from as u64, &p.data, now); + } + if phase == 0 + && exchanges[1] + .ready + .get(&10) + .is_some_and(|(_, b)| b == &model) + { + assert!(now < 90000, "local model transfer is too slow: {now} ms"); + let identity = exchanges[1].ready[&10].0.clone(); + exchanges[1].remember(identity); + exchanges[1].ready.get_mut(&10).unwrap().1.clear(); + exchanges[0].publish(vec![9]); + phase = 1; + } + if phase == 1 + && exchanges[1] + .ready + .get(&10) + .is_some_and(|(_, b)| b == &vec![9]) + { + exchanges[0].publish(model.clone()); + phase = 2; + finished_at = now; + } + if phase == 2 + && exchanges[1] + .ready + .get(&10) + .is_some_and(|(id, b)| id.size == model.len() && b.is_empty()) + { + reused = true; + if now - finished_at > 2000 { + phase = 3; + finished_at = now; + } + } + if phase == 3 && now - finished_at > 5000 { + assert!(last_model_packet < finished_at); + break; + } + } + assert_eq!(phase, 3); + assert!(reused); + } +} diff --git a/crates/skate-game/src/multiplayer/render.rs b/crates/skate-game/src/multiplayer/render.rs index a8be725d..d4413c3b 100644 --- a/crates/skate-game/src/multiplayer/render.rs +++ b/crates/skate-game/src/multiplayer/render.rs @@ -528,6 +528,7 @@ mod online_owned_tests { .unwrap(); let parts = crate::customiser_parts::Parts::for_test(library); let mut app = App::new(); + crate::custom_models::register_source(&mut app); app.add_plugins(( MinimalPlugins, AssetPlugin { @@ -544,6 +545,53 @@ mod online_owned_tests { .register_type::>(); app.finish(); app.cleanup(); + if let Ok(path) = std::env::var("SKATE_ONLINE_TEST_GLB") { + let bytes = std::fs::read(path).unwrap(); + super::super::appearance::validate_glb(&bytes).unwrap(); + let cache = super::super::appearance::cache_directory(); + std::fs::create_dir_all(cache).unwrap(); + std::fs::write(cache.join("received-test.glb"), bytes).unwrap(); + let handle = app + .world() + .resource::() + .load(GltfAssetLabel::Scene(0).from_asset("online-characters://received-test.glb")); + let root = app + .world_mut() + .spawn((SceneRoot(handle), Transform::default(), Visibility::Hidden)) + .id(); + let started = Instant::now(); + loop { + app.update(); + let w = app.world(); + if w.get::(root) + .is_some_and(|i| w.resource::().instance_is_ready(**i)) + { + break; + } + assert!( + started.elapsed().as_secs() < 45, + "received online import failed to load" + ); + std::thread::sleep(Duration::from_millis(10)); + } + let mut queries: SystemState<( + Query<(Entity, &SkinnedMesh)>, + Query<(&Name, &Transform)>, + Query<&ChildOf>, + )> = SystemState::new(app.world_mut()); + let (meshes, nodes, parents) = queries.get(app.world()); + assert!( + !crate::animation::AnimationStatus::for_scene( + root, &names, &meshes, &nodes, &parents + ) + .unwrap() + .online_bindings() + .is_empty() + ); + app.world_mut().entity_mut(root).despawn(); + app.update(); + std::fs::remove_file(cache.join("received-test.glb")).unwrap(); + } for gender in ["male", "female"] { let profile = parts.resolve(&parts.library.defaults[gender]).unwrap(); let candidate = app diff --git a/crates/skate-net/src/lobby.rs b/crates/skate-net/src/lobby.rs index e7d19ff4..9d574edd 100644 --- a/crates/skate-net/src/lobby.rs +++ b/crates/skate-net/src/lobby.rs @@ -210,7 +210,7 @@ impl Session { self.budget = if host.is_none() { DEFAULT_HOST_BUDGET } else { - DEFAULT_LINK_BUDGET + if self.loopback {2_000_000.} else {DEFAULT_LINK_BUDGET} }; self.credits = 12_000.; self.notice = if host.is_none() { @@ -221,6 +221,8 @@ impl Session { } pub fn set_loopback(&mut self, enabled: bool) { self.loopback = enabled; + self.budget=if enabled {12_000_000.} else {DEFAULT_HOST_BUDGET}; + self.set_congested(false); } pub fn is_host(&self) -> bool { self.host.is_none() @@ -230,9 +232,9 @@ impl Session { } pub fn set_congested(&mut self, congested: bool) { self.link_budget = if congested { - DEFAULT_LINK_BUDGET * 0.5 + if self.loopback {1_000_000.} else {DEFAULT_LINK_BUDGET * 0.5} } else { - DEFAULT_LINK_BUDGET + if self.loopback {2_000_000.} else {DEFAULT_LINK_BUDGET} }; } pub fn publish(&mut self, kind: u8, mut state: Packed, now: u64) { @@ -511,9 +513,9 @@ impl Session { pub fn service(&mut self, now: u64) -> Vec { let dt = now.saturating_sub(self.last_service).min(1000) as f64 / 1000.; self.last_service = now; - self.credits = (self.credits + dt * self.budget).min(12_000.); + self.credits = (self.credits + dt * self.budget).min(if self.loopback {128_000.} else {12_000.}); for l in self.links.values_mut() { - l.credits = (l.credits + dt * self.link_budget).min(3600.); + l.credits = (l.credits + dt * self.link_budget).min(if self.loopback {64_000.} else {3600.}); } if self.is_host() { let expired: Vec<_> = self diff --git a/docs/multiplayer-test.md b/docs/multiplayer-test.md index 6e97067b..e86e6d77 100644 --- a/docs/multiplayer-test.md +++ b/docs/multiplayer-test.md @@ -10,9 +10,13 @@ prepared character library. The same setup update that supplies the local customiser supplies these online assets. Imported characters transfer their converted, self-contained GLB automatically; -other players do not need to import the model. Updates and late joins use bounded, +other players do not need to import the model. Completed appearances are cached for +the connection: switching back sends only the identity, and acknowledged model data +stops sending. Departed receivers and completed requests are explicitly cleared. +Updates and late joins use bounded, reliable chunks through the existing host relay. Movement/collision packets keep -priority and existing bandwidth budgets. The HUD shows character transfer progress. +priority and existing internet bandwidth budgets. Loopback test connections use +a larger local budget so large files do not take minutes on one machine. The HUD shows character transfer progress. Each remote keeps its visible character until the new scenes, textures and every clothing rig are ready. Imported textures retain their own materials; retail characters use their authored clothing lighting. Remote colours/tattoos never