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, GameTransport, WorldState};
116 use aetheris_protocol::types::{
117 ClientId, ComponentKind, InputCommand, NetworkId, PlayerInputKind,
118 };
119 use wasm_bindgen::prelude::*;
120
121 #[wasm_bindgen]
122 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
123 pub enum ConnectionState {
124 Disconnected,
125 Connecting,
126 InGame,
127 Reconnecting,
128 Failed,
129 }
130
131 #[derive(Clone)]
133 pub struct SimulationSnapshot {
134 pub tick: u64,
135 pub entities: Vec<SabSlot>,
136 }
137
138 #[wasm_bindgen]
140 pub struct AetherisClient {
141 pub(crate) shared_world: SharedWorld,
142 pub(crate) world_state: ClientWorld,
143 pub(crate) render_state: Option<RenderState>,
144 pub(crate) transport: Option<Box<dyn GameTransport>>,
145 pub(crate) worker_id: usize,
146 pub(crate) session_token: Option<String>,
147
148 pub(crate) snapshots: std::collections::VecDeque<SimulationSnapshot>,
150
151 pub(crate) last_rtt_ms: f64,
153 ping_counter: u64,
154
155 reassembler: aetheris_protocol::Reassembler,
156 connection_state: ConnectionState,
157 reconnect_attempts: u32,
158 playground_rotation_enabled: bool,
159 playground_next_network_id: u64,
160 first_playground_tick: bool,
161
162 pub(crate) render_buffer: Vec<SabSlot>,
164 pub(crate) asset_registry: assets::AssetRegistry,
165
166 last_input_target: Option<NetworkId>,
168 last_input_actions: Vec<PlayerInputKind>,
169
170 pending_clear: bool,
171 last_clear_tick: u64,
172
173 last_process_time: f64,
175 tick_accumulator: f64,
176
177 playground_move_x: f32,
179 playground_move_y: f32,
180 playground_actions: u32,
181 last_fraction: f32,
182 }
183
184 #[wasm_bindgen]
185 impl AetherisClient {
186 #[wasm_bindgen(constructor)]
189 pub fn new(shared_world_ptr: Option<u32>) -> Result<AetherisClient, JsValue> {
190 console_error_panic_hook::set_once();
191
192 use std::sync::atomic::{AtomicBool, Ordering};
195 static LOGGER_INITIALIZED: AtomicBool = AtomicBool::new(false);
196 if !LOGGER_INITIALIZED.swap(true, Ordering::SeqCst) {
197 let config = tracing_wasm::WASMLayerConfigBuilder::new()
198 .set_max_level(tracing::Level::INFO)
199 .build();
200 tracing_wasm::set_as_global_default_with_config(config);
201 }
202
203 let shared_world = if let Some(ptr_val) = shared_world_ptr {
204 let ptr = ptr_val as *mut u8;
205
206 if ptr_val == 0 || !ptr_val.is_multiple_of(8) {
208 return Err(JsValue::from_str(
209 "Invalid shared_world_ptr: null or unaligned",
210 ));
211 }
212
213 unsafe { SharedWorld::from_ptr(ptr) }
218 } else {
219 SharedWorld::new()
220 };
221
222 let global = js_sys::global();
223 let (ua, lang) =
224 if let Ok(worker) = global.clone().dyn_into::<web_sys::WorkerGlobalScope>() {
225 let n = worker.navigator();
226 (n.user_agent().ok(), n.language())
227 } else if let Ok(window) = global.dyn_into::<web_sys::Window>() {
228 let n = window.navigator();
229 (n.user_agent().ok(), n.language())
230 } else {
231 (None, None)
232 };
233
234 tracing::info!(
235 "Aetheris Client: Environment [UA: {}, Lang: {}]",
236 ua.as_deref().unwrap_or("Unknown"),
237 lang.as_deref().unwrap_or("Unknown")
238 );
239
240 tracing::info!(
241 "AetherisClient initialized on worker {}",
242 crate::get_worker_id()
243 );
244
245 with_collector(|c| {
247 c.push_event(
248 1,
249 "wasm_client",
250 "AetherisClient initialized",
251 "wasm_init",
252 None,
253 );
254 });
255
256 let mut world_state = ClientWorld::new();
257 world_state.shared_world_ref = Some(shared_world.as_ptr() as usize);
258
259 Ok(Self {
260 shared_world,
261 world_state,
262 render_state: None,
263 transport: None,
264 worker_id: crate::get_worker_id(),
265 session_token: None,
266 snapshots: std::collections::VecDeque::with_capacity(8),
267 last_rtt_ms: 0.0,
268 ping_counter: 0,
269 reassembler: aetheris_protocol::Reassembler::new(),
270 connection_state: ConnectionState::Disconnected,
271 reconnect_attempts: 0,
272 playground_rotation_enabled: false,
273 playground_next_network_id: 1,
274 first_playground_tick: true,
275 render_buffer: Vec::with_capacity(crate::shared_world::MAX_ENTITIES),
276 asset_registry: assets::AssetRegistry::new(),
277 last_input_target: None,
278 last_input_actions: Vec::new(),
279 pending_clear: false,
280 last_clear_tick: 0,
281 last_process_time: crate::performance_now(),
282 tick_accumulator: 0.0,
283 playground_move_x: 0.0,
284 playground_move_y: 0.0,
285 playground_actions: 0,
286 last_fraction: 0.0,
287 })
288 }
289
290 #[wasm_bindgen]
291 pub async fn request_otp(base_url: String, email: String) -> Result<String, String> {
292 crate::auth::request_otp(base_url, email).await
293 }
294
295 #[wasm_bindgen]
296 pub async fn login_with_otp(
297 base_url: String,
298 request_id: String,
299 code: String,
300 ) -> Result<String, String> {
301 crate::auth::login_with_otp(base_url, request_id, code).await
302 }
303
304 #[wasm_bindgen]
305 pub async fn logout(base_url: String, session_token: String) -> Result<(), String> {
306 crate::auth::logout(base_url, session_token).await
307 }
308
309 #[wasm_bindgen(getter)]
310 pub fn connection_state(&self) -> ConnectionState {
311 self.connection_state
312 }
313
314 fn check_worker(&self) {
315 debug_assert_eq!(
316 self.worker_id,
317 crate::get_worker_id(),
318 "AetherisClient accessed from wrong worker! It is pin-bound to its creating thread."
319 );
320 }
321
322 pub fn shared_world_ptr(&self) -> u32 {
324 self.shared_world.as_ptr() as u32
325 }
326
327 pub async fn connect(
328 &mut self,
329 url: String,
330 cert_hash: Option<Vec<u8>>,
331 ) -> Result<(), JsValue> {
332 self.check_worker();
333
334 if self.connection_state == ConnectionState::Connecting
335 || self.connection_state == ConnectionState::InGame
336 || self.connection_state == ConnectionState::Reconnecting
337 {
338 return Ok(());
339 }
340
341 if self.connection_state == ConnectionState::Failed && self.reconnect_attempts > 0 {
343 with_collector(|c| {
344 c.push_event(
345 2,
346 "transport",
347 "Triggering reconnection",
348 "reconnect_attempt",
349 None,
350 );
351 });
352 }
353
354 self.connection_state = ConnectionState::Connecting;
355 tracing::info!(url = %url, "Connecting to server...");
356
357 let transport_result = WebTransportBridge::connect(&url, cert_hash.as_deref()).await;
358
359 match transport_result {
360 Ok(transport) => {
361 if let Some(token) = &self.session_token {
363 let encoder = SerdeEncoder::new();
364 let auth_event = NetworkEvent::Auth {
365 session_token: token.clone(),
366 };
367
368 match encoder.encode_event(&auth_event) {
369 Ok(data) => {
370 if let Err(e) = transport.send_reliable(ClientId(0), &data).await {
371 self.connection_state = ConnectionState::Failed;
372 tracing::error!(error = ?e, "Handshake failed: could not send auth packet");
373 return Err(JsValue::from_str(&format!(
374 "Failed to send auth packet: {:?}",
375 e
376 )));
377 }
378 tracing::info!("Auth packet sent to server");
379 }
380 Err(e) => {
381 self.connection_state = ConnectionState::Failed;
382 tracing::error!(error = ?e, "Handshake failed: could not encode auth packet");
383 return Err(JsValue::from_str("Failed to encode auth packet"));
384 }
385 }
386 } else {
387 tracing::warn!(
388 "Connecting without session token! Server will likely discard data."
389 );
390 }
391
392 self.transport = Some(Box::new(transport));
393 self.connection_state = ConnectionState::InGame;
394 self.reconnect_attempts = 0;
395 tracing::info!("WebTransport connection established");
396 with_collector(|c| {
398 c.push_event(
399 1,
400 "transport",
401 &format!("WebTransport connected: {url}"),
402 "connect_handshake",
403 None,
404 );
405 });
406 Ok(())
407 }
408 Err(e) => {
409 self.connection_state = ConnectionState::Failed;
410 tracing::error!(error = ?e, "Failed to establish WebTransport connection");
411 with_collector(|c| {
413 c.push_event(
414 3,
415 "transport",
416 &format!("WebTransport failed: {url} — {e:?}"),
417 "connect_handshake_failed",
418 None,
419 );
420 });
421 Err(JsValue::from_str(&format!("failed to connect: {e:?}")))
422 }
423 }
424 }
425
426 #[wasm_bindgen]
427 pub async fn reconnect(
428 &mut self,
429 url: String,
430 cert_hash: Option<Vec<u8>>,
431 ) -> Result<(), JsValue> {
432 self.check_worker();
433 self.connection_state = ConnectionState::Reconnecting;
434 self.reconnect_attempts += 1;
435
436 tracing::info!(
437 "Attempting reconnection... (attempt {})",
438 self.reconnect_attempts
439 );
440
441 self.connect(url, cert_hash).await
442 }
443
444 #[wasm_bindgen]
445 pub async fn wasm_load_asset(
446 &mut self,
447 handle: assets::AssetHandle,
448 url: String,
449 ) -> Result<(), JsValue> {
450 self.asset_registry.load_asset(handle, &url).await
451 }
452
453 pub fn set_session_token(&mut self, token: String) {
455 self.session_token = Some(token);
456 }
457
458 pub async fn init_renderer(&mut self, canvas: JsValue) -> Result<(), JsValue> {
461 self.check_worker();
462 use wasm_bindgen::JsCast;
463
464 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
465 backends: wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL,
466 flags: wgpu::InstanceFlags::default(),
467 ..wgpu::InstanceDescriptor::new_without_display_handle()
468 });
469
470 let (surface_target, width, height) =
472 if let Ok(html_canvas) = canvas.clone().dyn_into::<web_sys::HtmlCanvasElement>() {
473 let width = html_canvas.width();
474 let height = html_canvas.height();
475 tracing::info!(
476 "Initializing renderer on HTMLCanvasElement ({}x{})",
477 width,
478 height
479 );
480 (wgpu::SurfaceTarget::Canvas(html_canvas), width, height)
481 } else if let Ok(offscreen_canvas) =
482 canvas.clone().dyn_into::<web_sys::OffscreenCanvas>()
483 {
484 let width = offscreen_canvas.width();
485 let height = offscreen_canvas.height();
486 tracing::info!(
487 "Initializing renderer on OffscreenCanvas ({}x{})",
488 width,
489 height
490 );
491
492 let _ = offscreen_canvas.get_context("webgpu").map_err(|e| {
495 JsValue::from_str(&format!("Failed to get webgpu context: {:?}", e))
496 })?;
497
498 (
499 wgpu::SurfaceTarget::OffscreenCanvas(offscreen_canvas),
500 width,
501 height,
502 )
503 } else {
504 return Err(JsValue::from_str(
505 "Aetheris: Provided object is not a valid Canvas or OffscreenCanvas",
506 ));
507 };
508
509 let surface = instance
510 .create_surface(surface_target)
511 .map_err(|e| JsValue::from_str(&format!("Failed to create surface: {:?}", e)))?;
512
513 let render_state = RenderState::new(&instance, surface, width, height)
514 .await
515 .map_err(|e| JsValue::from_str(&format!("Failed to init renderer: {:?}", e)))?;
516
517 self.render_state = Some(render_state);
518
519 with_collector(|c| {
521 c.push_event(
522 1,
523 "render_worker",
524 &format!("Renderer initialized ({}x{})", width, height),
525 "render_pipeline_setup",
526 None,
527 );
528 });
529
530 Ok(())
531 }
532
533 #[wasm_bindgen]
534 pub fn resize(&mut self, width: u32, height: u32) {
535 if let Some(state) = &mut self.render_state {
536 state.resize(width, height);
537 }
538 }
539
540 #[cfg(debug_assertions)]
541 #[wasm_bindgen]
542 pub fn set_debug_mode(&mut self, mode: u32) {
543 self.check_worker();
544 if let Some(state) = &mut self.render_state {
545 state.set_debug_mode(match mode {
546 0 => crate::render::DebugRenderMode::Off,
547 1 => crate::render::DebugRenderMode::Wireframe,
548 2 => crate::render::DebugRenderMode::Components,
549 _ => crate::render::DebugRenderMode::Full,
550 });
551 }
552 }
553
554 #[wasm_bindgen]
555 pub fn set_theme_colors(&mut self, bg_base: &str, text_primary: &str) {
556 self.check_worker();
557 let clear = crate::render::parse_css_color(bg_base);
558 let label = crate::render::parse_css_color(text_primary);
559
560 tracing::info!(
561 "Aetheris Client: Applying theme colors [bg: {} -> {:?}, text: {} -> {:?}]",
562 bg_base,
563 clear,
564 text_primary,
565 label
566 );
567
568 if let Some(state) = &mut self.render_state {
569 state.set_clear_color(clear);
570 #[cfg(debug_assertions)]
571 state.set_label_color([
572 label.r as f32,
573 label.g as f32,
574 label.b as f32,
575 label.a as f32,
576 ]);
577 }
578 }
579
580 #[cfg(debug_assertions)]
581 #[wasm_bindgen]
582 pub fn cycle_debug_mode(&mut self) {
583 if let Some(state) = &mut self.render_state {
584 state.cycle_debug_mode();
585 }
586 }
587
588 #[cfg(debug_assertions)]
589 #[wasm_bindgen]
590 pub fn toggle_grid(&mut self) {
591 if let Some(state) = &mut self.render_state {
592 state.toggle_grid();
593 }
594 }
595
596 #[wasm_bindgen]
597 pub fn latest_tick(&self) -> u64 {
598 self.world_state.latest_tick
599 }
600
601 #[wasm_bindgen]
602 pub fn playground_apply_input(&mut self, move_x: f32, move_y: f32, actions_mask: u32) {
603 self.check_worker();
604 self.playground_move_x = move_x;
605 self.playground_move_y = move_y;
606 self.playground_actions = actions_mask;
607 }
608
609 pub async fn tick(&mut self) {
611 self.check_worker();
612 use aetheris_protocol::traits::{Encoder, WorldState};
613
614 let encoder = SerdeEncoder::new();
615
616 if let Some(_transport) = &self.transport {}
619
620 if let Some(transport) = &mut self.transport {
622 self.ping_counter = self.ping_counter.wrapping_add(1);
623 if self.ping_counter % 60 == 0 {
624 let now = performance_now();
626 let tick_u64 = now as u64;
627
628 if let Ok(data) = encoder.encode_event(&NetworkEvent::Ping {
629 client_id: ClientId(0), tick: tick_u64,
631 }) {
632 tracing::trace!(tick = tick_u64, "Sending Ping");
633 let _ = transport.send_unreliable(ClientId(0), &data).await;
634 }
635 }
636 }
637
638 if let Some(transport) = &mut self.transport {
640 let events = match transport.poll_events().await {
641 Ok(e) => e,
642 Err(e) => {
643 tracing::error!("Transport poll failure: {:?}", e);
644 return;
645 }
646 };
647 let mut updates: Vec<(ClientId, aetheris_protocol::events::ComponentUpdate)> =
648 Vec::new();
649
650 for event in events {
651 match event {
652 NetworkEvent::UnreliableMessage { data, client_id }
653 | NetworkEvent::ReliableMessage { data, client_id } => {
654 match encoder.decode(&data) {
655 Ok(update) => {
656 if self.last_clear_tick == 0
660 || update.tick > self.last_clear_tick
661 {
662 updates.push((client_id, update));
663 } else {
664 tracing::debug!(
665 network_id = update.network_id.0,
666 tick = update.tick,
667 last_clear_tick = self.last_clear_tick,
668 "Discarding stale update (tick <= last_clear_tick)"
669 );
670 }
671 }
672 Err(_) => {
673 if let Ok(event) = encoder.decode_event(&data) {
675 match event {
676 aetheris_protocol::events::NetworkEvent::GameEvent {
677 event: game_event,
678 ..
679 } => match &game_event {
680 aetheris_protocol::events::GameEvent::AsteroidDepleted {
681 network_id,
682 } => {
683 tracing::info!(?network_id, "Asteroid depleted");
684 self.world_state.entities.remove(&network_id);
685
686 for slot in self.world_state.entities.values_mut() {
687 if (slot.flags & 0x04) != 0
688 && slot.mining_target_id == (network_id.0 as u16)
689 {
690 slot.mining_active = 0;
691 slot.mining_target_id = 0;
692 tracing::info!("Cleared local mining target due to depletion");
693 }
694 }
695 }
696 aetheris_protocol::events::GameEvent::Possession {
697 network_id: _,
698 } => {
699 self.world_state.handle_game_event(&game_event);
700 }
701 aetheris_protocol::events::GameEvent::SystemManifest {
702 manifest,
703 } => {
704 tracing::debug!(
705 count = manifest.len(),
706 "Received SystemManifest from server"
707 );
708 self.world_state.system_manifest = manifest.clone();
709 }
710 },
711 aetheris_protocol::events::NetworkEvent::ClearWorld {
712 ..
713 } => {
714 tracing::info!(
715 "Server ClearWorld ack received (via ReliableMessage) — gate lowered"
716 );
717 self.pending_clear = false;
718 }
719 _ => {}
720 }
721 } else {
722 tracing::warn!(
723 "Failed to decode server message as update or wire event"
724 );
725 }
726 }
727 }
728 }
729 NetworkEvent::ClientConnected(id) => {
730 tracing::info!(?id, "Server connected");
731 }
732 NetworkEvent::ClientDisconnected(id) => {
733 tracing::warn!(?id, "Server disconnected");
734 }
735 NetworkEvent::Disconnected(_id) => {
736 tracing::error!("Transport disconnected locally");
737 self.connection_state = ConnectionState::Disconnected;
738 }
739 NetworkEvent::Ping { client_id: _, tick } => {
740 let pong = NetworkEvent::Pong { tick };
742 if let Ok(data) = encoder.encode_event(&pong) {
743 let _ = transport.send_reliable(ClientId(0), &data).await;
744 }
745 }
746 NetworkEvent::Pong { tick } => {
747 let now = performance_now();
749 let rtt = now - (tick as f64);
750 self.last_rtt_ms = rtt;
751
752 with_collector(|c| {
753 c.update_rtt(rtt);
754 });
755
756 #[cfg(feature = "metrics")]
757 metrics::gauge!("aetheris_client_rtt_ms").set(rtt);
758
759 tracing::trace!(rtt_ms = rtt, tick, "Received Pong / RTT update");
760 }
761 NetworkEvent::Auth { .. } => {
762 tracing::debug!("Received Auth event from server (unexpected)");
764 }
765 NetworkEvent::SessionClosed(id) => {
766 tracing::warn!(?id, "WebTransport session closed");
767 }
768 NetworkEvent::StreamReset(id) => {
769 tracing::error!(?id, "WebTransport stream reset");
770 }
771 NetworkEvent::ReplicationBatch { events, client_id } => {
772 for event in events {
773 if self.last_clear_tick == 0 || event.tick > self.last_clear_tick {
774 updates.push((
775 client_id,
776 aetheris_protocol::events::ComponentUpdate {
777 network_id: event.network_id,
778 component_kind: event.component_kind,
779 payload: event.payload,
780 tick: event.tick,
781 },
782 ));
783 }
784 }
785 }
786 NetworkEvent::Fragment {
787 client_id,
788 fragment,
789 } => {
790 if let Some(data) = self.reassembler.ingest(client_id, fragment) {
791 if let Ok(update) = encoder.decode(&data) {
792 if self.last_clear_tick == 0
793 || update.tick > self.last_clear_tick
794 {
795 updates.push((client_id, update));
796 }
797 }
798 }
799 }
800 NetworkEvent::StressTest { .. } => {
801 }
804 NetworkEvent::Spawn { .. } => {
805 }
807 NetworkEvent::ClearWorld { .. } => {
808 tracing::info!("Server ClearWorld ack received — gate lowered");
813 self.pending_clear = false;
814 }
815 NetworkEvent::GameEvent {
816 event: game_event, ..
817 } => {
818 match &game_event {
821 aetheris_protocol::events::GameEvent::AsteroidDepleted {
822 network_id,
823 } => {
824 tracing::info!(
825 ?network_id,
826 "Asteroid depleted (via GameEvent)"
827 );
828 self.world_state.entities.remove(&network_id);
830
831 for slot in self.world_state.entities.values_mut() {
833 if (slot.flags & 0x04) != 0
835 && slot.mining_target_id == (network_id.0 as u16)
836 {
837 slot.mining_active = 0;
838 slot.mining_target_id = 0;
839 tracing::info!(
840 "Cleared local mining target due to depletion"
841 );
842 }
843 }
844 }
845 aetheris_protocol::events::GameEvent::SystemManifest {
846 manifest,
847 } => {
848 tracing::info!(
849 count = manifest.len(),
850 "Received SystemManifest from server (via GameEvent)"
851 );
852 self.world_state.system_manifest = manifest.clone();
853 }
854 aetheris_protocol::events::GameEvent::Possession {
855 network_id: _,
856 } => {
857 self.world_state.handle_game_event(&game_event);
858 }
859 }
860 }
861 #[allow(unreachable_patterns)]
862 _ => {
863 tracing::debug!("Unhandled outer NetworkEvent variant");
864 }
865 }
866 }
867
868 if self.pending_clear {
872 if !updates.is_empty() {
873 tracing::debug!(
874 count = updates.len(),
875 "Discarding updates — pending_clear gate is raised"
876 );
877 }
878 } else {
879 if !updates.is_empty() {
880 let max_tick = updates.iter().map(|(_, u)| u.tick).max().unwrap_or(0);
881
882 if max_tick > 0 {
884 let drift =
885 (self.world_state.latest_tick as i32 - max_tick as i32).abs();
886 if self.first_playground_tick || drift > 20 {
887 tracing::info!(
888 latest = self.world_state.latest_tick,
889 server = max_tick,
890 drift,
891 "Syncing client latest_tick to server heartbeat"
892 );
893 self.world_state.latest_tick = max_tick;
894 self.first_playground_tick = false;
895 }
896 }
897
898 tracing::debug!(count = updates.len(), "Applying server updates to world");
899 self.world_state.apply_updates(&updates);
900 }
901 }
902 }
903
904 let now = crate::performance_now();
910 let delta_ms = now - self.last_process_time;
911 self.last_process_time = now;
912
913 let delta_ms = delta_ms.min(100.0);
915 self.tick_accumulator += delta_ms;
916
917 const DT_MS: f64 = 1000.0 / 60.0;
918 while self.tick_accumulator >= DT_MS {
919 let applied = self.world_state.playground_apply_input(
923 self.playground_move_x,
924 self.playground_move_y,
925 self.playground_actions,
926 );
927
928 if !applied && self.world_state.latest_tick % 120 == 0 {
929 tracing::warn!(
930 tick = self.world_state.latest_tick,
931 "Simulation loop running but no LocalPlayer (0x04) entity found to apply input to"
932 );
933 }
934
935 self.world_state.latest_tick += 1;
937 self.tick_accumulator -= DT_MS;
938 }
939
940 let fraction = (self.tick_accumulator as f32 / DT_MS as f32).clamp(0.0, 1.0);
943 let alpha = 0.8;
944 self.last_fraction = self.last_fraction * (1.0 - alpha) + fraction * alpha;
945 self.shared_world.set_sub_tick_fraction(self.last_fraction);
946 tracing::trace!(fraction, "Updated sub-tick fraction");
947
948 let sim_start = crate::performance_now();
952
953 self.flush_to_shared_world(self.world_state.latest_tick);
955
956 let sim_time_ms = crate::performance_now() - sim_start;
957 let count = self.world_state.entities.len() as u32;
958 with_collector(|c| {
959 c.record_sim(sim_time_ms);
960 c.update_entity_count(count);
961 });
962 }
963
964 fn flush_to_shared_world(&mut self, tick: u64) {
965 let entities = &self.world_state.entities;
966 let write_buffer = self.shared_world.get_write_buffer();
967
968 let mut count = 0;
969 for (i, slot) in entities.values().enumerate() {
970 if i >= MAX_ENTITIES {
971 tracing::warn!("Max entities reached in shared world! Overflow suppressed.");
972 break;
973 }
974 write_buffer[i] = *slot;
975 count += 1;
976 }
977
978 tracing::debug!(entity_count = count, tick, "Flushed world to SAB");
979 self.shared_world.commit_write(count as u32, tick);
980 }
981
982 #[wasm_bindgen]
983 pub async fn request_system_manifest(&mut self) -> Result<(), JsValue> {
984 self.check_worker();
985
986 if let Some(transport) = &self.transport {
987 let encoder = SerdeEncoder::new();
988 let event = NetworkEvent::RequestSystemManifest {
989 client_id: ClientId(0),
990 };
991
992 if let Ok(data) = encoder.encode_event(&event) {
993 transport
994 .send_reliable(ClientId(0), &data)
995 .await
996 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
997 tracing::info!("Sent RequestSystemManifest command to server");
998 }
999 }
1000 Ok(())
1001 }
1002
1003 #[wasm_bindgen]
1004 pub fn get_system_info(&self) -> Result<JsValue, JsValue> {
1005 serde_wasm_bindgen::to_value(&self.world_state.system_manifest)
1006 .map_err(|e| JsValue::from_str(&e.to_string()))
1007 }
1008
1009 #[wasm_bindgen]
1010 pub fn playground_spawn(&mut self, entity_type: u16, x: f32, y: f32, rotation: f32) {
1011 if self.world_state.entities.len() >= MAX_ENTITIES {
1013 tracing::warn!("playground_spawn: MAX_ENTITIES reached, spawn ignored.");
1014 return;
1015 }
1016
1017 if self.playground_next_network_id == 1 && !self.world_state.entities.is_empty() {
1019 self.playground_next_network_id = self
1020 .world_state
1021 .entities
1022 .keys()
1023 .map(|k| k.0)
1024 .max()
1025 .unwrap_or(0)
1026 + 1;
1027 }
1028
1029 let id = aetheris_protocol::types::NetworkId(self.playground_next_network_id);
1030 self.playground_next_network_id += 1;
1031 let slot = SabSlot {
1032 network_id: id.0,
1033 x,
1034 y,
1035 z: 0.0,
1036 rotation,
1037 dx: 0.0,
1038 dy: 0.0,
1039 dz: 0.0,
1040 hp: 100,
1041 shield: 100,
1042 entity_type,
1043 flags: 0x01, mining_active: 0,
1045 cargo_ore: 0,
1046 mining_target_id: 0,
1047 };
1048 self.world_state.entities.insert(id, slot);
1049 }
1050
1051 #[wasm_bindgen]
1052 pub async fn playground_spawn_net(
1053 &mut self,
1054 entity_type: u16,
1055 x: f32,
1056 y: f32,
1057 rot: f32,
1058 ) -> Result<(), JsValue> {
1059 self.check_worker();
1060
1061 if let Some(transport) = &self.transport {
1062 let encoder = SerdeEncoder::new();
1063 let event = NetworkEvent::Spawn {
1064 client_id: ClientId(0),
1065 entity_type,
1066 x,
1067 y,
1068 rot,
1069 };
1070
1071 if let Ok(data) = encoder.encode_event(&event) {
1072 transport
1073 .send_reliable(ClientId(0), &data)
1074 .await
1075 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1076 tracing::info!(entity_type, x, y, "Sent Spawn command to server");
1077 }
1078 } else {
1079 self.playground_spawn(entity_type, x, y, rot);
1081 }
1082 Ok(())
1083 }
1084
1085 #[wasm_bindgen]
1086 pub fn playground_clear(&mut self) {
1087 self.world_state.entities.clear();
1088 }
1089
1090 #[wasm_bindgen]
1094 pub async fn start_session_net(&mut self) -> Result<(), JsValue> {
1095 self.check_worker();
1096
1097 if let Some(transport) = &self.transport {
1098 let encoder = SerdeEncoder::new();
1099 let event = NetworkEvent::StartSession {
1100 client_id: ClientId(0),
1101 };
1102 if let Ok(data) = encoder.encode_event(&event) {
1103 transport
1104 .send_reliable(ClientId(0), &data)
1105 .await
1106 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1107 tracing::info!("Sent StartSession command to server");
1108 }
1109 }
1110 Ok(())
1111 }
1112
1113 #[wasm_bindgen]
1118 pub async fn send_input(
1119 &mut self,
1120 tick: u64,
1121 move_x: f32,
1122 move_y: f32,
1123 actions_mask: u32,
1124 target_id_arg: Option<u64>,
1125 ) -> Result<(), JsValue> {
1126 self.check_worker();
1127
1128 let target_id = if let Some(owned_id) = self.world_state.player_network_id {
1130 tracing::trace!(
1133 network_id = owned_id.0,
1134 "[send_input] Using player_network_id for input target"
1135 );
1136 Some(owned_id)
1137 } else {
1138 let fallback = self
1140 .world_state
1141 .entities
1142 .iter()
1143 .find(|(_, slot)| (slot.flags & 0x04) != 0)
1144 .map(|(id, _)| *id);
1145 tracing::trace!(
1146 fallback_id = ?fallback,
1147 "[send_input] No player_network_id set - using 0x04 flag fallback"
1148 );
1149 fallback
1150 };
1151
1152 let Some(target_id) = target_id else {
1153 tracing::trace!("[send_input] Input dropped: no controlled entity found");
1154 return Ok(());
1155 };
1156
1157 tracing::trace!(
1158 target_id = target_id.0,
1159 move_x,
1160 move_y,
1161 "[send_input] Sending input for entity"
1162 );
1163
1164 let mut actions = Vec::new();
1166
1167 if move_x.abs() > f32::EPSILON || move_y.abs() > f32::EPSILON {
1169 actions.push(PlayerInputKind::Move {
1170 x: move_x,
1171 y: move_y,
1172 });
1173 }
1174
1175 if (actions_mask & 0x01) != 0 {
1178 actions.push(PlayerInputKind::FirePrimary);
1179 }
1180 if (actions_mask & 0x02) != 0 {
1182 if let Some(id) = target_id_arg {
1183 actions.push(PlayerInputKind::ToggleMining {
1184 target: NetworkId(id),
1185 });
1186 } else {
1187 tracing::warn!("ToggleMining requested without target_id; dropping action");
1188 }
1189 }
1190
1191 let is_repeated = self.last_input_actions.len() == actions.len()
1193 && self
1194 .last_input_actions
1195 .iter()
1196 .zip(actions.iter())
1197 .all(|(a, b)| a == b)
1198 && self.last_input_target == Some(target_id);
1199
1200 if move_x.abs() > f32::EPSILON || move_y.abs() > f32::EPSILON || actions_mask != 0 {
1201 if is_repeated {
1202 tracing::trace!(
1203 tick,
1204 move_x,
1205 move_y,
1206 actions_mask,
1207 "Client sending input (repeated)"
1208 );
1209 } else {
1210 tracing::trace!(tick, move_x, move_y, actions_mask, "Client sending input");
1211 }
1212 }
1213
1214 let transport = self.transport.as_ref().ok_or_else(|| {
1215 JsValue::from_str("Cannot send input: transport not initialized or closed")
1216 })?;
1217
1218 if is_repeated {
1219 tracing::trace!(
1220 ?target_id,
1221 x = move_x,
1222 y = move_y,
1223 "Sending InputCommand (repeated)"
1224 );
1225 } else {
1226 tracing::trace!(?target_id, x = move_x, y = move_y, "Sending InputCommand");
1227 }
1228
1229 self.last_input_target = Some(target_id);
1231 self.last_input_actions = actions.clone();
1232
1233 let cmd = InputCommand {
1234 tick,
1235 actions,
1236 last_seen_input_tick: None,
1237 }
1238 .clamped();
1239
1240 let payload = rmp_serde::to_vec(&cmd)
1244 .map_err(|e| JsValue::from_str(&format!("Failed to encode InputCommand: {e:?}")))?;
1245
1246 let update = ReplicationEvent {
1247 network_id: target_id,
1248 component_kind: ComponentKind(128),
1249 payload,
1250 tick,
1251 };
1252
1253 let mut buffer = [0u8; 1024];
1254 let encoder = SerdeEncoder::new();
1255 let len = encoder.encode(&update, &mut buffer).map_err(|e| {
1256 JsValue::from_str(&format!("Failed to encode input replication event: {e:?}"))
1257 })?;
1258
1259 transport
1261 .send_unreliable(ClientId(0), &buffer[..len])
1262 .await
1263 .map_err(|e| {
1264 JsValue::from_str(&format!("Transport error during send_input: {e:?}"))
1265 })?;
1266
1267 Ok(())
1268 }
1269
1270 #[wasm_bindgen]
1271 pub async fn playground_clear_server(&mut self) -> Result<(), JsValue> {
1272 self.check_worker();
1273
1274 if let Some(transport) = &self.transport {
1275 let encoder = SerdeEncoder::new();
1276 let event = NetworkEvent::ClearWorld {
1277 client_id: ClientId(0),
1278 };
1279 if let Ok(data) = encoder.encode_event(&event) {
1280 transport
1281 .send_reliable(ClientId(0), &data)
1282 .await
1283 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1284 tracing::info!(
1285 "Sent ClearWorld command to server — suppressing updates until ack"
1286 );
1287 self.world_state.entities.clear();
1292 self.world_state.player_network_id = None;
1293 self.pending_clear = true;
1294 self.last_clear_tick = self.world_state.latest_tick;
1295 }
1296 } else {
1297 self.world_state.entities.clear();
1299 self.world_state.player_network_id = None;
1300 }
1301 Ok(())
1302 }
1303
1304 #[wasm_bindgen]
1305 pub fn playground_set_rotation_enabled(&mut self, enabled: bool) {
1306 self.playground_rotation_enabled = enabled;
1307 }
1310
1311 #[wasm_bindgen]
1312 pub async fn playground_stress_test(
1313 &mut self,
1314 count: u16,
1315 rotate: bool,
1316 ) -> Result<(), JsValue> {
1317 self.check_worker();
1318
1319 if let Some(transport) = &self.transport {
1320 let encoder = SerdeEncoder::new();
1321 let event = NetworkEvent::StressTest {
1322 client_id: ClientId(0),
1323 count,
1324 rotate,
1325 };
1326
1327 if let Ok(data) = encoder.encode_event(&event) {
1328 transport
1329 .send_reliable(ClientId(0), &data)
1330 .await
1331 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1332 tracing::info!(count, rotate, "Sent StressTest command to server");
1333 }
1334 self.playground_set_rotation_enabled(rotate);
1335 } else {
1336 self.playground_set_rotation_enabled(rotate);
1338 self.playground_clear();
1339 for _ in 0..count {
1340 self.playground_spawn(1, 0.0, 0.0, 0.0); }
1342 }
1343
1344 Ok(())
1345 }
1346
1347 #[wasm_bindgen]
1348 pub fn tick_playground(&mut self) {
1349 self.check_worker();
1350
1351 let sim_start = crate::performance_now();
1353
1354 let now = crate::performance_now();
1355 let delta_ms = now - self.last_process_time;
1356 self.last_process_time = now;
1357
1358 let delta_ms = delta_ms.min(100.0);
1360 self.tick_accumulator += delta_ms;
1361
1362 const DT_MS: f64 = 1000.0 / 60.0;
1363 let mut steps = 0;
1364 while self.tick_accumulator >= DT_MS {
1365 self.world_state.latest_tick += 1;
1366
1367 self.world_state.playground_apply_input(
1369 self.playground_move_x,
1370 self.playground_move_y,
1371 self.playground_actions,
1372 );
1373
1374 self.world_state.simulate();
1376
1377 self.tick_accumulator -= DT_MS;
1378 steps += 1;
1379 }
1380
1381 if steps > 0 {
1383 let fraction = (self.tick_accumulator as f32 / DT_MS as f32).clamp(0.0, 1.0);
1385 let alpha = 0.8;
1386 self.last_fraction = self.last_fraction * (1.0 - alpha) + fraction * alpha;
1387 self.shared_world.set_sub_tick_fraction(self.last_fraction);
1388
1389 let count = self.world_state.entities.len() as u32;
1390 self.flush_to_shared_world(self.world_state.latest_tick);
1391
1392 let sim_time_ms = crate::performance_now() - sim_start;
1393 with_collector(|c| {
1394 c.record_sim(sim_time_ms);
1395 c.update_entity_count(count);
1396 });
1397 }
1398 }
1399
1400 pub fn render(&mut self) -> f64 {
1402 self.check_worker();
1403
1404 let tick = self.shared_world.tick();
1405 let entities = self.shared_world.get_read_buffer();
1406 let bounds = self.shared_world.get_room_bounds();
1407 if let Some(state) = &mut self.render_state {
1408 state.set_room_bounds(bounds);
1409 }
1410
1411 thread_local! {
1413 static FRAME_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
1414 }
1415 FRAME_COUNT.with(|count| {
1416 let current = count.get();
1417 if current % 300 == 0 {
1418 tracing::debug!(
1419 "Aetheris Render Stats: Tick={}, Entities={}, Snapshots={}",
1420 tick,
1421 entities.len(),
1422 self.snapshots.len(),
1423 );
1424 }
1425 count.set(current + 1);
1426 });
1427
1428 let back_tick = self.snapshots.back().map(|s| s.tick).unwrap_or(0);
1430 if tick < back_tick && tick != 0 {
1431 tracing::warn!(
1432 tick,
1433 back_tick,
1434 "Simulation time went backwards! Clearing snapshot buffer."
1435 );
1436 self.snapshots.clear();
1437 }
1438
1439 if self.snapshots.is_empty() || tick > back_tick {
1440 tracing::trace!(tick, "Pushing new snapshot to buffer");
1441 self.snapshots.push_back(SimulationSnapshot {
1442 tick,
1443 entities: entities.to_vec(),
1444 });
1445 } else if tick == back_tick && tick != 0 {
1446 thread_local! {
1448 static STAGNANT_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
1449 }
1450 STAGNANT_COUNT.with(|count| {
1451 let cur = count.get() + 1;
1452 if cur % 300 == 0 {
1453 tracing::warn!(tick, "Render loop stalled on same tick for 300 frames");
1454 }
1455 count.set(cur);
1456 });
1457 }
1458
1459 let latest_tick = self.snapshots.back().map(|s| s.tick as f32).unwrap_or(0.0);
1464 let fraction = self.shared_world.sub_tick_fraction();
1465 let mut target_tick = latest_tick - 1.0 + fraction;
1466
1467 if !self.snapshots.is_empty() {
1469 let oldest_tick = self.snapshots[0].tick as f32;
1470 if target_tick < oldest_tick {
1471 target_tick = oldest_tick;
1472 }
1473 }
1474
1475 let ent_count = entities.len();
1476 let frame_time_ms = self.render_at_tick(target_tick);
1477
1478 let snap_count = self.snapshots.len() as u32;
1480 if tick % 60 == 0 {
1481 tracing::trace!(
1482 tick,
1483 ent_count,
1484 snap_count,
1485 target_tick,
1486 "Render Loop Active"
1487 );
1488 }
1489 with_collector(|c| {
1490 c.record_frame(frame_time_ms, 0.0);
1492 c.update_snapshot_count(snap_count);
1493 });
1494
1495 frame_time_ms
1496 }
1497
1498 fn render_at_tick(&mut self, target_tick: f32) -> f64 {
1499 if self.snapshots.len() < 2 {
1500 if let Some(state) = &mut self.render_state {
1503 let entities = if !self.snapshots.is_empty() {
1504 self.snapshots[0].entities.clone()
1505 } else {
1506 Vec::new()
1507 };
1508 return state.render_frame_with_compact_slots(&entities);
1509 }
1510 return 0.0;
1511 }
1512
1513 let mut s1_idx = 0;
1515 let mut found = false;
1516
1517 for i in 0..self.snapshots.len() - 1 {
1518 if (self.snapshots[i].tick as f32) <= target_tick
1519 && (self.snapshots[i + 1].tick as f32) > target_tick
1520 {
1521 s1_idx = i;
1522 found = true;
1523 break;
1524 }
1525 }
1526
1527 if !found {
1528 if target_tick < self.snapshots[0].tick as f32 {
1530 s1_idx = 0;
1531 } else {
1532 s1_idx = self.snapshots.len() - 2;
1533 }
1534 }
1535
1536 let s1 = self.snapshots.get(s1_idx).unwrap();
1537 let s2 = self.snapshots.get(s1_idx + 1).unwrap();
1538
1539 let tick_range = (s2.tick - s1.tick) as f32;
1540 let alpha = if tick_range > 0.0 {
1541 (target_tick - s1.tick as f32) / tick_range
1542 } else {
1543 1.0
1544 }
1545 .clamp(0.0, 1.0);
1546
1547 self.render_buffer.clear();
1549 self.render_buffer.extend_from_slice(&s2.entities);
1550
1551 let prev_map: std::collections::HashMap<u64, &SabSlot> =
1553 s1.entities.iter().map(|e| (e.network_id, e)).collect();
1554
1555 for ent in &mut self.render_buffer {
1556 if let Some(prev) = prev_map.get(&ent.network_id).copied() {
1557 if let Some(bounds) = &self.world_state.room_bounds {
1558 ent.x = lerp_wrapped(prev.x, ent.x, alpha, bounds.min_x, bounds.max_x);
1559 ent.y = lerp_wrapped(prev.y, ent.y, alpha, bounds.min_y, bounds.max_y);
1560 } else {
1561 ent.x = lerp(prev.x, ent.x, alpha);
1562 ent.y = lerp(prev.y, ent.y, alpha);
1563 }
1564 ent.z = lerp(prev.z, ent.z, alpha);
1565 ent.rotation = lerp_rotation(prev.rotation, ent.rotation, alpha);
1566 }
1567 }
1568
1569 let mut frame_time = 0.0;
1570 if let Some(state) = &mut self.render_state {
1571 frame_time = state.render_frame_with_compact_slots(&self.render_buffer);
1572 }
1573
1574 while self.snapshots.len() > 2 && (self.snapshots[0].tick as f32) < target_tick - 1.0 {
1578 self.snapshots.pop_front();
1579 }
1580
1581 while self.snapshots.len() > 16 {
1583 self.snapshots.pop_front();
1584 }
1585
1586 frame_time
1587 }
1588 }
1589
1590 fn lerp(a: f32, b: f32, alpha: f32) -> f32 {
1591 a + (b - a) * alpha
1592 }
1593
1594 fn lerp_wrapped(a: f32, b: f32, alpha: f32, min: f32, max: f32) -> f32 {
1595 let size = max - min;
1596 if size <= 0.0 {
1597 return a + (b - a) * alpha;
1598 }
1599
1600 let a_norm = (a - min).rem_euclid(size) + min;
1602 let b_norm = (b - min).rem_euclid(size) + min;
1603
1604 let mut diff = b_norm - a_norm;
1605 if diff.abs() > size * 0.5 {
1606 if diff > 0.0 {
1607 diff -= size;
1608 } else {
1609 diff += size;
1610 }
1611 }
1612 let res = a_norm + diff * alpha;
1613 (res - min).rem_euclid(size) + min
1615 }
1616
1617 fn lerp_rotation(a: f32, b: f32, alpha: f32) -> f32 {
1618 let mut diff = b - a;
1621 while diff < -std::f32::consts::PI {
1622 diff += std::f32::consts::TAU;
1623 }
1624 while diff > std::f32::consts::PI {
1625 diff -= std::f32::consts::TAU;
1626 }
1627 a + diff * alpha
1628 }
1629}