1#![warn(clippy::all, clippy::pedantic)]
7#![cfg_attr(
11 all(target_arch = "wasm32", feature = "nightly"),
12 feature(thread_local)
13)]
14
15pub mod auth;
16pub mod shared_world;
17pub mod world_state;
18
19#[cfg(target_arch = "wasm32")]
20pub mod metrics;
21
22#[cfg(test)]
23#[cfg(target_arch = "wasm32")]
24pub mod smoke_test;
25
26pub mod auth_proto {
30 #![allow(clippy::must_use_candidate, clippy::doc_markdown)]
31 tonic::include_proto!("aetheris.auth.v1");
32}
33
34#[cfg(target_arch = "wasm32")]
35pub mod transport;
36
37#[cfg(test)]
38pub mod transport_mock;
39
40#[cfg(target_arch = "wasm32")]
41pub mod render;
42
43#[cfg(any(target_arch = "wasm32", test))]
44pub mod render_primitives;
45
46#[cfg(any(target_arch = "wasm32", test))]
47pub mod assets;
48
49#[cfg(target_arch = "wasm32")]
50#[cfg_attr(feature = "nightly", thread_local)]
51static _TLS_ANCHOR: u8 = 0;
52
53use std::sync::atomic::AtomicUsize;
54#[cfg(target_arch = "wasm32")]
55use std::sync::atomic::Ordering;
56
57#[allow(dead_code)]
58static NEXT_WORKER_ID: AtomicUsize = AtomicUsize::new(1);
59
60#[cfg(target_arch = "wasm32")]
61thread_local! {
62 static WORKER_ID: usize = NEXT_WORKER_ID.fetch_add(1, Ordering::Relaxed);
63}
64
65#[must_use]
67pub fn performance_now() -> f64 {
68 #[cfg(target_arch = "wasm32")]
69 {
70 use wasm_bindgen::JsCast;
71 let global = js_sys::global();
72
73 if let Ok(worker) = global.clone().dyn_into::<web_sys::WorkerGlobalScope>() {
75 return worker.performance().map(|p| p.now()).unwrap_or(0.0);
76 }
77
78 if let Ok(window) = global.dyn_into::<web_sys::Window>() {
80 return window.performance().map(|p| p.now()).unwrap_or(0.0);
81 }
82
83 js_sys::Date::now()
85 }
86 #[cfg(not(target_arch = "wasm32"))]
87 {
88 0.0
89 }
90}
91
92#[allow(dead_code)]
93pub(crate) fn get_worker_id() -> usize {
94 #[cfg(target_arch = "wasm32")]
95 {
96 WORKER_ID.with(|&id| id)
97 }
98 #[cfg(not(target_arch = "wasm32"))]
99 {
100 0
101 }
102}
103
104#[cfg(target_arch = "wasm32")]
105mod wasm_impl {
106 use crate::assets;
107 use crate::metrics::with_collector;
108 use crate::performance_now;
109 use crate::render::RenderState;
110 use crate::shared_world::{MAX_ENTITIES, SabSlot, SharedWorld};
111 use crate::transport::WebTransportBridge;
112 use crate::world_state::ClientWorld;
113 use aetheris_encoder_serde::SerdeEncoder;
114 use aetheris_protocol::events::NetworkEvent;
115 use aetheris_protocol::traits::GameTransport;
116 use aetheris_protocol::types::ClientId;
117 use wasm_bindgen::prelude::*;
118
119 #[wasm_bindgen]
120 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
121 pub enum ConnectionState {
122 Disconnected,
123 Connecting,
124 InGame,
125 Reconnecting,
126 Failed,
127 }
128
129 #[derive(Clone)]
131 pub struct SimulationSnapshot {
132 pub tick: u64,
133 pub entities: Vec<SabSlot>,
134 }
135
136 #[wasm_bindgen]
138 pub struct AetherisClient {
139 shared_world: SharedWorld,
140 world_state: ClientWorld,
141 render_state: Option<RenderState>,
142 transport: Option<Box<dyn GameTransport>>,
143 worker_id: usize,
144 session_token: Option<String>,
145
146 snapshots: std::collections::VecDeque<SimulationSnapshot>,
148
149 last_rtt_ms: f64,
151 ping_counter: u64,
152
153 reassembler: aetheris_protocol::Reassembler,
154 connection_state: ConnectionState,
155 reconnect_attempts: u32,
156 playground_rotation_enabled: bool,
157 playground_next_network_id: u64,
158 first_playground_tick: bool,
159
160 render_buffer: Vec<SabSlot>,
162
163 asset_registry: assets::AssetRegistry,
164 }
165
166 #[wasm_bindgen]
167 impl AetherisClient {
168 #[wasm_bindgen(constructor)]
171 pub fn new(shared_world_ptr: Option<u32>) -> Result<AetherisClient, JsValue> {
172 console_error_panic_hook::set_once();
173
174 use std::sync::atomic::{AtomicBool, Ordering};
177 static LOGGER_INITIALIZED: AtomicBool = AtomicBool::new(false);
178 if !LOGGER_INITIALIZED.swap(true, Ordering::SeqCst) {
179 let config = tracing_wasm::WASMLayerConfigBuilder::new()
180 .set_max_level(tracing::Level::INFO)
181 .build();
182 tracing_wasm::set_as_global_default_with_config(config);
183 }
184
185 let shared_world = if let Some(ptr_val) = shared_world_ptr {
186 let ptr = ptr_val as *mut u8;
187
188 if ptr_val == 0 || !ptr_val.is_multiple_of(8) {
190 return Err(JsValue::from_str(
191 "Invalid shared_world_ptr: null or unaligned",
192 ));
193 }
194
195 unsafe { SharedWorld::from_ptr(ptr) }
200 } else {
201 SharedWorld::new()
202 };
203
204 tracing::info!(
205 "AetherisClient initialized on worker {}",
206 crate::get_worker_id()
207 );
208
209 with_collector(|c| {
211 c.push_event(
212 1,
213 "wasm_client",
214 "AetherisClient initialized",
215 "wasm_init",
216 None,
217 );
218 });
219
220 Ok(Self {
221 shared_world,
222 world_state: ClientWorld::new(),
223 render_state: None,
224 transport: None,
225 worker_id: crate::get_worker_id(),
226 session_token: None,
227 snapshots: std::collections::VecDeque::with_capacity(8),
228 last_rtt_ms: 0.0,
229 ping_counter: 0,
230 reassembler: aetheris_protocol::Reassembler::new(),
231 connection_state: ConnectionState::Disconnected,
232 reconnect_attempts: 0,
233 playground_rotation_enabled: false,
234 playground_next_network_id: 1,
235 first_playground_tick: true,
236 render_buffer: Vec::with_capacity(crate::shared_world::MAX_ENTITIES),
237 asset_registry: assets::AssetRegistry::new(),
238 })
239 }
240
241 #[wasm_bindgen]
242 pub async fn request_otp(base_url: String, email: String) -> Result<String, String> {
243 crate::auth::request_otp(base_url, email).await
244 }
245
246 #[wasm_bindgen]
247 pub async fn login_with_otp(
248 base_url: String,
249 request_id: String,
250 code: String,
251 ) -> Result<String, String> {
252 crate::auth::login_with_otp(base_url, request_id, code).await
253 }
254
255 #[wasm_bindgen]
256 pub async fn logout(base_url: String, session_token: String) -> Result<(), String> {
257 crate::auth::logout(base_url, session_token).await
258 }
259
260 #[wasm_bindgen(getter)]
261 pub fn connection_state(&self) -> ConnectionState {
262 self.connection_state
263 }
264
265 fn check_worker(&self) {
266 debug_assert_eq!(
267 self.worker_id,
268 crate::get_worker_id(),
269 "AetherisClient accessed from wrong worker! It is pin-bound to its creating thread."
270 );
271 }
272
273 pub fn shared_world_ptr(&self) -> u32 {
275 self.shared_world.as_ptr() as u32
276 }
277
278 pub async fn connect(
279 &mut self,
280 url: String,
281 cert_hash: Option<Vec<u8>>,
282 ) -> Result<(), JsValue> {
283 self.check_worker();
284
285 if self.connection_state == ConnectionState::Connecting
286 || self.connection_state == ConnectionState::InGame
287 || self.connection_state == ConnectionState::Reconnecting
288 {
289 return Ok(());
290 }
291
292 if self.connection_state == ConnectionState::Failed && self.reconnect_attempts > 0 {
294 with_collector(|c| {
295 c.push_event(
296 2,
297 "transport",
298 "Triggering reconnection",
299 "reconnect_attempt",
300 None,
301 );
302 });
303 }
304
305 self.connection_state = ConnectionState::Connecting;
306 tracing::info!(url = %url, "Connecting to server...");
307
308 let transport_result = WebTransportBridge::connect(&url, cert_hash.as_deref()).await;
309
310 match transport_result {
311 Ok(transport) => {
312 if let Some(token) = &self.session_token {
314 let encoder = SerdeEncoder::new();
315 let auth_event = NetworkEvent::Auth {
316 session_token: token.clone(),
317 };
318
319 match encoder.encode_event(&auth_event) {
320 Ok(data) => {
321 if let Err(e) = transport.send_reliable(ClientId(0), &data).await {
322 self.connection_state = ConnectionState::Failed;
323 tracing::error!(error = ?e, "Handshake failed: could not send auth packet");
324 return Err(JsValue::from_str(&format!(
325 "Failed to send auth packet: {:?}",
326 e
327 )));
328 }
329 tracing::info!("Auth packet sent to server");
330 }
331 Err(e) => {
332 self.connection_state = ConnectionState::Failed;
333 tracing::error!(error = ?e, "Handshake failed: could not encode auth packet");
334 return Err(JsValue::from_str("Failed to encode auth packet"));
335 }
336 }
337 } else {
338 tracing::warn!(
339 "Connecting without session token! Server will likely discard data."
340 );
341 }
342
343 self.transport = Some(Box::new(transport));
344 self.connection_state = ConnectionState::InGame;
345 self.reconnect_attempts = 0;
346 tracing::info!("WebTransport connection established");
347 with_collector(|c| {
349 c.push_event(
350 1,
351 "transport",
352 &format!("WebTransport connected: {url}"),
353 "connect_handshake",
354 None,
355 );
356 });
357 Ok(())
358 }
359 Err(e) => {
360 self.connection_state = ConnectionState::Failed;
361 tracing::error!(error = ?e, "Failed to establish WebTransport connection");
362 with_collector(|c| {
364 c.push_event(
365 3,
366 "transport",
367 &format!("WebTransport failed: {url} — {e:?}"),
368 "connect_handshake_failed",
369 None,
370 );
371 });
372 Err(JsValue::from_str(&format!("failed to connect: {e:?}")))
373 }
374 }
375 }
376
377 #[wasm_bindgen]
378 pub async fn reconnect(
379 &mut self,
380 url: String,
381 cert_hash: Option<Vec<u8>>,
382 ) -> Result<(), JsValue> {
383 self.check_worker();
384 self.connection_state = ConnectionState::Reconnecting;
385 self.reconnect_attempts += 1;
386
387 tracing::info!(
388 "Attempting reconnection... (attempt {})",
389 self.reconnect_attempts
390 );
391
392 self.connect(url, cert_hash).await
393 }
394
395 #[wasm_bindgen]
396 pub async fn wasm_load_asset(
397 &mut self,
398 handle: assets::AssetHandle,
399 url: String,
400 ) -> Result<(), JsValue> {
401 self.asset_registry.load_asset(handle, &url).await
402 }
403
404 pub fn set_session_token(&mut self, token: String) {
406 self.session_token = Some(token);
407 }
408
409 pub async fn init_renderer(&mut self, canvas: JsValue) -> Result<(), JsValue> {
412 self.check_worker();
413 use wasm_bindgen::JsCast;
414
415 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
416 backends: wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL,
417 flags: wgpu::InstanceFlags::default(),
418 ..wgpu::InstanceDescriptor::new_without_display_handle()
419 });
420
421 let (surface_target, width, height) =
423 if let Ok(html_canvas) = canvas.clone().dyn_into::<web_sys::HtmlCanvasElement>() {
424 let width = html_canvas.width();
425 let height = html_canvas.height();
426 tracing::info!(
427 "Initializing renderer on HTMLCanvasElement ({}x{})",
428 width,
429 height
430 );
431 (wgpu::SurfaceTarget::Canvas(html_canvas), width, height)
432 } else if let Ok(offscreen_canvas) =
433 canvas.clone().dyn_into::<web_sys::OffscreenCanvas>()
434 {
435 let width = offscreen_canvas.width();
436 let height = offscreen_canvas.height();
437 tracing::info!(
438 "Initializing renderer on OffscreenCanvas ({}x{})",
439 width,
440 height
441 );
442
443 let _ = offscreen_canvas.get_context("webgpu").map_err(|e| {
446 JsValue::from_str(&format!("Failed to get webgpu context: {:?}", e))
447 })?;
448
449 (
450 wgpu::SurfaceTarget::OffscreenCanvas(offscreen_canvas),
451 width,
452 height,
453 )
454 } else {
455 return Err(JsValue::from_str(
456 "Aetheris: Provided object is not a valid Canvas or OffscreenCanvas",
457 ));
458 };
459
460 let surface = instance
461 .create_surface(surface_target)
462 .map_err(|e| JsValue::from_str(&format!("Failed to create surface: {:?}", e)))?;
463
464 let render_state = RenderState::new(&instance, surface, width, height)
465 .await
466 .map_err(|e| JsValue::from_str(&format!("Failed to init renderer: {:?}", e)))?;
467
468 self.render_state = Some(render_state);
469
470 with_collector(|c| {
472 c.push_event(
473 1,
474 "render_worker",
475 &format!("Renderer initialized ({}x{})", width, height),
476 "render_pipeline_setup",
477 None,
478 );
479 });
480
481 Ok(())
482 }
483
484 #[wasm_bindgen]
485 pub fn resize(&mut self, width: u32, height: u32) {
486 if let Some(state) = &mut self.render_state {
487 state.resize(width, height);
488 }
489 }
490
491 #[cfg(debug_assertions)]
492 #[wasm_bindgen]
493 pub fn set_debug_mode(&mut self, mode: u32) {
494 self.check_worker();
495 if let Some(state) = &mut self.render_state {
496 state.set_debug_mode(match mode {
497 0 => crate::render::DebugRenderMode::Off,
498 1 => crate::render::DebugRenderMode::Wireframe,
499 2 => crate::render::DebugRenderMode::Components,
500 _ => crate::render::DebugRenderMode::Full,
501 });
502 }
503 }
504
505 #[wasm_bindgen]
506 pub fn set_theme_colors(&mut self, bg_base: &str, text_primary: &str) {
507 self.check_worker();
508 let clear = crate::render::parse_css_color(bg_base);
509 let label = crate::render::parse_css_color(text_primary);
510
511 tracing::info!(
512 "Aetheris Client: Applying theme colors [bg: {} -> {:?}, text: {} -> {:?}]",
513 bg_base,
514 clear,
515 text_primary,
516 label
517 );
518
519 if let Some(state) = &mut self.render_state {
520 state.set_clear_color(clear);
521 #[cfg(debug_assertions)]
522 state.set_label_color([
523 label.r as f32,
524 label.g as f32,
525 label.b as f32,
526 label.a as f32,
527 ]);
528 }
529 }
530
531 #[cfg(debug_assertions)]
532 #[wasm_bindgen]
533 pub fn cycle_debug_mode(&mut self) {
534 if let Some(state) = &mut self.render_state {
535 state.cycle_debug_mode();
536 }
537 }
538
539 #[cfg(debug_assertions)]
540 #[wasm_bindgen]
541 pub fn toggle_grid(&mut self) {
542 if let Some(state) = &mut self.render_state {
543 state.toggle_grid();
544 }
545 }
546
547 pub async fn tick(&mut self) {
549 self.check_worker();
550 use aetheris_protocol::traits::{Encoder, WorldState};
551
552 let encoder = SerdeEncoder::new();
553
554 if let Some(_transport) = &self.transport {}
557
558 if let Some(transport) = &mut self.transport {
560 self.ping_counter = self.ping_counter.wrapping_add(1);
561 if self.ping_counter % 60 == 0 {
562 let now = performance_now();
564 let tick_u64 = now as u64;
565
566 if let Ok(data) = encoder.encode_event(&NetworkEvent::Ping {
567 client_id: ClientId(0), tick: tick_u64,
569 }) {
570 web_sys::console::debug_1(
571 &format!("[Aetheris] Sending Ping: tick={tick_u64}").into(),
572 );
573 let _ = transport.send_unreliable(ClientId(0), &data).await;
574 }
575 }
576 }
577
578 if let Some(transport) = &mut self.transport {
580 let events = match transport.poll_events().await {
581 Ok(e) => e,
582 Err(e) => {
583 tracing::error!("Transport poll failure: {:?}", e);
584 return;
585 }
586 };
587 let mut updates: Vec<(ClientId, aetheris_protocol::events::ComponentUpdate)> =
588 Vec::new();
589
590 for event in events {
591 match event {
592 NetworkEvent::UnreliableMessage { data, client_id }
593 | NetworkEvent::ReliableMessage { data, client_id } => {
594 if let Ok(update) = encoder.decode(&data) {
595 updates.push((client_id, update));
596 }
597 }
598 NetworkEvent::ClientConnected(id) => {
599 tracing::info!(?id, "Server connected");
600 }
601 NetworkEvent::ClientDisconnected(id) => {
602 tracing::warn!(?id, "Server disconnected");
603 }
604 NetworkEvent::Disconnected(_id) => {
605 tracing::error!("Transport disconnected locally");
606 self.connection_state = ConnectionState::Disconnected;
607 }
608 NetworkEvent::Ping { client_id: _, tick } => {
609 let pong = NetworkEvent::Pong { tick };
611 if let Ok(data) = encoder.encode_event(&pong) {
612 let _ = transport.send_reliable(ClientId(0), &data).await;
613 }
614 }
615 NetworkEvent::Pong { tick } => {
616 let now = performance_now();
618 let rtt = now - (tick as f64);
619 self.last_rtt_ms = rtt;
620
621 with_collector(|c| {
622 c.update_rtt(rtt);
623 });
624
625 #[cfg(feature = "metrics")]
626 metrics::gauge!("aetheris_client_rtt_ms").set(rtt);
627
628 web_sys::console::debug_1(
629 &format!("[Aetheris] Received Pong: tick={tick} RTT={rtt:.1}ms")
630 .into(),
631 );
632 tracing::debug!(rtt_ms = rtt, "RTT Update");
633 }
634 NetworkEvent::Auth { .. } => {
635 tracing::debug!("Received Auth event from server (unexpected)");
637 }
638 NetworkEvent::SessionClosed(id) => {
639 tracing::warn!(?id, "WebTransport session closed");
640 }
641 NetworkEvent::StreamReset(id) => {
642 tracing::error!(?id, "WebTransport stream reset");
643 }
644 NetworkEvent::Fragment {
645 client_id,
646 fragment,
647 } => {
648 if let Some(data) = self.reassembler.ingest(client_id, fragment) {
649 if let Ok(update) = encoder.decode(&data) {
650 updates.push((client_id, update));
651 }
652 }
653 }
654 NetworkEvent::StressTest { .. } => {
655 }
658 NetworkEvent::Spawn { .. } => {
659 }
661 NetworkEvent::ClearWorld { .. } => {
662 tracing::info!("Server initiated world clear");
663 self.world_state.entities.clear();
664 }
665 }
666 }
667
668 self.world_state.apply_updates(&updates);
670 }
671
672 if self.playground_rotation_enabled {
674 for slot in self.world_state.entities.values_mut() {
675 slot.rotation = (slot.rotation + 0.05) % std::f32::consts::TAU;
676 }
677 }
678
679 self.world_state.latest_tick += 1;
682
683 self.flush_to_shared_world(self.world_state.latest_tick);
685 }
686
687 fn flush_to_shared_world(&mut self, tick: u64) {
688 let entities = &self.world_state.entities;
689 let write_buffer = self.shared_world.get_write_buffer();
690
691 let mut count = 0;
692 for (i, slot) in entities.values().enumerate() {
693 if i >= MAX_ENTITIES {
694 tracing::warn!("Max entities reached in shared world! Overflow suppressed.");
695 break;
696 }
697 write_buffer[i] = *slot;
698 count += 1;
699 }
700
701 self.shared_world.commit_write(count as u32, tick);
702 }
703
704 #[wasm_bindgen]
705 pub fn playground_spawn(&mut self, entity_type: u16, x: f32, y: f32, rotation: f32) {
706 if self.world_state.entities.len() >= MAX_ENTITIES {
708 tracing::warn!("playground_spawn: MAX_ENTITIES reached, spawn ignored.");
709 return;
710 }
711
712 if self.playground_next_network_id == 1 && !self.world_state.entities.is_empty() {
714 self.playground_next_network_id = self
715 .world_state
716 .entities
717 .keys()
718 .map(|k| k.0)
719 .max()
720 .unwrap_or(0)
721 + 1;
722 }
723
724 let id = aetheris_protocol::types::NetworkId(self.playground_next_network_id);
725 self.playground_next_network_id += 1;
726 let slot = SabSlot {
727 network_id: id.0,
728 x,
729 y,
730 z: 0.0,
731 rotation,
732 dx: 0.0,
733 dy: 0.0,
734 dz: 0.0,
735 hp: 100,
736 shield: 100,
737 entity_type,
738 flags: 0x01, padding: [0; 5],
740 };
741 self.world_state.entities.insert(id, slot);
742 }
743
744 #[wasm_bindgen]
745 pub async fn playground_spawn_net(
746 &mut self,
747 entity_type: u16,
748 x: f32,
749 y: f32,
750 rot: f32,
751 ) -> Result<(), JsValue> {
752 self.check_worker();
753
754 if let Some(transport) = &self.transport {
755 let encoder = SerdeEncoder::new();
756 let event = NetworkEvent::Spawn {
757 client_id: ClientId(0),
758 entity_type,
759 x,
760 y,
761 rot,
762 };
763
764 if let Ok(data) = encoder.encode_event(&event) {
765 transport
766 .send_reliable(ClientId(0), &data)
767 .await
768 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
769 tracing::info!(entity_type, x, y, "Sent Spawn command to server");
770 }
771 } else {
772 self.playground_spawn(entity_type, x, y, rot);
774 }
775 Ok(())
776 }
777
778 #[wasm_bindgen]
779 pub fn playground_clear(&mut self) {
780 self.world_state.entities.clear();
781 }
782
783 #[wasm_bindgen]
784 pub async fn playground_clear_server(&mut self) -> Result<(), JsValue> {
785 self.check_worker();
786
787 if let Some(transport) = &self.transport {
788 let encoder = SerdeEncoder::new();
789 let event = NetworkEvent::ClearWorld {
790 client_id: ClientId(0),
791 };
792 if let Ok(data) = encoder.encode_event(&event) {
793 transport
794 .send_reliable(ClientId(0), &data)
795 .await
796 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
797 tracing::info!("Sent ClearWorld command to server");
798 self.world_state.entities.clear();
799 }
800 } else {
801 self.world_state.entities.clear();
803 }
804 Ok(())
805 }
806
807 #[wasm_bindgen]
808 pub fn playground_set_rotation_enabled(&mut self, enabled: bool) {
809 self.playground_rotation_enabled = enabled;
810 }
813
814 #[wasm_bindgen]
815 pub async fn playground_stress_test(
816 &mut self,
817 count: u16,
818 rotate: bool,
819 ) -> Result<(), JsValue> {
820 self.check_worker();
821
822 if let Some(transport) = &self.transport {
823 let encoder = SerdeEncoder::new();
824 let event = NetworkEvent::StressTest {
825 client_id: ClientId(0),
826 count,
827 rotate,
828 };
829
830 if let Ok(data) = encoder.encode_event(&event) {
831 transport
832 .send_reliable(ClientId(0), &data)
833 .await
834 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
835 tracing::info!(count, rotate, "Sent StressTest command to server");
836 }
837 self.playground_set_rotation_enabled(rotate);
838 } else {
839 self.playground_set_rotation_enabled(rotate);
841 self.playground_clear();
842 for _ in 0..count {
843 self.playground_spawn(1, 0.0, 0.0, 0.0); }
845 }
846
847 Ok(())
848 }
849
850 #[wasm_bindgen]
851 pub async fn tick_playground(&mut self) {
852 self.check_worker();
853
854 if self.first_playground_tick {
856 self.first_playground_tick = false;
857 with_collector(|c| {
858 c.push_event(
859 1,
860 "wasm_client",
861 "Playground simulation loop started",
862 "tick_playground_loop_start",
863 None,
864 );
865 });
866 }
867
868 let sim_start = crate::performance_now();
870 self.world_state.latest_tick += 1;
871
872 if self.playground_rotation_enabled {
873 for slot in self.world_state.entities.values_mut() {
874 slot.rotation = (slot.rotation + 0.05) % std::f32::consts::TAU;
875 }
876 }
877
878 let count = self.world_state.entities.len() as u32;
880 self.flush_to_shared_world(self.world_state.latest_tick);
881
882 let sim_time_ms = crate::performance_now() - sim_start;
883 with_collector(|c| {
884 c.record_sim(sim_time_ms);
885 c.update_entity_count(count);
886 });
887 }
888
889 pub fn render(&mut self) -> f64 {
891 self.check_worker();
892
893 let tick = self.shared_world.tick();
894 let entities = self.shared_world.get_read_buffer();
895
896 thread_local! {
898 static FRAME_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
899 }
900 FRAME_COUNT.with(|count| {
901 let current = count.get();
902 if current % 300 == 0 {
903 tracing::debug!(
904 "Aetheris Render Stats: Tick={}, Entities={}, Snapshots={}",
905 tick,
906 entities.len(),
907 self.snapshots.len(),
908 );
909 }
910 count.set(current + 1);
911 });
912
913 if tick == 0 {
914 let mut frame_time_ms = 0.0;
916 if let Some(state) = &mut self.render_state {
917 frame_time_ms = state.render_frame_with_compact_slots(&[]);
918 with_collector(|c| {
919 c.record_frame(frame_time_ms, 0.0);
922 });
923 }
924 return frame_time_ms;
925 }
926
927 if self.snapshots.is_empty()
929 || tick > self.snapshots.back().map(|s| s.tick).unwrap_or(0)
930 {
931 self.snapshots.push_back(SimulationSnapshot {
932 tick,
933 entities: entities.to_vec(),
934 });
935 }
936
937 let mut frame_time_ms = 0.0;
941 if !self.snapshots.is_empty() {
942 let latest_tick = self.snapshots.back().unwrap().tick as f32;
943 let target_tick = latest_tick - 2.0;
944 frame_time_ms = self.render_at_tick(target_tick);
945 }
946
947 let snap_count = self.snapshots.len() as u32;
949 with_collector(|c| {
950 c.record_frame(frame_time_ms, 0.0);
952 c.update_snapshot_count(snap_count);
953 });
954
955 frame_time_ms
956 }
957
958 fn render_at_tick(&mut self, target_tick: f32) -> f64 {
959 if self.snapshots.len() < 2 {
960 if let Some(state) = &mut self.render_state {
963 let entities = if !self.snapshots.is_empty() {
964 self.snapshots[0].entities.clone()
965 } else {
966 Vec::new()
967 };
968 return state.render_frame_with_compact_slots(&entities);
969 }
970 return 0.0;
971 }
972
973 let mut s1_idx = 0;
975 let mut found = false;
976
977 for i in 0..self.snapshots.len() - 1 {
978 if (self.snapshots[i].tick as f32) <= target_tick
979 && (self.snapshots[i + 1].tick as f32) > target_tick
980 {
981 s1_idx = i;
982 found = true;
983 break;
984 }
985 }
986
987 if !found {
988 if target_tick < self.snapshots[0].tick as f32 {
990 s1_idx = 0;
991 } else {
992 s1_idx = self.snapshots.len() - 2;
993 }
994 }
995
996 let s1 = self.snapshots.get(s1_idx).unwrap();
997 let s2 = self.snapshots.get(s1_idx + 1).unwrap();
998
999 let tick_range = (s2.tick - s1.tick) as f32;
1000 let alpha = if tick_range > 0.0 {
1001 (target_tick - s1.tick as f32) / tick_range
1002 } else {
1003 1.0
1004 }
1005 .clamp(0.0, 1.0);
1006
1007 self.render_buffer.clear();
1009 self.render_buffer.extend_from_slice(&s2.entities);
1010
1011 let prev_map: std::collections::HashMap<u64, &SabSlot> =
1013 s1.entities.iter().map(|e| (e.network_id, e)).collect();
1014
1015 for ent in &mut self.render_buffer {
1016 if let Some(prev) = prev_map.get(&ent.network_id).copied() {
1017 ent.x = lerp(prev.x, ent.x, alpha);
1018 ent.y = lerp(prev.y, ent.y, alpha);
1019 ent.z = lerp(prev.z, ent.z, alpha);
1020 ent.rotation = lerp_rotation(prev.rotation, ent.rotation, alpha);
1021 }
1022 }
1023
1024 let mut frame_time = 0.0;
1025 if let Some(state) = &mut self.render_state {
1026 frame_time = state.render_frame_with_compact_slots(&self.render_buffer);
1027 }
1028
1029 while self.snapshots.len() > 2 && (self.snapshots[0].tick as f32) < target_tick - 1.0 {
1033 self.snapshots.pop_front();
1034 }
1035
1036 while self.snapshots.len() > 16 {
1038 self.snapshots.pop_front();
1039 }
1040
1041 frame_time
1042 }
1043 }
1044
1045 fn lerp(a: f32, b: f32, alpha: f32) -> f32 {
1046 a + (b - a) * alpha
1047 }
1048
1049 fn lerp_rotation(a: f32, b: f32, alpha: f32) -> f32 {
1050 let mut diff = b - a;
1053 while diff < -std::f32::consts::PI {
1054 diff += std::f32::consts::TAU;
1055 }
1056 while diff > std::f32::consts::PI {
1057 diff -= std::f32::consts::TAU;
1058 }
1059 a + diff * alpha
1060 }
1061
1062 #[cfg(test)]
1063 mod tests {
1064 use super::*;
1065 use crate::transport_mock::MockTransport;
1066
1067 #[tokio::test]
1068 async fn test_transport_disconnection() {
1069 let mut client = AetherisClient::new(None).unwrap();
1070 let mock = MockTransport::new();
1071 client.transport = Some(Box::new(mock.clone()));
1072 client.connection_state = ConnectionState::InGame;
1073
1074 mock.set_closed(true);
1076
1077 client.tick().await;
1079
1080 assert_eq!(client.connection_state, ConnectionState::Disconnected);
1081 }
1082 }
1083}