Skip to main content

aetheris_client_wasm/
shared_world.rs

1//! Lock-free double-buffered compact replication layout.
2//!
3//! This module implements the "Shared World" logic which allows the Game Worker
4//! to write authoritative updates while the Render Worker reads a stable snapshot
5//! without blocking, satisfying the zero-cost synchronization requirement of M360.
6
7use bytemuck::{Pod, Zeroable};
8use core::sync::atomic::{AtomicU64, Ordering};
9use std::collections::HashSet;
10use std::sync::{Mutex, OnceLock};
11
12static VALID_POINTERS: OnceLock<Mutex<HashSet<usize>>> = OnceLock::new();
13
14fn get_registry() -> &'static Mutex<HashSet<usize>> {
15    VALID_POINTERS.get_or_init(|| Mutex::new(HashSet::new()))
16}
17
18/// Maximum number of entities supported in the compact shared buffer.
19/// Total slots = 16,384 (8,192 per buffer).
20pub const MAX_ENTITIES: usize = 8192;
21
22/// A single replicated entity state in the compact shared buffer (48 bytes).
23/// Optimized for Void Rush (2D gameplay with 3D elevation).
24#[derive(Copy, Clone, Debug, Pod, Zeroable)]
25#[repr(C)]
26pub struct SabSlot {
27    /// Network-wide unique entity identifier.
28    pub network_id: u64, // Offset 0, size 8
29    /// World-space position X.
30    pub x: f32, // Offset 8, size 4
31    /// World-space position Y.
32    pub y: f32, // Offset 12, size 4
33    /// World-space position Z.
34    pub z: f32, // Offset 16, size 4
35    /// Orientation yaw (rotation around Z axis).
36    pub rotation: f32, // Offset 20, size 4
37    /// Velocity vector X.
38    pub dx: f32, // Offset 24, size 4
39    /// Velocity vector Y.
40    pub dy: f32, // Offset 28, size 4
41    /// Velocity vector Z.
42    pub dz: f32, // Offset 32, size 4
43    /// Current integrity (health).
44    pub integrity: u16, // Offset 36, size 2
45    /// Current priority (shield).
46    pub priority: u16, // Offset 38, size 2
47    /// Entity type identifier.
48    pub entity_type: u16, // Offset 40, size 2
49    /// Bitfield flags (Alive: 0, Visible: 1, `LocalPlayer`: 2, Interpolate: 3, ...).
50    pub flags: u8, // Offset 42, size 1
51    /// Extraction state (0: inactive, 1: active).
52    pub extraction_active: u8, // Offset 43, size 1
53    /// Current payload count.
54    pub payload_count: u16, // Offset 44, size 2
55    /// Maximum payload capacity.
56    pub payload_capacity: u16, // Offset 46, size 2
57    /// Network ID of the extraction target (truncated to 16-bit for Phase 1).
58    pub extraction_target_id: u16, // Offset 48, size 2
59    /// Network ID of the interaction target (truncated to 16-bit for Phase 1).
60    pub interaction_target_id: u16, // Offset 50, size 2
61    /// Number of frames to display the interaction flash.
62    pub interaction_flash_ticks: u8, // Offset 52, size 1
63    /// Padding to maintain 8-byte alignment (`SabSlot` size: 56 bytes).
64    pub padding: [u8; 3], // Offset 53, size 3
65}
66
67/// The header for the `SharedArrayBuffer`.
68///
69/// `state` packs `entity_count` (high 32 bits) and `flip_bit` (low 32 bits) into a
70/// single `AtomicU64` so that readers always observe a consistent pair with a single
71/// acquire load, eliminating the TOCTOU window that existed when they were separate
72/// `AtomicU32` fields.
73#[derive(Debug)]
74#[repr(C)]
75pub struct SabHeader {
76    /// Packed atomic state: `high 32 bits = entity_count`, `low 32 bits = flip_bit` (0 or 1).
77    /// Updated with a single `Release` store in `commit_write`.
78    pub state: AtomicU64, // Offset 0
79    /// The latest server tick corresponding to the data in the active buffer.
80    pub tick: AtomicU64, // Offset 8
81    pub workspace_min_x: core::sync::atomic::AtomicU32, // Offset 16
82    pub workspace_min_y: core::sync::atomic::AtomicU32, // Offset 20
83    pub workspace_max_x: core::sync::atomic::AtomicU32, // Offset 24
84    pub workspace_max_y: core::sync::atomic::AtomicU32, // Offset 28
85    /// Seqlock counter for workspace bounds. Odd = write in progress; even = stable.
86    pub workspace_bounds_seq: core::sync::atomic::AtomicU32, // Offset 32
87    /// Sub-tick progress (0.0 to 1.0) for visual interpolation.
88    pub sub_tick_fraction: core::sync::atomic::AtomicU32, // Offset 36
89}
90
91/// Total size in bytes required for the compact replication layout.
92/// 32 bytes (Header) + 384 KiB (Buffer A) + 384 KiB (Buffer B) = 768 KiB + 32 bytes.
93/// Note: Rounded to 768 KiB in documentation, exact size is 786,464 bytes.
94pub const SHARED_MEMORY_SIZE: usize =
95    core::mem::size_of::<SabHeader>() + (core::mem::size_of::<SabSlot>() * MAX_ENTITIES * 2);
96
97/// Returns the size in bytes required for the shared world buffer.
98#[cfg(target_arch = "wasm32")]
99#[wasm_bindgen::prelude::wasm_bindgen]
100pub fn shared_world_size() -> usize {
101    SHARED_MEMORY_SIZE
102}
103
104/// A lock-free double buffer for compact entity replication.
105/// This points into a `SharedArrayBuffer` allocated by the Main Thread.
106pub struct SharedWorld {
107    ptr: *mut u8,
108    owns_memory: bool,
109}
110
111impl SharedWorld {
112    /// Initializes the `SharedWorld` from a raw memory pointer.
113    ///
114    /// # Safety
115    /// The pointer must remain valid for the lifetime of this object and must
116    /// point to a region of at least `SHARED_MEMORY_SIZE` bytes.
117    pub unsafe fn from_ptr(ptr: *mut u8) -> Self {
118        Self {
119            ptr,
120            owns_memory: false,
121        }
122    }
123
124    /// Creates a new `SharedWorld` by allocating its own memory (fallback/local use).
125    #[allow(clippy::missing_panics_doc)]
126    #[must_use]
127    pub fn new() -> Self {
128        let layout = core::alloc::Layout::from_size_align(SHARED_MEMORY_SIZE, 8)
129            .expect("Invalid SHARED_MEMORY_SIZE or alignment constants");
130        let ptr = unsafe { std::alloc::alloc_zeroed(layout) };
131
132        if ptr.is_null() {
133            std::alloc::handle_alloc_error(layout);
134        }
135
136        // Register the pointer for JS-boundary validation
137        get_registry()
138            .lock()
139            .expect("Registry mutex poisoned")
140            .insert(ptr as usize);
141
142        Self {
143            ptr,
144            owns_memory: true,
145        }
146    }
147
148    /// Validates if a raw pointer was registered by a living `SharedWorld` instance.
149    #[allow(clippy::missing_panics_doc)]
150    #[must_use]
151    pub fn is_valid(ptr: *mut u8) -> bool {
152        get_registry()
153            .lock()
154            .expect("Registry mutex poisoned")
155            .contains(&(ptr as usize))
156    }
157
158    /// Returns the raw pointer to the base of the shared world buffer.
159    #[must_use]
160    pub fn as_ptr(&self) -> *mut u8 {
161        self.ptr
162    }
163
164    #[allow(clippy::cast_ptr_alignment)]
165    fn header(&self) -> &SabHeader {
166        unsafe { &*(self.ptr.cast::<SabHeader>()) }
167    }
168
169    /// Returns the active buffer index (0 or 1).
170    #[must_use]
171    pub fn active_index(&self) -> u32 {
172        (self.header().state.load(Ordering::Acquire) & 0xFFFF_FFFF) as u32
173    }
174
175    /// Returns the entity count for the active buffer.
176    #[must_use]
177    pub fn entity_count(&self) -> u32 {
178        (self.header().state.load(Ordering::Acquire) >> 32) as u32
179    }
180
181    /// Returns the server tick for the active buffer.
182    #[must_use]
183    pub fn tick(&self) -> u64 {
184        self.header().tick.load(Ordering::Acquire)
185    }
186
187    /// Returns the sub-tick progress fraction (0.0 to 1.0).
188    #[must_use]
189    pub fn sub_tick_fraction(&self) -> f32 {
190        f32::from_bits(self.header().sub_tick_fraction.load(Ordering::Acquire))
191    }
192
193    /// Updates the sub-tick progress fraction.
194    pub fn set_sub_tick_fraction(&self, fraction: f32) {
195        self.header()
196            .sub_tick_fraction
197            .store(fraction.to_bits(), Ordering::Release);
198    }
199
200    /// Returns a slice of the entities in the buffer index i.
201    #[allow(clippy::cast_ptr_alignment)]
202    fn get_buffer(&self, idx: usize) -> &[SabSlot] {
203        let offset = core::mem::size_of::<SabHeader>()
204            + (idx * MAX_ENTITIES * core::mem::size_of::<SabSlot>());
205        unsafe { core::slice::from_raw_parts(self.ptr.add(offset).cast::<SabSlot>(), MAX_ENTITIES) }
206    }
207
208    /// Returns a mutable slice of the entities in the buffer index i.
209    #[allow(clippy::cast_ptr_alignment, clippy::mut_from_ref)]
210    fn get_buffer_mut(&self, idx: usize) -> &mut [SabSlot] {
211        let offset = core::mem::size_of::<SabHeader>()
212            + (idx * MAX_ENTITIES * core::mem::size_of::<SabSlot>());
213        unsafe {
214            core::slice::from_raw_parts_mut(self.ptr.add(offset).cast::<SabSlot>(), MAX_ENTITIES)
215        }
216    }
217
218    /// Returns the entities currently visible to readers.
219    ///
220    /// Both the active buffer index and the entity count are derived from a single
221    /// atomic load, so readers always see a consistent pair.
222    #[must_use]
223    pub fn get_read_buffer(&self) -> &[SabSlot] {
224        let state = self.header().state.load(Ordering::Acquire);
225        let active = (state & 0xFFFF_FFFF) as usize;
226        let count = ((state >> 32) as usize).min(MAX_ENTITIES);
227        &self.get_buffer(active)[..count]
228    }
229
230    /// Returns the buffer currently available for writing (inactive buffer).
231    #[must_use]
232    pub fn get_write_buffer(&self) -> &mut [SabSlot] {
233        let active = self.active_index() as usize;
234        let inactive = 1 - active;
235        self.get_buffer_mut(inactive)
236    }
237
238    /// Swaps the active buffer and updates the entity count and tick.
239    pub fn commit_write(&self, entity_count: u32, tick: u64) {
240        let active = self.active_index();
241        let next_active = 1 - active;
242
243        let packed = (u64::from(entity_count) << 32) | u64::from(next_active);
244        self.header().tick.store(tick, Ordering::Release);
245        self.header().state.store(packed, Ordering::Release);
246    }
247
248    /// Updates the workspace bounds using a seqlock so readers always see a consistent
249    /// rectangle. The sequence number is bumped to an odd value before writing and
250    /// back to an even value (with `Release` ordering) after, matching the acquire
251    /// fence in `get_workspace_bounds`.
252    pub fn set_workspace_bounds(&self, min_x: f32, min_y: f32, max_x: f32, max_y: f32) {
253        let h = self.header();
254        let seq = h.workspace_bounds_seq.load(Ordering::Relaxed);
255        // Mark write in progress: odd sequence number.
256        h.workspace_bounds_seq
257            .store(seq.wrapping_add(1), Ordering::Relaxed);
258        core::sync::atomic::fence(Ordering::Release);
259        h.workspace_min_x.store(min_x.to_bits(), Ordering::Relaxed);
260        h.workspace_min_y.store(min_y.to_bits(), Ordering::Relaxed);
261        h.workspace_max_x.store(max_x.to_bits(), Ordering::Relaxed);
262        h.workspace_max_y.store(max_y.to_bits(), Ordering::Relaxed);
263        // Mark write complete: even sequence number, visible to readers.
264        h.workspace_bounds_seq
265            .store(seq.wrapping_add(2), Ordering::Release);
266    }
267
268    /// Reads the workspace bounds, retrying if a concurrent write is detected via the
269    /// seqlock. Guaranteed to return a consistent (non-torn) rectangle.
270    #[must_use]
271    pub fn get_workspace_bounds(&self) -> (f32, f32, f32, f32) {
272        let h = self.header();
273        loop {
274            let seq1 = h.workspace_bounds_seq.load(Ordering::Acquire);
275            if seq1 & 1 != 0 {
276                // Write in progress — spin.
277                core::hint::spin_loop();
278                continue;
279            }
280            let min_x = f32::from_bits(h.workspace_min_x.load(Ordering::Relaxed));
281            let min_y = f32::from_bits(h.workspace_min_y.load(Ordering::Relaxed));
282            let max_x = f32::from_bits(h.workspace_max_x.load(Ordering::Relaxed));
283            let max_y = f32::from_bits(h.workspace_max_y.load(Ordering::Relaxed));
284            core::sync::atomic::fence(Ordering::Acquire);
285            let seq2 = h.workspace_bounds_seq.load(Ordering::Relaxed);
286            if seq1 == seq2 {
287                return (min_x, min_y, max_x, max_y);
288            }
289            // Torn read — retry.
290            core::hint::spin_loop();
291        }
292    }
293}
294
295impl Drop for SharedWorld {
296    #[allow(clippy::missing_panics_doc)]
297    fn drop(&mut self) {
298        if self.owns_memory {
299            if let Ok(mut reg) = get_registry().lock() {
300                reg.remove(&(self.ptr as usize));
301            }
302
303            let layout = core::alloc::Layout::from_size_align(SHARED_MEMORY_SIZE, 8)
304                .expect("Invalid SHARED_MEMORY_SIZE or alignment constants");
305
306            unsafe { std::alloc::dealloc(self.ptr, layout) };
307        }
308    }
309}
310
311impl Default for SharedWorld {
312    fn default() -> Self {
313        Self::new()
314    }
315}