Files
benilla-octo/crates/benilla/src/cursor.rs
T
SamandClaude Fable 5 2ad0043523 benilla: initial public release
A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:27:01 +07:00

514 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String> {
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<u8> {
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<Vec<u8>> {
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<crate::target::WorldCursor>,
script: Option<bevy::ecs::system::NonSend<benilla_ui::script::UiScript>>,
pending_pick: Option<Res<crate::ui_craft::PendingItemCast>>,
mut displayed: ResMut<DisplayedCursor>,
) {
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::<DisplayedCursor>();
#[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::<other::PayloadCursorImages>()
.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<String, Handle<Image>>);
/// 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<String, Handle<Image>>);
pub(super) fn setup(
mut commands: Commands,
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
) {
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<super::DisplayedCursor>,
cursors: Option<Res<CursorImages>>,
script: Option<NonSend<benilla_ui::script::UiScript>>,
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut payload_cursors: ResMut<PayloadCursorImages>,
window: Option<Single<Entity, With<PrimaryWindow>>>,
mut current: Local<Option<String>>,
mut decode_failed: Local<HashSet<String>>,
) {
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<Image>) {
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<NSCursor>>);
/// 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<String, Retained<NSCursor>>);
/// 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::<WorldAssets>().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<NonSend<NativeCursors>>,
mut payload_cursors: NonSendMut<PayloadCursors>,
script: Option<NonSend<benilla_ui::script::UiScript>>,
world_assets: Option<ResMut<WorldAssets>>,
mode: Res<super::DisplayedCursor>,
rig: Res<CameraControl>,
mut focus: MessageReader<bevy::window::WindowFocused>,
mut was_looking: Local<bool>,
mut rects_disabled: Local<bool>,
mut decode_failed: Local<HashSet<String>>,
mut last_set: Local<Option<String>>,
) {
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<Retained<NSCursor>> {
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 },
))
}
}