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, GameTransport, WorldState};
116    use aetheris_protocol::types::{
117        ClientId, ComponentKind, InputCommand, NetworkId, PlayerInputKind,
118    };
119    use wasm_bindgen::prelude::*;
120
121    #[wasm_bindgen]
122    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
123    pub enum ConnectionState {
124        Disconnected,
125        Connecting,
126        InGame,
127        Reconnecting,
128        Failed,
129    }
130
131    /// A snapshot of the world for interpolation.
132    #[derive(Clone)]
133    pub struct SimulationSnapshot {
134        pub tick: u64,
135        pub entities: Vec<SabSlot>,
136    }
137
138    /// Global state held by the WASM instance.
139    #[wasm_bindgen]
140    pub struct AetherisClient {
141        pub(crate) shared_world: SharedWorld,
142        pub(crate) world_state: ClientWorld,
143        pub(crate) render_state: Option<RenderState>,
144        pub(crate) transport: Option<Box<dyn GameTransport>>,
145        pub(crate) worker_id: usize,
146        pub(crate) session_token: Option<String>,
147
148        // Interpolation state (Render Worker only)
149        pub(crate) snapshots: std::collections::VecDeque<SimulationSnapshot>,
150
151        // Network metrics
152        pub(crate) last_rtt_ms: f64,
153        ping_counter: u64,
154
155        reassembler: aetheris_protocol::Reassembler,
156        connection_state: ConnectionState,
157        reconnect_attempts: u32,
158        playground_rotation_enabled: bool,
159        playground_next_network_id: u64,
160        first_playground_tick: bool,
161
162        // Reusable buffer for zero-allocation rendering (Render Worker only)
163        pub(crate) render_buffer: Vec<SabSlot>,
164        pub(crate) asset_registry: assets::AssetRegistry,
165
166        // Input noise reduction
167        last_input_target: Option<NetworkId>,
168        last_input_actions: Vec<PlayerInputKind>,
169
170        pending_clear: bool,
171        last_clear_tick: u64,
172
173        // Fixed-Timestep Simulation (M10105/M1020)
174        last_process_time: f64,
175        tick_accumulator: f64,
176
177        // Playground Input Buffering
178        playground_move_x: f32,
179        playground_move_y: f32,
180        playground_actions: u32,
181        last_fraction: f32,
182    }
183
184    #[wasm_bindgen]
185    impl AetherisClient {
186        /// Creates a new AetherisClient instance.
187        /// If a pointer is provided, it will use it as the backing storage (shared memory).
188        #[wasm_bindgen(constructor)]
189        pub fn new(shared_world_ptr: Option<u32>) -> Result<AetherisClient, JsValue> {
190            console_error_panic_hook::set_once();
191
192            // tracing_wasm doesn't have a clean try_init for global default.
193            // We use a static atomic to ensure only the first worker sets the global default.
194            use std::sync::atomic::{AtomicBool, Ordering};
195            static LOGGER_INITIALIZED: AtomicBool = AtomicBool::new(false);
196            if !LOGGER_INITIALIZED.swap(true, Ordering::SeqCst) {
197                let config = tracing_wasm::WASMLayerConfigBuilder::new()
198                    .set_max_level(tracing::Level::INFO)
199                    .build();
200                tracing_wasm::set_as_global_default_with_config(config);
201            }
202
203            let shared_world = if let Some(ptr_val) = shared_world_ptr {
204                let ptr = ptr_val as *mut u8;
205
206                // Security: Validate the incoming pointer before use
207                if ptr_val == 0 || !ptr_val.is_multiple_of(8) {
208                    return Err(JsValue::from_str(
209                        "Invalid shared_world_ptr: null or unaligned",
210                    ));
211                }
212
213                // JS-allocated SharedArrayBuffer pointers are not in the Rust registry
214                // (only Rust-owned allocations are registered). The null/alignment checks
215                // above are the only feasible boundary validation for externally-provided
216                // pointers; trusting the caller is required by the SAB contract.
217                unsafe { SharedWorld::from_ptr(ptr) }
218            } else {
219                SharedWorld::new()
220            };
221
222            let global = js_sys::global();
223            let (ua, lang) =
224                if let Ok(worker) = global.clone().dyn_into::<web_sys::WorkerGlobalScope>() {
225                    let n = worker.navigator();
226                    (n.user_agent().ok(), n.language())
227                } else if let Ok(window) = global.dyn_into::<web_sys::Window>() {
228                    let n = window.navigator();
229                    (n.user_agent().ok(), n.language())
230                } else {
231                    (None, None)
232                };
233
234            tracing::info!(
235                "Aetheris Client: Environment [UA: {}, Lang: {}]",
236                ua.as_deref().unwrap_or("Unknown"),
237                lang.as_deref().unwrap_or("Unknown")
238            );
239
240            tracing::info!(
241                "AetherisClient initialized on worker {}",
242                crate::get_worker_id()
243            );
244
245            // M10105 — emit wasm_init lifecycle span
246            with_collector(|c| {
247                c.push_event(
248                    1,
249                    "wasm_client",
250                    "AetherisClient initialized",
251                    "wasm_init",
252                    None,
253                );
254            });
255
256            let mut world_state = ClientWorld::new();
257            world_state.shared_world_ref = Some(shared_world.as_ptr() as usize);
258
259            Ok(Self {
260                shared_world,
261                world_state,
262                render_state: None,
263                transport: None,
264                worker_id: crate::get_worker_id(),
265                session_token: None,
266                snapshots: std::collections::VecDeque::with_capacity(8),
267                last_rtt_ms: 0.0,
268                ping_counter: 0,
269                reassembler: aetheris_protocol::Reassembler::new(),
270                connection_state: ConnectionState::Disconnected,
271                reconnect_attempts: 0,
272                playground_rotation_enabled: false,
273                playground_next_network_id: 1,
274                first_playground_tick: true,
275                render_buffer: Vec::with_capacity(crate::shared_world::MAX_ENTITIES),
276                asset_registry: assets::AssetRegistry::new(),
277                last_input_target: None,
278                last_input_actions: Vec::new(),
279                pending_clear: false,
280                last_clear_tick: 0,
281                last_process_time: crate::performance_now(),
282                tick_accumulator: 0.0,
283                playground_move_x: 0.0,
284                playground_move_y: 0.0,
285                playground_actions: 0,
286                last_fraction: 0.0,
287            })
288        }
289
290        #[wasm_bindgen]
291        pub fn set_view_state(&mut self, state: u32) {
292            use crate::render::ViewState;
293            let state = match state {
294                0 => ViewState::Logo,
295                1 => ViewState::Roaming,
296                2 => ViewState::Entering,
297                3 => ViewState::Playing,
298                _ => return,
299            };
300
301            if let Some(rs) = &mut self.render_state {
302                rs.set_view_state(state);
303            } else {
304                tracing::warn!("set_view_state called but render_state is None");
305            }
306        }
307
308        #[wasm_bindgen]
309        pub async fn request_otp(base_url: String, email: String) -> Result<String, String> {
310            crate::auth::request_otp(base_url, email).await
311        }
312
313        #[wasm_bindgen]
314        pub async fn login_with_otp(
315            base_url: String,
316            request_id: String,
317            code: String,
318        ) -> Result<String, String> {
319            crate::auth::login_with_otp(base_url, request_id, code).await
320        }
321
322        #[wasm_bindgen]
323        pub async fn logout(base_url: String, session_token: String) -> Result<(), String> {
324            crate::auth::logout(base_url, session_token).await
325        }
326
327        #[wasm_bindgen(getter)]
328        pub fn connection_state(&self) -> ConnectionState {
329            self.connection_state
330        }
331
332        fn check_worker(&self) {
333            debug_assert_eq!(
334                self.worker_id,
335                crate::get_worker_id(),
336                "AetherisClient accessed from wrong worker! It is pin-bound to its creating thread."
337            );
338        }
339
340        /// Returns the raw pointer to the shared world buffer.
341        pub fn shared_world_ptr(&self) -> u32 {
342            self.shared_world.as_ptr() as u32
343        }
344
345        pub async fn connect(
346            &mut self,
347            url: String,
348            cert_hash: Option<Vec<u8>>,
349        ) -> Result<(), JsValue> {
350            self.check_worker();
351
352            if self.connection_state == ConnectionState::Connecting
353                || self.connection_state == ConnectionState::InGame
354                || self.connection_state == ConnectionState::Reconnecting
355            {
356                return Ok(());
357            }
358
359            // M10105 — emit reconnect_attempt if it looks like one
360            if self.connection_state == ConnectionState::Failed && self.reconnect_attempts > 0 {
361                with_collector(|c| {
362                    c.push_event(
363                        2,
364                        "transport",
365                        "Triggering reconnection",
366                        "reconnect_attempt",
367                        None,
368                    );
369                });
370            }
371
372            self.connection_state = ConnectionState::Connecting;
373            tracing::info!(url = %url, "Connecting to server...");
374
375            let transport_result = WebTransportBridge::connect(&url, cert_hash.as_deref()).await;
376
377            match transport_result {
378                Ok(transport) => {
379                    // Security: Send Auth message immediately after connection
380                    if let Some(token) = &self.session_token {
381                        let encoder = SerdeEncoder::new();
382                        let auth_event = NetworkEvent::Auth {
383                            session_token: token.clone(),
384                        };
385
386                        match encoder.encode_event(&auth_event) {
387                            Ok(data) => {
388                                if let Err(e) = transport.send_reliable(ClientId(0), &data).await {
389                                    self.connection_state = ConnectionState::Failed;
390                                    tracing::error!(error = ?e, "Handshake failed: could not send auth packet");
391                                    return Err(JsValue::from_str(&format!(
392                                        "Failed to send auth packet: {:?}",
393                                        e
394                                    )));
395                                }
396                                tracing::info!("Auth packet sent to server");
397                            }
398                            Err(e) => {
399                                self.connection_state = ConnectionState::Failed;
400                                tracing::error!(error = ?e, "Handshake failed: could not encode auth packet");
401                                return Err(JsValue::from_str("Failed to encode auth packet"));
402                            }
403                        }
404                    } else {
405                        tracing::warn!(
406                            "Connecting without session token! Server will likely discard data."
407                        );
408                    }
409
410                    self.transport = Some(Box::new(transport));
411                    self.connection_state = ConnectionState::InGame;
412                    self.reconnect_attempts = 0;
413                    tracing::info!("WebTransport connection established");
414                    // M10105 — connect_handshake lifecycle span
415                    with_collector(|c| {
416                        c.push_event(
417                            1,
418                            "transport",
419                            &format!("WebTransport connected: {url}"),
420                            "connect_handshake",
421                            None,
422                        );
423                    });
424                    Ok(())
425                }
426                Err(e) => {
427                    self.connection_state = ConnectionState::Failed;
428                    tracing::error!(error = ?e, "Failed to establish WebTransport connection");
429                    // M10105 — connect_handshake_failed lifecycle span (ERROR level)
430                    with_collector(|c| {
431                        c.push_event(
432                            3,
433                            "transport",
434                            &format!("WebTransport failed: {url} — {e:?}"),
435                            "connect_handshake_failed",
436                            None,
437                        );
438                    });
439                    Err(JsValue::from_str(&format!("failed to connect: {e:?}")))
440                }
441            }
442        }
443
444        #[wasm_bindgen]
445        pub async fn reconnect(
446            &mut self,
447            url: String,
448            cert_hash: Option<Vec<u8>>,
449        ) -> Result<(), JsValue> {
450            self.check_worker();
451            self.connection_state = ConnectionState::Reconnecting;
452            self.reconnect_attempts += 1;
453
454            tracing::info!(
455                "Attempting reconnection... (attempt {})",
456                self.reconnect_attempts
457            );
458
459            self.connect(url, cert_hash).await
460        }
461
462        #[wasm_bindgen]
463        pub async fn wasm_load_asset(
464            &mut self,
465            handle: assets::AssetHandle,
466            url: String,
467        ) -> Result<(), JsValue> {
468            self.asset_registry.load_asset(handle, &url).await
469        }
470
471        /// Sets the session token to be used for authentication upon connection.
472        pub fn set_session_token(&mut self, token: String) {
473            self.session_token = Some(token);
474        }
475
476        /// Initializes rendering with a canvas element.
477        /// Accepts either web_sys::HtmlCanvasElement or web_sys::OffscreenCanvas.
478        pub async fn init_renderer(&mut self, canvas: JsValue) -> Result<(), JsValue> {
479            self.check_worker();
480            use wasm_bindgen::JsCast;
481
482            let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
483                backends: wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL,
484                flags: wgpu::InstanceFlags::default(),
485                ..wgpu::InstanceDescriptor::new_without_display_handle()
486            });
487
488            // Handle both HtmlCanvasElement and OffscreenCanvas for Worker support
489            let (surface_target, width, height) =
490                if let Ok(html_canvas) = canvas.clone().dyn_into::<web_sys::HtmlCanvasElement>() {
491                    let width = html_canvas.width();
492                    let height = html_canvas.height();
493                    tracing::info!(
494                        "Initializing renderer on HTMLCanvasElement ({}x{})",
495                        width,
496                        height
497                    );
498                    (wgpu::SurfaceTarget::Canvas(html_canvas), width, height)
499                } else if let Ok(offscreen_canvas) =
500                    canvas.clone().dyn_into::<web_sys::OffscreenCanvas>()
501                {
502                    let width = offscreen_canvas.width();
503                    let height = offscreen_canvas.height();
504                    tracing::info!(
505                        "Initializing renderer on OffscreenCanvas ({}x{})",
506                        width,
507                        height
508                    );
509
510                    // Critical fix for wgpu 0.20+ on WASM workers:
511                    // Ensure the context is initialized with the 'webgpu' id before creating the surface.
512                    let _ = offscreen_canvas.get_context("webgpu").map_err(|e| {
513                        JsValue::from_str(&format!("Failed to get webgpu context: {:?}", e))
514                    })?;
515
516                    (
517                        wgpu::SurfaceTarget::OffscreenCanvas(offscreen_canvas),
518                        width,
519                        height,
520                    )
521                } else {
522                    return Err(JsValue::from_str(
523                        "Aetheris: Provided object is not a valid Canvas or OffscreenCanvas",
524                    ));
525                };
526
527            let surface = instance
528                .create_surface(surface_target)
529                .map_err(|e| JsValue::from_str(&format!("Failed to create surface: {:?}", e)))?;
530
531            let render_state = RenderState::new(&instance, surface, width, height)
532                .await
533                .map_err(|e| JsValue::from_str(&format!("Failed to init renderer: {:?}", e)))?;
534
535            self.render_state = Some(render_state);
536
537            // M10105 — emit render_pipeline_setup lifecycle span
538            with_collector(|c| {
539                c.push_event(
540                    1,
541                    "render_worker",
542                    &format!("Renderer initialized ({}x{})", width, height),
543                    "render_pipeline_setup",
544                    None,
545                );
546            });
547
548            Ok(())
549        }
550
551        #[wasm_bindgen]
552        pub fn resize(&mut self, width: u32, height: u32) {
553            if let Some(state) = &mut self.render_state {
554                state.resize(width, height);
555            }
556        }
557
558        #[cfg(debug_assertions)]
559        #[wasm_bindgen]
560        pub fn set_debug_mode(&mut self, mode: u32) {
561            self.check_worker();
562            if let Some(state) = &mut self.render_state {
563                state.set_debug_mode(match mode {
564                    0 => crate::render::DebugRenderMode::Off,
565                    1 => crate::render::DebugRenderMode::Wireframe,
566                    2 => crate::render::DebugRenderMode::Components,
567                    _ => crate::render::DebugRenderMode::Full,
568                });
569            }
570        }
571
572        #[wasm_bindgen]
573        pub fn set_theme_colors(&mut self, bg_base: &str, text_primary: &str) {
574            self.check_worker();
575            let clear = crate::render::parse_css_color(bg_base);
576            let label = crate::render::parse_css_color(text_primary);
577
578            tracing::info!(
579                "Aetheris Client: Applying theme colors [bg: {} -> {:?}, text: {} -> {:?}]",
580                bg_base,
581                clear,
582                text_primary,
583                label
584            );
585
586            if let Some(state) = &mut self.render_state {
587                state.set_clear_color(clear);
588                #[cfg(debug_assertions)]
589                state.set_label_color([
590                    label.r as f32,
591                    label.g as f32,
592                    label.b as f32,
593                    label.a as f32,
594                ]);
595            }
596        }
597
598        #[cfg(debug_assertions)]
599        #[wasm_bindgen]
600        pub fn cycle_debug_mode(&mut self) {
601            if let Some(state) = &mut self.render_state {
602                state.cycle_debug_mode();
603            }
604        }
605
606        #[cfg(debug_assertions)]
607        #[wasm_bindgen]
608        pub fn toggle_grid(&mut self) {
609            if let Some(state) = &mut self.render_state {
610                state.toggle_grid();
611            }
612        }
613
614        #[wasm_bindgen]
615        pub fn latest_tick(&self) -> u64 {
616            self.world_state.latest_tick
617        }
618
619        #[wasm_bindgen]
620        pub fn playground_apply_input(&mut self, move_x: f32, move_y: f32, actions_mask: u32) {
621            self.check_worker();
622            self.playground_move_x = move_x;
623            self.playground_move_y = move_y;
624            self.playground_actions = actions_mask;
625        }
626
627        /// Simulation tick called by the Network Worker at a fixed rate (e.g. 20Hz).
628        pub async fn tick(&mut self) {
629            self.check_worker();
630            use aetheris_protocol::traits::{Encoder, WorldState};
631
632            let encoder = SerdeEncoder::new();
633
634            // 0. Reconnection Logic
635            // TODO: poll transport.closed() promise and trigger reconnection state machine
636            if let Some(_transport) = &self.transport {}
637
638            // 0.1 Periodic Ping (approx. every 1 second at 60Hz)
639            if let Some(transport) = &mut self.transport {
640                self.ping_counter = self.ping_counter.wrapping_add(1);
641                if self.ping_counter % 60 == 0 {
642                    // Use current time as timestamp (ms)
643                    let now = performance_now();
644                    let tick_u64 = now as u64;
645
646                    if let Ok(data) = encoder.encode_event(&NetworkEvent::Ping {
647                        client_id: ClientId(0), // Client doesn't know its ID yet usually
648                        tick: tick_u64,
649                    }) {
650                        tracing::trace!(tick = tick_u64, "Sending Ping");
651                        let _ = transport.send_unreliable(ClientId(0), &data).await;
652                    }
653                }
654            }
655
656            // 1. Poll Network
657            if let Some(transport) = &mut self.transport {
658                let events = match transport.poll_events().await {
659                    Ok(e) => e,
660                    Err(e) => {
661                        tracing::error!("Transport poll failure: {:?}", e);
662                        return;
663                    }
664                };
665                let mut updates: Vec<(ClientId, aetheris_protocol::events::ComponentUpdate)> =
666                    Vec::new();
667
668                for event in events {
669                    match event {
670                        NetworkEvent::UnreliableMessage { data, client_id }
671                        | NetworkEvent::ReliableMessage { data, client_id } => {
672                            match encoder.decode(&data) {
673                                Ok(update) => {
674                                    // M10105 — Always filter by epoch tick so stale datagrams are
675                                    // rejected even after the reliable ClearWorld ack has lowered
676                                    // pending_clear (unreliable datagrams may overtake the ack).
677                                    if self.last_clear_tick == 0
678                                        || update.tick > self.last_clear_tick
679                                    {
680                                        updates.push((client_id, update));
681                                    } else {
682                                        tracing::debug!(
683                                            network_id = update.network_id.0,
684                                            tick = update.tick,
685                                            last_clear_tick = self.last_clear_tick,
686                                            "Discarding stale update (tick <= last_clear_tick)"
687                                        );
688                                    }
689                                }
690                                Err(_) => {
691                                    // Try to decode as a protocol/wire event instead
692                                    if let Ok(event) = encoder.decode_event(&data) {
693                                        match event {
694                                            aetheris_protocol::events::NetworkEvent::GameEvent {
695                                                event: game_event,
696                                                ..
697                                            } => match &game_event {
698                                                aetheris_protocol::events::GameEvent::AsteroidDepleted {
699                                                    network_id,
700                                                } => {
701                                                    tracing::info!(?network_id, "Asteroid depleted");
702                                                    self.world_state.entities.remove(&network_id);
703
704                                                    for slot in self.world_state.entities.values_mut() {
705                                                        if (slot.flags & 0x04) != 0
706                                                            && slot.mining_target_id == (network_id.0 as u16)
707                                                        {
708                                                            slot.mining_active = 0;
709                                                            slot.mining_target_id = 0;
710                                                            tracing::info!("Cleared local mining target due to depletion");
711                                                        }
712                                                    }
713                                                }
714                                                aetheris_protocol::events::GameEvent::Possession {
715                                                    network_id: _,
716                                                } => {
717                                                    self.world_state.handle_game_event(&game_event);
718                                                }
719                                                aetheris_protocol::events::GameEvent::SystemManifest {
720                                                    manifest,
721                                                } => {
722                                                    tracing::debug!(
723                                                        count = manifest.len(),
724                                                        "Received SystemManifest from server"
725                                                    );
726                                                    self.world_state.system_manifest = manifest.clone();
727                                                }
728                                            },
729                                            aetheris_protocol::events::NetworkEvent::ClearWorld {
730                                                ..
731                                            } => {
732                                                tracing::info!(
733                                                    "Server ClearWorld ack received (via ReliableMessage) — gate lowered"
734                                                );
735                                                self.pending_clear = false;
736                                            }
737                                            _ => {}
738                                        }
739                                    } else {
740                                        tracing::warn!(
741                                            "Failed to decode server message as update or wire event"
742                                        );
743                                    }
744                                }
745                            }
746                        }
747                        NetworkEvent::ClientConnected(id) => {
748                            tracing::info!(?id, "Server connected");
749                        }
750                        NetworkEvent::ClientDisconnected(id) => {
751                            tracing::warn!(?id, "Server disconnected");
752                        }
753                        NetworkEvent::Disconnected(_id) => {
754                            tracing::error!("Transport disconnected locally");
755                            self.connection_state = ConnectionState::Disconnected;
756                        }
757                        NetworkEvent::Ping { client_id: _, tick } => {
758                            // Immediately reflect the ping as a pong with same tick
759                            let pong = NetworkEvent::Pong { tick };
760                            if let Ok(data) = encoder.encode_event(&pong) {
761                                let _ = transport.send_reliable(ClientId(0), &data).await;
762                            }
763                        }
764                        NetworkEvent::Pong { tick } => {
765                            // Calculate RTT from our own outgoing pings
766                            let now = performance_now();
767                            let rtt = now - (tick as f64);
768                            self.last_rtt_ms = rtt;
769
770                            with_collector(|c| {
771                                c.update_rtt(rtt);
772                            });
773
774                            #[cfg(feature = "metrics")]
775                            metrics::gauge!("aetheris_client_rtt_ms").set(rtt);
776
777                            tracing::trace!(rtt_ms = rtt, tick, "Received Pong / RTT update");
778                        }
779                        NetworkEvent::Auth { .. } => {
780                            // Client initiated event usually, ignore if received from server
781                            tracing::debug!("Received Auth event from server (unexpected)");
782                        }
783                        NetworkEvent::SessionClosed(id) => {
784                            tracing::warn!(?id, "WebTransport session closed");
785                        }
786                        NetworkEvent::StreamReset(id) => {
787                            tracing::error!(?id, "WebTransport stream reset");
788                        }
789                        NetworkEvent::ReplicationBatch { events, client_id } => {
790                            for event in events {
791                                if self.last_clear_tick == 0 || event.tick > self.last_clear_tick {
792                                    updates.push((
793                                        client_id,
794                                        aetheris_protocol::events::ComponentUpdate {
795                                            network_id: event.network_id,
796                                            component_kind: event.component_kind,
797                                            payload: event.payload,
798                                            tick: event.tick,
799                                        },
800                                    ));
801                                }
802                            }
803                        }
804                        NetworkEvent::Fragment {
805                            client_id,
806                            fragment,
807                        } => {
808                            if let Some(data) = self.reassembler.ingest(client_id, fragment) {
809                                if let Ok(update) = encoder.decode(&data) {
810                                    if self.last_clear_tick == 0
811                                        || update.tick > self.last_clear_tick
812                                    {
813                                        updates.push((client_id, update));
814                                    }
815                                }
816                            }
817                        }
818                        NetworkEvent::StressTest { .. } => {
819                            // Client-side, we don't handle incoming stress test events usually,
820                            // they are processed by the server.
821                        }
822                        NetworkEvent::Spawn { .. } => {
823                            // Handled by GameWorker via p_spawn
824                        }
825                        NetworkEvent::ClearWorld { .. } => {
826                            // Reliable ack: guaranteed to arrive after all unreliable
827                            // datagrams sent before it (QUIC ordering).  Lower the gate
828                            // and do a final flush — from this point forward, no stale
829                            // entity updates can arrive.
830                            tracing::info!("Server ClearWorld ack received — gate lowered");
831                            self.pending_clear = false;
832                        }
833                        NetworkEvent::GameEvent {
834                            event: game_event, ..
835                        } => {
836                            // Forward to inner GameEvent logic if needed,
837                            // or just handle the depletion here if it's the only one.
838                            match &game_event {
839                                aetheris_protocol::events::GameEvent::AsteroidDepleted {
840                                    network_id,
841                                } => {
842                                    tracing::info!(
843                                        ?network_id,
844                                        "Asteroid depleted (via GameEvent)"
845                                    );
846                                    // Instant local despawn to hide latency
847                                    self.world_state.entities.remove(&network_id);
848
849                                    // Clear local mining target if it matches the depleted asteroid
850                                    for slot in self.world_state.entities.values_mut() {
851                                        // flags & 0x04 is local player
852                                        if (slot.flags & 0x04) != 0
853                                            && slot.mining_target_id == (network_id.0 as u16)
854                                        {
855                                            slot.mining_active = 0;
856                                            slot.mining_target_id = 0;
857                                            tracing::info!(
858                                                "Cleared local mining target due to depletion"
859                                            );
860                                        }
861                                    }
862                                }
863                                aetheris_protocol::events::GameEvent::SystemManifest {
864                                    manifest,
865                                } => {
866                                    tracing::info!(
867                                        count = manifest.len(),
868                                        "Received SystemManifest from server (via GameEvent)"
869                                    );
870                                    self.world_state.system_manifest = manifest.clone();
871                                }
872                                aetheris_protocol::events::GameEvent::Possession {
873                                    network_id: _,
874                                } => {
875                                    self.world_state.handle_game_event(&game_event);
876                                }
877                            }
878                        }
879                        #[allow(unreachable_patterns)]
880                        _ => {
881                            tracing::debug!("Unhandled outer NetworkEvent variant");
882                        }
883                    }
884                }
885
886                // 2. Apply updates to the Simulation World
887                // Skip while a ClearWorld ack is pending: any updates arriving
888                // now are stale datagrams from before the clear command.
889                if self.pending_clear {
890                    if !updates.is_empty() {
891                        tracing::debug!(
892                            count = updates.len(),
893                            "Discarding updates — pending_clear gate is raised"
894                        );
895                    }
896                } else {
897                    if !updates.is_empty() {
898                        let max_tick = updates.iter().map(|(_, u)| u.tick).max().unwrap_or(0);
899
900                        // M1020 — Continuous Clock Sync to prevent 1-2s rubber-banding
901                        if max_tick > 0 {
902                            let drift =
903                                (self.world_state.latest_tick as i32 - max_tick as i32).abs();
904                            if self.first_playground_tick || drift > 20 {
905                                tracing::info!(
906                                    latest = self.world_state.latest_tick,
907                                    server = max_tick,
908                                    drift,
909                                    "Syncing client latest_tick to server heartbeat"
910                                );
911                                self.world_state.latest_tick = max_tick;
912                                self.first_playground_tick = false;
913                            }
914                        }
915
916                        tracing::debug!(count = updates.len(), "Applying server updates to world");
917                        self.world_state.apply_updates(&updates);
918                    }
919                }
920            }
921
922            // 2.5. Fixed-Timestep Simulation Loop (M1020)
923            // The JS side (game.worker.ts) already calls tick() at ~60Hz via setTimeout.
924            // Using `if` instead of `while` prevents double-stepping when the JS timer fires
925            // slightly late (e.g. 17ms instead of 16.67ms): accumulated drift of ~0.33ms/frame
926            // would otherwise cause a second physics step every ~50 frames, causing ship lurches.
927            let now = crate::performance_now();
928            let delta_ms = now - self.last_process_time;
929            self.last_process_time = now;
930
931            // Limit delta to prevent "spiral of death" after long freezes (max 5 frames)
932            let delta_ms = delta_ms.min(100.0);
933            self.tick_accumulator += delta_ms;
934
935            const DT_MS: f64 = 1000.0 / 60.0;
936            while self.tick_accumulator >= DT_MS {
937                // 1. Apply buffered playground input locally (Prediction)
938                // We keep this enabled in the playground to maintain responsiveness (M1020).
939                // Reconciliation jitter is now mitigated by wrap-aware interpolation.
940                let applied = self.world_state.playground_apply_input(
941                    self.playground_move_x,
942                    self.playground_move_y,
943                    self.playground_actions,
944                );
945
946                if !applied && self.world_state.latest_tick % 120 == 0 {
947                    tracing::warn!(
948                        tick = self.world_state.latest_tick,
949                        "Simulation loop running but no LocalPlayer (0x04) entity found to apply input to"
950                    );
951                }
952
953                // 2. Advance global tick
954                self.world_state.latest_tick += 1;
955                self.tick_accumulator -= DT_MS;
956            }
957
958            // 2.5.5. Publish sub-tick fraction for smooth rendering (M10105)
959            // Use EMA smoothing to prevent jitter from browser timer noise
960            let fraction = (self.tick_accumulator as f32 / DT_MS as f32).clamp(0.0, 1.0);
961            let alpha = 0.8;
962            self.last_fraction = self.last_fraction * (1.0 - alpha) + fraction * alpha;
963            self.shared_world.set_sub_tick_fraction(self.last_fraction);
964            tracing::trace!(fraction, "Updated sub-tick fraction");
965
966            // 2.6. Client-side rotation animation (local, not replicated by server)
967
968            // M10105 — measure simulation time
969            let sim_start = crate::performance_now();
970
971            // 3. Write Authoritative Snapshot to Shared World for the Render Worker
972            self.flush_to_shared_world(self.world_state.latest_tick);
973
974            let sim_time_ms = crate::performance_now() - sim_start;
975            let count = self.world_state.entities.len() as u32;
976
977            // VS-02 — Update real-time cargo metrics for UI
978            let (cargo_ore, cargo_cap) = self
979                .world_state
980                .player_network_id
981                .and_then(|id| self.world_state.entities.get(&id))
982                .map_or((0, 0), |s| (s.cargo_ore as u32, s.cargo_capacity as u32));
983
984            with_collector(|c| {
985                c.record_sim(sim_time_ms);
986                c.update_entity_count(count);
987                c.update_cargo(cargo_ore, cargo_cap);
988            });
989        }
990
991        fn flush_to_shared_world(&mut self, tick: u64) {
992            let entities = &self.world_state.entities;
993            let write_buffer = self.shared_world.get_write_buffer();
994
995            let mut count = 0;
996            for (i, slot) in entities.values().enumerate() {
997                if i >= MAX_ENTITIES {
998                    tracing::warn!("Max entities reached in shared world! Overflow suppressed.");
999                    break;
1000                }
1001                write_buffer[i] = *slot;
1002                count += 1;
1003            }
1004
1005            tracing::debug!(entity_count = count, tick, "Flushed world to SAB");
1006            self.shared_world.commit_write(count as u32, tick);
1007        }
1008
1009        #[wasm_bindgen]
1010        pub async fn request_system_manifest(&mut self) -> Result<(), JsValue> {
1011            self.check_worker();
1012
1013            if let Some(transport) = &self.transport {
1014                let encoder = SerdeEncoder::new();
1015                let event = NetworkEvent::RequestSystemManifest {
1016                    client_id: ClientId(0),
1017                };
1018
1019                if let Ok(data) = encoder.encode_event(&event) {
1020                    transport
1021                        .send_reliable(ClientId(0), &data)
1022                        .await
1023                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1024                    tracing::info!("Sent RequestSystemManifest command to server");
1025                }
1026            }
1027            Ok(())
1028        }
1029
1030        #[wasm_bindgen]
1031        pub fn get_system_info(&self) -> Result<JsValue, JsValue> {
1032            serde_wasm_bindgen::to_value(&self.world_state.system_manifest)
1033                .map_err(|e| JsValue::from_str(&e.to_string()))
1034        }
1035
1036        #[wasm_bindgen]
1037        pub fn playground_spawn(&mut self, entity_type: u16, x: f32, y: f32, rotation: f32) {
1038            // Security: Prevent overflow in playground mode
1039            if self.world_state.entities.len() >= MAX_ENTITIES {
1040                tracing::warn!("playground_spawn: MAX_ENTITIES reached, spawn ignored.");
1041                return;
1042            }
1043
1044            // Sync ID generator if it's currently at default but world is seeded
1045            if self.playground_next_network_id == 1 && !self.world_state.entities.is_empty() {
1046                self.playground_next_network_id = self
1047                    .world_state
1048                    .entities
1049                    .keys()
1050                    .map(|k| k.0)
1051                    .max()
1052                    .unwrap_or(0)
1053                    + 1;
1054            }
1055
1056            let id = aetheris_protocol::types::NetworkId(self.playground_next_network_id);
1057            self.playground_next_network_id += 1;
1058            let slot = SabSlot {
1059                network_id: id.0,
1060                x,
1061                y,
1062                z: 0.0,
1063                rotation,
1064                dx: 0.0,
1065                dy: 0.0,
1066                dz: 0.0,
1067                hp: 100,
1068                shield: 100,
1069                entity_type,
1070                flags: 0x01, // ALIVE
1071                mining_active: 0,
1072                cargo_ore: 0,
1073                cargo_capacity: 0,
1074                mining_target_id: 0,
1075                padding: [0; 6],
1076            };
1077            self.world_state.entities.insert(id, slot);
1078        }
1079
1080        #[wasm_bindgen]
1081        pub async fn playground_spawn_net(
1082            &mut self,
1083            entity_type: u16,
1084            x: f32,
1085            y: f32,
1086            rot: f32,
1087        ) -> Result<(), JsValue> {
1088            self.check_worker();
1089
1090            if let Some(transport) = &self.transport {
1091                let encoder = SerdeEncoder::new();
1092                let event = NetworkEvent::Spawn {
1093                    client_id: ClientId(0),
1094                    entity_type,
1095                    x,
1096                    y,
1097                    rot,
1098                };
1099
1100                if let Ok(data) = encoder.encode_event(&event) {
1101                    transport
1102                        .send_reliable(ClientId(0), &data)
1103                        .await
1104                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1105                    tracing::info!(entity_type, x, y, "Sent Spawn command to server");
1106                }
1107            } else {
1108                // Local fallback
1109                self.playground_spawn(entity_type, x, y, rot);
1110            }
1111            Ok(())
1112        }
1113
1114        #[wasm_bindgen]
1115        pub fn playground_clear(&mut self) {
1116            self.world_state.entities.clear();
1117        }
1118
1119        /// Sends a StartSession command to the server.
1120        /// The server will spawn the session Interceptor and send back a Possession event.
1121        /// Only valid when connected; does nothing in local sandbox mode.
1122        #[wasm_bindgen]
1123        pub async fn start_session_net(&mut self) -> Result<(), JsValue> {
1124            self.check_worker();
1125
1126            if let Some(transport) = &self.transport {
1127                let encoder = SerdeEncoder::new();
1128                let event = NetworkEvent::StartSession {
1129                    client_id: ClientId(0),
1130                };
1131                if let Ok(data) = encoder.encode_event(&event) {
1132                    transport
1133                        .send_reliable(ClientId(0), &data)
1134                        .await
1135                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1136                    tracing::info!("Sent StartSession command to server");
1137                }
1138            }
1139            Ok(())
1140        }
1141
1142        /// Sends a movement/action input command to the server.
1143        ///
1144        /// The input is encoded as an unreliable component update (Kind 128)
1145        /// and sent to the server for processing in the next tick.
1146        #[wasm_bindgen]
1147        pub async fn send_input(
1148            &mut self,
1149            tick: u64,
1150            move_x: f32,
1151            move_y: f32,
1152            actions_mask: u32,
1153            target_id_arg: Option<u64>,
1154        ) -> Result<(), JsValue> {
1155            self.check_worker();
1156
1157            // 1. Identify the controlled player entity to target the command correctly
1158            let target_id = if let Some(owned_id) = self.world_state.player_network_id {
1159                // If we have an explicit possession ID from the server, use it.
1160                // This is the most reliable method (M1038).
1161                tracing::trace!(
1162                    network_id = owned_id.0,
1163                    "[send_input] Using player_network_id for input target"
1164                );
1165                Some(owned_id)
1166            } else {
1167                // Fallback: Identify via replication flags if possession event hasn't arrived yet
1168                let fallback = self
1169                    .world_state
1170                    .entities
1171                    .iter()
1172                    .find(|(_, slot)| (slot.flags & 0x04) != 0)
1173                    .map(|(id, _)| *id);
1174                tracing::trace!(
1175                    fallback_id = ?fallback,
1176                    "[send_input] No player_network_id set - using 0x04 flag fallback"
1177                );
1178                fallback
1179            };
1180
1181            let Some(target_id) = target_id else {
1182                tracing::trace!("[send_input] Input dropped: no controlled entity found");
1183                return Ok(());
1184            };
1185
1186            tracing::trace!(
1187                target_id = target_id.0,
1188                move_x,
1189                move_y,
1190                "[send_input] Sending input for entity"
1191            );
1192
1193            // 2. Prepare actions vector
1194            let mut actions = Vec::new();
1195
1196            // Movement action
1197            if move_x.abs() > f32::EPSILON || move_y.abs() > f32::EPSILON {
1198                actions.push(PlayerInputKind::Move {
1199                    x: move_x,
1200                    y: move_y,
1201                });
1202            }
1203
1204            // Bitmask actions (M1020 mapping)
1205            // Bit 0: FirePrimary
1206            if (actions_mask & 0x01) != 0 {
1207                actions.push(PlayerInputKind::FirePrimary);
1208            }
1209            // Bit 1: ToggleMining
1210            if (actions_mask & 0x02) != 0 {
1211                let target = if let Some(id) = target_id_arg {
1212                    Some(NetworkId(id))
1213                } else {
1214                    // VS-02 Auto-target: find nearest asteroid
1215                    if let Some(player_slot) = self.world_state.entities.get(&target_id) {
1216                        let player_pos = (player_slot.x, player_slot.y);
1217                        self.world_state
1218                            .entities
1219                            .iter()
1220                            .filter(|(_, slot)| slot.entity_type == 5) // Asteroid (kind 5)
1221                            .map(|(id, slot)| {
1222                                let dx = slot.x - player_pos.0;
1223                                let dy = slot.y - player_pos.1;
1224                                (id, dx * dx + dy * dy)
1225                            })
1226                            .min_by(|a, b| {
1227                                a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)
1228                            })
1229                            .map(|(id, _)| *id)
1230                    } else {
1231                        None
1232                    }
1233                };
1234
1235                if let Some(id) = target {
1236                    actions.push(PlayerInputKind::ToggleMining { target: id });
1237                } else {
1238                    tracing::warn!(
1239                        "ToggleMining requested without target_id and no asteroid nearby; dropping action"
1240                    );
1241                }
1242            }
1243
1244            // 3. Noise reduction check
1245            let is_repeated = self.last_input_actions.len() == actions.len()
1246                && self
1247                    .last_input_actions
1248                    .iter()
1249                    .zip(actions.iter())
1250                    .all(|(a, b)| a == b)
1251                && self.last_input_target == Some(target_id);
1252
1253            if move_x.abs() > f32::EPSILON || move_y.abs() > f32::EPSILON || actions_mask != 0 {
1254                if is_repeated {
1255                    tracing::trace!(
1256                        tick,
1257                        move_x,
1258                        move_y,
1259                        actions_mask,
1260                        "Client sending input (repeated)"
1261                    );
1262                } else {
1263                    tracing::trace!(tick, move_x, move_y, actions_mask, "Client sending input");
1264                }
1265            }
1266
1267            let transport = self.transport.as_ref().ok_or_else(|| {
1268                JsValue::from_str("Cannot send input: transport not initialized or closed")
1269            })?;
1270
1271            if is_repeated {
1272                tracing::trace!(
1273                    ?target_id,
1274                    x = move_x,
1275                    y = move_y,
1276                    "Sending InputCommand (repeated)"
1277                );
1278            } else {
1279                tracing::trace!(?target_id, x = move_x, y = move_y, "Sending InputCommand");
1280            }
1281
1282            // Update last input state
1283            self.last_input_target = Some(target_id);
1284            self.last_input_actions = actions.clone();
1285
1286            let cmd = InputCommand {
1287                tick,
1288                actions,
1289                last_seen_input_tick: None,
1290            }
1291            .clamped();
1292
1293            // 4. Encode as a ComponentUpdate-compatible packet
1294            // We use ComponentKind(128) as the convention for InputCommands.
1295            // The server's TickScheduler will decode this as a standard game update.
1296            let payload = rmp_serde::to_vec(&cmd)
1297                .map_err(|e| JsValue::from_str(&format!("Failed to encode InputCommand: {e:?}")))?;
1298
1299            let update = ReplicationEvent {
1300                network_id: target_id,
1301                component_kind: ComponentKind(128),
1302                payload,
1303                tick,
1304            };
1305
1306            let mut buffer = [0u8; 1024];
1307            let encoder = SerdeEncoder::new();
1308            let len = encoder.encode(&update, &mut buffer).map_err(|e| {
1309                JsValue::from_str(&format!("Failed to encode input replication event: {e:?}"))
1310            })?;
1311
1312            // 3. Send via unreliable datagram
1313            transport
1314                .send_unreliable(ClientId(0), &buffer[..len])
1315                .await
1316                .map_err(|e| {
1317                    JsValue::from_str(&format!("Transport error during send_input: {e:?}"))
1318                })?;
1319
1320            Ok(())
1321        }
1322
1323        #[wasm_bindgen]
1324        pub async fn playground_clear_server(&mut self) -> Result<(), JsValue> {
1325            self.check_worker();
1326
1327            if let Some(transport) = &self.transport {
1328                let encoder = SerdeEncoder::new();
1329                let event = NetworkEvent::ClearWorld {
1330                    client_id: ClientId(0),
1331                };
1332                if let Ok(data) = encoder.encode_event(&event) {
1333                    transport
1334                        .send_reliable(ClientId(0), &data)
1335                        .await
1336                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1337                    tracing::info!(
1338                        "Sent ClearWorld command to server — suppressing updates until ack"
1339                    );
1340                    // Immediately clear local state and raise the gate.  All incoming
1341                    // entity updates are suppressed until the server's reliable ClearWorld
1342                    // ack arrives, preventing stale in-flight datagrams from re-adding
1343                    // entities that were just despawned on the server.
1344                    self.world_state.entities.clear();
1345                    self.world_state.player_network_id = None;
1346                    self.pending_clear = true;
1347                    self.last_clear_tick = self.world_state.latest_tick;
1348                }
1349            } else {
1350                // No transport, clear immediately (no in-flight datagrams to worry about)
1351                self.world_state.entities.clear();
1352                self.world_state.player_network_id = None;
1353            }
1354            Ok(())
1355        }
1356
1357        #[wasm_bindgen]
1358        pub fn playground_set_rotation_enabled(&mut self, enabled: bool) {
1359            self.playground_rotation_enabled = enabled;
1360            // Note: Rotation toggle is currently local-authoritative in playground_tick,
1361            // but for server-side replication it would need a network event.
1362        }
1363
1364        #[wasm_bindgen]
1365        pub async fn playground_stress_test(
1366            &mut self,
1367            count: u16,
1368            rotate: bool,
1369        ) -> Result<(), JsValue> {
1370            self.check_worker();
1371
1372            if let Some(transport) = &self.transport {
1373                let encoder = SerdeEncoder::new();
1374                let event = NetworkEvent::StressTest {
1375                    client_id: ClientId(0),
1376                    count,
1377                    rotate,
1378                };
1379
1380                if let Ok(data) = encoder.encode_event(&event) {
1381                    transport
1382                        .send_reliable(ClientId(0), &data)
1383                        .await
1384                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1385                    tracing::info!(count, rotate, "Sent StressTest command to server");
1386                }
1387                self.playground_set_rotation_enabled(rotate);
1388            } else {
1389                // Fallback to local behavior if not connected
1390                self.playground_set_rotation_enabled(rotate);
1391                self.playground_clear();
1392                for _ in 0..count {
1393                    self.playground_spawn(1, 0.0, 0.0, 0.0); // Simple spawn
1394                }
1395            }
1396
1397            Ok(())
1398        }
1399
1400        #[wasm_bindgen]
1401        pub fn tick_playground(&mut self) {
1402            self.check_worker();
1403
1404            // M10105 — measure simulation time
1405            let sim_start = crate::performance_now();
1406
1407            let now = crate::performance_now();
1408            let delta_ms = now - self.last_process_time;
1409            self.last_process_time = now;
1410
1411            // Limit delta to prevent "spiral of death" (max 5 frames)
1412            let delta_ms = delta_ms.min(100.0);
1413            self.tick_accumulator += delta_ms;
1414
1415            const DT_MS: f64 = 1000.0 / 60.0;
1416            let mut steps = 0;
1417            while self.tick_accumulator >= DT_MS {
1418                self.world_state.latest_tick += 1;
1419
1420                // 1. Apply playground input (respected by prediction_enabled flag internally)
1421                self.world_state.playground_apply_input(
1422                    self.playground_move_x,
1423                    self.playground_move_y,
1424                    self.playground_actions,
1425                );
1426
1427                // 2. Local physics simulation
1428                self.world_state.simulate();
1429
1430                self.tick_accumulator -= DT_MS;
1431                steps += 1;
1432            }
1433
1434            // Sync to shared world if we simulated at least one step
1435            if steps > 0 {
1436                // Publish sub-tick fraction for smooth rendering (M10105)
1437                let fraction = (self.tick_accumulator as f32 / DT_MS as f32).clamp(0.0, 1.0);
1438                let alpha = 0.8;
1439                self.last_fraction = self.last_fraction * (1.0 - alpha) + fraction * alpha;
1440                self.shared_world.set_sub_tick_fraction(self.last_fraction);
1441
1442                let count = self.world_state.entities.len() as u32;
1443                self.flush_to_shared_world(self.world_state.latest_tick);
1444
1445                let sim_time_ms = crate::performance_now() - sim_start;
1446                with_collector(|c| {
1447                    c.record_sim(sim_time_ms);
1448                    c.update_entity_count(count);
1449                });
1450            }
1451        }
1452
1453        /// Render frame called by the Render Worker.
1454        pub fn render(&mut self) -> f64 {
1455            self.check_worker();
1456
1457            let tick = self.shared_world.tick();
1458            let entities = self.shared_world.get_read_buffer();
1459            let bounds = self.shared_world.get_room_bounds();
1460            if let Some(state) = &mut self.render_state {
1461                state.set_room_bounds(bounds);
1462            }
1463
1464            // Periodic diagnostic log for render worker
1465            thread_local! {
1466                static FRAME_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
1467            }
1468            FRAME_COUNT.with(|count| {
1469                let current = count.get();
1470                if current % 300 == 0 {
1471                    tracing::debug!(
1472                        "Aetheris Render Stats: Tick={}, Entities={}, Snapshots={}",
1473                        tick,
1474                        entities.len(),
1475                        self.snapshots.len(),
1476                    );
1477                }
1478                count.set(current + 1);
1479            });
1480
1481            // 1. Buffer new snapshots — only push when tick advances
1482            let back_tick = self.snapshots.back().map(|s| s.tick).unwrap_or(0);
1483            if tick < back_tick && tick != 0 {
1484                tracing::warn!(
1485                    tick,
1486                    back_tick,
1487                    "Simulation time went backwards! Clearing snapshot buffer."
1488                );
1489                self.snapshots.clear();
1490            }
1491
1492            if self.snapshots.is_empty() || tick > back_tick {
1493                tracing::trace!(tick, "Pushing new snapshot to buffer");
1494                self.snapshots.push_back(SimulationSnapshot {
1495                    tick,
1496                    entities: entities.to_vec(),
1497                });
1498            } else if tick == back_tick && tick != 0 {
1499                // Diagnostic for stagnant tick
1500                thread_local! {
1501                    static STAGNANT_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
1502                }
1503                STAGNANT_COUNT.with(|count| {
1504                    let cur = count.get() + 1;
1505                    if cur % 300 == 0 {
1506                        tracing::warn!(tick, "Render loop stalled on same tick for 300 frames");
1507                    }
1508                    count.set(cur);
1509                });
1510            }
1511
1512            // 2. Calculate target playback tick with high-precision sub-tick fraction.
1513            // Stay 2 ticks behind latest so we always have an (s1, s2) interpolation pair.
1514            // We use the shared_world's sub_tick_fraction to smoothly transition between
1515            // simulation steps at the monitor's full refresh rate (e.g. 144Hz).
1516            let latest_tick = self.snapshots.back().map(|s| s.tick as f32).unwrap_or(0.0);
1517            let fraction = self.shared_world.sub_tick_fraction();
1518            let mut target_tick = latest_tick - 1.0 + fraction;
1519
1520            // Ensure target_tick is within available snapshot range if possible
1521            if !self.snapshots.is_empty() {
1522                let oldest_tick = self.snapshots[0].tick as f32;
1523                if target_tick < oldest_tick {
1524                    target_tick = oldest_tick;
1525                }
1526            }
1527
1528            let ent_count = entities.len();
1529            let frame_time_ms = self.render_at_tick(target_tick);
1530
1531            // M10105 — record accurate frame time (from WGPU) + snapshot depth.
1532            let snap_count = self.snapshots.len() as u32;
1533            if tick % 60 == 0 {
1534                tracing::trace!(
1535                    tick,
1536                    ent_count,
1537                    snap_count,
1538                    target_tick,
1539                    "Render Loop Active"
1540                );
1541            }
1542            with_collector(|c| {
1543                // FPS is computed in the worker; we only report duration here.
1544                c.record_frame(frame_time_ms, 0.0);
1545                c.update_snapshot_count(snap_count);
1546            });
1547
1548            frame_time_ms
1549        }
1550
1551        fn render_at_tick(&mut self, target_tick: f32) -> f64 {
1552            if self.snapshots.len() < 2 {
1553                // If we don't have enough snapshots for interpolation,
1554                // we still want to render the background or at least one frame.
1555                if let Some(state) = &mut self.render_state {
1556                    let entities = if !self.snapshots.is_empty() {
1557                        self.snapshots[0].entities.clone()
1558                    } else {
1559                        Vec::new()
1560                    };
1561                    return state.render_frame_with_compact_slots(&entities);
1562                }
1563                return 0.0;
1564            }
1565
1566            // Find snapshots S1, S2 such that S1.tick <= target_tick < S2.tick
1567            let mut s1_idx = 0;
1568            let mut found = false;
1569
1570            for i in 0..self.snapshots.len() - 1 {
1571                if (self.snapshots[i].tick as f32) <= target_tick
1572                    && (self.snapshots[i + 1].tick as f32) > target_tick
1573                {
1574                    s1_idx = i;
1575                    found = true;
1576                    break;
1577                }
1578            }
1579
1580            if !found {
1581                // If we are outside the buffer range, clamp to the nearest edge
1582                if target_tick < self.snapshots[0].tick as f32 {
1583                    s1_idx = 0;
1584                } else {
1585                    s1_idx = self.snapshots.len() - 2;
1586                }
1587            }
1588
1589            let s1 = self.snapshots.get(s1_idx).unwrap();
1590            let s2 = self.snapshots.get(s1_idx + 1).unwrap();
1591
1592            let tick_range = (s2.tick - s1.tick) as f32;
1593            let alpha = if tick_range > 0.0 {
1594                (target_tick - s1.tick as f32) / tick_range
1595            } else {
1596                1.0
1597            }
1598            .clamp(0.0, 1.0);
1599
1600            // Interpolate entities into a reusable buffer to avoid per-frame heap allocations
1601            self.render_buffer.clear();
1602            self.render_buffer.extend_from_slice(&s2.entities);
1603
1604            // Build a lookup map from the previous snapshot for O(1) access per entity.
1605            let prev_map: std::collections::HashMap<u64, &SabSlot> =
1606                s1.entities.iter().map(|e| (e.network_id, e)).collect();
1607
1608            for ent in &mut self.render_buffer {
1609                if let Some(prev) = prev_map.get(&ent.network_id).copied() {
1610                    if let Some(bounds) = &self.world_state.room_bounds {
1611                        ent.x = lerp_wrapped(prev.x, ent.x, alpha, bounds.min_x, bounds.max_x);
1612                        ent.y = lerp_wrapped(prev.y, ent.y, alpha, bounds.min_y, bounds.max_y);
1613                    } else {
1614                        ent.x = lerp(prev.x, ent.x, alpha);
1615                        ent.y = lerp(prev.y, ent.y, alpha);
1616                    }
1617                    ent.z = lerp(prev.z, ent.z, alpha);
1618                    ent.rotation = lerp_rotation(prev.rotation, ent.rotation, alpha);
1619                }
1620            }
1621
1622            let mut frame_time = 0.0;
1623            if let Some(state) = &mut self.render_state {
1624                frame_time = state.render_frame_with_compact_slots(&self.render_buffer);
1625            }
1626
1627            // 3. Prune old snapshots.
1628            // We keep the oldest one that is still relevant for interpolation (index 0)
1629            // and everything newer. We prune snapshots that are entirely behind our window.
1630            while self.snapshots.len() > 2 && (self.snapshots[0].tick as f32) < target_tick - 1.0 {
1631                self.snapshots.pop_front();
1632            }
1633
1634            // Safety cap: prevent unbounded growth if simulation stops but render continues
1635            while self.snapshots.len() > 16 {
1636                self.snapshots.pop_front();
1637            }
1638
1639            frame_time
1640        }
1641    }
1642
1643    fn lerp(a: f32, b: f32, alpha: f32) -> f32 {
1644        a + (b - a) * alpha
1645    }
1646
1647    fn lerp_wrapped(a: f32, b: f32, alpha: f32, min: f32, max: f32) -> f32 {
1648        let size = max - min;
1649        if size <= 0.0 {
1650            return a + (b - a) * alpha;
1651        }
1652
1653        // Ensure inputs are within [min, max) before calculating diff
1654        let a_norm = (a - min).rem_euclid(size) + min;
1655        let b_norm = (b - min).rem_euclid(size) + min;
1656
1657        let mut diff = b_norm - a_norm;
1658        if diff.abs() > size * 0.5 {
1659            if diff > 0.0 {
1660                diff -= size;
1661            } else {
1662                diff += size;
1663            }
1664        }
1665        let res = a_norm + diff * alpha;
1666        // Final wrap to keep result strictly in [min, max)
1667        (res - min).rem_euclid(size) + min
1668    }
1669
1670    fn lerp_rotation(a: f32, b: f32, alpha: f32) -> f32 {
1671        // Simple rotation lerp for Phase 1.
1672        // Handles 2pi wraparound for smooth visuals.
1673        let mut diff = b - a;
1674        while diff < -std::f32::consts::PI {
1675            diff += std::f32::consts::TAU;
1676        }
1677        while diff > std::f32::consts::PI {
1678            diff -= std::f32::consts::TAU;
1679        }
1680        a + diff * alpha
1681    }
1682}