//! The mouse cursor — the real client's `Interface\Cursor\*.blp` set, shown as a crisp **hardware //! (OS-composited) cursor** so it has zero lag, matching the reference 1.12 client (whose cursor is //! lag-free on this same machine). Which cursor shows is the targeting classifier's call //! ([`crate::target::WorldCursor`], the wow-re cursor RE's decision tree): Point by default, the sword over an //! attackable unit, the speech bubble / pouch / trainer / taxi over service NPCs, loot/skin over //! corpses — each with its grayed `Unable*` twin out of range. The whole set preloads at startup //! (18 tiny BLPs); a stem missing from the archives falls back toward the base cursor, then Point. //! //! macOS needs native AppKit. winit drives the cursor through the legacy cursor-rect API //! (`addCursorRect`/`resetCursorRects`), which a continuously-redrawing Metal view doesn't honor on //! mouse-move — so winit's `CursorIcon::Custom` and `CursorOptions.visible = false` both revert to the //! arrow the instant you move (verified against winit's source). We bypass that: build an `NSCursor` //! per mode from its BLP, call `NSWindow::disableCursorRects` to stop AppKit's cursor-rect //! reconciliation, then `[cursor set]` the current mode directly (re-asserted each non-look frame). //! During mouselook we `NSCursor::hide()`/`unhide()` (the reliable app-global hide counter). Other //! platforms use winit's `CursorIcon::Custom` (which works there), swapped on mode change; their //! hide-on-look goes through `CursorOptions.visible`, handled in `player::control`. //! //! **The held cursor payload** (decision 0216 §5, wow-re cursor-system.md §1 VERIFIED): while //! `UiScript::cursor_payload()` holds a payload with a resolved icon, the HARDWARE cursor becomes //! that icon instead of the classified mode — the real client composites the item's `Interface\ //! Icons\…` art into its drag bitmap and uploads it via the same `SetHardwareCursor` path the mode //! art uses. Both platform `drive` fns below check the held payload FIRST, each frame, and fall //! back to the mode cursor when nothing is held or its icon fails to decode; each caches its built //! cursor per icon path ([`other::PayloadCursorImages`]/[`macos::PayloadCursors`]) — repeated //! pickups of the same icon never re-decode. The icons are 64×64 (unlike the mode BLPs, already //! 32×32); [`box_downsample_32`] shrinks one to the cursor's fixed 32×32 with hotspot `(0, 0)` — //! the pointer sits at the icon's top-left corner, the icon hangs down-right, matching the //! reference look (and `ui_script::extract::cursor_icon_quad`'s capture-only stand-in). use crate::assets::AssetSet; use bevy::prelude::*; /// The held cursor payload's icon path (`Interface\Icons\…`, extensionless — the DBC/FrameXML /// convention), any arm — `None` if nothing is held or that arm's icon hasn't resolved yet /// (either way, the caller falls back to the mode cursor). fn payload_icon(script: &benilla_ui::script::UiScript) -> Option { use benilla_ui::script::CursorPayload; match script.cursor_payload()? { CursorPayload::Item(i) => i.texture, CursorPayload::Spell(s) => s.texture, CursorPayload::Action(a) => a.texture, } } /// Box-downsample a raw RGBA8 image (top-to-bottom raster order — /// [`crate::assets::WorldAssets::decode_rgba`]'s layout) to a fixed 32×32 buffer: each output /// texel averages a `(w/32)×(h/32)` block of the source. Vanilla item icons are 64×64 — a clean /// 2×2 average per output texel (decision 0216 §5) — but this degrades gracefully for any other /// source size. fn box_downsample_32(w: u32, h: u32, rgba: &[u8]) -> Vec { const OUT: u32 = 32; let mut out = vec![0u8; (OUT * OUT * 4) as usize]; if w == 0 || h == 0 { return out; } for oy in 0..OUT { let y0 = oy * h / OUT; let y1 = ((oy + 1) * h / OUT).max(y0 + 1).min(h); for ox in 0..OUT { let x0 = ox * w / OUT; let x1 = ((ox + 1) * w / OUT).max(x0 + 1).min(w); let mut sum = [0u32; 4]; let mut n = 0u32; for y in y0..y1 { for x in x0..x1 { let i = ((y * w + x) * 4) as usize; for (c, s) in sum.iter_mut().enumerate() { *s += u32::from(rgba[i + c]); } n += 1; } } let o = ((oy * OUT + ox) * 4) as usize; for (c, byte) in out[o..o + 4].iter_mut().enumerate() { *byte = (sum[c] / n) as u8; } } } out } /// Decode a payload icon and box-downsample it to a 32×32 hardware-cursor-ready RGBA8 buffer /// ([`box_downsample_32`]), alpha forced fully opaque — the client's own drag-bitmap composite /// (byte-verified, wow-re cursor-dragdrop-payload.md: 64×64 → 2×2 box filter → 32×32, alpha /// written 0xFF; folded back by 0218). `None` on a missing/undecodable icon. fn decode_payload_cursor_rgba( assets: &mut crate::assets::WorldAssets, path: &str, ) -> Option> { let (w, h, rgba) = assets.decode_rgba(path)?; let mut out = box_downsample_32(w, h, &rgba); for a in out.iter_mut().skip(3).step_by(4) { *a = 0xFF; } Some(out) } /// Every cursor BLP stem benilla can currently show ([`crate::target::WorldCursor::stem`]): the /// classifier's modes + their grayed twins. Preloaded once at startup. const CURSOR_STEMS: &[&str] = &[ "Point", "Attack", "UnableAttack", "Speak", "UnableSpeak", "Pickup", "UnablePickup", "Interact", "UnableInteract", "Buy", "UnableBuy", "Inspect", // the Ctrl-hover magnifier (ShowInspectCursor) AND the TEXT-GameObject plaque cursor "UnableInspect", "Trainer", "UnableTrainer", "Taxi", "UnableTaxi", "Skin", "UnableSkin", "Repair", // the repair-mode base cursor (never grayed — the shipped UnableRepair is unreachable) // The data-driven GameObject cursors (decision 0236, wow-re cursor-system §4): a mailbox's Mail, // a lock's Mine / GatherHerbs (grayed out of reach), a picked lock's PickLock (never grayed). "Mail", "UnableMail", "Mine", "UnableMine", "GatherHerbs", "UnableGatherHerbs", "PickLock", // The spell-targeting pair (wow-re cursor-system.md §5): Cast(2) / UnableCast(22). Cast is // the armed-enchant-pick overlay's mode; the grayed twin preloads with it (the overlay's // valid/invalid split is its named refinement). "Cast", "UnableCast", ]; /// A cursor stem's archive path. fn cursor_path(stem: &str) -> String { format!("Interface\\Cursor\\{stem}.blp") } /// The **displayed** cursor — the world classifier's base [`crate::target::WorldCursor`] with the /// UI overlays applied, mirroring the client's displayed-vs-base mode pair (`0xbe2c2c`/`0xbe2c4c`, /// wow-re cursor-system.md §7): a world classification always wins; over plain UI, the repair-mode /// latch shows Repair(17) (the locked base `ShowRepairCursor` sets), else the bag hover's sell /// latch shows Buy(3) (armed only while the base is Point — `ShowContainerSellCursor 0x4fa460`). /// Read by the platform drivers instead of `WorldCursor` directly. #[derive(Resource, Clone, Copy, PartialEq, Eq, Debug, Default)] pub(crate) struct DisplayedCursor(pub(crate) crate::target::WorldCursor); fn overlay_ui_cursor( base: Res, script: Option>, pending_pick: Option>, mut displayed: ResMut, ) { use crate::target::{CursorKind, WorldCursor}; use benilla_ui::script::UiCursorMode; let mut out = *base; // Spell-targeting pre-empts the WHOLE classifier — the real dispatcher's step 2 runs before // any object resolve (wow-re cursor-system.md §5, VERIFIED; the 0446/0452 named deferral). // benilla's one targeting state is the armed enchant pick. INTERIM: plain Cast everywhere // while armed — the ref's valid/invalid split (Cast in range / UnableCast out, computed by // `0x6e6810`/`0x6e6350`) needs the item-target validity law derived before it's honest to // gray anything. if pending_pick.is_some_and(|p| p.spell_id != 0) { displayed.0 = WorldCursor { kind: CursorKind::Cast, unable: false, }; return; } if out == WorldCursor::default() { if let Some(script) = script.as_ref() { // Repair is the locked base-mode override (`ShowRepairCursor`) and wins over the // displayed-cursor family — the real client parks the base at Repair, so the sell/ // inspect gate (base == Point) bails while it holds (wow-re cursor-system.md §7). if script.repair_mode() { out.kind = CursorKind::Repair; } else { match script.ui_cursor() { Some(UiCursorMode::Buy) => out.kind = CursorKind::Buy, Some(UiCursorMode::UnableBuy) => { out = WorldCursor { kind: CursorKind::Buy, unable: true, } } Some(UiCursorMode::Inspect) => out.kind = CursorKind::Inspect, None => {} } } } } displayed.0 = out; } pub(crate) struct CursorPlugin; impl Plugin for CursorPlugin { fn build(&self, app: &mut App) { app.init_resource::(); #[cfg(target_os = "macos")] app.add_systems(Startup, macos::setup.after(AssetSet::Open)) .add_systems(Update, (overlay_ui_cursor, macos::drive).chain()); #[cfg(not(target_os = "macos"))] app.init_resource::() .add_systems(Startup, other::setup.after(AssetSet::Open)) .add_systems(Update, (overlay_ui_cursor, other::drive).chain()); } } /// Non-macOS: winit's custom OS cursor works fine — preload every mode's image and swap the /// window's `CursorIcon` when the classified mode changes. Hide-on-look is `CursorOptions.visible`, /// driven by `player::control`. #[cfg(not(target_os = "macos"))] mod other { use super::{cursor_path, payload_icon, CURSOR_STEMS}; use crate::assets::{cursor_texture, WorldAssets}; use bevy::platform::collections::{HashMap, HashSet}; use bevy::prelude::*; use bevy::window::{CursorIcon, CustomCursor, CustomCursorImage, PrimaryWindow}; /// stem → decoded cursor image, preloaded at startup. #[derive(Resource, Default)] pub(super) struct CursorImages(HashMap>); /// Held-payload icon path → its decoded 32×32 hardware cursor (decision 0216 §5), built /// lazily on first use (unlike [`CursorImages`]'s fixed startup preload — the icon set is far /// too large to preload) and cached so a repeated pickup never re-decodes. #[derive(Resource, Default)] pub(super) struct PayloadCursorImages(HashMap>); pub(super) fn setup( mut commands: Commands, world_assets: Option>, mut images: ResMut>, ) { let Some(mut world_assets) = world_assets else { return; }; let mut map = HashMap::default(); for stem in CURSOR_STEMS { match world_assets.decode_cursor(&cursor_path(stem), &mut images) { Some(handle) => { map.insert(stem.to_string(), handle); } None => warn!("cursor {stem}.blp missing/undecodable — mode falls back"), } } commands.insert_resource(CursorImages(map)); } /// Each frame: while a cursor payload with a resolved icon is held, show ITS 32×32 hardware /// cursor (decoded/downsampled on first use, then cached by path); otherwise swap to the /// classified mode (base-stem fallback, then Point, then OS) — unchanged from before 0216 §5. #[allow(clippy::too_many_arguments)] pub(super) fn drive( mut commands: Commands, cursor: Res, cursors: Option>, script: Option>, world_assets: Option>, mut images: ResMut>, mut payload_cursors: ResMut, window: Option>>, mut current: Local>, mut decode_failed: Local>, ) { let Some(window) = window else { return; }; let held_icon = script.as_ref().and_then(|s| payload_icon(s)); if let Some(icon) = held_icon { if current.as_deref() == Some(icon.as_str()) { return; // already showing this icon } if !payload_cursors.0.contains_key(&icon) && !decode_failed.contains(&icon) { let built = world_assets.and_then(|mut a| { let rgba = super::decode_payload_cursor_rgba(&mut a, &icon)?; Some(images.add(cursor_texture(32, 32, rgba))) }); match built { Some(handle) => { payload_cursors.0.insert(icon.clone(), handle); } None => { warn!("cursor payload icon {icon} missing/undecodable — mode cursor kept"); decode_failed.insert(icon.clone()); } } } if let Some(handle) = payload_cursors.0.get(&icon) { set_custom_cursor(&mut commands, *window, handle.clone()); *current = Some(icon); return; } // Decode failed (or still pending catalogs): fall through to the mode cursor below. } let Some(cursors) = cursors else { return; }; let stem = cursor.0.stem(); if current.as_deref() == Some(stem.as_str()) { return; } let handle = cursors .0 .get(&stem) .or_else(|| cursors.0.get(stem.trim_start_matches("Unable"))) .or_else(|| cursors.0.get("Point")); if let Some(handle) = handle { set_custom_cursor(&mut commands, *window, handle.clone()); } *current = Some(stem); } /// The shared `CustomCursor::Image` insert — hotspot `(0, 0)`: the vanilla mode cursors' active /// tip is their top-left pixel, and the held-payload icon is downsampled to match that same /// top-left hotspot convention (decision 0216 §5). fn set_custom_cursor(commands: &mut Commands, window: Entity, handle: Handle) { commands .entity(window) .insert(CursorIcon::Custom(CustomCursor::Image(CustomCursorImage { handle, texture_atlas: None, flip_x: false, flip_y: false, rect: None, hotspot: (0, 0), }))); } } #[cfg(target_os = "macos")] mod macos { use std::collections::{HashMap, HashSet}; use crate::assets::LockRecover; use crate::assets::WorldAssets; use crate::player::CameraControl; use benilla_formats::read_texture_rgba; use bevy::prelude::*; use objc2::rc::Retained; use objc2::ClassType; // brings `alloc()` into scope use objc2_app_kit::{NSApplication, NSCursor, NSImage}; use objc2_foundation::{MainThreadMarker, NSData, NSPoint}; use super::{cursor_path, payload_icon, CURSOR_STEMS}; /// The built `NSCursor` per mode stem. Held as a non-send resource because AppKit types aren't /// `Send`/`Sync` and must only be touched on the main thread. `pub(super)` only so it can appear /// in `drive`'s param list (the plugin registers `drive` from the parent module). pub(super) struct NativeCursors(HashMap<&'static str, Retained>); /// Held-payload icon path → its built 32×32 `NSCursor` (decision 0216 §5) — the mac twin of /// [`super::other::PayloadCursorImages`], built lazily on first use and cached by path. pub(super) struct PayloadCursors(HashMap>); /// Build every mode's `NSCursor` from its BLP and store them. An **exclusive** system so it runs /// on the main thread (AppKit's requirement) and can insert the non-send resource. The /// window-level `disableCursorRects` + first `set` happen in [`drive`], once the window /// definitely exists. pub(super) fn setup(world: &mut World) { // Inserted unconditionally (ahead of the native-cursor early-return below) — `drive` reads // it every frame regardless of whether any MODE cursor decoded. world.insert_non_send_resource(PayloadCursors(HashMap::new())); let mut cursors = HashMap::new(); for stem in CURSOR_STEMS { let Some((w, h, rgba)) = world.get_resource_mut::().and_then(|wa| { read_texture_rgba(&mut wa.chain.lock_recover(), &cursor_path(stem)).ok() }) else { warn!("cursor {stem}.blp missing/undecodable — mode falls back"); continue; }; match build_cursor(w, h, &rgba) { Some(cursor) => { cursors.insert(*stem, cursor); } None => warn!("failed to build NSCursor for {stem}.blp — mode falls back"), } } if cursors.is_empty() { warn!("no cursor BLPs available — using the OS cursor"); return; } world.insert_non_send_resource(NativeCursors(cursors)); } /// Each frame (main thread, forced by the `NonSend` params): the first frame, stop AppKit's /// cursor-rect reconciliation on our window (the thing that reverts winit's cursor on move); /// then, while not looking, assert the cursor to show — the held payload's icon (decision 0216 /// §5, decoded/downsampled to 32×32 on first use and cached by path) if one is held and /// resolved, else the classified mode (base-stem fallback, then Point); on entering/leaving /// mouselook, hide/show it via the app-global hide counter. #[allow(clippy::too_many_arguments)] pub(super) fn drive( cursors: Option>, mut payload_cursors: NonSendMut, script: Option>, world_assets: Option>, mode: Res, rig: Res, mut focus: MessageReader, mut was_looking: Local, mut rects_disabled: Local, mut decode_failed: Local>, mut last_set: Local>, ) { let Some(cursors) = cursors else { return; }; // macOS resets the app's cursor to the arrow on every activation — a set that happened // while we weren't key (the very first frames run before the window shows; any cmd-tab // away) is simply lost. Re-assert on each focus gain, the moments AppKit forgets us. // (Before this, the glue screens showed the OS arrow: their cursor never changes off // Point, so nothing ever re-asserted after activation; in-world the constant mouselook // transitions masked it.) for ev in focus.read() { if ev.focused { *last_set = None; } } let held_icon = script.as_ref().and_then(|s| payload_icon(s)); if let Some(icon) = &held_icon { if !payload_cursors.0.contains_key(icon) && !decode_failed.contains(icon) { let built = world_assets.and_then(|mut a| { let rgba = super::decode_payload_cursor_rgba(&mut a, icon)?; build_cursor(32, 32, &rgba) }); match built { Some(cursor) => { payload_cursors.0.insert(icon.clone(), cursor); } None => { warn!("cursor payload icon {icon} missing/undecodable — mode cursor kept"); decode_failed.insert(icon.clone()); } } } } let looking = rig.is_looking(); let stem = mode.0.stem(); // The held payload's cursor wins over the classified mode (falls back to the mode cursor // when nothing is held, or its icon hasn't resolved/decoded this frame). The KEY names what // we chose, so the set below fires only on a real cursor change — `NSCursor::set` is a // WindowServer round-trip that intermittently stalls the main thread for milliseconds, and // asserting it unconditionally every frame was the single biggest line of the 0366 // frame-time tail. `disableCursorRects` (below, once) is what makes the one-shot set safe: // AppKit no longer reverts the cursor on mouse-move. let key = held_icon .as_ref() .filter(|icon| payload_cursors.0.contains_key(icon.as_str())) .map(|icon| format!("payload:{icon}")) .unwrap_or_else(|| format!("mode:{stem}")); let cursor = held_icon .as_ref() .and_then(|icon| payload_cursors.0.get(icon.as_str())) .or_else(|| cursors.0.get(stem.as_str())) .or_else(|| cursors.0.get(stem.trim_start_matches("Unable"))) .or_else(|| cursors.0.get("Point")); // SAFETY: main thread (guaranteed by the `NonSend` params). hide/unhide are balanced across // the look transition so the app-global hide counter never drifts. unsafe { if !*rects_disabled { if let Some(mtm) = MainThreadMarker::new() { if let Some(window) = NSApplication::sharedApplication(mtm) .windows() .firstObject() { window.disableCursorRects(); *rects_disabled = true; } } } if looking && !*was_looking { NSCursor::hide(); // The next un-look must re-assert even an unchanged cursor (the hidden period is // AppKit's to fiddle with). *last_set = None; } else if !looking && *was_looking { NSCursor::unhide(); } if !looking && last_set.as_deref() != Some(key.as_str()) { if let Some(cursor) = cursor { cursor.set(); *last_set = Some(key); } } } *was_looking = looking; } /// RGBA → `NSCursor`. Goes via PNG + `NSImage::initWithData` (standard image data) rather than /// hand-rolling an `NSBitmapImageRep`. fn build_cursor(width: u32, height: u32, rgba: &[u8]) -> Option> { let buf = image::RgbaImage::from_raw(width, height, rgba.to_vec())?; let mut png = Vec::new(); image::DynamicImage::ImageRgba8(buf) .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) .ok()?; let data = NSData::with_bytes(&png); let image = NSImage::initWithData(NSImage::alloc(), &data)?; // Hotspot = the vanilla cursors' top-left active tip. Some(NSCursor::initWithImage_hotSpot( NSCursor::alloc(), &image, NSPoint { x: 0.0, y: 0.0 }, )) } }