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