1#![warn(clippy::all, clippy::pedantic)]
7#![cfg_attr(
11 all(target_arch = "wasm32", feature = "nightly"),
12 feature(thread_local)
13)]
14
15pub mod auth;
16pub mod shared_world;
17pub mod world_state;
18
19#[cfg(target_arch = "wasm32")]
20pub mod metrics;
21
22#[cfg(test)]
23#[cfg(target_arch = "wasm32")]
24pub mod smoke_test;
25
26pub mod auth_proto {
30 #![allow(clippy::must_use_candidate, clippy::doc_markdown)]
31 tonic::include_proto!("aetheris.auth.v1");
32}
33
34#[cfg(target_arch = "wasm32")]
35pub mod transport;
36
37#[cfg(test)]
38pub mod transport_mock;
39
40#[cfg(target_arch = "wasm32")]
41pub mod render;
42
43#[cfg(any(target_arch = "wasm32", test))]
44pub mod render_primitives;
45
46#[cfg(any(target_arch = "wasm32", test))]
47pub mod assets;
48
49#[cfg(target_arch = "wasm32")]
50#[cfg_attr(feature = "nightly", thread_local)]
51static _TLS_ANCHOR: u8 = 0;
52
53use std::sync::atomic::AtomicUsize;
54#[cfg(target_arch = "wasm32")]
55use std::sync::atomic::Ordering;
56
57#[allow(dead_code)]
58static NEXT_WORKER_ID: AtomicUsize = AtomicUsize::new(1);
59
60#[cfg(target_arch = "wasm32")]
61thread_local! {
62 static WORKER_ID: usize = NEXT_WORKER_ID.fetch_add(1, Ordering::Relaxed);
63}
64
65#[must_use]
67pub fn performance_now() -> f64 {
68 #[cfg(target_arch = "wasm32")]
69 {
70 use wasm_bindgen::JsCast;
71 let global = js_sys::global();
72
73 if let Ok(worker) = global.clone().dyn_into::<web_sys::WorkerGlobalScope>() {
75 return worker.performance().map(|p| p.now()).unwrap_or(0.0);
76 }
77
78 if let Ok(window) = global.dyn_into::<web_sys::Window>() {
80 return window.performance().map(|p| p.now()).unwrap_or(0.0);
81 }
82
83 js_sys::Date::now()
85 }
86 #[cfg(not(target_arch = "wasm32"))]
87 {
88 0.0
89 }
90}
91
92#[allow(dead_code)]
93pub(crate) fn get_worker_id() -> usize {
94 #[cfg(target_arch = "wasm32")]
95 {
96 WORKER_ID.with(|&id| id)
97 }
98 #[cfg(not(target_arch = "wasm32"))]
99 {
100 0
101 }
102}
103
104#[cfg(target_arch = "wasm32")]
105mod wasm_impl {
106 use crate::assets;
107 use crate::metrics::with_collector;
108 use crate::performance_now;
109 use crate::render::RenderState;
110 use crate::shared_world::{MAX_ENTITIES, SabSlot, SharedWorld};
111 use crate::transport::WebTransportBridge;
112 use crate::world_state::ClientWorld;
113 use aetheris_encoder_serde::SerdeEncoder;
114 use aetheris_protocol::events::{NetworkEvent, ReplicationEvent};
115 use aetheris_protocol::traits::{Encoder, GameTransport, 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 #[derive(Clone)]
133 pub struct SimulationSnapshot {
134 pub tick: u64,
135 pub entities: Vec<SabSlot>,
136 }
137
138 #[wasm_bindgen]
140 pub struct AetherisClient {
141 pub(crate) shared_world: SharedWorld,
142 pub(crate) world_state: ClientWorld,
143 pub(crate) render_state: Option<RenderState>,
144 pub(crate) transport: Option<Box<dyn GameTransport>>,
145 pub(crate) worker_id: usize,
146 pub(crate) session_token: Option<String>,
147
148 pub(crate) snapshots: std::collections::VecDeque<SimulationSnapshot>,
150
151 pub(crate) last_rtt_ms: f64,
153 ping_counter: u64,
154
155 reassembler: aetheris_protocol::Reassembler,
156 connection_state: ConnectionState,
157 reconnect_attempts: u32,
158 playground_rotation_enabled: bool,
159 playground_next_network_id: u64,
160 first_playground_tick: bool,
161
162 pub(crate) render_buffer: Vec<SabSlot>,
164 pub(crate) asset_registry: assets::AssetRegistry,
165
166 last_input_target: Option<NetworkId>,
168 last_input_actions: Vec<PlayerInputKind>,
169
170 pending_clear: bool,
171 last_clear_tick: u64,
172
173 last_process_time: f64,
175 tick_accumulator: f64,
176
177 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 #[wasm_bindgen(constructor)]
189 pub fn new(shared_world_ptr: Option<u32>) -> Result<AetherisClient, JsValue> {
190 console_error_panic_hook::set_once();
191
192 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 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 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 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 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 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 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 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 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 pub fn set_session_token(&mut self, token: String) {
473 self.session_token = Some(token);
474 }
475
476 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 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 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 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 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 if let Some(_transport) = &self.transport {}
637
638 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 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), 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 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 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 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 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 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 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 }
822 NetworkEvent::Spawn { .. } => {
823 }
825 NetworkEvent::ClearWorld { .. } => {
826 tracing::info!("Server ClearWorld ack received — gate lowered");
831 self.pending_clear = false;
832 }
833 NetworkEvent::GameEvent {
834 event: game_event, ..
835 } => {
836 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 self.world_state.entities.remove(&network_id);
848
849 for slot in self.world_state.entities.values_mut() {
851 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 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 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 let now = crate::performance_now();
928 let delta_ms = now - self.last_process_time;
929 self.last_process_time = now;
930
931 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 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 self.world_state.latest_tick += 1;
955 self.tick_accumulator -= DT_MS;
956 }
957
958 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 let sim_start = crate::performance_now();
970
971 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 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 if self.world_state.entities.len() >= MAX_ENTITIES {
1040 tracing::warn!("playground_spawn: MAX_ENTITIES reached, spawn ignored.");
1041 return;
1042 }
1043
1044 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, 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 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 #[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 #[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 let target_id = if let Some(owned_id) = self.world_state.player_network_id {
1159 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 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 let mut actions = Vec::new();
1195
1196 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 if (actions_mask & 0x01) != 0 {
1207 actions.push(PlayerInputKind::FirePrimary);
1208 }
1209 if (actions_mask & 0x02) != 0 {
1211 let target = if let Some(id) = target_id_arg {
1212 Some(NetworkId(id))
1213 } else {
1214 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) .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 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 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 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 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 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 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 }
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 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); }
1395 }
1396
1397 Ok(())
1398 }
1399
1400 #[wasm_bindgen]
1401 pub fn tick_playground(&mut self) {
1402 self.check_worker();
1403
1404 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 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 self.world_state.playground_apply_input(
1422 self.playground_move_x,
1423 self.playground_move_y,
1424 self.playground_actions,
1425 );
1426
1427 self.world_state.simulate();
1429
1430 self.tick_accumulator -= DT_MS;
1431 steps += 1;
1432 }
1433
1434 if steps > 0 {
1436 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 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 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 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 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 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 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 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 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 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 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 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 self.render_buffer.clear();
1602 self.render_buffer.extend_from_slice(&s2.entities);
1603
1604 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 while self.snapshots.len() > 2 && (self.snapshots[0].tick as f32) < target_tick - 1.0 {
1631 self.snapshots.pop_front();
1632 }
1633
1634 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 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 (res - min).rem_euclid(size) + min
1668 }
1669
1670 fn lerp_rotation(a: f32, b: f32, alpha: f32) -> f32 {
1671 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}