Files
benilla-octo/crates/benilla/src/raid_marks.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

323 lines
13 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.
//! Raid-target marker **overhead billboards** — wow-re `name-render-geometry-law.md` §6
//! (VERIFIED): for each unit holding a mark on the 8-slot board (decision 0434 §6,
//! `GroupState::raid_targets`) and NOT carrying a live V-nameplate (the `[CGUnit+0xe60]==0`
//! mutual exclusion — the plate shows its own raid-icon child instead, `vplates`), the client
//! draws a separate world billboard: the anchor unit-quad LUT `(−.5,1,0),(.5,1,0),(.5,0,0),
//! (−.5,0,0)` (bottom-anchored, h-centered), the 4-column `UI-RaidTargetingIcons` atlas cell
//! (`col = idx&3`, `row = idx>>2`, cell 0.25), index list `{0,1,3,3,1,2}`.
//!
//! Seat (§6): if the unit's overhead NAME shows this frame, the marker's bottom sits **one
//! line-pitch above the top of the name block** — world Z = `anchor + (lineCount + 1)·scale`;
//! with no name it sits at the bare anchor. Camera-facing, world-scaled by the shared name
//! height-scale law ([`crate::nameplates::height_scale`]) — same world-pass unlit/Blend state
//! as the names (walls occlude; the skipped depth-write is the shared named divergence).
//!
//! Size (VERIFIED, wow-re `name-render-geometry-law.md` §6, commit 5e78ea75): the marker is a
//! **fixed unit (1×1) world billboard** — `scale` feeds only the seat (the z-raise), NEVER the
//! quad geometry. The ref builds the quad by a verbatim vertex copy of the unit LUT (no `fmul`
//! on position) and billboards it through a world-matrix-cancellation chain (`0x7bca80…`) that
//! re-normalizes the basis to unit length, so the on-screen size is a world/view-transform
//! quantity independent of the name `scale`. (The earlier "one pitch square = `scale`·1" was
//! REFUTED — that under-sized the marker ~5× for a small unit.) Drawn here at [`MARK_WORLD_SIZE`],
//! a fixed world unit, camera-facing; the absolute pixel projection is the render boundary (a
//! director look-call). The ref's third gate (`0x605f30()==0`) is unresolved and not reproduced.
use bevy::prelude::*;
use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback};
use crate::nameplates::{height_scale, Nameplates};
use crate::net::GuidIndex;
use crate::player::WorldCamera;
use crate::ui_party::GroupState;
use crate::vplates::VPlates;
/// The mark atlas (4×2 icons in a 4-column grid, cell 0.25) — the same art the popup's submenu
/// rows and the plate child slice.
const MARK_TEXTURE: &str = "mpq://interface/targetingframe/ui-raidtargetingicons.blp";
/// The marker's world size (VERIFIED, wow-re §6 / commit 5e78ea75): a **fixed** world unit,
/// independent of the name `scale`. The ref's quad is the bare unit LUT billboarded by a
/// world-matrix-cancellation chain that leaves a unit-length basis; the pixel projection is the
/// render boundary (a director look-call — adjust here if it reads too big/small).
const MARK_WORLD_SIZE: f32 = 1.0;
/// One live marker: the billboard entity, the unit it rides, and its wire icon slot.
#[derive(Clone, Copy)]
struct LiveMark {
marker: Entity,
unit: Entity,
}
/// The marker caches: the shared material, one tiny quad mesh per icon (lazy), and the live
/// marker per board slot (wire icon 0..7).
#[derive(Resource, Default)]
pub(crate) struct RaidMarks {
material: Option<Handle<StandardMaterial>>,
meshes: [Option<Handle<Mesh>>; 8],
live: [Option<LiveMark>; 8],
}
/// Marker component on a mark billboard entity (root-level, following its unit).
#[derive(Component)]
struct RaidMarkBillboard;
/// The §6 quad for one wire icon: the LUT positions (local X ∈ [−.5, .5], Y ∈ [0, 1], bottom
/// anchored), the atlas cell UVs, the ref's own index list.
fn mark_mesh(icon: u32) -> Mesh {
use bevy::asset::RenderAssetUsages;
use bevy::mesh::{Indices, PrimitiveTopology};
let (u0, v0) = ((icon & 3) as f32 * 0.25, (icon >> 2) as f32 * 0.25);
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(
Mesh::ATTRIBUTE_POSITION,
vec![
[-0.5, 1.0, 0.0],
[0.5, 1.0, 0.0],
[0.5, 0.0, 0.0],
[-0.5, 0.0, 0.0],
],
);
mesh.insert_attribute(
Mesh::ATTRIBUTE_UV_0,
vec![
[u0, v0],
[u0 + 0.25, v0],
[u0 + 0.25, v0 + 0.25],
[u0, v0 + 0.25],
],
);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 0.0, 1.0]; 4]);
mesh.insert_indices(Indices::U32(vec![0, 1, 3, 3, 1, 2]));
mesh
}
/// The marker's world transform for `unit` this frame — the §6 seat over the shared
/// anchor/scale law. Generic over the joint-globals filter like [`overhead_anchor`] (the placer
/// passes a disjoint query).
#[allow(clippy::too_many_arguments)] // the anchor law's full input set, like its callers
fn mark_place<F: bevy::ecs::query::QueryFilter>(
unit: Entity,
tf: &Transform,
plates: &Nameplates,
facing: Quat,
attach: &Query<&BoneAttach>,
fallback: &Query<&OverheadFallback>,
globals: &Query<&GlobalTransform, F>,
mounts: &Query<(), With<crate::entities::mount::MountChild>>,
) -> Transform {
let anchor = overhead_anchor(unit, tf, attach, fallback, globals, mounts);
// `scale` (the name's world height-law) drives ONLY the seat — the marker sits one
// line-pitch above the top of the name block. The quad SIZE is a fixed world unit (§6:
// `scale` never enters the marker geometry), not `scale`-scaled.
let scale = height_scale(anchor.y - tf.translation.y);
let lift = match plates.line_count(unit) {
Some(lines) => (lines as f32 + 1.0) * scale,
None => 0.0,
};
Transform {
translation: anchor + Vec3::Y * lift,
rotation: facing,
scale: Vec3::splat(MARK_WORLD_SIZE),
}
}
/// Drive the markers: walk the 8-slot board, resolve each marked guid to its streamed entity
/// (an out-of-range mark simply doesn't draw — the client can't render a unit it can't see),
/// gate on the plate exclusion, and (re)build the billboard entities. Runs after the name
/// driver (the seat reads this frame's line counts) — spawn-frame seat here, the per-frame
/// re-seat is [`place_raid_marks`].
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // one Bevy system's full input set
fn drive_raid_marks(
mut commands: Commands,
group: Res<GroupState>,
index: Res<GuidIndex>,
vplates: Res<VPlates>,
plates: Res<Nameplates>,
units: Query<&Transform>,
camera: Query<&Transform, With<WorldCamera>>,
anchor_q: (
Query<&BoneAttach>,
Query<&OverheadFallback>,
Query<&GlobalTransform>,
Query<(), With<crate::entities::mount::MountChild>>,
),
mut marks: ResMut<RaidMarks>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
asset_server: Res<AssetServer>,
) {
let Ok(cam_tf) = camera.single() else {
return;
};
let facing = cam_tf.rotation;
let material = marks
.material
.get_or_insert_with(|| {
materials.add(StandardMaterial {
base_color: Color::WHITE,
base_color_texture: Some(asset_server.load::<Image>(MARK_TEXTURE)),
// The names' world-pass state: unlit, depth-TESTED (walls occlude), Blend
// (the shared skipped-depth-write divergence).
unlit: true,
alpha_mode: AlphaMode::Blend,
cull_mode: None,
..default()
})
})
.clone();
for slot in 0..8usize {
let guid = group.raid_targets[slot];
let unit = (guid != 0)
.then(|| index.0.get(&guid).copied())
.flatten()
// The plate exclusion + a despawning unit both unmark the overlay.
.filter(|e| !vplates.0.contains(e) && units.contains(*e));
let stale = match (marks.live[slot], unit) {
(Some(live), Some(unit)) if live.unit == unit => continue, // placed per frame below
(live, _) => live,
};
if let Some(live) = stale {
if let Ok(mut e) = commands.get_entity(live.marker) {
e.despawn();
}
marks.live[slot] = None;
}
let Some(unit) = unit else {
continue;
};
let mesh = marks.meshes[slot]
.get_or_insert_with(|| meshes.add(mark_mesh(slot as u32)))
.clone();
let place = units.get(unit).map(|tf| {
mark_place(
unit,
tf,
&plates,
facing,
&anchor_q.0,
&anchor_q.1,
&anchor_q.2,
&anchor_q.3,
)
});
let marker = commands
.spawn((
Mesh3d(mesh),
MeshMaterial3d(material.clone()),
place.unwrap_or_default(),
RaidMarkBillboard,
))
.id();
marks.live[slot] = Some(LiveMark { marker, unit });
}
}
/// Seat every live marker from THIS frame's propagated pose (the nameplates placer's twin —
/// same PostUpdate window, so a moving unit's mark never trails its name).
#[allow(clippy::type_complexity)]
fn place_raid_marks(
marks: Res<RaidMarks>,
plates: Res<Nameplates>,
camera: Query<&Transform, (With<WorldCamera>, Without<RaidMarkBillboard>)>,
units: Query<&Transform, (Without<RaidMarkBillboard>, Without<WorldCamera>)>,
mut mark_tfs: Query<(&mut Transform, &mut GlobalTransform), With<RaidMarkBillboard>>,
anchor_q: (
Query<&BoneAttach>,
Query<&OverheadFallback>,
Query<&GlobalTransform, Without<RaidMarkBillboard>>,
Query<(), With<crate::entities::mount::MountChild>>,
),
) {
let Ok(cam_tf) = camera.single() else {
return;
};
let facing = cam_tf.rotation;
for live in marks.live.iter().flatten() {
let (Ok(tf), Ok((mut mtf, mut mglobal))) =
(units.get(live.unit), mark_tfs.get_mut(live.marker))
else {
continue; // spawned this frame and not yet flushed, or unit despawning — next frame
};
let place = mark_place(
live.unit,
tf,
&plates,
facing,
&anchor_q.0,
&anchor_q.1,
&anchor_q.2,
&anchor_q.3,
);
*mtf = place;
*mglobal = GlobalTransform::from(place);
}
}
/// Registers the marker driver (Update, after the name driver whose line counts seat it and the
/// V-plate drive whose exclusion gates it) and the per-frame placer (PostUpdate, the nameplates
/// placer's window).
pub(crate) struct RaidMarksPlugin;
impl Plugin for RaidMarksPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<RaidMarks>()
.add_systems(
Update,
drive_raid_marks
.after(crate::nameplates::drive_nameplates)
.after(crate::vplates::VPlateSet),
)
.add_systems(
PostUpdate,
place_raid_marks
.after(bevy::transform::TransformSystems::Propagate)
.before(bevy::camera::visibility::VisibilitySystems::CheckVisibility),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The §6 quad against the verified laws: the LUT positions (bottom-anchored, h-centered,
/// one unit square) and the 4-column atlas cells (`col = idx&3`, `row = idx>>2`, cell 0.25)
/// — skull (Lua 8 = wire 7) lands on the second row's last cell.
#[test]
fn mark_mesh_matches_the_lut_and_atlas_laws() {
let mesh = mark_mesh(0);
let pos = mesh
.attribute(Mesh::ATTRIBUTE_POSITION)
.and_then(|a| a.as_float3())
.unwrap()
.to_vec();
assert_eq!(
pos,
vec![
[-0.5, 1.0, 0.0],
[0.5, 1.0, 0.0],
[0.5, 0.0, 0.0],
[-0.5, 0.0, 0.0]
],
"the 0xce875c unit-quad LUT"
);
let cell = |icon: u32| {
let mesh = mark_mesh(icon);
let uvs = match mesh.attribute(Mesh::ATTRIBUTE_UV_0).unwrap() {
bevy::mesh::VertexAttributeValues::Float32x2(v) => v.clone(),
other => panic!("uv attribute shape: {other:?}"),
};
uvs[0] // the TL corner names the cell
};
assert_eq!(cell(0), [0.0, 0.0], "star: col 0, row 0");
assert_eq!(cell(3), [0.75, 0.0], "triangle: col 3, row 0");
assert_eq!(cell(4), [0.0, 0.25], "moon: col 0, row 1");
assert_eq!(cell(7), [0.75, 0.25], "skull: col 3, row 1");
}
}