Cache delivered online models and stop completed transfers with fair chunk scheduling

This commit is contained in:
chazmm committed 2026-09-09 16:38:49 +01:00
1 parent f8e10c8ec9
commit 94e5f613f3
5 files changed
+220 -11

No files matched your search

@@ -27,6 +27,7 @@ pub(super) struct Appearances {
local: String,
pub looks: BTreeMap<u64, ([u8; 32], Look)>,
seen: BTreeMap<u64, [u8; 32]>,
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<Vec<u8>, 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<Look, String> {
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}"),
@@ -40,10 +40,15 @@ pub struct Exchange {
incoming: BTreeMap<u64, Incoming>,
pub ready: BTreeMap<u64, (Identity, Vec<u8>)>,
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<u8> = (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);
}
}
@@ -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::<MeshMaterial3d<StandardMaterial>>();
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::<AssetServer>()
.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::<SceneInstance>(root)
.is_some_and(|i| w.resource::<SceneSpawner>().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
+7 -5
View File
@@ -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<Outgoing> {
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
+6 -2
View File
@@ -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