Skip to main content

aetheris_client_wasm/
lib.rs

1//! Aetheris WASM client logic.
2//!
3//! This crate implements the browser-based client for the Aetheris Engine,
4//! using `WebWorkers` for multi-threaded execution and `WebGPU` for rendering.
5
6#![warn(clippy::all, clippy::pedantic)]
7// Required to declare `#[thread_local]` statics on nightly (wasm32 target).
8// This feature gate is only active when compiling for WASM — see the
9// `_TLS_ANCHOR` declaration below.
10#![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
26/// Protobuf message types generated from `auth.proto` (prost only — no service stubs).
27/// Used by `auth.rs` so it doesn't need the `aetheris-protocol` `grpc` feature,
28/// which would pull in `tonic::transport` and hence `mio` (incompatible with wasm32).
29pub 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(target_arch = "wasm32")]
38pub mod render;
39
40#[cfg(target_arch = "wasm32")]
41pub mod render_primitives;
42
43#[cfg(target_arch = "wasm32")]
44#[cfg_attr(feature = "nightly", thread_local)]
45static _TLS_ANCHOR: u8 = 0;
46
47use std::sync::atomic::AtomicUsize;
48#[cfg(target_arch = "wasm32")]
49use std::sync::atomic::Ordering;
50
51#[allow(dead_code)]
52static NEXT_WORKER_ID: AtomicUsize = AtomicUsize::new(1);
53
54#[cfg(target_arch = "wasm32")]
55thread_local! {
56    static WORKER_ID: usize = NEXT_WORKER_ID.fetch_add(1, Ordering::Relaxed);
57}
58
59/// Helper to get `performance.now()` in both Window and Worker contexts.
60#[must_use]
61pub fn performance_now() -> f64 {
62    #[cfg(target_arch = "wasm32")]
63    {
64        use wasm_bindgen::JsCast;
65        let global = js_sys::global();
66
67        // Try WorkerGlobalScope first
68        if let Ok(worker) = global.clone().dyn_into::<web_sys::WorkerGlobalScope>() {
69            return worker.performance().map(|p| p.now()).unwrap_or(0.0);
70        }
71
72        // Try Window
73        if let Ok(window) = global.dyn_into::<web_sys::Window>() {
74            return window.performance().map(|p| p.now()).unwrap_or(0.0);
75        }
76
77        // Fallback to Date
78        js_sys::Date::now()
79    }
80    #[cfg(not(target_arch = "wasm32"))]
81    {
82        0.0
83    }
84}
85
86#[allow(dead_code)]
87pub(crate) fn get_worker_id() -> usize {
88    #[cfg(target_arch = "wasm32")]
89    {
90        WORKER_ID.with(|&id| id)
91    }
92    #[cfg(not(target_arch = "wasm32"))]
93    {
94        0
95    }
96}
97
98#[cfg(target_arch = "wasm32")]
99mod wasm_impl {
100    use crate::metrics::with_collector;
101    use crate::performance_now;
102    use crate::render::RenderState;
103    use crate::shared_world::{MAX_ENTITIES, SabSlot, SharedWorld};
104    use crate::transport::WebTransportBridge;
105    use crate::world_state::ClientWorld;
106    use aetheris_encoder_serde::SerdeEncoder;
107    use aetheris_protocol::events::NetworkEvent;
108    use aetheris_protocol::traits::GameTransport;
109    use aetheris_protocol::types::ClientId;
110    use wasm_bindgen::prelude::*;
111
112    #[wasm_bindgen]
113    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
114    pub enum ConnectionState {
115        Disconnected,
116        Connecting,
117        InGame,
118        Reconnecting,
119        Failed,
120    }
121
122    /// A snapshot of the world for interpolation.
123    #[derive(Clone)]
124    pub struct SimulationSnapshot {
125        pub tick: u64,
126        pub entities: Vec<SabSlot>,
127    }
128
129    /// Global state held by the WASM instance.
130    #[wasm_bindgen]
131    pub struct AetherisClient {
132        shared_world: SharedWorld,
133        world_state: ClientWorld,
134        render_state: Option<RenderState>,
135        transport: Option<WebTransportBridge>,
136        worker_id: usize,
137        session_token: Option<String>,
138
139        // Interpolation state (Render Worker only)
140        snapshots: std::collections::VecDeque<SimulationSnapshot>,
141
142        // Network metrics
143        last_rtt_ms: f64,
144        ping_counter: u64,
145
146        reassembler: aetheris_protocol::Reassembler,
147        connection_state: ConnectionState,
148        reconnect_attempts: u32,
149        playground_rotation_enabled: bool,
150        playground_next_network_id: u64,
151        first_playground_tick: bool,
152
153        // Reusable buffer for zero-allocation rendering (Render Worker only)
154        render_buffer: Vec<SabSlot>,
155    }
156
157    #[wasm_bindgen]
158    impl AetherisClient {
159        /// Creates a new AetherisClient instance.
160        /// If a pointer is provided, it will use it as the backing storage (shared memory).
161        #[wasm_bindgen(constructor)]
162        pub fn new(shared_world_ptr: Option<u32>) -> Result<AetherisClient, JsValue> {
163            console_error_panic_hook::set_once();
164
165            // tracing_wasm doesn't have a clean try_init for global default.
166            // We use a static atomic to ensure only the first worker sets the global default.
167            use std::sync::atomic::{AtomicBool, Ordering};
168            static LOGGER_INITIALIZED: AtomicBool = AtomicBool::new(false);
169            if !LOGGER_INITIALIZED.swap(true, Ordering::SeqCst) {
170                let config = tracing_wasm::WASMLayerConfigBuilder::new()
171                    .set_max_level(tracing::Level::INFO)
172                    .build();
173                tracing_wasm::set_as_global_default_with_config(config);
174            }
175
176            let shared_world = if let Some(ptr_val) = shared_world_ptr {
177                let ptr = ptr_val as *mut u8;
178
179                // Security: Validate the incoming pointer before use
180                if ptr_val == 0 || !ptr_val.is_multiple_of(8) {
181                    return Err(JsValue::from_str(
182                        "Invalid shared_world_ptr: null or unaligned",
183                    ));
184                }
185
186                // JS-allocated SharedArrayBuffer pointers are not in the Rust registry
187                // (only Rust-owned allocations are registered). The null/alignment checks
188                // above are the only feasible boundary validation for externally-provided
189                // pointers; trusting the caller is required by the SAB contract.
190                unsafe { SharedWorld::from_ptr(ptr) }
191            } else {
192                SharedWorld::new()
193            };
194
195            tracing::info!(
196                "AetherisClient initialized on worker {}",
197                crate::get_worker_id()
198            );
199
200            // M10105 — emit wasm_init lifecycle span
201            with_collector(|c| {
202                c.push_event(
203                    1,
204                    "wasm_client",
205                    "AetherisClient initialized",
206                    "wasm_init",
207                    None,
208                );
209            });
210
211            Ok(Self {
212                shared_world,
213                world_state: ClientWorld::new(),
214                render_state: None,
215                transport: None,
216                worker_id: crate::get_worker_id(),
217                session_token: None,
218                snapshots: std::collections::VecDeque::with_capacity(8),
219                last_rtt_ms: 0.0,
220                ping_counter: 0,
221                reassembler: aetheris_protocol::Reassembler::new(),
222                connection_state: ConnectionState::Disconnected,
223                reconnect_attempts: 0,
224                playground_rotation_enabled: false,
225                playground_next_network_id: 1,
226                first_playground_tick: true,
227                render_buffer: Vec::with_capacity(crate::shared_world::MAX_ENTITIES),
228            })
229        }
230
231        #[wasm_bindgen]
232        pub async fn request_otp(base_url: String, email: String) -> Result<String, String> {
233            crate::auth::request_otp(base_url, email).await
234        }
235
236        #[wasm_bindgen]
237        pub async fn login_with_otp(
238            base_url: String,
239            request_id: String,
240            code: String,
241        ) -> Result<String, String> {
242            crate::auth::login_with_otp(base_url, request_id, code).await
243        }
244
245        #[wasm_bindgen]
246        pub async fn logout(base_url: String, session_token: String) -> Result<(), String> {
247            crate::auth::logout(base_url, session_token).await
248        }
249
250        #[wasm_bindgen(getter)]
251        pub fn connection_state(&self) -> ConnectionState {
252            self.connection_state
253        }
254
255        fn check_worker(&self) {
256            debug_assert_eq!(
257                self.worker_id,
258                crate::get_worker_id(),
259                "AetherisClient accessed from wrong worker! It is pin-bound to its creating thread."
260            );
261        }
262
263        /// Returns the raw pointer to the shared world buffer.
264        pub fn shared_world_ptr(&self) -> u32 {
265            self.shared_world.as_ptr() as u32
266        }
267
268        pub async fn connect(
269            &mut self,
270            url: String,
271            cert_hash: Option<Vec<u8>>,
272        ) -> Result<(), JsValue> {
273            self.check_worker();
274
275            if self.connection_state == ConnectionState::Connecting
276                || self.connection_state == ConnectionState::InGame
277                || self.connection_state == ConnectionState::Reconnecting
278            {
279                return Ok(());
280            }
281
282            // M10105 — emit reconnect_attempt if it looks like one
283            if self.connection_state == ConnectionState::Failed && self.reconnect_attempts > 0 {
284                with_collector(|c| {
285                    c.push_event(
286                        2,
287                        "transport",
288                        "Triggering reconnection",
289                        "reconnect_attempt",
290                        None,
291                    );
292                });
293            }
294
295            self.connection_state = ConnectionState::Connecting;
296            tracing::info!(url = %url, "Connecting to server...");
297
298            let transport_result = WebTransportBridge::connect(&url, cert_hash.as_deref()).await;
299
300            match transport_result {
301                Ok(transport) => {
302                    // Security: Send Auth message immediately after connection
303                    if let Some(token) = &self.session_token {
304                        let encoder = SerdeEncoder::new();
305                        let auth_event = NetworkEvent::Auth {
306                            session_token: token.clone(),
307                        };
308
309                        match encoder.encode_event(&auth_event) {
310                            Ok(data) => {
311                                if let Err(e) = transport.send_reliable(ClientId(0), &data).await {
312                                    self.connection_state = ConnectionState::Failed;
313                                    tracing::error!(error = ?e, "Handshake failed: could not send auth packet");
314                                    return Err(JsValue::from_str(&format!(
315                                        "Failed to send auth packet: {:?}",
316                                        e
317                                    )));
318                                }
319                                tracing::info!("Auth packet sent to server");
320                            }
321                            Err(e) => {
322                                self.connection_state = ConnectionState::Failed;
323                                tracing::error!(error = ?e, "Handshake failed: could not encode auth packet");
324                                return Err(JsValue::from_str("Failed to encode auth packet"));
325                            }
326                        }
327                    } else {
328                        tracing::warn!(
329                            "Connecting without session token! Server will likely discard data."
330                        );
331                    }
332
333                    self.transport = Some(transport);
334                    self.connection_state = ConnectionState::InGame;
335                    self.reconnect_attempts = 0;
336                    tracing::info!("WebTransport connection established");
337                    // M10105 — connect_handshake lifecycle span
338                    with_collector(|c| {
339                        c.push_event(
340                            1,
341                            "transport",
342                            &format!("WebTransport connected: {url}"),
343                            "connect_handshake",
344                            None,
345                        );
346                    });
347                    Ok(())
348                }
349                Err(e) => {
350                    self.connection_state = ConnectionState::Failed;
351                    tracing::error!(error = ?e, "Failed to establish WebTransport connection");
352                    // M10105 — connect_handshake_failed lifecycle span (ERROR level)
353                    with_collector(|c| {
354                        c.push_event(
355                            3,
356                            "transport",
357                            &format!("WebTransport failed: {url} — {e:?}"),
358                            "connect_handshake_failed",
359                            None,
360                        );
361                    });
362                    Err(JsValue::from_str(&format!("failed to connect: {e:?}")))
363                }
364            }
365        }
366
367        /// Sets the session token to be used for authentication upon connection.
368        pub fn set_session_token(&mut self, token: String) {
369            self.session_token = Some(token);
370        }
371
372        /// Initializes rendering with a canvas element.
373        /// Accepts either web_sys::HtmlCanvasElement or web_sys::OffscreenCanvas.
374        pub async fn init_renderer(&mut self, canvas: JsValue) -> Result<(), JsValue> {
375            self.check_worker();
376            use wasm_bindgen::JsCast;
377
378            let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
379                backends: wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL,
380                flags: wgpu::InstanceFlags::default(),
381                ..wgpu::InstanceDescriptor::new_without_display_handle()
382            });
383
384            // Handle both HtmlCanvasElement and OffscreenCanvas for Worker support
385            let (surface_target, width, height) =
386                if let Ok(html_canvas) = canvas.clone().dyn_into::<web_sys::HtmlCanvasElement>() {
387                    let width = html_canvas.width();
388                    let height = html_canvas.height();
389                    tracing::info!(
390                        "Initializing renderer on HTMLCanvasElement ({}x{})",
391                        width,
392                        height
393                    );
394                    (wgpu::SurfaceTarget::Canvas(html_canvas), width, height)
395                } else if let Ok(offscreen_canvas) =
396                    canvas.clone().dyn_into::<web_sys::OffscreenCanvas>()
397                {
398                    let width = offscreen_canvas.width();
399                    let height = offscreen_canvas.height();
400                    tracing::info!(
401                        "Initializing renderer on OffscreenCanvas ({}x{})",
402                        width,
403                        height
404                    );
405
406                    // Critical fix for wgpu 0.20+ on WASM workers:
407                    // Ensure the context is initialized with the 'webgpu' id before creating the surface.
408                    let _ = offscreen_canvas.get_context("webgpu").map_err(|e| {
409                        JsValue::from_str(&format!("Failed to get webgpu context: {:?}", e))
410                    })?;
411
412                    (
413                        wgpu::SurfaceTarget::OffscreenCanvas(offscreen_canvas),
414                        width,
415                        height,
416                    )
417                } else {
418                    return Err(JsValue::from_str(
419                        "Aetheris: Provided object is not a valid Canvas or OffscreenCanvas",
420                    ));
421                };
422
423            let surface = instance
424                .create_surface(surface_target)
425                .map_err(|e| JsValue::from_str(&format!("Failed to create surface: {:?}", e)))?;
426
427            let render_state = RenderState::new(&instance, surface, width, height)
428                .await
429                .map_err(|e| JsValue::from_str(&format!("Failed to init renderer: {:?}", e)))?;
430
431            self.render_state = Some(render_state);
432
433            // M10105 — emit render_pipeline_setup lifecycle span
434            with_collector(|c| {
435                c.push_event(
436                    1,
437                    "render_worker",
438                    &format!("Renderer initialized ({}x{})", width, height),
439                    "render_pipeline_setup",
440                    None,
441                );
442            });
443
444            Ok(())
445        }
446
447        #[wasm_bindgen]
448        pub fn resize(&mut self, width: u32, height: u32) {
449            if let Some(state) = &mut self.render_state {
450                state.resize(width, height);
451            }
452        }
453
454        #[cfg(debug_assertions)]
455        #[wasm_bindgen]
456        pub fn set_debug_mode(&mut self, mode: u32) {
457            self.check_worker();
458            if let Some(state) = &mut self.render_state {
459                state.set_debug_mode(match mode {
460                    0 => crate::render::DebugRenderMode::Off,
461                    1 => crate::render::DebugRenderMode::Wireframe,
462                    2 => crate::render::DebugRenderMode::Components,
463                    _ => crate::render::DebugRenderMode::Full,
464                });
465            }
466        }
467
468        #[wasm_bindgen]
469        pub fn set_theme_colors(&mut self, bg_base: &str, text_primary: &str) {
470            self.check_worker();
471            let clear = crate::render::parse_css_color(bg_base);
472            let label = crate::render::parse_css_color(text_primary);
473
474            tracing::info!(
475                "Aetheris Client: Applying theme colors [bg: {} -> {:?}, text: {} -> {:?}]",
476                bg_base,
477                clear,
478                text_primary,
479                label
480            );
481
482            if let Some(state) = &mut self.render_state {
483                state.set_clear_color(clear);
484                #[cfg(debug_assertions)]
485                state.set_label_color([
486                    label.r as f32,
487                    label.g as f32,
488                    label.b as f32,
489                    label.a as f32,
490                ]);
491            }
492        }
493
494        #[cfg(debug_assertions)]
495        #[wasm_bindgen]
496        pub fn cycle_debug_mode(&mut self) {
497            if let Some(state) = &mut self.render_state {
498                state.cycle_debug_mode();
499            }
500        }
501
502        #[cfg(debug_assertions)]
503        #[wasm_bindgen]
504        pub fn toggle_grid(&mut self) {
505            if let Some(state) = &mut self.render_state {
506                state.toggle_grid();
507            }
508        }
509
510        /// Simulation tick called by the Network Worker at a fixed rate (e.g. 20Hz).
511        pub async fn tick(&mut self) {
512            self.check_worker();
513            use aetheris_protocol::traits::{Encoder, GameTransport, WorldState};
514
515            let encoder = SerdeEncoder::new();
516
517            // 0. Reconnection Logic
518            // TODO: poll transport.closed() promise and trigger reconnection state machine
519            if let Some(_transport) = &self.transport {}
520
521            // 0.1 Periodic Ping (approx. every 1 second at 60Hz)
522            if let Some(transport) = &mut self.transport {
523                self.ping_counter = self.ping_counter.wrapping_add(1);
524                if self.ping_counter % 60 == 0 {
525                    // Use current time as timestamp (ms)
526                    let now = performance_now();
527                    let tick_u64 = now as u64;
528
529                    if let Ok(data) = encoder.encode_event(&NetworkEvent::Ping {
530                        client_id: ClientId(0), // Client doesn't know its ID yet usually
531                        tick: tick_u64,
532                    }) {
533                        web_sys::console::debug_1(
534                            &format!("[Aetheris] Sending Ping: tick={tick_u64}").into(),
535                        );
536                        let _ = transport.send_unreliable(ClientId(0), &data).await;
537                    }
538                }
539            }
540
541            // 1. Poll Network
542            if let Some(transport) = &mut self.transport {
543                let events = match transport.poll_events().await {
544                    Ok(e) => e,
545                    Err(e) => {
546                        tracing::error!("Transport poll failure: {:?}", e);
547                        return;
548                    }
549                };
550                let mut updates: Vec<(ClientId, aetheris_protocol::events::ComponentUpdate)> =
551                    Vec::new();
552
553                for event in events {
554                    match event {
555                        NetworkEvent::UnreliableMessage { data, client_id }
556                        | NetworkEvent::ReliableMessage { data, client_id } => {
557                            if let Ok(update) = encoder.decode(&data) {
558                                updates.push((client_id, update));
559                            }
560                        }
561                        NetworkEvent::ClientConnected(id) => {
562                            tracing::info!(?id, "Server connected");
563                        }
564                        NetworkEvent::ClientDisconnected(id) => {
565                            tracing::warn!(?id, "Server disconnected");
566                        }
567                        NetworkEvent::Ping { client_id: _, tick } => {
568                            // Immediately reflect the ping as a pong with same tick
569                            let pong = NetworkEvent::Pong { tick };
570                            if let Ok(data) = encoder.encode_event(&pong) {
571                                let _ = transport.send_reliable(ClientId(0), &data).await;
572                            }
573                        }
574                        NetworkEvent::Pong { tick } => {
575                            // Calculate RTT from our own outgoing pings
576                            let now = performance_now();
577                            let rtt = now - (tick as f64);
578                            self.last_rtt_ms = rtt;
579
580                            with_collector(|c| {
581                                c.update_rtt(rtt);
582                            });
583
584                            #[cfg(feature = "metrics")]
585                            metrics::gauge!("aetheris_client_rtt_ms").set(rtt);
586
587                            web_sys::console::debug_1(
588                                &format!("[Aetheris] Received Pong: tick={tick} RTT={rtt:.1}ms")
589                                    .into(),
590                            );
591                            tracing::debug!(rtt_ms = rtt, "RTT Update");
592                        }
593                        NetworkEvent::Auth { .. } => {
594                            // Client initiated event usually, ignore if received from server
595                            tracing::debug!("Received Auth event from server (unexpected)");
596                        }
597                        NetworkEvent::SessionClosed(id) => {
598                            tracing::warn!(?id, "WebTransport session closed");
599                        }
600                        NetworkEvent::StreamReset(id) => {
601                            tracing::error!(?id, "WebTransport stream reset");
602                        }
603                        NetworkEvent::Fragment {
604                            client_id,
605                            fragment,
606                        } => {
607                            if let Some(data) = self.reassembler.add(client_id, fragment) {
608                                if let Ok(update) = encoder.decode(&data) {
609                                    updates.push((client_id, update));
610                                }
611                            }
612                        }
613                        NetworkEvent::StressTest { .. } => {
614                            // Client-side, we don't handle incoming stress test events usually,
615                            // they are processed by the server.
616                        }
617                        NetworkEvent::Spawn { .. } => {
618                            // Handled by GameWorker via p_spawn
619                        }
620                        NetworkEvent::ClearWorld { .. } => {
621                            tracing::info!("Server initiated world clear");
622                            self.world_state.entities.clear();
623                        }
624                    }
625                }
626
627                // 2. Apply updates to the Simulation World
628                self.world_state.apply_updates(&updates);
629            }
630
631            // 2.5. Client-side rotation animation (local, not replicated by server)
632            if self.playground_rotation_enabled {
633                for slot in self.world_state.entities.values_mut() {
634                    slot.rotation = (slot.rotation + 0.05) % std::f32::consts::TAU;
635                }
636            }
637
638            // Advance local tick every frame so the render worker always sees a new snapshot,
639            // even when the server sends no updates (static entities, client-side animation).
640            self.world_state.latest_tick += 1;
641
642            // 3. Write Authoritative Snapshot to Shared World for the Render Worker
643            self.flush_to_shared_world(self.world_state.latest_tick);
644        }
645
646        fn flush_to_shared_world(&mut self, tick: u64) {
647            let entities = &self.world_state.entities;
648            let write_buffer = self.shared_world.get_write_buffer();
649
650            let mut count = 0;
651            for (i, slot) in entities.values().enumerate() {
652                if i >= MAX_ENTITIES {
653                    tracing::warn!("Max entities reached in shared world! Overflow suppressed.");
654                    break;
655                }
656                write_buffer[i] = *slot;
657                count += 1;
658            }
659
660            self.shared_world.commit_write(count as u32, tick);
661        }
662
663        #[wasm_bindgen]
664        pub fn playground_spawn(&mut self, entity_type: u16, x: f32, y: f32, rotation: f32) {
665            // Security: Prevent overflow in playground mode
666            if self.world_state.entities.len() >= MAX_ENTITIES {
667                tracing::warn!("playground_spawn: MAX_ENTITIES reached, spawn ignored.");
668                return;
669            }
670
671            // Sync ID generator if it's currently at default but world is seeded
672            if self.playground_next_network_id == 1 && !self.world_state.entities.is_empty() {
673                self.playground_next_network_id = self
674                    .world_state
675                    .entities
676                    .keys()
677                    .map(|k| k.0)
678                    .max()
679                    .unwrap_or(0)
680                    + 1;
681            }
682
683            let id = aetheris_protocol::types::NetworkId(self.playground_next_network_id);
684            self.playground_next_network_id += 1;
685            let slot = SabSlot {
686                network_id: id.0,
687                x,
688                y,
689                z: 0.0,
690                rotation,
691                dx: 0.0,
692                dy: 0.0,
693                dz: 0.0,
694                hp: 100,
695                shield: 100,
696                entity_type,
697                flags: 0x01, // ALIVE
698                padding: [0; 5],
699            };
700            self.world_state.entities.insert(id, slot);
701        }
702
703        #[wasm_bindgen]
704        pub async fn playground_spawn_net(
705            &mut self,
706            entity_type: u16,
707            x: f32,
708            y: f32,
709            rot: f32,
710        ) -> Result<(), JsValue> {
711            self.check_worker();
712
713            if let Some(transport) = &self.transport {
714                let encoder = SerdeEncoder::new();
715                let event = NetworkEvent::Spawn {
716                    client_id: ClientId(0),
717                    entity_type,
718                    x,
719                    y,
720                    rot,
721                };
722
723                if let Ok(data) = encoder.encode_event(&event) {
724                    transport
725                        .send_reliable(ClientId(0), &data)
726                        .await
727                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
728                    tracing::info!(entity_type, x, y, "Sent Spawn command to server");
729                }
730            } else {
731                // Local fallback
732                self.playground_spawn(entity_type, x, y, rot);
733            }
734            Ok(())
735        }
736
737        #[wasm_bindgen]
738        pub fn playground_clear(&mut self) {
739            self.world_state.entities.clear();
740        }
741
742        #[wasm_bindgen]
743        pub async fn playground_clear_server(&mut self) -> Result<(), JsValue> {
744            self.check_worker();
745
746            if let Some(transport) = &self.transport {
747                let encoder = SerdeEncoder::new();
748                let event = NetworkEvent::ClearWorld {
749                    client_id: ClientId(0),
750                };
751                if let Ok(data) = encoder.encode_event(&event) {
752                    transport
753                        .send_reliable(ClientId(0), &data)
754                        .await
755                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
756                    tracing::info!("Sent ClearWorld command to server");
757                    self.world_state.entities.clear();
758                }
759            } else {
760                // No transport, clear immediately
761                self.world_state.entities.clear();
762            }
763            Ok(())
764        }
765
766        #[wasm_bindgen]
767        pub fn playground_set_rotation_enabled(&mut self, enabled: bool) {
768            self.playground_rotation_enabled = enabled;
769            // Note: Rotation toggle is currently local-authoritative in playground_tick,
770            // but for server-side replication it would need a network event.
771        }
772
773        #[wasm_bindgen]
774        pub async fn playground_stress_test(
775            &mut self,
776            count: u16,
777            rotate: bool,
778        ) -> Result<(), JsValue> {
779            self.check_worker();
780
781            if let Some(transport) = &self.transport {
782                let encoder = SerdeEncoder::new();
783                let event = NetworkEvent::StressTest {
784                    client_id: ClientId(0),
785                    count,
786                    rotate,
787                };
788
789                if let Ok(data) = encoder.encode_event(&event) {
790                    transport
791                        .send_reliable(ClientId(0), &data)
792                        .await
793                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
794                    tracing::info!(count, rotate, "Sent StressTest command to server");
795                }
796                self.playground_set_rotation_enabled(rotate);
797            } else {
798                // Fallback to local behavior if not connected
799                self.playground_set_rotation_enabled(rotate);
800                self.playground_clear();
801                for _ in 0..count {
802                    self.playground_spawn(1, 0.0, 0.0, 0.0); // Simple spawn
803                }
804            }
805
806            Ok(())
807        }
808
809        #[wasm_bindgen]
810        pub async fn tick_playground(&mut self) {
811            self.check_worker();
812
813            // M10105 — emit tick_playground_loop_start lifecycle span
814            if self.first_playground_tick {
815                self.first_playground_tick = false;
816                with_collector(|c| {
817                    c.push_event(
818                        1,
819                        "wasm_client",
820                        "Playground simulation loop started",
821                        "tick_playground_loop_start",
822                        None,
823                    );
824                });
825            }
826
827            // M10105 — measure simulation time
828            let sim_start = crate::performance_now();
829            self.world_state.latest_tick += 1;
830
831            if self.playground_rotation_enabled {
832                for slot in self.world_state.entities.values_mut() {
833                    slot.rotation = (slot.rotation + 0.05) % std::f32::consts::TAU;
834                }
835            }
836
837            // Sync to shared world
838            let count = self.world_state.entities.len() as u32;
839            self.flush_to_shared_world(self.world_state.latest_tick);
840
841            let sim_time_ms = crate::performance_now() - sim_start;
842            with_collector(|c| {
843                c.record_sim(sim_time_ms);
844                c.update_entity_count(count);
845            });
846        }
847
848        /// Render frame called by the Render Worker.
849        pub fn render(&mut self) -> f64 {
850            self.check_worker();
851
852            let tick = self.shared_world.tick();
853            let entities = self.shared_world.get_read_buffer();
854
855            // Periodic diagnostic log for render worker
856            thread_local! {
857                static FRAME_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
858            }
859            FRAME_COUNT.with(|count| {
860                let current = count.get();
861                if current % 300 == 0 {
862                    tracing::debug!(
863                        "Aetheris Render Stats: Tick={}, Entities={}, Snapshots={}",
864                        tick,
865                        entities.len(),
866                        self.snapshots.len(),
867                    );
868                }
869                count.set(current + 1);
870            });
871
872            if tick == 0 {
873                // Background only or placeholder if no simulation is running yet
874                let mut frame_time_ms = 0.0;
875                if let Some(state) = &mut self.render_state {
876                    frame_time_ms = state.render_frame_with_compact_slots(&[]);
877                    with_collector(|c| {
878                        // FPS is computed in the worker; we only report duration here.
879                        // FPS=0 here as it is not authoritative.
880                        c.record_frame(frame_time_ms, 0.0);
881                    });
882                }
883                return frame_time_ms;
884            }
885
886            // 1. Buffer new snapshots — only push when tick advances
887            if self.snapshots.is_empty()
888                || tick > self.snapshots.back().map(|s| s.tick).unwrap_or(0)
889            {
890                self.snapshots.push_back(SimulationSnapshot {
891                    tick,
892                    entities: entities.to_vec(),
893                });
894            }
895
896            // 2. Calculate target playback tick.
897            // Stay 2 ticks behind latest so we always have an (s1, s2) interpolation pair.
898            // This is rate-independent: works for both the 20 Hz server and the 60 Hz playground.
899            let mut frame_time_ms = 0.0;
900            if !self.snapshots.is_empty() {
901                let latest_tick = self.snapshots.back().unwrap().tick as f32;
902                let target_tick = latest_tick - 2.0;
903                frame_time_ms = self.render_at_tick(target_tick);
904            }
905
906            // M10105 — record accurate frame time (from WGPU) + snapshot depth.
907            let snap_count = self.snapshots.len() as u32;
908            with_collector(|c| {
909                // FPS is computed in the worker; we only report duration here.
910                c.record_frame(frame_time_ms, 0.0);
911                c.update_snapshot_count(snap_count);
912            });
913
914            frame_time_ms
915        }
916
917        fn render_at_tick(&mut self, target_tick: f32) -> f64 {
918            if self.snapshots.len() < 2 {
919                // If we don't have enough snapshots for interpolation,
920                // we still want to render the background or at least one frame.
921                if let Some(state) = &mut self.render_state {
922                    let entities = if !self.snapshots.is_empty() {
923                        self.snapshots[0].entities.clone()
924                    } else {
925                        Vec::new()
926                    };
927                    return state.render_frame_with_compact_slots(&entities);
928                }
929                return 0.0;
930            }
931
932            // Find snapshots S1, S2 such that S1.tick <= target_tick < S2.tick
933            let mut s1_idx = 0;
934            let mut found = false;
935
936            for i in 0..self.snapshots.len() - 1 {
937                if (self.snapshots[i].tick as f32) <= target_tick
938                    && (self.snapshots[i + 1].tick as f32) > target_tick
939                {
940                    s1_idx = i;
941                    found = true;
942                    break;
943                }
944            }
945
946            if !found {
947                // If we are outside the buffer range, clamp to the nearest edge
948                if target_tick < self.snapshots[0].tick as f32 {
949                    s1_idx = 0;
950                } else {
951                    s1_idx = self.snapshots.len() - 2;
952                }
953            }
954
955            let s1 = self.snapshots.get(s1_idx).unwrap();
956            let s2 = self.snapshots.get(s1_idx + 1).unwrap();
957
958            let tick_range = (s2.tick - s1.tick) as f32;
959            let alpha = if tick_range > 0.0 {
960                (target_tick - s1.tick as f32) / tick_range
961            } else {
962                1.0
963            }
964            .clamp(0.0, 1.0);
965
966            // Interpolate entities into a reusable buffer to avoid per-frame heap allocations
967            self.render_buffer.clear();
968            self.render_buffer.extend_from_slice(&s2.entities);
969
970            // Build a lookup map from the previous snapshot for O(1) access per entity.
971            let prev_map: std::collections::HashMap<u64, &SabSlot> =
972                s1.entities.iter().map(|e| (e.network_id, e)).collect();
973
974            for ent in &mut self.render_buffer {
975                if let Some(prev) = prev_map.get(&ent.network_id).copied() {
976                    ent.x = lerp(prev.x, ent.x, alpha);
977                    ent.y = lerp(prev.y, ent.y, alpha);
978                    ent.z = lerp(prev.z, ent.z, alpha);
979                    ent.rotation = lerp_rotation(prev.rotation, ent.rotation, alpha);
980                }
981            }
982
983            let mut frame_time = 0.0;
984            if let Some(state) = &mut self.render_state {
985                frame_time = state.render_frame_with_compact_slots(&self.render_buffer);
986            }
987
988            // 3. Prune old snapshots.
989            // We keep the oldest one that is still relevant for interpolation (index 0)
990            // and everything newer. We prune snapshots that are entirely behind our window.
991            while self.snapshots.len() > 2 && (self.snapshots[0].tick as f32) < target_tick - 1.0 {
992                self.snapshots.pop_front();
993            }
994
995            // Safety cap: prevent unbounded growth if simulation stops but render continues
996            while self.snapshots.len() > 16 {
997                self.snapshots.pop_front();
998            }
999
1000            frame_time
1001        }
1002    }
1003
1004    fn lerp(a: f32, b: f32, alpha: f32) -> f32 {
1005        a + (b - a) * alpha
1006    }
1007
1008    fn lerp_rotation(a: f32, b: f32, alpha: f32) -> f32 {
1009        // Simple rotation lerp for Phase 1.
1010        // Handles 2pi wraparound for smooth visuals.
1011        let mut diff = b - a;
1012        while diff < -std::f32::consts::PI {
1013            diff += std::f32::consts::TAU;
1014        }
1015        while diff > std::f32::consts::PI {
1016            diff -= std::f32::consts::TAU;
1017        }
1018        a + diff * alpha
1019    }
1020
1021    /// Fallback entry point for non-worker environments.
1022    #[cfg(not(test))]
1023    #[wasm_bindgen(start)]
1024    pub fn main() {
1025        console_error_panic_hook::set_once();
1026    }
1027}