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, ReplicationEvent};
115 use aetheris_protocol::traits::{Encoder, PlatformTransport, WorldState};
116 use aetheris_protocol::types::{
117 ClientId, ComponentKind, InputCommand, NetworkId, PlayerInputKind,
118 };
119 use std::cell::RefCell;
120 use wasm_bindgen::prelude::*;
121
122 fn lerp(a: f32, b: f32, alpha: f32) -> f32 {
123 a + (b - a) * alpha
124 }
125
126 fn lerp_wrapped(a: f32, b: f32, alpha: f32, min: f32, max: f32) -> f32 {
127 let size = max - min;
128 if size <= 0.0 {
129 return a + (b - a) * alpha;
130 }
131
132 let a_norm = (a - min).rem_euclid(size) + min;
134 let b_norm = (b - min).rem_euclid(size) + min;
135
136 let mut diff = b_norm - a_norm;
137 if diff.abs() > size * 0.5 {
138 if diff > 0.0 {
139 diff -= size;
140 } else {
141 diff += size;
142 }
143 }
144 let res = a_norm + diff * alpha;
145 (res - min).rem_euclid(size) + min
147 }
148
149 fn lerp_rotation(a: f32, b: f32, alpha: f32) -> f32 {
150 let mut diff = b - a;
153 while diff < -std::f32::consts::PI {
154 diff += std::f32::consts::TAU;
155 }
156 while diff > std::f32::consts::PI {
157 diff -= std::f32::consts::TAU;
158 }
159 a + diff * alpha
160 }
161
162 #[wasm_bindgen]
163 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
164 pub enum ConnectionState {
165 Disconnected,
166 Connecting,
167 InGame,
168 Reconnecting,
169 Failed,
170 }
171
172 #[derive(Clone)]
174 pub struct SimulationSnapshot {
175 pub tick: u64,
176 pub entities: Vec<SabSlot>,
177 }
178
179 #[wasm_bindgen]
180 pub async fn auth_request_otp(base_url: String, email: String) -> Result<String, JsValue> {
181 crate::auth::request_otp(base_url, email)
182 .await
183 .map_err(|e| e.into())
184 }
185
186 #[wasm_bindgen]
187 pub async fn auth_login_with_otp(
188 base_url: String,
189 request_id: String,
190 code: String,
191 ) -> Result<String, JsValue> {
192 crate::auth::login_with_otp(base_url, request_id, code)
193 .await
194 .map_err(|e| e.into())
195 }
196
197 #[wasm_bindgen]
199 pub struct PlatformClient {
200 worker_id: usize,
201 session_token: RefCell<Option<String>>,
202
203 last_rtt_ms: RefCell<f64>,
204 ping_counter: RefCell<u64>,
205
206 reassembler: RefCell<aetheris_protocol::Reassembler>,
207 connection_state: RefCell<ConnectionState>,
208 reconnect_attempts: RefCell<u32>,
209
210 shared_world: crate::shared_world::SharedWorld,
211 world_state: RefCell<crate::world_state::ClientWorld>,
212 render_state: RefCell<Option<crate::render::RenderState>>,
213 transport: RefCell<Option<Box<dyn aetheris_protocol::traits::PlatformTransport>>>,
214
215 playground_rotation_enabled: RefCell<bool>,
216 playground_next_network_id: RefCell<u64>,
217 first_playground_tick: RefCell<bool>,
218
219 pub(crate) render_buffer: RefCell<Vec<SabSlot>>,
220 pub(crate) asset_registry: crate::assets::AssetRegistry,
221
222 last_input_target: RefCell<Option<NetworkId>>,
223 last_input_actions: RefCell<Vec<PlayerInputKind>>,
224
225 pending_clear: RefCell<bool>,
226 last_clear_tick: RefCell<u64>,
227
228 last_process_time: RefCell<f64>,
229 tick_accumulator: RefCell<f64>,
230
231 playground_move_x: RefCell<f32>,
232 playground_move_y: RefCell<f32>,
233 playground_actions: RefCell<u32>,
234 last_fraction: RefCell<f32>,
235 last_actions_mask: RefCell<u32>,
236 last_cursor_x: RefCell<f32>,
237 last_cursor_y: RefCell<f32>,
238 last_cursor_send_time: RefCell<f64>,
239
240 snapshots: RefCell<std::collections::VecDeque<SimulationSnapshot>>,
241 }
242
243 #[wasm_bindgen]
244 impl PlatformClient {
245 #[wasm_bindgen(constructor)]
248 pub fn new(shared_world_ptr: Option<u32>) -> Result<PlatformClient, JsValue> {
249 console_error_panic_hook::set_once();
250
251 use std::sync::atomic::{AtomicBool, Ordering};
254 static LOGGER_INITIALIZED: AtomicBool = AtomicBool::new(false);
255 if !LOGGER_INITIALIZED.swap(true, Ordering::SeqCst) {
256 let config = tracing_wasm::WASMLayerConfigBuilder::new()
257 .set_max_level(tracing::Level::INFO)
258 .build();
259 tracing_wasm::set_as_global_default_with_config(config);
260 }
261
262 let shared_world = if let Some(ptr_val) = shared_world_ptr {
263 let ptr = ptr_val as *mut u8;
264
265 if ptr_val == 0 || !ptr_val.is_multiple_of(8) {
267 return Err(JsValue::from_str(
268 "Invalid shared_world_ptr: null or unaligned",
269 ));
270 }
271
272 unsafe { SharedWorld::from_ptr(ptr) }
277 } else {
278 SharedWorld::new()
279 };
280
281 let global = js_sys::global();
282 let (ua, lang) =
283 if let Ok(worker) = global.clone().dyn_into::<web_sys::WorkerGlobalScope>() {
284 let n = worker.navigator();
285 (n.user_agent().ok(), n.language())
286 } else if let Ok(window) = global.dyn_into::<web_sys::Window>() {
287 let n = window.navigator();
288 (n.user_agent().ok(), n.language())
289 } else {
290 (None, None)
291 };
292
293 tracing::info!(
294 "Platform Client: Environment [UA: {}, Lang: {}]",
295 ua.as_deref().unwrap_or("Unknown"),
296 lang.as_deref().unwrap_or("Unknown")
297 );
298
299 tracing::info!(
300 "PlatformClient initialized on worker {}",
301 crate::get_worker_id()
302 );
303
304 with_collector(|c| {
306 c.push_event(
307 1,
308 "wasm_client",
309 "PlatformClient initialized",
310 "wasm_init",
311 None,
312 );
313 });
314
315 let mut world_state = ClientWorld::new();
316 world_state.shared_world_ref = Some(shared_world.as_ptr() as usize);
317
318 Ok(Self {
319 shared_world,
320 world_state: RefCell::new(world_state),
321 render_state: RefCell::new(None),
322 transport: RefCell::new(None),
323 worker_id: crate::get_worker_id(),
324 session_token: RefCell::new(None),
325 snapshots: RefCell::new(std::collections::VecDeque::with_capacity(8)),
326 last_rtt_ms: RefCell::new(0.0),
327 ping_counter: RefCell::new(0),
328 reassembler: RefCell::new(aetheris_protocol::Reassembler::new()),
329 connection_state: RefCell::new(ConnectionState::Disconnected),
330 reconnect_attempts: RefCell::new(0),
331 playground_rotation_enabled: RefCell::new(false),
332 playground_next_network_id: RefCell::new(1),
333 first_playground_tick: RefCell::new(true),
334 render_buffer: RefCell::new(Vec::with_capacity(crate::shared_world::MAX_ENTITIES)),
335 asset_registry: assets::AssetRegistry::new(),
336 last_input_target: RefCell::new(None),
337 last_input_actions: RefCell::new(Vec::new()),
338 pending_clear: RefCell::new(false),
339 last_clear_tick: RefCell::new(0),
340 last_process_time: RefCell::new(crate::performance_now()),
341 tick_accumulator: RefCell::new(0.0),
342 playground_move_x: RefCell::new(0.0),
343 playground_move_y: RefCell::new(0.0),
344 playground_actions: RefCell::new(0),
345 last_fraction: RefCell::new(0.0),
346 last_actions_mask: RefCell::new(0),
347 last_cursor_x: RefCell::new(-1.0),
348 last_cursor_y: RefCell::new(-1.0),
349 last_cursor_send_time: RefCell::new(0.0),
350 })
351 }
352
353 fn check_worker(&self) {
354 debug_assert_eq!(
355 self.worker_id,
356 crate::get_worker_id(),
357 "PlatformClient accessed from wrong worker! It is pin-bound to its creating thread."
358 );
359 }
360
361 pub fn shared_world_ptr(&self) -> u32 {
363 self.shared_world.as_ptr() as u32
364 }
365
366 #[wasm_bindgen]
367 pub fn set_view_state(&self, state: u32) {
368 use crate::render::ViewState;
369 let state = match state {
370 0 => ViewState::Logo,
371 1 => ViewState::Roaming,
372 2 => ViewState::Entering,
373 3 => ViewState::Playing,
374 _ => return,
375 };
376
377 let mut rs = self.render_state.borrow_mut();
378 if let Some(rs) = &mut *rs {
379 rs.set_view_state(state);
380 } else {
381 tracing::warn!("set_view_state called but render_state is None");
382 }
383 }
384
385 #[wasm_bindgen(getter)]
386 pub fn connection_state(&self) -> ConnectionState {
387 *self.connection_state.borrow()
388 }
389
390 pub async fn connect(
391 &self,
392 url: String,
393 cert_hash: Option<Vec<u8>>,
394 ) -> Result<(), JsValue> {
395 self.check_worker();
396
397 {
398 let state = self.connection_state.borrow();
399 if *state == ConnectionState::Connecting
400 || *state == ConnectionState::InGame
401 || *state == ConnectionState::Reconnecting
402 {
403 return Ok(());
404 }
405 }
406
407 {
409 let state = self.connection_state.borrow();
410 let attempts = self.reconnect_attempts.borrow();
411 if *state == ConnectionState::Failed && *attempts > 0 {
412 with_collector(|c| {
413 c.push_event(
414 2,
415 "transport",
416 "Triggering reconnection",
417 "reconnect_attempt",
418 None,
419 );
420 });
421 }
422 }
423
424 *self.connection_state.borrow_mut() = ConnectionState::Connecting;
425 tracing::info!(url = %url, "Connecting to server...");
426
427 let transport_result = WebTransportBridge::connect(&url, cert_hash.as_deref()).await;
428
429 match transport_result {
430 Ok(transport) => {
431 let token_opt = self.session_token.borrow().clone();
433 if let Some(token) = token_opt {
434 if let Err(e) = transport.send_raw_auth_token(&token).await {
435 *self.connection_state.borrow_mut() = ConnectionState::Failed;
436 tracing::error!(error = ?e, "Transport handshake failed");
437 return Err(JsValue::from_str(&format!(
438 "Failed to send raw auth token: {:?}",
439 e
440 )));
441 }
442 tracing::info!("Raw auth token accepted by transport");
443
444 let encoder = SerdeEncoder::new();
446 let auth_event = NetworkEvent::Auth {
447 session_token: token.clone(),
448 };
449
450 if let Ok(data) = encoder.encode_event(&auth_event) {
451 if let Err(e) = transport.send_reliable(ClientId(0), &data).await {
452 tracing::error!(error = ?e, "Application auth failed");
453 } else {
454 tracing::info!("Application auth packet sent");
455 }
456 }
457 } else {
458 tracing::warn!(
459 "Connecting without session token! Server will likely discard data."
460 );
461 }
462
463 *self.transport.borrow_mut() = Some(Box::new(transport));
464 *self.connection_state.borrow_mut() = ConnectionState::InGame;
465 *self.reconnect_attempts.borrow_mut() = 0;
466 tracing::info!("WebTransport connection established");
467 with_collector(|c| {
469 c.push_event(
470 1,
471 "transport",
472 &format!("WebTransport connected: {url}"),
473 "connect_handshake",
474 None,
475 );
476 });
477 Ok(())
478 }
479 Err(e) => {
480 *self.connection_state.borrow_mut() = ConnectionState::Failed;
481 tracing::error!(error = ?e, "Failed to establish WebTransport connection");
482 with_collector(|c| {
484 c.push_event(
485 3,
486 "transport",
487 &format!("WebTransport failed: {url} — {e:?}"),
488 "connect_handshake_failed",
489 None,
490 );
491 });
492 Err(JsValue::from_str(&format!("failed to connect: {e:?}")))
493 }
494 }
495 }
496
497 #[wasm_bindgen]
498 pub async fn disconnect(&self) {
499 self.check_worker();
500
501 let transport = self.transport.borrow_mut().take();
503 if let Some(transport) = transport {
504 let _ = transport.disconnect(ClientId(0)).await;
506 }
507
508 *self.connection_state.borrow_mut() = ConnectionState::Disconnected;
509 tracing::info!("PlatformClient disconnected explicitly");
510 }
511
512 #[wasm_bindgen]
513 pub async fn reconnect(
514 &self,
515 url: String,
516 cert_hash: Option<Vec<u8>>,
517 ) -> Result<(), JsValue> {
518 self.check_worker();
519 *self.connection_state.borrow_mut() = ConnectionState::Reconnecting;
520 *self.reconnect_attempts.borrow_mut() += 1;
521
522 let attempts = *self.reconnect_attempts.borrow();
523 tracing::info!("Attempting reconnection... (attempt {})", attempts);
524
525 self.connect(url, cert_hash).await
526 }
527
528 #[wasm_bindgen]
529 pub async fn wasm_load_asset(
530 &self,
531 handle: assets::AssetHandle,
532 url: String,
533 ) -> Result<(), JsValue> {
534 self.asset_registry.load_asset(handle, &url).await
535 }
536
537 pub fn set_session_token(&self, token: String) {
539 *self.session_token.borrow_mut() = Some(token);
540 }
541
542 pub async fn init_renderer(&self, canvas: JsValue) -> Result<(), JsValue> {
545 self.check_worker();
546 use wasm_bindgen::JsCast;
547
548 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
549 backends: wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL,
550 flags: wgpu::InstanceFlags::default(),
551 ..wgpu::InstanceDescriptor::new_without_display_handle()
552 });
553
554 let (surface_target, width, height) =
556 if let Ok(html_canvas) = canvas.clone().dyn_into::<web_sys::HtmlCanvasElement>() {
557 let width = html_canvas.width();
558 let height = html_canvas.height();
559 tracing::info!(
560 "Initializing renderer on HTMLCanvasElement ({}x{})",
561 width,
562 height
563 );
564 (wgpu::SurfaceTarget::Canvas(html_canvas), width, height)
565 } else if let Ok(offscreen_canvas) =
566 canvas.clone().dyn_into::<web_sys::OffscreenCanvas>()
567 {
568 let width = offscreen_canvas.width();
569 let height = offscreen_canvas.height();
570 tracing::info!(
571 "Initializing renderer on OffscreenCanvas ({}x{})",
572 width,
573 height
574 );
575
576 let _ = offscreen_canvas.get_context("webgpu").map_err(|e| {
579 JsValue::from_str(&format!("Failed to get webgpu context: {:?}", e))
580 })?;
581
582 (
583 wgpu::SurfaceTarget::OffscreenCanvas(offscreen_canvas),
584 width,
585 height,
586 )
587 } else {
588 return Err(JsValue::from_str(
589 "Aetheris: Provided object is not a valid Canvas or OffscreenCanvas",
590 ));
591 };
592
593 let surface = instance
594 .create_surface(surface_target)
595 .map_err(|e| JsValue::from_str(&format!("Failed to create surface: {:?}", e)))?;
596
597 let render_state = RenderState::new(&instance, surface, width, height)
598 .await
599 .map_err(|e| JsValue::from_str(&format!("Failed to init renderer: {:?}", e)))?;
600
601 *self.render_state.borrow_mut() = Some(render_state);
602
603 with_collector(|c| {
605 c.push_event(
606 1,
607 "render_worker",
608 &format!("Renderer initialized ({}x{})", width, height),
609 "render_pipeline_setup",
610 None,
611 );
612 });
613
614 Ok(())
615 }
616
617 #[wasm_bindgen]
618 pub fn resize(&self, width: u32, height: u32) {
619 let mut rs = self.render_state.borrow_mut();
620 if let Some(state) = &mut *rs {
621 state.resize(width, height);
622 }
623 }
624
625 #[cfg(debug_assertions)]
626 #[wasm_bindgen]
627 pub fn set_debug_mode(&self, mode: u32) {
628 self.check_worker();
629 let mut rs = self.render_state.borrow_mut();
630 if let Some(state) = &mut *rs {
631 state.set_debug_mode(match mode {
632 0 => crate::render::DebugRenderMode::Off,
633 1 => crate::render::DebugRenderMode::Wireframe,
634 2 => crate::render::DebugRenderMode::Components,
635 _ => crate::render::DebugRenderMode::Full,
636 });
637 }
638 }
639
640 #[wasm_bindgen]
641 pub fn set_theme_colors(&self, bg_base: &str, text_primary: &str) {
642 self.check_worker();
643 let clear = crate::render::parse_css_color(bg_base);
644 let label = crate::render::parse_css_color(text_primary);
645
646 tracing::info!(
647 "Aetheris Client: Applying theme colors [bg: {} -> {:?}, text: {} -> {:?}]",
648 bg_base,
649 clear,
650 text_primary,
651 label
652 );
653
654 let mut rs = self.render_state.borrow_mut();
655 if let Some(state) = &mut *rs {
656 state.set_clear_color(clear);
657 #[cfg(debug_assertions)]
658 state.set_label_color([
659 label.r as f32,
660 label.g as f32,
661 label.b as f32,
662 label.a as f32,
663 ]);
664 }
665 }
666
667 #[cfg(debug_assertions)]
668 #[wasm_bindgen]
669 pub fn cycle_debug_mode(&self) {
670 let mut rs = self.render_state.borrow_mut();
671 if let Some(state) = &mut *rs {
672 state.cycle_debug_mode();
673 }
674 }
675
676 #[cfg(debug_assertions)]
677 #[wasm_bindgen]
678 pub fn toggle_grid(&self) {
679 let mut rs = self.render_state.borrow_mut();
680 if let Some(state) = &mut *rs {
681 state.toggle_grid();
682 }
683 }
684
685 #[wasm_bindgen]
686 pub fn latest_tick(&self) -> u64 {
687 self.world_state.borrow().latest_tick
688 }
689
690 #[wasm_bindgen]
691 pub fn playground_apply_input(&self, move_x: f32, move_y: f32, actions_mask: u32) {
692 self.check_worker();
693 *self.last_cursor_x.borrow_mut() = move_x;
694 *self.last_cursor_y.borrow_mut() = move_y;
695 *self.last_actions_mask.borrow_mut() = actions_mask;
696 }
697
698 pub async fn tick(&self) {
700 self.check_worker();
701 use aetheris_protocol::traits::{Encoder, WorldState};
702
703 let encoder = SerdeEncoder::new();
704
705 let ping_data = {
710 let mut ping_counter = self.ping_counter.borrow_mut();
711 *ping_counter = ping_counter.wrapping_add(1);
712 if *ping_counter % 60 == 0 {
713 let now = performance_now();
714 let tick_u64 = now as u64;
715 encoder
716 .encode_event(&NetworkEvent::Ping {
717 client_id: ClientId(0),
718 tick: tick_u64,
719 })
720 .ok()
721 } else {
722 None
723 }
724 };
725 if let Some(data) = ping_data {
726 let send_fut = {
731 let mut transport_guard = self.transport.borrow_mut();
732 if let Some(transport) = &mut *transport_guard {
733 let t: *mut dyn aetheris_protocol::traits::PlatformTransport =
736 &mut **transport;
737 Some(unsafe { &mut *t }.send_unreliable(ClientId(0), &data))
740 } else {
741 None
742 }
743 };
745 if let Some(fut) = send_fut {
746 let _ = fut.await;
747 }
748 }
749
750 let mut collected_game_events = Vec::new();
752
753 let events = {
754 let poll_fut = {
758 let mut transport_guard = self.transport.borrow_mut();
759 transport_guard.as_mut().map(|t| {
760 let t: *mut dyn aetheris_protocol::traits::PlatformTransport = &mut **t;
761 unsafe { &mut *t }.poll_events()
762 })
763 };
765 match poll_fut {
766 Some(fut) => match fut.await {
767 Ok(e) => Some(e),
768 Err(e) => {
769 tracing::error!("Transport poll failure: {:?}", e);
770 None
771 }
772 },
773 None => None,
774 }
775 };
776
777 if let Some(events) = events {
778 let mut updates: Vec<(ClientId, aetheris_protocol::events::ComponentUpdate)> =
779 Vec::new();
780
781 for event in events {
782 match event {
783 NetworkEvent::UnreliableMessage { data, client_id }
784 | NetworkEvent::ReliableMessage { data, client_id } => {
785 match encoder.decode(&data) {
786 Ok(update) => {
787 let last_clear_tick = *self.last_clear_tick.borrow();
788 if last_clear_tick == 0 || update.tick > last_clear_tick {
789 updates.push((client_id, update));
790 } else {
791 tracing::debug!(
792 network_id = update.network_id.0,
793 tick = update.tick,
794 last_clear_tick = last_clear_tick,
795 "Discarding stale update (tick <= last_clear_tick)"
796 );
797 }
798 }
799 Err(_) => {
800 if let Ok(event) = encoder.decode_event(&data) {
801 match event {
802 aetheris_protocol::events::NetworkEvent::PlatformEvent {
803 event: platform_event,
804 ..
805 } => {
806 collected_game_events.push(platform_event);
807 }
808 aetheris_protocol::events::NetworkEvent::ClearWorld {
809 ..
810 } => {
811 tracing::info!(
812 "Server ClearWorld ack received (via ReliableMessage) — gate lowered"
813 );
814 *self.pending_clear.borrow_mut() = false;
815 }
816 aetheris_protocol::events::NetworkEvent::EntityDespawned {
817 network_id,
818 ..
819 } => {
820 let mut world = self.world_state.borrow_mut();
821 world.entities.remove(&network_id);
822 tracing::warn!(
823 ?network_id,
824 "Entity despawned via decoded ReliableMessage"
825 );
826 }
827 aetheris_protocol::events::NetworkEvent::EntitySpawned {
828 network_id,
829 kind,
830 ..
831 } => {
832 tracing::info!(
833 ?network_id,
834 ?kind,
835 "Entity spawned via decoded ReliableMessage (awaiting replication)"
836 );
837 }
838 _ => {}
839 }
840 } else {
841 tracing::warn!(
842 "Failed to decode server message as update or wire event"
843 );
844 }
845 }
846 }
847 }
848 NetworkEvent::ClientConnected(id) => {
849 tracing::info!(?id, "Server connected");
850 }
851 NetworkEvent::ClientDisconnected(id) => {
852 tracing::warn!(?id, "Server disconnected");
853 }
854 NetworkEvent::Disconnected(_id) => {
855 tracing::info!("Transport disconnected (session closed)");
856 *self.connection_state.borrow_mut() = ConnectionState::Disconnected;
857 }
858 NetworkEvent::Ping { client_id: _, tick } => {
859 let pong = NetworkEvent::Pong { tick };
860 if let Ok(data) = encoder.encode_event(&pong) {
861 let pong_fut = {
863 let transport_guard = self.transport.borrow();
864 transport_guard.as_ref().map(|t| {
865 let t: *const dyn aetheris_protocol::traits::PlatformTransport = &**t;
866 unsafe { &*t }.send_reliable(ClientId(0), &data)
867 })
868 };
870 if let Some(fut) = pong_fut {
871 let _ = fut.await;
872 }
873 }
874 }
875 NetworkEvent::Pong { tick } => {
876 let now = performance_now();
877 let rtt = now - (tick as f64);
878 *self.last_rtt_ms.borrow_mut() = rtt;
879
880 with_collector(|c| {
881 c.update_rtt(rtt);
882 });
883
884 #[cfg(feature = "metrics")]
885 metrics::gauge!("aetheris_client_rtt_ms").set(rtt);
886
887 tracing::trace!(rtt_ms = rtt, tick, "Received Pong / RTT update");
888 }
889 NetworkEvent::Auth { .. } => {
890 tracing::debug!("Received Auth event from server (unexpected)");
891 }
892 NetworkEvent::SessionClosed(id) => {
893 tracing::warn!(?id, "WebTransport session closed");
894 }
895 NetworkEvent::StreamReset(id) => {
896 tracing::error!(?id, "WebTransport stream reset");
897 }
898 NetworkEvent::ReplicationBatch { events, client_id } => {
899 let last_clear_tick = *self.last_clear_tick.borrow();
900 for event in events {
901 if last_clear_tick == 0 || event.tick > last_clear_tick {
902 updates.push((
903 client_id,
904 aetheris_protocol::events::ComponentUpdate {
905 network_id: event.network_id,
906 component_kind: event.component_kind,
907 payload: event.payload,
908 tick: event.tick,
909 },
910 ));
911 }
912 }
913 }
914 NetworkEvent::Fragment {
915 client_id,
916 fragment,
917 } => {
918 let mut reassembler = self.reassembler.borrow_mut();
919 if let Some(data) = reassembler.ingest(client_id, fragment) {
920 if let Ok(update) = encoder.decode(&data) {
921 let last_clear_tick = *self.last_clear_tick.borrow();
922 if last_clear_tick == 0 || update.tick > last_clear_tick {
923 updates.push((client_id, update));
924 }
925 }
926 }
927 }
928 NetworkEvent::StressTest { .. } => {}
929 NetworkEvent::Spawn { .. } => {}
930 NetworkEvent::ClearWorld { .. } => {
931 tracing::info!("Server ClearWorld ack received — gate lowered");
932 *self.pending_clear.borrow_mut() = false;
933 }
934 NetworkEvent::PlatformEvent {
935 event: platform_event,
936 ..
937 } => {
938 collected_game_events.push(platform_event);
939 }
940 NetworkEvent::EntityDespawned { network_id, .. } => {
941 let mut world = self.world_state.borrow_mut();
942 world.entities.remove(&network_id);
943 tracing::warn!(?network_id, "Entity despawned via NetworkEvent");
944 }
945 #[allow(unreachable_patterns)]
946 _ => {
947 tracing::debug!("Unhandled outer NetworkEvent variant");
948 }
949 }
950 }
951
952 let pending_clear = *self.pending_clear.borrow();
954 if pending_clear {
955 if !updates.is_empty() {
956 tracing::debug!(
957 count = updates.len(),
958 "Discarding updates — pending_clear gate is raised"
959 );
960 }
961 } else {
962 if !updates.is_empty() {
963 let max_tick = updates.iter().map(|(_, u)| u.tick).max().unwrap_or(0);
964
965 let mut world = self.world_state.borrow_mut();
966 if max_tick > 0 {
967 let drift = (world.latest_tick as i32 - max_tick as i32).abs();
968 let first_tick = *self.first_playground_tick.borrow();
969 if first_tick || drift > 20 {
970 tracing::info!(
971 latest = world.latest_tick,
972 server = max_tick,
973 drift,
974 first = first_tick,
975 "Syncing client latest_tick to server authoritative tick"
976 );
977 world.latest_tick = max_tick;
978 *self.first_playground_tick.borrow_mut() = false;
979 } else {
980 tracing::trace!(
981 latest = world.latest_tick,
982 server = max_tick,
983 drift,
984 "Client tick is in sync"
985 );
986 }
987 }
988
989 tracing::debug!(count = updates.len(), "Applying server updates to world");
990 world.apply_updates(&updates);
991 }
992 }
993 }
994
995 for platform_event in collected_game_events {
997 self.dispatch_platform_event(&platform_event);
998 }
999
1000 let now = crate::performance_now();
1002 let delta_ms = {
1003 let mut last_process_time = self.last_process_time.borrow_mut();
1004 let delta = now - *last_process_time;
1005 *last_process_time = now;
1006 delta
1007 };
1008
1009 let delta_ms = delta_ms.min(100.0);
1011 {
1012 let mut tick_accumulator = self.tick_accumulator.borrow_mut();
1013 *tick_accumulator += delta_ms;
1014
1015 const DT_MS: f64 = 1000.0 / 60.0;
1016 while *tick_accumulator >= DT_MS {
1017 let mut world = self.world_state.borrow_mut();
1018
1019 let applied = world.playground_apply_input(
1021 *self.playground_move_x.borrow(),
1022 *self.playground_move_y.borrow(),
1023 *self.playground_actions.borrow(),
1024 );
1025
1026 if !applied && world.latest_tick % 120 == 0 {
1027 tracing::warn!(
1028 tick = world.latest_tick,
1029 "Simulation loop running but no LocalPlayer (0x04) entity found to apply input to"
1030 );
1031 }
1032
1033 world.latest_tick += 1;
1034 world.simulate();
1035 *tick_accumulator -= DT_MS;
1036 }
1037
1038 let fraction = (*tick_accumulator as f32 / DT_MS as f32).clamp(0.0, 1.0);
1040 let alpha = 0.8;
1041 let mut last_fraction = self.last_fraction.borrow_mut();
1042 *last_fraction = *last_fraction * (1.0 - alpha) + fraction * alpha;
1043 self.shared_world.set_sub_tick_fraction(*last_fraction);
1044 }
1045
1046 let sim_start = crate::performance_now();
1047
1048 let latest_tick = self.world_state.borrow().latest_tick;
1050 self.flush_to_shared_world(latest_tick);
1051
1052 let sim_time_ms = crate::performance_now() - sim_start;
1053
1054 {
1055 let world = self.world_state.borrow();
1056 let count = world.entities.len() as u32;
1057 let (payload_count, payload_cap) = world
1058 .player_network_id
1059 .and_then(|id| world.entities.get(&id))
1060 .map_or((0, 0), |s| {
1061 (s.payload_count as u32, s.payload_capacity as u32)
1062 });
1063
1064 with_collector(|c| {
1065 c.record_sim(sim_time_ms);
1066 c.update_entity_count(count);
1067 c.update_payload(payload_count, payload_cap);
1068 });
1069 }
1070 }
1071
1072 fn flush_to_shared_world(&self, tick: u64) {
1073 let world = self.world_state.borrow();
1074 let entities = &world.entities;
1075 let write_buffer = self.shared_world.get_write_buffer();
1076
1077 let mut count = 0;
1078 for (i, slot) in entities.values().enumerate() {
1079 if i >= MAX_ENTITIES {
1080 tracing::warn!("Max entities reached in shared world! Overflow suppressed.");
1081 break;
1082 }
1083 write_buffer[i] = *slot;
1084 count += 1;
1085 }
1086
1087 tracing::debug!(entity_count = count, tick, "Flushed world to SAB");
1088 self.shared_world.commit_write(count as u32, tick);
1089
1090 if let Some(player_id) = world.player_network_id {
1092 if let Some(slot) = entities.get(&player_id) {
1093 with_collector(|c| {
1094 c.update_payload(slot.payload_count as u32, slot.payload_capacity as u32);
1095 });
1096 }
1097 }
1098 }
1099
1100 #[wasm_bindgen]
1101 pub async fn request_workspace_manifest(&self) -> Result<(), JsValue> {
1102 self.check_worker();
1103
1104 let transport_guard = self.transport.borrow();
1105 if let Some(transport) = &*transport_guard {
1106 let encoder = SerdeEncoder::new();
1107 let event = NetworkEvent::RequestWorkspaceManifest {
1108 client_id: ClientId(0),
1109 };
1110
1111 if let Ok(data) = encoder.encode_event(&event) {
1112 transport
1113 .send_reliable(ClientId(0), &data)
1114 .await
1115 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1116 tracing::info!("Sent RequestWorkspaceManifest command to server");
1117 }
1118 }
1119 Ok(())
1120 }
1121
1122 #[wasm_bindgen]
1123 pub fn get_workspace_info(&self) -> Result<JsValue, JsValue> {
1124 let world = self.world_state.borrow();
1125 serde_wasm_bindgen::to_value(&world.workspace_manifest)
1126 .map_err(|e| JsValue::from_str(&e.to_string()))
1127 }
1128
1129 fn dispatch_platform_event(
1130 &self,
1131 platform_event: &aetheris_protocol::events::PlatformEvent,
1132 ) {
1133 let mut world = self.world_state.borrow_mut();
1134 match platform_event {
1135 aetheris_protocol::events::PlatformEvent::ResourceExhausted { network_id } => {
1136 tracing::info!(?network_id, "Resource exhausted (via PlatformEvent)");
1137 world.entities.remove(network_id);
1138
1139 for slot in world.entities.values_mut() {
1140 if (slot.flags & 0x04) != 0
1141 && slot.extraction_target_id == (network_id.0 as u16)
1142 {
1143 slot.extraction_active = 0;
1144 slot.extraction_target_id = 0;
1145 tracing::info!("Cleared local extraction target due to exhaustion");
1146 }
1147 }
1148 }
1149 aetheris_protocol::events::PlatformEvent::WorkspaceManifest { manifest } => {
1150 tracing::info!(
1151 count = manifest.len(),
1152 "Received WorkspaceManifest from server (via PlatformEvent)"
1153 );
1154 world.workspace_manifest = manifest.clone();
1155 }
1156 aetheris_protocol::events::PlatformEvent::Possession { .. }
1157 | aetheris_protocol::events::PlatformEvent::Interaction { .. }
1158 | aetheris_protocol::events::PlatformEvent::Termination { .. }
1159 | aetheris_protocol::events::PlatformEvent::Reinitialization { .. }
1160 | aetheris_protocol::events::PlatformEvent::PayloadCollected { .. } => {
1161 world.handle_platform_event(platform_event);
1162 }
1163 }
1164 }
1165
1166 #[wasm_bindgen]
1167 pub fn wasm_get_entity_statuses(&self) -> JsValue {
1168 #[derive(serde::Serialize)]
1169 struct EntityStatus {
1170 network_id: String,
1171 integrity: u16,
1172 max_integrity: u16,
1173 priority: u16,
1174 max_priority: u16,
1175 entity_type: u16,
1176 is_player: bool,
1177 }
1178
1179 let world = self.world_state.borrow();
1180 let mut entities: Vec<&SabSlot> = world.entities.values().collect();
1181 entities.sort_by_key(|slot| slot.network_id);
1182
1183 let statuses: Vec<EntityStatus> = entities
1184 .into_iter()
1185 .map(|slot| {
1186 let (max_integrity, max_priority) =
1188 aetheris_protocol::types::get_default_properties(slot.entity_type);
1189
1190 EntityStatus {
1191 network_id: slot.network_id.to_string(),
1192 integrity: slot.integrity,
1193 max_integrity,
1194 priority: slot.priority,
1195 max_priority,
1196 entity_type: slot.entity_type,
1197 is_player: (slot.flags & 0x04) != 0,
1198 }
1199 })
1200 .collect();
1201
1202 match serde_wasm_bindgen::to_value(&statuses) {
1203 Ok(val) => val,
1204 Err(e) => {
1205 web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
1206 "wasm_get_entity_statuses: serde_wasm_bindgen::to_value failed: {e}"
1207 )));
1208 wasm_bindgen::JsValue::NULL
1209 }
1210 }
1211 }
1212
1213 #[wasm_bindgen]
1214 pub fn get_presence(&self) -> JsValue {
1215 #[derive(serde::Serialize)]
1216 struct PresenceInfo {
1217 id: String,
1218 x: f32,
1219 y: f32,
1220 name: String,
1221 }
1222
1223 let world = self.world_state.borrow();
1224 let presences: Vec<PresenceInfo> = world
1225 .entities
1226 .iter()
1227 .filter(|(_, slot)| slot.entity_type == 0x2007)
1228 .map(|(id, slot)| PresenceInfo {
1229 id: id.0.to_string(),
1230 x: slot.x,
1231 y: slot.y,
1232 name: format!("User {}", id.0),
1233 })
1234 .collect();
1235
1236 match serde_wasm_bindgen::to_value(&presences) {
1237 Ok(val) => val,
1238 Err(e) => {
1239 web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
1240 "get_presence: serde_wasm_bindgen::to_value failed: {e}"
1241 )));
1242 wasm_bindgen::JsValue::NULL
1243 }
1244 }
1245 }
1246
1247 #[wasm_bindgen]
1248 pub fn playground_spawn(&self, entity_type: u16, x: f32, y: f32, rotation: f32) {
1249 let mut world = self.world_state.borrow_mut();
1250 if world.entities.len() >= MAX_ENTITIES {
1251 tracing::warn!("playground_spawn: MAX_ENTITIES reached, spawn ignored.");
1252 return;
1253 }
1254
1255 let mut next_id = self.playground_next_network_id.borrow_mut();
1256 if *next_id == 1 && !world.entities.is_empty() {
1258 *next_id = world.entities.keys().map(|k| k.0).max().unwrap_or(0) + 1;
1259 }
1260
1261 let id = aetheris_protocol::types::NetworkId(*next_id);
1262 *next_id += 1;
1263 let (integrity, priority) =
1264 aetheris_protocol::types::get_default_properties(entity_type);
1265 let slot = SabSlot {
1266 network_id: id.0,
1267 x,
1268 y,
1269 z: 0.0,
1270 rotation,
1271 dx: 0.0,
1272 dy: 0.0,
1273 dz: 0.0,
1274 integrity,
1275 priority,
1276 entity_type,
1277 flags: 0x01, extraction_active: 0,
1279 payload_count: 0,
1280 payload_capacity: 0,
1281 extraction_target_id: 0,
1282 interaction_target_id: 0,
1283 interaction_flash_ticks: 0,
1284 padding: [0; 3],
1285 };
1286 world.entities.insert(id, slot);
1287 }
1288
1289 #[wasm_bindgen]
1290 pub async fn playground_spawn_net(
1291 &self,
1292 entity_type: u16,
1293 x: f32,
1294 y: f32,
1295 rot: f32,
1296 ) -> Result<(), JsValue> {
1297 self.check_worker();
1298
1299 let transport_guard = self.transport.borrow();
1300 if let Some(transport) = &*transport_guard {
1301 let encoder = SerdeEncoder::new();
1302 let event = NetworkEvent::Spawn {
1303 client_id: ClientId(0),
1304 entity_type,
1305 x,
1306 y,
1307 rot,
1308 };
1309
1310 if let Ok(data) = encoder.encode_event(&event) {
1311 transport
1312 .send_reliable(ClientId(0), &data)
1313 .await
1314 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1315 tracing::info!(entity_type, x, y, "Sent Spawn command to server");
1316 }
1317 } else {
1318 self.playground_spawn(entity_type, x, y, rot);
1320 }
1321 Ok(())
1322 }
1323
1324 #[wasm_bindgen]
1325 pub fn playground_clear(&self) {
1326 self.world_state.borrow_mut().entities.clear();
1327 }
1328
1329 #[wasm_bindgen]
1333 pub async fn start_session_net(&self) -> Result<(), JsValue> {
1334 self.check_worker();
1335
1336 let transport_guard = self.transport.borrow();
1337 if let Some(transport) = &*transport_guard {
1338 let encoder = SerdeEncoder::new();
1339 let event = NetworkEvent::StartSession {
1340 client_id: ClientId(0),
1341 };
1342 if let Ok(data) = encoder.encode_event(&event) {
1343 transport
1344 .send_reliable(ClientId(0), &data)
1345 .await
1346 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1347 tracing::info!("Sent StartSession command to server");
1348 }
1349 }
1350 Ok(())
1351 }
1352
1353 #[wasm_bindgen]
1354 pub fn get_networked_entities(&self) -> Vec<u32> {
1355 self.world_state
1356 .borrow()
1357 .entities
1358 .keys()
1359 .map(|id| id.0 as u32)
1360 .collect()
1361 }
1362
1363 #[wasm_bindgen]
1364 pub fn get_entity_type(&self, network_id: u32) -> u32 {
1365 self.world_state
1366 .borrow()
1367 .entities
1368 .get(&aetheris_protocol::types::NetworkId(u64::from(network_id)))
1369 .map(|slot| u32::from(slot.entity_type))
1370 .unwrap_or(0)
1371 }
1372
1373 #[wasm_bindgen]
1374 pub fn player_network_id(&self) -> Option<u32> {
1375 self.world_state
1376 .borrow()
1377 .player_network_id
1378 .map(|id| id.0 as u32)
1379 }
1380
1381 #[wasm_bindgen]
1386 pub async fn send_input(
1387 &self,
1388 tick: u64,
1389 move_x: f32,
1390 move_y: f32,
1391 actions_mask: u32,
1392 target_id_arg: Option<u64>,
1393 ) -> Result<(), JsValue> {
1394 self.check_worker();
1395
1396 let target_id = {
1398 let world = self.world_state.borrow();
1399 if let Some(owned_id) = world.player_network_id {
1400 Some(owned_id)
1403 } else {
1404 world
1406 .entities
1407 .iter()
1408 .find(|(_, slot)| (slot.flags & 0x04) != 0)
1409 .map(|(id, _)| *id)
1410 }
1411 };
1412
1413 let Some(target_id) = target_id else {
1414 tracing::trace!("[send_input] Input dropped: no controlled entity found");
1415 return Ok(());
1416 };
1417
1418 let mut actions = Vec::new();
1420
1421 if move_x.abs() > f32::EPSILON || move_y.abs() > f32::EPSILON {
1423 actions.push(PlayerInputKind::Move {
1424 x: move_x,
1425 y: move_y,
1426 });
1427 }
1428
1429 if (actions_mask & 0x04) != 0 {
1432 actions.push(PlayerInputKind::FireTool);
1433 }
1434 if (actions_mask & 0x02) != 0 && (*self.last_actions_mask.borrow() & 0x02) == 0 {
1436 let target = if let Some(id) = target_id_arg {
1437 Some(NetworkId(id))
1438 } else {
1439 let world = self.world_state.borrow();
1441 if let Some(player_slot) = world.entities.get(&target_id) {
1442 let (player_x, player_y) = (player_slot.x, player_slot.y);
1443 world
1444 .entities
1445 .iter()
1446 .filter(|(_, slot)| slot.entity_type == 5) .filter(|(_, slot)| {
1448 let dist_sq = (slot.x - player_x) * (slot.x - player_x)
1449 + (slot.y - player_y) * (slot.y - player_y);
1450 dist_sq < 25.0 })
1452 .min_by(|(_, a), (_, b)| {
1453 let dist_a = (a.x - player_x) * (a.x - player_x)
1454 + (a.y - player_y) * (a.y - player_y);
1455 let dist_b = (b.x - player_x) * (b.x - player_x)
1456 + (b.y - player_y) * (b.y - player_y);
1457 dist_a
1458 .partial_cmp(&dist_b)
1459 .unwrap_or(std::cmp::Ordering::Equal)
1460 })
1461 .map(|(id, _)| *id)
1462 } else {
1463 None
1464 }
1465 };
1466
1467 if let Some(id) = target {
1468 actions.push(PlayerInputKind::ToggleExtraction { target: id });
1469 } else {
1470 tracing::warn!(
1471 "ToggleExtraction requested without target_id and no resource nearby; dropping action"
1472 );
1473 }
1474 }
1475
1476 *self.last_actions_mask.borrow_mut() = actions_mask;
1477
1478 let is_repeated = {
1480 let last_actions = self.last_input_actions.borrow();
1481 last_actions.len() == actions.len()
1482 && last_actions.iter().zip(actions.iter()).all(|(a, b)| a == b)
1483 && *self.last_input_target.borrow() == Some(target_id)
1484 };
1485
1486 if move_x.abs() > f32::EPSILON || move_y.abs() > f32::EPSILON || actions_mask != 0 {
1487 if is_repeated {
1488 tracing::trace!(
1489 tick,
1490 move_x,
1491 move_y,
1492 actions_mask,
1493 "Client sending input (repeated)"
1494 );
1495 } else {
1496 tracing::trace!(tick, move_x, move_y, actions_mask, "Client sending input");
1497 }
1498 }
1499
1500 let transport_guard = self.transport.borrow();
1501 let Some(transport) = &*transport_guard else {
1502 return Err(JsValue::from_str(
1503 "Cannot send input: transport not initialized or closed",
1504 ));
1505 };
1506
1507 *self.last_input_target.borrow_mut() = Some(target_id);
1509 *self.last_input_actions.borrow_mut() = actions.clone();
1510
1511 let cmd = InputCommand {
1512 tick,
1513 actions,
1514 actions_mask,
1515 last_seen_input_tick: None,
1516 }
1517 .clamped();
1518
1519 let payload = rmp_serde::to_vec(&cmd)
1523 .map_err(|e| JsValue::from_str(&format!("Failed to encode InputCommand: {e:?}")))?;
1524
1525 let update = ReplicationEvent {
1526 network_id: target_id,
1527 component_kind: ComponentKind(128),
1528 payload,
1529 tick,
1530 };
1531
1532 let mut buffer = [0u8; 1024];
1533 let encoder = SerdeEncoder::new();
1534 let len = encoder.encode(&update, &mut buffer).map_err(|e| {
1535 JsValue::from_str(&format!("Failed to encode input replication event: {e:?}"))
1536 })?;
1537
1538 transport
1540 .send_unreliable(ClientId(0), &buffer[..len])
1541 .await
1542 .map_err(|e| {
1543 JsValue::from_str(&format!("Transport error during send_input: {e:?}"))
1544 })?;
1545
1546 Ok(())
1547 }
1548
1549 #[wasm_bindgen]
1551 pub async fn send_cursor_move(&self, tick: u64, x: f32, y: f32) -> Result<(), JsValue> {
1552 self.check_worker();
1553
1554 let now = crate::performance_now();
1556 {
1557 let mut last_cursor_send_time = self.last_cursor_send_time.borrow_mut();
1558 let dt = now - *last_cursor_send_time;
1559 let dist_sq = (x - *self.last_cursor_x.borrow()).powi(2)
1560 + (y - *self.last_cursor_y.borrow()).powi(2);
1561
1562 if dt < 50.0 && dist_sq < 0.0001 {
1564 return Ok(());
1565 }
1566 *last_cursor_send_time = now;
1567 }
1568
1569 let transport_guard = self.transport.borrow();
1570 let Some(transport) = &*transport_guard else {
1571 return Err(JsValue::from_str(
1572 "Cannot send cursor move: transport not initialized or closed",
1573 ));
1574 };
1575
1576 let target_id = {
1578 let world = self.world_state.borrow();
1579 world.player_network_id.or_else(|| {
1580 world
1581 .entities
1582 .iter()
1583 .find(|(_, slot)| (slot.flags & 0x04) != 0)
1584 .map(|(id, _)| *id)
1585 })
1586 };
1587
1588 let Some(target_id) = target_id else {
1589 return Ok(());
1590 };
1591
1592 let cmd = InputCommand {
1594 tick,
1595 actions: vec![PlayerInputKind::CursorMove { x, y }],
1596 actions_mask: 0,
1597 last_seen_input_tick: None,
1598 }
1599 .clamped();
1600
1601 let payload = rmp_serde::to_vec(&cmd)
1602 .map_err(|e| JsValue::from_str(&format!("Failed to encode CursorMove: {e:?}")))?;
1603
1604 let update = ReplicationEvent {
1605 network_id: target_id,
1606 component_kind: ComponentKind(128),
1607 payload,
1608 tick,
1609 };
1610
1611 let mut buffer = [0u8; 512];
1612 let encoder = SerdeEncoder::new();
1613 let len = encoder.encode(&update, &mut buffer).map_err(|e| {
1614 JsValue::from_str(&format!("Failed to encode cursor replication event: {e:?}"))
1615 })?;
1616
1617 transport
1618 .send_unreliable(ClientId(0), &buffer[..len])
1619 .await
1620 .map_err(|e| {
1621 JsValue::from_str(&format!("Transport error during send_cursor_move: {e:?}"))
1622 })?;
1623
1624 *self.last_cursor_x.borrow_mut() = x;
1625 *self.last_cursor_y.borrow_mut() = y;
1626
1627 Ok(())
1628 }
1629
1630 #[wasm_bindgen]
1631 pub async fn playground_clear_server(&self) -> Result<(), JsValue> {
1632 self.check_worker();
1633
1634 let transport_guard = self.transport.borrow();
1635 if let Some(transport) = &*transport_guard {
1636 let encoder = SerdeEncoder::new();
1637 let event = NetworkEvent::ClearWorld {
1638 client_id: ClientId(0),
1639 };
1640 if let Ok(data) = encoder.encode_event(&event) {
1641 transport
1642 .send_reliable(ClientId(0), &data)
1643 .await
1644 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1645 tracing::info!(
1646 "Sent ClearWorld command to server — suppressing updates until ack"
1647 );
1648 let mut world = self.world_state.borrow_mut();
1653 let latest_tick = world.latest_tick;
1654 world.entities.clear();
1655 world.player_network_id = None;
1656 *self.pending_clear.borrow_mut() = true;
1657 *self.last_clear_tick.borrow_mut() = latest_tick;
1658 }
1659 } else {
1660 let mut world = self.world_state.borrow_mut();
1662 world.entities.clear();
1663 world.player_network_id = None;
1664 }
1665 Ok(())
1666 }
1667
1668 #[wasm_bindgen]
1669 pub fn playground_set_rotation_enabled(&self, enabled: bool) {
1670 *self.playground_rotation_enabled.borrow_mut() = enabled;
1671 }
1672
1673 #[wasm_bindgen]
1674 pub async fn playground_stress_test(
1675 &self,
1676 count: u16,
1677 rotate: bool,
1678 ) -> Result<(), JsValue> {
1679 self.check_worker();
1680
1681 let transport_guard = self.transport.borrow();
1682 if let Some(transport) = &*transport_guard {
1683 let encoder = SerdeEncoder::new();
1684 let event = NetworkEvent::StressTest {
1685 client_id: ClientId(0),
1686 count,
1687 rotate,
1688 };
1689
1690 if let Ok(data) = encoder.encode_event(&event) {
1691 transport
1692 .send_reliable(ClientId(0), &data)
1693 .await
1694 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1695 tracing::info!(count, rotate, "Sent StressTest command to server");
1696 }
1697 self.playground_set_rotation_enabled(rotate);
1698 } else {
1699 self.playground_set_rotation_enabled(rotate);
1701 self.playground_clear();
1702 for _ in 0..count {
1703 self.playground_spawn(1, 0.0, 0.0, 0.0); }
1705 }
1706
1707 Ok(())
1708 }
1709
1710 #[wasm_bindgen]
1711 pub fn tick_playground(&self) {
1712 self.check_worker();
1713
1714 let sim_start = crate::performance_now();
1716
1717 let now = crate::performance_now();
1718 let delta_ms = {
1719 let mut last_process_time = self.last_process_time.borrow_mut();
1720 let delta = now - *last_process_time;
1721 *last_process_time = now;
1722 delta
1723 };
1724
1725 let delta_ms = delta_ms.min(100.0);
1727 let mut tick_accumulator = self.tick_accumulator.borrow_mut();
1728 *tick_accumulator += delta_ms;
1729
1730 const DT_MS: f64 = 1000.0 / 60.0;
1731 let mut steps = 0;
1732 while *tick_accumulator >= DT_MS {
1733 let mut world = self.world_state.borrow_mut();
1734 world.latest_tick += 1;
1735
1736 world.playground_apply_input(
1738 *self.playground_move_x.borrow(),
1739 *self.playground_move_y.borrow(),
1740 *self.playground_actions.borrow(),
1741 );
1742
1743 world.simulate();
1745
1746 *tick_accumulator -= DT_MS;
1747 steps += 1;
1748 }
1749
1750 if steps > 0 {
1752 let fraction = (*tick_accumulator as f32 / DT_MS as f32).clamp(0.0, 1.0);
1754 let alpha = 0.8;
1755 let mut last_fraction = self.last_fraction.borrow_mut();
1756 *last_fraction = *last_fraction * (1.0 - alpha) + fraction * alpha;
1757 self.shared_world.set_sub_tick_fraction(*last_fraction);
1758
1759 let world = self.world_state.borrow();
1760 let count = world.entities.len() as u32;
1761 self.flush_to_shared_world(world.latest_tick);
1762
1763 let sim_time_ms = crate::performance_now() - sim_start;
1764 with_collector(|c| {
1765 c.record_sim(sim_time_ms);
1766 c.update_entity_count(count);
1767 });
1768 }
1769 }
1770
1771 pub fn render(&self) -> f64 {
1773 self.check_worker();
1774
1775 let tick = self.shared_world.tick();
1776 let entities = self.shared_world.get_read_buffer();
1777 let bounds = self.shared_world.get_workspace_bounds();
1778 {
1779 let mut render_state = self.render_state.borrow_mut();
1780 if let Some(state) = &mut *render_state {
1781 state.set_workspace_bounds(bounds);
1782 }
1783 }
1784
1785 thread_local! {
1787 static FRAME_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
1788 }
1789 FRAME_COUNT.with(|count| {
1790 let current = count.get();
1791 if current % 300 == 0 {
1792 tracing::debug!(
1793 "Aetheris Render Stats: Tick={}, Entities={}, Snapshots={}",
1794 tick,
1795 entities.len(),
1796 self.snapshots.borrow().len(),
1797 );
1798 }
1799 count.set(current + 1);
1800 });
1801
1802 let mut snapshots = self.snapshots.borrow_mut();
1804 let back_tick = snapshots.back().map(|s| s.tick).unwrap_or(0);
1805 if tick < back_tick && tick != 0 {
1806 tracing::warn!(
1807 tick,
1808 back_tick,
1809 "Simulation time went backwards! Clearing snapshot buffer."
1810 );
1811 snapshots.clear();
1812 }
1813
1814 if snapshots.is_empty() || tick > back_tick {
1815 tracing::trace!(tick, "Pushing new snapshot to buffer");
1816 snapshots.push_back(SimulationSnapshot {
1817 tick,
1818 entities: entities.to_vec(),
1819 });
1820 } else if tick == back_tick && tick != 0 {
1821 thread_local! {
1823 static STAGNANT_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
1824 }
1825 STAGNANT_COUNT.with(|count| {
1826 let cur = count.get() + 1;
1827 if cur % 300 == 0 {
1828 tracing::warn!(tick, "Render loop stalled on same tick for 300 frames");
1829 }
1830 count.set(cur);
1831 });
1832 }
1833
1834 let latest_tick = snapshots.back().map(|s| s.tick as f32).unwrap_or(0.0);
1839 let fraction = self.shared_world.sub_tick_fraction();
1840 let mut target_tick = latest_tick - 1.0 + fraction;
1841
1842 if !snapshots.is_empty() {
1844 let oldest_tick = snapshots[0].tick as f32;
1845 if target_tick < oldest_tick {
1846 target_tick = oldest_tick;
1847 }
1848 }
1849
1850 let ent_count = entities.len();
1851 let snap_count = snapshots.len() as u32;
1852 drop(snapshots); let frame_time_ms = self.render_at_tick(target_tick);
1854
1855 if tick % 60 == 0 {
1857 tracing::trace!(
1858 tick,
1859 ent_count,
1860 snap_count,
1861 target_tick,
1862 "Render Loop Active"
1863 );
1864 }
1865 with_collector(|c| {
1866 c.record_frame(frame_time_ms, 0.0);
1868 c.update_snapshot_count(snap_count);
1869 });
1870
1871 frame_time_ms
1872 }
1873
1874 fn render_at_tick(&self, target_tick: f32) -> f64 {
1875 let mut snapshots = self.snapshots.borrow_mut();
1876 if snapshots.len() < 2 {
1877 let mut render_state = self.render_state.borrow_mut();
1880 if let Some(state) = &mut *render_state {
1881 let entities = if !snapshots.is_empty() {
1882 snapshots[0].entities.clone()
1883 } else {
1884 Vec::new()
1885 };
1886 return state.render_frame_with_compact_slots(&entities);
1887 }
1888 return 0.0;
1889 }
1890
1891 let mut s1_idx = 0;
1893 let mut found = false;
1894
1895 for i in 0..snapshots.len() - 1 {
1896 if (snapshots[i].tick as f32) <= target_tick
1897 && (snapshots[i + 1].tick as f32) > target_tick
1898 {
1899 s1_idx = i;
1900 found = true;
1901 break;
1902 }
1903 }
1904
1905 if !found {
1906 if target_tick < snapshots[0].tick as f32 {
1908 s1_idx = 0;
1909 } else {
1910 s1_idx = snapshots.len() - 2;
1911 }
1912 }
1913
1914 let s1 = &snapshots[s1_idx];
1915 let s2 = &snapshots[s1_idx + 1];
1916
1917 let tick_range = (s2.tick - s1.tick) as f32;
1918 let alpha = if tick_range > 0.0 {
1919 (target_tick - s1.tick as f32) / tick_range
1920 } else {
1921 1.0
1922 }
1923 .clamp(0.0, 1.0);
1924
1925 let mut render_buffer = self.render_buffer.borrow_mut();
1927 render_buffer.clear();
1928 render_buffer.extend_from_slice(&s2.entities);
1929
1930 let prev_map: std::collections::HashMap<u64, &SabSlot> =
1932 s1.entities.iter().map(|e| (e.network_id, e)).collect();
1933
1934 let world = self.world_state.borrow();
1935 for ent in &mut *render_buffer {
1936 if let Some(prev) = prev_map.get(&ent.network_id).copied() {
1937 if let Some(bounds) = &world.workspace_bounds {
1938 ent.x = lerp_wrapped(prev.x, ent.x, alpha, bounds.min_x, bounds.max_x);
1939 ent.y = lerp_wrapped(prev.y, ent.y, alpha, bounds.min_y, bounds.max_y);
1940 } else {
1941 ent.x = lerp(prev.x, ent.x, alpha);
1942 ent.y = lerp(prev.y, ent.y, alpha);
1943 }
1944 ent.z = lerp(prev.z, ent.z, alpha);
1945 ent.rotation = lerp_rotation(prev.rotation, ent.rotation, alpha);
1946 } else {
1947 let dt = 1.0 / 60.0;
1951 let remaining = 1.0 - alpha;
1952
1953 if let Some(bounds) = &world.workspace_bounds {
1954 ent.x = lerp_wrapped(
1956 ent.x,
1957 ent.x - ent.dx * dt * remaining,
1958 1.0,
1959 bounds.min_x,
1960 bounds.max_x,
1961 );
1962 ent.y = lerp_wrapped(
1963 ent.y,
1964 ent.y - ent.dy * dt * remaining,
1965 1.0,
1966 bounds.min_y,
1967 bounds.max_y,
1968 );
1969 } else {
1970 ent.x -= ent.dx * dt * remaining;
1971 ent.y -= ent.dy * dt * remaining;
1972 }
1973 ent.z -= ent.dz * dt * remaining;
1974 }
1975 }
1976
1977 let mut frame_time = 0.0;
1978 let mut render_state = self.render_state.borrow_mut();
1979 if let Some(state) = &mut *render_state {
1980 frame_time = state.render_frame_with_compact_slots(&render_buffer);
1981 }
1982
1983 while snapshots.len() > 2 && (snapshots[0].tick as f32) < target_tick - 1.0 {
1987 snapshots.pop_front();
1988 }
1989
1990 while snapshots.len() > 16 {
1992 snapshots.pop_front();
1993 }
1994
1995 frame_time
1996 }
1997 }
1998}