Skip to main content

whiteout/
casc_ext.rs

1// SPDX-License-Identifier: BSD-3-Clause
2// Copyright (c) 2026 Fernando Sahmkow
3
4//! CASC entry points that need hand-written marshalling.
5//!
6//! `Storage::openOnline` takes an options struct mixing an interface
7//! pointer with a `std::function`, `Storage::readBatch` passes value
8//! objects in both directions, and progress reporting is a callback — none
9//! of those shapes is something the codegen can express, so they all cross
10//! through the shims in `bindings/c/whiteout_casc_shims.cpp`.
11//!
12//! The methods are inherent `impl`s on [`crate::casc::Storage`], so they are
13//! callable without importing anything from here. Only the request/result
14//! types below need a `use`.
15
16use core::ffi::{c_char, c_void};
17use std::ffi::{CStr, CString};
18
19use crate::casc::{FileIdHint, Storage};
20use crate::support::RawCString;
21
22extern "C" {
23    fn whiteout_casc_shim_openOnline(
24        product: *const c_char,
25        region: *const c_char,
26        build_key: *const c_char,
27        http: *mut c_void,
28        cache_dir: *const c_char,
29        locale_mask: u32,
30        pool: *mut c_void,
31    ) -> *mut c_void;
32
33    fn whiteout_casc_shim_openWithProgress(
34        path: *const c_char,
35        product: *const c_char,
36        locale_mask: u32,
37        flags: u32,
38        progress: Option<ProgressFn>,
39        user: *mut c_void,
40        pool: *mut c_void,
41    ) -> *mut c_void;
42
43    fn whiteout_casc_shim_openOnlineWithProgress(
44        product: *const c_char,
45        region: *const c_char,
46        build_key: *const c_char,
47        http: *mut c_void,
48        cache_dir: *const c_char,
49        locale_mask: u32,
50        flags: u32,
51        progress: Option<ProgressFn>,
52        user: *mut c_void,
53        pool: *mut c_void,
54    ) -> *mut c_void;
55
56    fn whiteout_casc_shim_setProgressCallback(
57        self_: *mut c_void,
58        progress: Option<ProgressFn>,
59        user: *mut c_void,
60    );
61
62    fn whiteout_casc_shim_progressStepName(step: i32) -> *const c_char;
63
64    fn whiteout_casc_shim_readBatch(
65        self_: *const c_void,
66        paths: *const *const c_char,
67        file_data_ids: *const i32,
68        hints: *const i32,
69        count: usize,
70    ) -> *mut c_void;
71
72    fn whiteout_casc_shim_readBatch_count(snapshot: *mut c_void) -> usize;
73    fn whiteout_casc_shim_readBatch_data_at(
74        snapshot: *mut c_void,
75        index: usize,
76    ) -> crate::support::RawBytes;
77    fn whiteout_casc_shim_readBatch_success_at(snapshot: *mut c_void, index: usize) -> i32;
78    fn whiteout_casc_shim_readBatch_error_at(snapshot: *mut c_void, index: usize) -> RawCString;
79    fn whiteout_casc_shim_readBatch_free(snapshot: *mut c_void);
80}
81
82// ── Progress reporting ──────────────────────────────────────────────────
83
84/// Stage of the open sequence an event belongs to.
85#[repr(i32)]
86#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
87pub enum ProgressStep {
88    /// Online: versions/cdns endpoint lookup.
89    ResolvingVersion = 0,
90    /// Build config (fetch or disk read + parse).
91    LoadingBuildConfig = 1,
92    /// CDN config (fetch or disk read + parse).
93    LoadingCdnConfig = 2,
94    /// Local `.idx` bucket files.
95    LoadingIndexFiles = 3,
96    /// Local `data.NNN` archives being memory-mapped.
97    MappingArchives = 4,
98    /// Online: per-archive `.index` fetches.
99    LoadingArchiveIndexes = 5,
100    /// ENCODING manifest decode + parse.
101    LoadingEncodingTable = 6,
102    /// TVFS sub-manifests (~870 on WoW retail).
103    LoadingVfsManifests = 7,
104    /// ROOT manifest decode + parse.
105    LoadingRootManifest = 8,
106    /// Storage is usable. Always the final event.
107    Ready = 9,
108}
109
110impl ProgressStep {
111    fn from_raw(v: i32) -> ProgressStep {
112        match v {
113            0 => ProgressStep::ResolvingVersion,
114            1 => ProgressStep::LoadingBuildConfig,
115            2 => ProgressStep::LoadingCdnConfig,
116            3 => ProgressStep::LoadingIndexFiles,
117            4 => ProgressStep::MappingArchives,
118            5 => ProgressStep::LoadingArchiveIndexes,
119            6 => ProgressStep::LoadingEncodingTable,
120            7 => ProgressStep::LoadingVfsManifests,
121            8 => ProgressStep::LoadingRootManifest,
122            _ => ProgressStep::Ready,
123        }
124    }
125
126    /// English label for this step, as the library spells it.
127    pub fn name(self) -> &'static str {
128        // SAFETY: the shim returns a static, NUL-terminated literal.
129        unsafe {
130            CStr::from_ptr(whiteout_casc_shim_progressStepName(self as i32))
131                .to_str()
132                .unwrap_or("Unknown")
133        }
134    }
135}
136
137/// Position of an event within its step's lifetime.
138#[repr(i32)]
139#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
140pub enum ProgressState {
141    /// The step is starting. Always paired with an `End`.
142    Begin = 0,
143    /// Counters advanced. Throttled — samples may be dropped.
144    Update = 1,
145    /// The step finished; the counters hold the final tally.
146    End = 2,
147}
148
149impl ProgressState {
150    fn from_raw(v: i32) -> ProgressState {
151        match v {
152            0 => ProgressState::Begin,
153            1 => ProgressState::Update,
154            _ => ProgressState::End,
155        }
156    }
157}
158
159/// Layout of `whiteout_casc_ProgressInfo` (bindings/c/whiteout_casc_progress.h).
160#[repr(C)]
161struct RawProgressInfo {
162    size: u32,
163    step: i32,
164    state: i32,
165    _pad: i32,
166    object: *const c_char,
167    current: u64,
168    total: u64,
169    bytes_done: u64,
170    bytes_total: u64,
171    step_index: u32,
172    step_count: u32,
173    elapsed_ms: f64,
174    overall_fraction: f64,
175}
176
177/// A single progress event.
178///
179/// `object` borrows from the native event, so it is only valid inside the
180/// callback — copy it out if you need to keep it.
181#[derive(Clone, Copy, Debug)]
182pub struct ProgressInfo<'a> {
183    /// Which phase this event belongs to.
184    pub step: ProgressStep,
185    /// Where in the phase's lifetime the event sits.
186    pub state: ProgressState,
187    /// What is being worked on: an archive key, a filename, `"ENCODING"`.
188    pub object: &'a str,
189    /// Items processed in this step.
190    pub current: u64,
191    /// Items in this step; 0 when unknown.
192    pub total: u64,
193    /// Bytes transferred in this step; 0 when untracked.
194    pub bytes_done: u64,
195    /// Expected bytes for this step; 0 when unknown.
196    pub bytes_total: u64,
197    /// Position of `step` in the planned sequence.
198    pub step_index: u32,
199    /// Steps planned for this operation.
200    pub step_count: u32,
201    /// Milliseconds since the operation started.
202    pub elapsed_ms: f64,
203    /// Completion of the whole operation, in `0.0..=1.0`.
204    pub overall_fraction: f64,
205}
206
207type ProgressFn = extern "C" fn(user: *mut c_void, info: *const RawProgressInfo) -> i32;
208
209/// Boxed closure plus the panic it may have raised. An `extern "C"` frame
210/// must not unwind, so a panicking callback is caught here, turned into a
211/// cancel, and resumed once the native call has returned.
212struct ProgressCtx<'f> {
213    handler: &'f mut dyn FnMut(&ProgressInfo) -> bool,
214    panic: Option<Box<dyn core::any::Any + Send>>,
215}
216
217extern "C" fn progress_trampoline(user: *mut c_void, info: *const RawProgressInfo) -> i32 {
218    if user.is_null() || info.is_null() {
219        return 1;
220    }
221    // SAFETY: `user` is the ProgressCtx we passed to the shim, which outlives
222    // the native call, and `info` is valid for the duration of this call.
223    let ctx = unsafe { &mut *(user as *mut ProgressCtx) };
224    if ctx.panic.is_some() {
225        return 0; // already unwinding — stop asking
226    }
227    let raw = unsafe { &*info };
228    let object = if raw.object.is_null() {
229        ""
230    } else {
231        unsafe { CStr::from_ptr(raw.object) }.to_str().unwrap_or("")
232    };
233    let event = ProgressInfo {
234        step: ProgressStep::from_raw(raw.step),
235        state: ProgressState::from_raw(raw.state),
236        object,
237        current: raw.current,
238        total: raw.total,
239        bytes_done: raw.bytes_done,
240        bytes_total: raw.bytes_total,
241        step_index: raw.step_index,
242        step_count: raw.step_count,
243        elapsed_ms: raw.elapsed_ms,
244        overall_fraction: raw.overall_fraction,
245    };
246
247    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (ctx.handler)(&event)));
248    match result {
249        Ok(keep_going) => i32::from(keep_going),
250        Err(payload) => {
251            ctx.panic = Some(payload);
252            0
253        }
254    }
255}
256
257/// Runs `body` with a trampoline bound to `handler`, resuming any panic the
258/// handler raised once the native call is over.
259fn with_progress_ctx<R>(
260    handler: Option<&mut dyn FnMut(&ProgressInfo) -> bool>,
261    body: impl FnOnce(Option<ProgressFn>, *mut c_void) -> R,
262) -> R {
263    match handler {
264        None => body(None, core::ptr::null_mut()),
265        Some(handler) => {
266            let mut ctx = ProgressCtx {
267                handler,
268                panic: None,
269            };
270            let out = body(
271                Some(progress_trampoline),
272                &mut ctx as *mut ProgressCtx as *mut c_void,
273            );
274            if let Some(payload) = ctx.panic.take() {
275                std::panic::resume_unwind(payload);
276            }
277            out
278        }
279    }
280}
281
282/// Anything that can be handed to C++ as an `interfaces::HttpHandler*`.
283///
284/// Implemented for both the trampoline wrapper (your own Rust
285/// implementation) and the library's built-in client, so either can drive
286/// [`Storage::open_online`].
287pub trait AsHttpHandler {
288    /// The raw `interfaces::HttpHandler*` this value stands for.
289    fn as_http_ptr(&self) -> *mut c_void;
290}
291
292impl AsHttpHandler for crate::interfaces::HostHttpHandler {
293    fn as_http_ptr(&self) -> *mut c_void {
294        self.as_ptr()
295    }
296}
297
298impl AsHttpHandler for crate::host::SimpleHttpHandler {
299    fn as_http_ptr(&self) -> *mut c_void {
300        self.raw.as_ptr() as *mut c_void
301    }
302}
303
304/// One file to read in a batch: either by CASC path, or by WoW-style
305/// FileDataId.
306#[derive(Clone, Debug, PartialEq, Eq)]
307pub enum BatchReadRequest {
308    /// Read by CASC path, e.g. `"Base\\creatures\\beast\\beast.m2"`.
309    Path(String),
310    /// Read by FileDataId, with a sub-type hint for the lookup.
311    FileId {
312        /// The FileDataId to resolve.
313        id: i32,
314        /// Which entry to pick when the id maps to several.
315        hint: FileIdHint,
316    },
317}
318
319impl BatchReadRequest {
320    /// Read by CASC path.
321    pub fn path(path: impl Into<String>) -> Self {
322        BatchReadRequest::Path(path.into())
323    }
324
325    /// Read by FileDataId, taking the primary entry.
326    pub fn file_id(id: i32) -> Self {
327        BatchReadRequest::FileId {
328            id,
329            hint: FileIdHint::None,
330        }
331    }
332}
333
334/// Result of a single file in a batch read, in request order.
335#[derive(Clone, Debug, PartialEq, Eq)]
336pub struct BatchReadResult {
337    /// File contents, or `None` when the read failed.
338    pub data: Option<Vec<u8>>,
339    /// Diagnostic message; empty on success.
340    pub error: String,
341}
342
343impl BatchReadResult {
344    /// Whether this file was read successfully.
345    pub fn is_ok(&self) -> bool {
346        self.data.is_some()
347    }
348}
349
350impl Storage {
351    /// Open a CDN-backed (online) storage.
352    ///
353    /// The returned [`Storage`] exposes the same read API as a local one.
354    ///
355    /// - `product` — product code, e.g. `"wow"`, `"w3"`, `"d3"`, `"fenris"`.
356    /// - `region` — region for version lookup; empty defaults to `"us"`.
357    /// - `http` — HTTP transport; required.
358    /// - `build_key` — optional hex build-config key; `None` takes the
359    ///   latest active build.
360    /// - `cache_dir` — optional on-disk cache; `None` keeps everything in
361    ///   memory.
362    /// - `locale_mask` — locale filter, 0 accepts all.
363    pub fn open_online(
364        product: &str,
365        region: &str,
366        http: &dyn AsHttpHandler,
367        build_key: Option<&str>,
368        cache_dir: Option<&str>,
369        locale_mask: u32,
370        pool: Option<&crate::interfaces::HostWorkerPool>,
371    ) -> Option<Storage> {
372        let product_cstr = CString::new(product).unwrap_or_default();
373        let region_cstr =
374            CString::new(if region.is_empty() { "us" } else { region }).unwrap_or_default();
375        let build_key_cstr = CString::new(build_key.unwrap_or("")).unwrap_or_default();
376        let cache_dir_cstr = CString::new(cache_dir.unwrap_or("")).unwrap_or_default();
377
378        // SAFETY: every pointer is live for the duration of the call, and
379        // the shim either returns a fresh Storage* or null.
380        unsafe {
381            Storage::from_raw(whiteout_casc_shim_openOnline(
382                product_cstr.as_ptr(),
383                region_cstr.as_ptr(),
384                build_key_cstr.as_ptr(),
385                http.as_http_ptr(),
386                cache_dir_cstr.as_ptr(),
387                locale_mask,
388                pool.map_or(core::ptr::null_mut(), |p| p.as_ptr()),
389            ) as *mut _)
390        }
391    }
392
393    /// Open a local storage, reporting progress as it goes.
394    ///
395    /// `progress` is called for every event until it returns `false`, which
396    /// cancels the open — the storage then comes back as `None` with
397    /// `last_error()` reporting cancellation. It may be called from worker
398    /// threads during the parallel phases, but never from two at once, and it
399    /// never blocks one: a slow handler costs dropped `Update` samples rather
400    /// than throughput.
401    ///
402    /// - `product` — optional product code selecting a build from a
403    ///   multi-product `.build.info`, e.g. `"w3"` vs `"w3t"`.
404    /// - `flags` — `StorageFeatureFlags` bitmask; 0 loads everything eagerly.
405    pub fn open_with_progress(
406        path: &str,
407        product: Option<&str>,
408        locale_mask: u32,
409        flags: u32,
410        pool: Option<&crate::interfaces::HostWorkerPool>,
411        progress: &mut dyn FnMut(&ProgressInfo) -> bool,
412    ) -> Option<Storage> {
413        let path_cstr = CString::new(path).unwrap_or_default();
414        let product_cstr = CString::new(product.unwrap_or("")).unwrap_or_default();
415        let pool_ptr = pool.map_or(core::ptr::null_mut(), |p| p.as_ptr());
416
417        with_progress_ctx(Some(progress), |cb, user| {
418            // SAFETY: every pointer is live for the duration of the call, and
419            // the shim either returns a fresh Storage* or null.
420            unsafe {
421                Storage::from_raw(whiteout_casc_shim_openWithProgress(
422                    path_cstr.as_ptr(),
423                    product_cstr.as_ptr(),
424                    locale_mask,
425                    flags,
426                    cb,
427                    user,
428                    pool_ptr,
429                ) as *mut _)
430            }
431        })
432    }
433
434    /// Open a CDN-backed storage, reporting progress as it goes.
435    ///
436    /// Same reporting and cancellation rules as [`Storage::open_with_progress`].
437    /// `flags` is a `StorageFeatureFlags` bitmask; pass 0 to keep the online
438    /// default (fully lazy).
439    #[allow(clippy::too_many_arguments)]
440    pub fn open_online_with_progress(
441        product: &str,
442        region: &str,
443        http: &dyn AsHttpHandler,
444        build_key: Option<&str>,
445        cache_dir: Option<&str>,
446        locale_mask: u32,
447        flags: u32,
448        pool: Option<&crate::interfaces::HostWorkerPool>,
449        progress: &mut dyn FnMut(&ProgressInfo) -> bool,
450    ) -> Option<Storage> {
451        let product_cstr = CString::new(product).unwrap_or_default();
452        let region_cstr =
453            CString::new(if region.is_empty() { "us" } else { region }).unwrap_or_default();
454        let build_key_cstr = CString::new(build_key.unwrap_or("")).unwrap_or_default();
455        let cache_dir_cstr = CString::new(cache_dir.unwrap_or("")).unwrap_or_default();
456        let http_ptr = http.as_http_ptr();
457        let pool_ptr = pool.map_or(core::ptr::null_mut(), |p| p.as_ptr());
458
459        with_progress_ctx(Some(progress), |cb, user| {
460            // SAFETY: as above.
461            unsafe {
462                Storage::from_raw(whiteout_casc_shim_openOnlineWithProgress(
463                    product_cstr.as_ptr(),
464                    region_cstr.as_ptr(),
465                    build_key_cstr.as_ptr(),
466                    http_ptr,
467                    cache_dir_cstr.as_ptr(),
468                    locale_mask,
469                    flags,
470                    cb,
471                    user,
472                    pool_ptr,
473                ) as *mut _)
474            }
475        })
476    }
477
478    /// Report progress for work that happens after open — the deferred load a
479    /// `LoadOnDemand` storage does on first access, and `prefetch()`.
480    ///
481    /// Scoped rather than a plain setter: the callback is installed for the
482    /// duration of `body` and cleared afterwards, so its borrow can't outlive
483    /// what the native side holds.
484    ///
485    /// ```no_run
486    /// # use whiteout::casc::Storage;
487    /// # let mut storage: Storage = unimplemented!();
488    /// storage.with_progress(&mut |info| {
489    ///     println!("{} {:.0}%", info.step.name(), info.overall_fraction * 100.0);
490    ///     true
491    /// }, |s| s.prefetch());
492    /// ```
493    pub fn with_progress<R>(
494        &mut self,
495        progress: &mut dyn FnMut(&ProgressInfo) -> bool,
496        body: impl FnOnce(&mut Storage) -> R,
497    ) -> R {
498        let handle = self.raw.as_ptr() as *mut c_void;
499        with_progress_ctx(Some(progress), |cb, user| {
500            // SAFETY: the callback is cleared before this scope ends, so the
501            // native side never holds a pointer to a dead context.
502            unsafe { whiteout_casc_shim_setProgressCallback(handle, cb, user) };
503            let out = body(self);
504            unsafe { whiteout_casc_shim_setProgressCallback(handle, None, core::ptr::null_mut()) };
505            out
506        })
507    }
508
509    /// Read every requested file in one native call.
510    ///
511    /// Results come back in request order; an individual failure yields a
512    /// result with `data == None` and does not affect the others. When the
513    /// storage was opened with a worker pool, resolution / raw read / BLTE
514    /// decode overlap across files — considerably faster than reading one
515    /// file at a time.
516    pub fn read_batch(&self, requests: &[BatchReadRequest]) -> Vec<BatchReadResult> {
517        if requests.is_empty() {
518            return Vec::new();
519        }
520
521        // The CStrings must outlive the call, so they are kept alongside
522        // the pointer array rather than being built inline.
523        let mut owned: Vec<Option<CString>> = Vec::with_capacity(requests.len());
524        let mut ids: Vec<i32> = Vec::with_capacity(requests.len());
525        let mut hints: Vec<i32> = Vec::with_capacity(requests.len());
526        for r in requests {
527            match r {
528                BatchReadRequest::Path(p) => {
529                    owned.push(Some(CString::new(p.as_str()).unwrap_or_default()));
530                    ids.push(-1);
531                    hints.push(0);
532                }
533                BatchReadRequest::FileId { id, hint } => {
534                    owned.push(None);
535                    ids.push(*id);
536                    hints.push(*hint as i32);
537                }
538            }
539        }
540        let ptrs: Vec<*const c_char> = owned
541            .iter()
542            .map(|o| o.as_ref().map_or(core::ptr::null(), |c| c.as_ptr()))
543            .collect();
544
545        // SAFETY: the parallel arrays are all `requests.len()` long and stay
546        // alive across the call; the snapshot is freed before returning.
547        unsafe {
548            let snap = whiteout_casc_shim_readBatch(
549                self.raw.as_ptr() as *const c_void,
550                ptrs.as_ptr(),
551                ids.as_ptr(),
552                hints.as_ptr(),
553                requests.len(),
554            );
555            if snap.is_null() {
556                return Vec::new();
557            }
558            let n = whiteout_casc_shim_readBatch_count(snap);
559            let mut out = Vec::with_capacity(n);
560            for i in 0..n {
561                let ok = whiteout_casc_shim_readBatch_success_at(snap, i) != 0;
562                // Borrowed views into the snapshot (`owner` is null), so
563                // copy them out rather than taking ownership.
564                let data = if ok {
565                    let raw = whiteout_casc_shim_readBatch_data_at(snap, i);
566                    if raw.data.is_null() {
567                        Some(Vec::new())
568                    } else {
569                        Some(core::slice::from_raw_parts(raw.data, raw.size).to_vec())
570                    }
571                } else {
572                    None
573                };
574                let raw_err = whiteout_casc_shim_readBatch_error_at(snap, i);
575                let error = if raw_err.chars.is_null() {
576                    String::new()
577                } else {
578                    String::from_utf8_lossy(core::slice::from_raw_parts(
579                        raw_err.chars as *const u8,
580                        raw_err.length,
581                    ))
582                    .into_owned()
583                };
584                out.push(BatchReadResult { data, error });
585            }
586            whiteout_casc_shim_readBatch_free(snap);
587            out
588        }
589    }
590}