mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Cache delivered online models and stop completed transfers with fair chunk scheduling
This commit is contained in:
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user