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};
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
174 #[wasm_bindgen]
175 impl AetherisClient {
176 #[wasm_bindgen(constructor)]
179 pub fn new(shared_world_ptr: Option<u32>) -> Result<AetherisClient, JsValue> {
180 console_error_panic_hook::set_once();
181
182 use std::sync::atomic::{AtomicBool, Ordering};
185 static LOGGER_INITIALIZED: AtomicBool = AtomicBool::new(false);
186 if !LOGGER_INITIALIZED.swap(true, Ordering::SeqCst) {
187 let config = tracing_wasm::WASMLayerConfigBuilder::new()
188 .set_max_level(tracing::Level::INFO)
189 .build();
190 tracing_wasm::set_as_global_default_with_config(config);
191 }
192
193 let shared_world = if let Some(ptr_val) = shared_world_ptr {
194 let ptr = ptr_val as *mut u8;
195
196 if ptr_val == 0 || !ptr_val.is_multiple_of(8) {
198 return Err(JsValue::from_str(
199 "Invalid shared_world_ptr: null or unaligned",
200 ));
201 }
202
203 unsafe { SharedWorld::from_ptr(ptr) }
208 } else {
209 SharedWorld::new()
210 };
211
212 let global = js_sys::global();
213 let (ua, lang) =
214 if let Ok(worker) = global.clone().dyn_into::<web_sys::WorkerGlobalScope>() {
215 let n = worker.navigator();
216 (n.user_agent().ok(), n.language())
217 } else if let Ok(window) = global.dyn_into::<web_sys::Window>() {
218 let n = window.navigator();
219 (n.user_agent().ok(), n.language())
220 } else {
221 (None, None)
222 };
223
224 tracing::info!(
225 "Aetheris Client: Environment [UA: {}, Lang: {}]",
226 ua.as_deref().unwrap_or("Unknown"),
227 lang.as_deref().unwrap_or("Unknown")
228 );
229
230 tracing::info!(
231 "AetherisClient initialized on worker {}",
232 crate::get_worker_id()
233 );
234
235 with_collector(|c| {
237 c.push_event(
238 1,
239 "wasm_client",
240 "AetherisClient initialized",
241 "wasm_init",
242 None,
243 );
244 });
245
246 let mut world_state = ClientWorld::new();
247 world_state.shared_world_ref = Some(shared_world.as_ptr() as usize);
248
249 Ok(Self {
250 shared_world,
251 world_state,
252 render_state: None,
253 transport: None,
254 worker_id: crate::get_worker_id(),
255 session_token: None,
256 snapshots: std::collections::VecDeque::with_capacity(8),
257 last_rtt_ms: 0.0,
258 ping_counter: 0,
259 reassembler: aetheris_protocol::Reassembler::new(),
260 connection_state: ConnectionState::Disconnected,
261 reconnect_attempts: 0,
262 playground_rotation_enabled: false,
263 playground_next_network_id: 1,
264 first_playground_tick: true,
265 render_buffer: Vec::with_capacity(crate::shared_world::MAX_ENTITIES),
266 asset_registry: assets::AssetRegistry::new(),
267 last_input_target: None,
268 last_input_actions: Vec::new(),
269 pending_clear: false,
270 last_clear_tick: 0,
271 })
272 }
273
274 #[wasm_bindgen]
275 pub async fn request_otp(base_url: String, email: String) -> Result<String, String> {
276 crate::auth::request_otp(base_url, email).await
277 }
278
279 #[wasm_bindgen]
280 pub async fn login_with_otp(
281 base_url: String,
282 request_id: String,
283 code: String,
284 ) -> Result<String, String> {
285 crate::auth::login_with_otp(base_url, request_id, code).await
286 }
287
288 #[wasm_bindgen]
289 pub async fn logout(base_url: String, session_token: String) -> Result<(), String> {
290 crate::auth::logout(base_url, session_token).await
291 }
292
293 #[wasm_bindgen(getter)]
294 pub fn connection_state(&self) -> ConnectionState {
295 self.connection_state
296 }
297
298 fn check_worker(&self) {
299 debug_assert_eq!(
300 self.worker_id,
301 crate::get_worker_id(),
302 "AetherisClient accessed from wrong worker! It is pin-bound to its creating thread."
303 );
304 }
305
306 pub fn shared_world_ptr(&self) -> u32 {
308 self.shared_world.as_ptr() as u32
309 }
310
311 pub async fn connect(
312 &mut self,
313 url: String,
314 cert_hash: Option<Vec<u8>>,
315 ) -> Result<(), JsValue> {
316 self.check_worker();
317
318 if self.connection_state == ConnectionState::Connecting
319 || self.connection_state == ConnectionState::InGame
320 || self.connection_state == ConnectionState::Reconnecting
321 {
322 return Ok(());
323 }
324
325 if self.connection_state == ConnectionState::Failed && self.reconnect_attempts > 0 {
327 with_collector(|c| {
328 c.push_event(
329 2,
330 "transport",
331 "Triggering reconnection",
332 "reconnect_attempt",
333 None,
334 );
335 });
336 }
337
338 self.connection_state = ConnectionState::Connecting;
339 tracing::info!(url = %url, "Connecting to server...");
340
341 let transport_result = WebTransportBridge::connect(&url, cert_hash.as_deref()).await;
342
343 match transport_result {
344 Ok(transport) => {
345 if let Some(token) = &self.session_token {
347 let encoder = SerdeEncoder::new();
348 let auth_event = NetworkEvent::Auth {
349 session_token: token.clone(),
350 };
351
352 match encoder.encode_event(&auth_event) {
353 Ok(data) => {
354 if let Err(e) = transport.send_reliable(ClientId(0), &data).await {
355 self.connection_state = ConnectionState::Failed;
356 tracing::error!(error = ?e, "Handshake failed: could not send auth packet");
357 return Err(JsValue::from_str(&format!(
358 "Failed to send auth packet: {:?}",
359 e
360 )));
361 }
362 tracing::info!("Auth packet sent to server");
363 }
364 Err(e) => {
365 self.connection_state = ConnectionState::Failed;
366 tracing::error!(error = ?e, "Handshake failed: could not encode auth packet");
367 return Err(JsValue::from_str("Failed to encode auth packet"));
368 }
369 }
370 } else {
371 tracing::warn!(
372 "Connecting without session token! Server will likely discard data."
373 );
374 }
375
376 self.transport = Some(Box::new(transport));
377 self.connection_state = ConnectionState::InGame;
378 self.reconnect_attempts = 0;
379 tracing::info!("WebTransport connection established");
380 with_collector(|c| {
382 c.push_event(
383 1,
384 "transport",
385 &format!("WebTransport connected: {url}"),
386 "connect_handshake",
387 None,
388 );
389 });
390 Ok(())
391 }
392 Err(e) => {
393 self.connection_state = ConnectionState::Failed;
394 tracing::error!(error = ?e, "Failed to establish WebTransport connection");
395 with_collector(|c| {
397 c.push_event(
398 3,
399 "transport",
400 &format!("WebTransport failed: {url} — {e:?}"),
401 "connect_handshake_failed",
402 None,
403 );
404 });
405 Err(JsValue::from_str(&format!("failed to connect: {e:?}")))
406 }
407 }
408 }
409
410 #[wasm_bindgen]
411 pub async fn reconnect(
412 &mut self,
413 url: String,
414 cert_hash: Option<Vec<u8>>,
415 ) -> Result<(), JsValue> {
416 self.check_worker();
417 self.connection_state = ConnectionState::Reconnecting;
418 self.reconnect_attempts += 1;
419
420 tracing::info!(
421 "Attempting reconnection... (attempt {})",
422 self.reconnect_attempts
423 );
424
425 self.connect(url, cert_hash).await
426 }
427
428 #[wasm_bindgen]
429 pub async fn wasm_load_asset(
430 &mut self,
431 handle: assets::AssetHandle,
432 url: String,
433 ) -> Result<(), JsValue> {
434 self.asset_registry.load_asset(handle, &url).await
435 }
436
437 pub fn set_session_token(&mut self, token: String) {
439 self.session_token = Some(token);
440 }
441
442 pub async fn init_renderer(&mut self, canvas: JsValue) -> Result<(), JsValue> {
445 self.check_worker();
446 use wasm_bindgen::JsCast;
447
448 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
449 backends: wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL,
450 flags: wgpu::InstanceFlags::default(),
451 ..wgpu::InstanceDescriptor::new_without_display_handle()
452 });
453
454 let (surface_target, width, height) =
456 if let Ok(html_canvas) = canvas.clone().dyn_into::<web_sys::HtmlCanvasElement>() {
457 let width = html_canvas.width();
458 let height = html_canvas.height();
459 tracing::info!(
460 "Initializing renderer on HTMLCanvasElement ({}x{})",
461 width,
462 height
463 );
464 (wgpu::SurfaceTarget::Canvas(html_canvas), width, height)
465 } else if let Ok(offscreen_canvas) =
466 canvas.clone().dyn_into::<web_sys::OffscreenCanvas>()
467 {
468 let width = offscreen_canvas.width();
469 let height = offscreen_canvas.height();
470 tracing::info!(
471 "Initializing renderer on OffscreenCanvas ({}x{})",
472 width,
473 height
474 );
475
476 let _ = offscreen_canvas.get_context("webgpu").map_err(|e| {
479 JsValue::from_str(&format!("Failed to get webgpu context: {:?}", e))
480 })?;
481
482 (
483 wgpu::SurfaceTarget::OffscreenCanvas(offscreen_canvas),
484 width,
485 height,
486 )
487 } else {
488 return Err(JsValue::from_str(
489 "Aetheris: Provided object is not a valid Canvas or OffscreenCanvas",
490 ));
491 };
492
493 let surface = instance
494 .create_surface(surface_target)
495 .map_err(|e| JsValue::from_str(&format!("Failed to create surface: {:?}", e)))?;
496
497 let render_state = RenderState::new(&instance, surface, width, height)
498 .await
499 .map_err(|e| JsValue::from_str(&format!("Failed to init renderer: {:?}", e)))?;
500
501 self.render_state = Some(render_state);
502
503 with_collector(|c| {
505 c.push_event(
506 1,
507 "render_worker",
508 &format!("Renderer initialized ({}x{})", width, height),
509 "render_pipeline_setup",
510 None,
511 );
512 });
513
514 Ok(())
515 }
516
517 #[wasm_bindgen]
518 pub fn resize(&mut self, width: u32, height: u32) {
519 if let Some(state) = &mut self.render_state {
520 state.resize(width, height);
521 }
522 }
523
524 #[cfg(debug_assertions)]
525 #[wasm_bindgen]
526 pub fn set_debug_mode(&mut self, mode: u32) {
527 self.check_worker();
528 if let Some(state) = &mut self.render_state {
529 state.set_debug_mode(match mode {
530 0 => crate::render::DebugRenderMode::Off,
531 1 => crate::render::DebugRenderMode::Wireframe,
532 2 => crate::render::DebugRenderMode::Components,
533 _ => crate::render::DebugRenderMode::Full,
534 });
535 }
536 }
537
538 #[wasm_bindgen]
539 pub fn set_theme_colors(&mut self, bg_base: &str, text_primary: &str) {
540 self.check_worker();
541 let clear = crate::render::parse_css_color(bg_base);
542 let label = crate::render::parse_css_color(text_primary);
543
544 tracing::info!(
545 "Aetheris Client: Applying theme colors [bg: {} -> {:?}, text: {} -> {:?}]",
546 bg_base,
547 clear,
548 text_primary,
549 label
550 );
551
552 if let Some(state) = &mut self.render_state {
553 state.set_clear_color(clear);
554 #[cfg(debug_assertions)]
555 state.set_label_color([
556 label.r as f32,
557 label.g as f32,
558 label.b as f32,
559 label.a as f32,
560 ]);
561 }
562 }
563
564 #[cfg(debug_assertions)]
565 #[wasm_bindgen]
566 pub fn cycle_debug_mode(&mut self) {
567 if let Some(state) = &mut self.render_state {
568 state.cycle_debug_mode();
569 }
570 }
571
572 #[cfg(debug_assertions)]
573 #[wasm_bindgen]
574 pub fn toggle_grid(&mut self) {
575 if let Some(state) = &mut self.render_state {
576 state.toggle_grid();
577 }
578 }
579
580 #[wasm_bindgen]
581 pub fn latest_tick(&self) -> u64 {
582 self.world_state.latest_tick
583 }
584
585 #[wasm_bindgen]
586 pub fn playground_apply_input(&mut self, move_x: f32, move_y: f32, actions_mask: u32) {
587 self.check_worker();
588 self.world_state
589 .playground_apply_input(move_x, move_y, actions_mask);
590 }
591
592 pub async fn tick(&mut self) {
594 self.check_worker();
595 use aetheris_protocol::traits::{Encoder, WorldState};
596
597 let encoder = SerdeEncoder::new();
598
599 if let Some(_transport) = &self.transport {}
602
603 if let Some(transport) = &mut self.transport {
605 self.ping_counter = self.ping_counter.wrapping_add(1);
606 if self.ping_counter % 60 == 0 {
607 let now = performance_now();
609 let tick_u64 = now as u64;
610
611 if let Ok(data) = encoder.encode_event(&NetworkEvent::Ping {
612 client_id: ClientId(0), tick: tick_u64,
614 }) {
615 tracing::trace!(tick = tick_u64, "Sending Ping");
616 let _ = transport.send_unreliable(ClientId(0), &data).await;
617 }
618 }
619 }
620
621 if let Some(transport) = &mut self.transport {
623 let events = match transport.poll_events().await {
624 Ok(e) => e,
625 Err(e) => {
626 tracing::error!("Transport poll failure: {:?}", e);
627 return;
628 }
629 };
630 let mut updates: Vec<(ClientId, aetheris_protocol::events::ComponentUpdate)> =
631 Vec::new();
632
633 for event in events {
634 match event {
635 NetworkEvent::UnreliableMessage { data, client_id }
636 | NetworkEvent::ReliableMessage { data, client_id } => {
637 match encoder.decode(&data) {
638 Ok(update) => {
639 if self.last_clear_tick == 0
643 || update.tick > self.last_clear_tick
644 {
645 updates.push((client_id, update));
646 } else {
647 tracing::debug!(
648 network_id = update.network_id.0,
649 tick = update.tick,
650 last_clear_tick = self.last_clear_tick,
651 "Discarding stale update (tick <= last_clear_tick)"
652 );
653 }
654 }
655 Err(_) => {
656 if let Ok(event) = encoder.decode_event(&data) {
658 match event {
659 aetheris_protocol::events::NetworkEvent::GameEvent {
660 event: game_event,
661 ..
662 } => match &game_event {
663 aetheris_protocol::events::GameEvent::AsteroidDepleted {
664 network_id,
665 } => {
666 tracing::info!(?network_id, "Asteroid depleted");
667 self.world_state.entities.remove(&network_id);
668
669 for slot in self.world_state.entities.values_mut() {
670 if (slot.flags & 0x04) != 0
671 && slot.mining_target_id == (network_id.0 as u16)
672 {
673 slot.mining_active = 0;
674 slot.mining_target_id = 0;
675 tracing::info!("Cleared local mining target due to depletion");
676 }
677 }
678 }
679 aetheris_protocol::events::GameEvent::Possession {
680 network_id: _,
681 } => {
682 self.world_state.handle_game_event(&game_event);
683 }
684 aetheris_protocol::events::GameEvent::SystemManifest {
685 manifest,
686 } => {
687 tracing::info!(
688 count = manifest.len(),
689 "Received SystemManifest from server"
690 );
691 self.world_state.system_manifest = manifest.clone();
692 }
693 },
694 aetheris_protocol::events::NetworkEvent::ClearWorld {
695 ..
696 } => {
697 tracing::info!(
698 "Server ClearWorld ack received (via ReliableMessage) — gate lowered"
699 );
700 self.pending_clear = false;
701 }
702 _ => {}
703 }
704 } else {
705 tracing::warn!(
706 "Failed to decode server message as update or wire event"
707 );
708 }
709 }
710 }
711 }
712 NetworkEvent::ClientConnected(id) => {
713 tracing::info!(?id, "Server connected");
714 }
715 NetworkEvent::ClientDisconnected(id) => {
716 tracing::warn!(?id, "Server disconnected");
717 }
718 NetworkEvent::Disconnected(_id) => {
719 tracing::error!("Transport disconnected locally");
720 self.connection_state = ConnectionState::Disconnected;
721 }
722 NetworkEvent::Ping { client_id: _, tick } => {
723 let pong = NetworkEvent::Pong { tick };
725 if let Ok(data) = encoder.encode_event(&pong) {
726 let _ = transport.send_reliable(ClientId(0), &data).await;
727 }
728 }
729 NetworkEvent::Pong { tick } => {
730 let now = performance_now();
732 let rtt = now - (tick as f64);
733 self.last_rtt_ms = rtt;
734
735 with_collector(|c| {
736 c.update_rtt(rtt);
737 });
738
739 #[cfg(feature = "metrics")]
740 metrics::gauge!("aetheris_client_rtt_ms").set(rtt);
741
742 tracing::trace!(rtt_ms = rtt, tick, "Received Pong / RTT update");
743 }
744 NetworkEvent::Auth { .. } => {
745 tracing::debug!("Received Auth event from server (unexpected)");
747 }
748 NetworkEvent::SessionClosed(id) => {
749 tracing::warn!(?id, "WebTransport session closed");
750 }
751 NetworkEvent::StreamReset(id) => {
752 tracing::error!(?id, "WebTransport stream reset");
753 }
754 NetworkEvent::ReplicationBatch { events, client_id } => {
755 for event in events {
756 if self.last_clear_tick == 0 || event.tick > self.last_clear_tick {
757 updates.push((
758 client_id,
759 aetheris_protocol::events::ComponentUpdate {
760 network_id: event.network_id,
761 component_kind: event.component_kind,
762 payload: event.payload,
763 tick: event.tick,
764 },
765 ));
766 }
767 }
768 }
769 NetworkEvent::Fragment {
770 client_id,
771 fragment,
772 } => {
773 if let Some(data) = self.reassembler.ingest(client_id, fragment) {
774 if let Ok(update) = encoder.decode(&data) {
775 if self.last_clear_tick == 0
776 || update.tick > self.last_clear_tick
777 {
778 updates.push((client_id, update));
779 }
780 }
781 }
782 }
783 NetworkEvent::StressTest { .. } => {
784 }
787 NetworkEvent::Spawn { .. } => {
788 }
790 NetworkEvent::ClearWorld { .. } => {
791 tracing::info!("Server ClearWorld ack received — gate lowered");
796 self.pending_clear = false;
797 }
798 NetworkEvent::GameEvent {
799 event: game_event, ..
800 } => {
801 match &game_event {
804 aetheris_protocol::events::GameEvent::AsteroidDepleted {
805 network_id,
806 } => {
807 tracing::info!(
808 ?network_id,
809 "Asteroid depleted (via GameEvent)"
810 );
811 self.world_state.entities.remove(&network_id);
813
814 for slot in self.world_state.entities.values_mut() {
816 if (slot.flags & 0x04) != 0
818 && slot.mining_target_id == (network_id.0 as u16)
819 {
820 slot.mining_active = 0;
821 slot.mining_target_id = 0;
822 tracing::info!(
823 "Cleared local mining target due to depletion"
824 );
825 }
826 }
827 }
828 aetheris_protocol::events::GameEvent::SystemManifest {
829 manifest,
830 } => {
831 tracing::info!(
832 count = manifest.len(),
833 "Received SystemManifest from server (via GameEvent)"
834 );
835 self.world_state.system_manifest = manifest.clone();
836 }
837 aetheris_protocol::events::GameEvent::Possession {
838 network_id: _,
839 } => {
840 self.world_state.handle_game_event(&game_event);
841 }
842 }
843 }
844 #[allow(unreachable_patterns)]
845 _ => {
846 tracing::debug!("Unhandled outer NetworkEvent variant");
847 }
848 }
849 }
850
851 if self.pending_clear {
855 if !updates.is_empty() {
856 tracing::debug!(
857 count = updates.len(),
858 "Discarding updates — pending_clear gate is raised"
859 );
860 }
861 } else {
862 if !updates.is_empty() {
863 tracing::debug!(count = updates.len(), "Applying server updates to world");
864 }
865 self.world_state.apply_updates(&updates);
866 }
867 }
868
869 if self.playground_rotation_enabled {
871 for slot in self.world_state.entities.values_mut() {
872 slot.rotation = (slot.rotation + 0.05) % std::f32::consts::TAU;
873 }
874 }
875
876 self.world_state.latest_tick += 1;
879
880 let sim_start = crate::performance_now();
882
883 self.flush_to_shared_world(self.world_state.latest_tick);
885
886 let sim_time_ms = crate::performance_now() - sim_start;
887 let count = self.world_state.entities.len() as u32;
888 with_collector(|c| {
889 c.record_sim(sim_time_ms);
890 c.update_entity_count(count);
891 });
892 }
893
894 fn flush_to_shared_world(&mut self, tick: u64) {
895 let entities = &self.world_state.entities;
896 let write_buffer = self.shared_world.get_write_buffer();
897
898 let mut count = 0;
899 for (i, slot) in entities.values().enumerate() {
900 if i >= MAX_ENTITIES {
901 tracing::warn!("Max entities reached in shared world! Overflow suppressed.");
902 break;
903 }
904 write_buffer[i] = *slot;
905 count += 1;
906 }
907
908 tracing::debug!(entity_count = count, tick, "Flushed world to SAB");
909 self.shared_world.commit_write(count as u32, tick);
910 }
911
912 #[wasm_bindgen]
913 pub async fn request_system_manifest(&mut self) -> Result<(), JsValue> {
914 self.check_worker();
915
916 if let Some(transport) = &self.transport {
917 let encoder = SerdeEncoder::new();
918 let event = NetworkEvent::RequestSystemManifest {
919 client_id: ClientId(0),
920 };
921
922 if let Ok(data) = encoder.encode_event(&event) {
923 transport
924 .send_reliable(ClientId(0), &data)
925 .await
926 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
927 tracing::info!("Sent RequestSystemManifest command to server");
928 }
929 }
930 Ok(())
931 }
932
933 #[wasm_bindgen]
934 pub fn get_system_info(&self) -> Result<JsValue, JsValue> {
935 serde_wasm_bindgen::to_value(&self.world_state.system_manifest)
936 .map_err(|e| JsValue::from_str(&e.to_string()))
937 }
938
939 #[wasm_bindgen]
940 pub fn playground_spawn(&mut self, entity_type: u16, x: f32, y: f32, rotation: f32) {
941 if self.world_state.entities.len() >= MAX_ENTITIES {
943 tracing::warn!("playground_spawn: MAX_ENTITIES reached, spawn ignored.");
944 return;
945 }
946
947 if self.playground_next_network_id == 1 && !self.world_state.entities.is_empty() {
949 self.playground_next_network_id = self
950 .world_state
951 .entities
952 .keys()
953 .map(|k| k.0)
954 .max()
955 .unwrap_or(0)
956 + 1;
957 }
958
959 let id = aetheris_protocol::types::NetworkId(self.playground_next_network_id);
960 self.playground_next_network_id += 1;
961 let slot = SabSlot {
962 network_id: id.0,
963 x,
964 y,
965 z: 0.0,
966 rotation,
967 dx: 0.0,
968 dy: 0.0,
969 dz: 0.0,
970 hp: 100,
971 shield: 100,
972 entity_type,
973 flags: 0x01, mining_active: 0,
975 cargo_ore: 0,
976 mining_target_id: 0,
977 };
978 self.world_state.entities.insert(id, slot);
979 }
980
981 #[wasm_bindgen]
982 pub async fn playground_spawn_net(
983 &mut self,
984 entity_type: u16,
985 x: f32,
986 y: f32,
987 rot: f32,
988 ) -> Result<(), JsValue> {
989 self.check_worker();
990
991 if let Some(transport) = &self.transport {
992 let encoder = SerdeEncoder::new();
993 let event = NetworkEvent::Spawn {
994 client_id: ClientId(0),
995 entity_type,
996 x,
997 y,
998 rot,
999 };
1000
1001 if let Ok(data) = encoder.encode_event(&event) {
1002 transport
1003 .send_reliable(ClientId(0), &data)
1004 .await
1005 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1006 tracing::info!(entity_type, x, y, "Sent Spawn command to server");
1007 }
1008 } else {
1009 self.playground_spawn(entity_type, x, y, rot);
1011 }
1012 Ok(())
1013 }
1014
1015 #[wasm_bindgen]
1016 pub fn playground_clear(&mut self) {
1017 self.world_state.entities.clear();
1018 }
1019
1020 #[wasm_bindgen]
1024 pub async fn start_session_net(&mut self) -> Result<(), JsValue> {
1025 self.check_worker();
1026
1027 if let Some(transport) = &self.transport {
1028 let encoder = SerdeEncoder::new();
1029 let event = NetworkEvent::StartSession {
1030 client_id: ClientId(0),
1031 };
1032 if let Ok(data) = encoder.encode_event(&event) {
1033 transport
1034 .send_reliable(ClientId(0), &data)
1035 .await
1036 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1037 tracing::info!("Sent StartSession command to server");
1038 }
1039 }
1040 Ok(())
1041 }
1042
1043 #[wasm_bindgen]
1048 pub async fn send_input(
1049 &mut self,
1050 tick: u64,
1051 move_x: f32,
1052 move_y: f32,
1053 actions_mask: u32,
1054 target_id_arg: Option<u64>,
1055 ) -> Result<(), JsValue> {
1056 self.check_worker();
1057
1058 let target_id = if let Some(owned_id) = self.world_state.player_network_id {
1060 tracing::info!(
1063 network_id = owned_id.0,
1064 "[send_input] Using player_network_id for input target"
1065 );
1066 Some(owned_id)
1067 } else {
1068 let fallback = self
1070 .world_state
1071 .entities
1072 .iter()
1073 .find(|(_, slot)| (slot.flags & 0x04) != 0)
1074 .map(|(id, _)| *id);
1075 tracing::trace!(
1076 fallback_id = ?fallback,
1077 "[send_input] No player_network_id set - using 0x04 flag fallback"
1078 );
1079 fallback
1080 };
1081
1082 let Some(target_id) = target_id else {
1083 tracing::trace!("[send_input] Input dropped: no controlled entity found");
1084 return Ok(());
1085 };
1086
1087 tracing::info!(
1088 target_id = target_id.0,
1089 move_x,
1090 move_y,
1091 "[send_input] Sending input for entity"
1092 );
1093
1094 let mut actions = Vec::new();
1096
1097 if move_x.abs() > f32::EPSILON || move_y.abs() > f32::EPSILON {
1099 actions.push(PlayerInputKind::Move {
1100 x: move_x,
1101 y: move_y,
1102 });
1103 }
1104
1105 if (actions_mask & 0x01) != 0 {
1108 actions.push(PlayerInputKind::FirePrimary);
1109 }
1110 if (actions_mask & 0x02) != 0 {
1112 if let Some(id) = target_id_arg {
1113 actions.push(PlayerInputKind::ToggleMining {
1114 target: NetworkId(id),
1115 });
1116 } else {
1117 tracing::warn!("ToggleMining requested without target_id; dropping action");
1118 }
1119 }
1120
1121 let is_repeated = self.last_input_actions.len() == actions.len()
1123 && self
1124 .last_input_actions
1125 .iter()
1126 .zip(actions.iter())
1127 .all(|(a, b)| a == b)
1128 && self.last_input_target == Some(target_id);
1129
1130 if move_x.abs() > f32::EPSILON || move_y.abs() > f32::EPSILON || actions_mask != 0 {
1131 if is_repeated {
1132 tracing::trace!(
1133 tick,
1134 move_x,
1135 move_y,
1136 actions_mask,
1137 "Client sending input (repeated)"
1138 );
1139 } else {
1140 tracing::info!(tick, move_x, move_y, actions_mask, "Client sending input");
1141 }
1142 }
1143
1144 let transport = self.transport.as_ref().ok_or_else(|| {
1145 JsValue::from_str("Cannot send input: transport not initialized or closed")
1146 })?;
1147
1148 if is_repeated {
1149 tracing::trace!(
1150 ?target_id,
1151 x = move_x,
1152 y = move_y,
1153 "Sending InputCommand (repeated)"
1154 );
1155 } else {
1156 tracing::info!(?target_id, x = move_x, y = move_y, "Sending InputCommand");
1157 }
1158
1159 self.last_input_target = Some(target_id);
1161 self.last_input_actions = actions.clone();
1162
1163 let cmd = InputCommand {
1164 tick,
1165 actions,
1166 last_seen_input_tick: None,
1167 }
1168 .clamped();
1169
1170 let payload = rmp_serde::to_vec(&cmd)
1174 .map_err(|e| JsValue::from_str(&format!("Failed to encode InputCommand: {e:?}")))?;
1175
1176 let update = ReplicationEvent {
1177 network_id: target_id,
1178 component_kind: ComponentKind(128),
1179 payload,
1180 tick,
1181 };
1182
1183 let mut buffer = [0u8; 1024];
1184 let encoder = SerdeEncoder::new();
1185 let len = encoder.encode(&update, &mut buffer).map_err(|e| {
1186 JsValue::from_str(&format!("Failed to encode input replication event: {e:?}"))
1187 })?;
1188
1189 transport
1191 .send_unreliable(ClientId(0), &buffer[..len])
1192 .await
1193 .map_err(|e| {
1194 JsValue::from_str(&format!("Transport error during send_input: {e:?}"))
1195 })?;
1196
1197 Ok(())
1198 }
1199
1200 #[wasm_bindgen]
1201 pub async fn playground_clear_server(&mut self) -> Result<(), JsValue> {
1202 self.check_worker();
1203
1204 if let Some(transport) = &self.transport {
1205 let encoder = SerdeEncoder::new();
1206 let event = NetworkEvent::ClearWorld {
1207 client_id: ClientId(0),
1208 };
1209 if let Ok(data) = encoder.encode_event(&event) {
1210 transport
1211 .send_reliable(ClientId(0), &data)
1212 .await
1213 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1214 tracing::info!(
1215 "Sent ClearWorld command to server — suppressing updates until ack"
1216 );
1217 self.world_state.entities.clear();
1222 self.world_state.player_network_id = None;
1223 self.pending_clear = true;
1224 self.last_clear_tick = self.world_state.latest_tick;
1225 }
1226 } else {
1227 self.world_state.entities.clear();
1229 self.world_state.player_network_id = None;
1230 }
1231 Ok(())
1232 }
1233
1234 #[wasm_bindgen]
1235 pub fn playground_set_rotation_enabled(&mut self, enabled: bool) {
1236 self.playground_rotation_enabled = enabled;
1237 }
1240
1241 #[wasm_bindgen]
1242 pub async fn playground_stress_test(
1243 &mut self,
1244 count: u16,
1245 rotate: bool,
1246 ) -> Result<(), JsValue> {
1247 self.check_worker();
1248
1249 if let Some(transport) = &self.transport {
1250 let encoder = SerdeEncoder::new();
1251 let event = NetworkEvent::StressTest {
1252 client_id: ClientId(0),
1253 count,
1254 rotate,
1255 };
1256
1257 if let Ok(data) = encoder.encode_event(&event) {
1258 transport
1259 .send_reliable(ClientId(0), &data)
1260 .await
1261 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1262 tracing::info!(count, rotate, "Sent StressTest command to server");
1263 }
1264 self.playground_set_rotation_enabled(rotate);
1265 } else {
1266 self.playground_set_rotation_enabled(rotate);
1268 self.playground_clear();
1269 for _ in 0..count {
1270 self.playground_spawn(1, 0.0, 0.0, 0.0); }
1272 }
1273
1274 Ok(())
1275 }
1276
1277 #[wasm_bindgen]
1278 pub async fn tick_playground(&mut self) {
1279 self.check_worker();
1280
1281 if self.first_playground_tick {
1283 self.first_playground_tick = false;
1284 with_collector(|c| {
1285 c.push_event(
1286 1,
1287 "wasm_client",
1288 "Playground simulation loop started",
1289 "tick_playground_loop_start",
1290 None,
1291 );
1292 });
1293 }
1294
1295 let sim_start = crate::performance_now();
1297 self.world_state.latest_tick += 1;
1298
1299 if self.playground_rotation_enabled {
1300 for slot in self.world_state.entities.values_mut() {
1301 slot.rotation = (slot.rotation + 0.05) % std::f32::consts::TAU;
1302 }
1303 }
1304
1305 let count = self.world_state.entities.len() as u32;
1307 self.flush_to_shared_world(self.world_state.latest_tick);
1308
1309 let sim_time_ms = crate::performance_now() - sim_start;
1310 with_collector(|c| {
1311 c.record_sim(sim_time_ms);
1312 c.update_entity_count(count);
1313 });
1314 }
1315
1316 pub fn render(&mut self) -> f64 {
1318 self.check_worker();
1319
1320 let tick = self.shared_world.tick();
1321 let entities = self.shared_world.get_read_buffer();
1322 let bounds = self.shared_world.get_room_bounds();
1323 if let Some(state) = &mut self.render_state {
1324 state.set_room_bounds(bounds);
1325 }
1326
1327 thread_local! {
1329 static FRAME_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
1330 }
1331 FRAME_COUNT.with(|count| {
1332 let current = count.get();
1333 if current % 300 == 0 {
1334 tracing::debug!(
1335 "Aetheris Render Stats: Tick={}, Entities={}, Snapshots={}",
1336 tick,
1337 entities.len(),
1338 self.snapshots.len(),
1339 );
1340 }
1341 count.set(current + 1);
1342 });
1343
1344 if tick == 0 {
1345 let mut frame_time_ms = 0.0;
1347 if let Some(state) = &mut self.render_state {
1348 frame_time_ms = state.render_frame_with_compact_slots(&[]);
1349 with_collector(|c| {
1350 c.record_frame(frame_time_ms, 0.0);
1353 });
1354 }
1355 return frame_time_ms;
1356 }
1357
1358 if self.snapshots.is_empty()
1360 || tick > self.snapshots.back().map(|s| s.tick).unwrap_or(0)
1361 {
1362 self.snapshots.push_back(SimulationSnapshot {
1363 tick,
1364 entities: entities.to_vec(),
1365 });
1366 }
1367
1368 let mut frame_time_ms = 0.0;
1372 if !self.snapshots.is_empty() {
1373 let latest_tick = self.snapshots.back().unwrap().tick as f32;
1374 let target_tick = latest_tick - 2.0;
1375 frame_time_ms = self.render_at_tick(target_tick);
1376 }
1377
1378 let snap_count = self.snapshots.len() as u32;
1380 with_collector(|c| {
1381 c.record_frame(frame_time_ms, 0.0);
1383 c.update_snapshot_count(snap_count);
1384 });
1385
1386 frame_time_ms
1387 }
1388
1389 fn render_at_tick(&mut self, target_tick: f32) -> f64 {
1390 if self.snapshots.len() < 2 {
1391 if let Some(state) = &mut self.render_state {
1394 let entities = if !self.snapshots.is_empty() {
1395 self.snapshots[0].entities.clone()
1396 } else {
1397 Vec::new()
1398 };
1399 return state.render_frame_with_compact_slots(&entities);
1400 }
1401 return 0.0;
1402 }
1403
1404 let mut s1_idx = 0;
1406 let mut found = false;
1407
1408 for i in 0..self.snapshots.len() - 1 {
1409 if (self.snapshots[i].tick as f32) <= target_tick
1410 && (self.snapshots[i + 1].tick as f32) > target_tick
1411 {
1412 s1_idx = i;
1413 found = true;
1414 break;
1415 }
1416 }
1417
1418 if !found {
1419 if target_tick < self.snapshots[0].tick as f32 {
1421 s1_idx = 0;
1422 } else {
1423 s1_idx = self.snapshots.len() - 2;
1424 }
1425 }
1426
1427 let s1 = self.snapshots.get(s1_idx).unwrap();
1428 let s2 = self.snapshots.get(s1_idx + 1).unwrap();
1429
1430 let tick_range = (s2.tick - s1.tick) as f32;
1431 let alpha = if tick_range > 0.0 {
1432 (target_tick - s1.tick as f32) / tick_range
1433 } else {
1434 1.0
1435 }
1436 .clamp(0.0, 1.0);
1437
1438 self.render_buffer.clear();
1440 self.render_buffer.extend_from_slice(&s2.entities);
1441
1442 let prev_map: std::collections::HashMap<u64, &SabSlot> =
1444 s1.entities.iter().map(|e| (e.network_id, e)).collect();
1445
1446 for ent in &mut self.render_buffer {
1447 if let Some(prev) = prev_map.get(&ent.network_id).copied() {
1448 ent.x = lerp(prev.x, ent.x, alpha);
1449 ent.y = lerp(prev.y, ent.y, alpha);
1450 ent.z = lerp(prev.z, ent.z, alpha);
1451 ent.rotation = lerp_rotation(prev.rotation, ent.rotation, alpha);
1452 }
1453 }
1454
1455 let mut frame_time = 0.0;
1456 if let Some(state) = &mut self.render_state {
1457 frame_time = state.render_frame_with_compact_slots(&self.render_buffer);
1458 }
1459
1460 while self.snapshots.len() > 2 && (self.snapshots[0].tick as f32) < target_tick - 1.0 {
1464 self.snapshots.pop_front();
1465 }
1466
1467 while self.snapshots.len() > 16 {
1469 self.snapshots.pop_front();
1470 }
1471
1472 frame_time
1473 }
1474 }
1475
1476 fn lerp(a: f32, b: f32, alpha: f32) -> f32 {
1477 a + (b - a) * alpha
1478 }
1479
1480 fn lerp_rotation(a: f32, b: f32, alpha: f32) -> f32 {
1481 let mut diff = b - a;
1484 while diff < -std::f32::consts::PI {
1485 diff += std::f32::consts::TAU;
1486 }
1487 while diff > std::f32::consts::PI {
1488 diff -= std::f32::consts::TAU;
1489 }
1490 a + diff * alpha
1491 }
1492}