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