right-click works on a nameplate, and the honor tab names your rank

- right-clicking a nameplate acts again: both buttons read the press pick, as the reference picks once on the down edge and never re-picks on release (2230)
- the honor tab's Highest Rank and title name the rank: a rank title's team digit is the race, not the faction group, so a GM-flagged character keeps its rank (2227)
- a raid's party feed watches only its members' descriptors, and the empty 2D opaque pass is skipped: ~0.7 ms a frame in a crowd (2225)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-15 16:57:38 +07:00
co-authored by Claude Fable 5.1
parent ec3085ea42
commit 58164abde7
16 changed files with 697 additions and 71 deletions
@@ -1680,6 +1680,10 @@ fn seat_a_session(script: &mut UiScript) {
// `realm .. " - " .. faction` at file scope, so a nil faction is 24 addons stopping on
// `attempt to concatenate local 'faction'`. Every playable race has a side.
faction_group: Some("Alliance".into()),
// `0x5efe00`'s team digit for Human (race 1) — the rank-title key's second `%d`.
// Seated beside the faction group because they are different questions
// (`ui_unit::race_pvp_team`), not because they agree here.
pvp_team: crate::ui_unit::race_pvp_team(1),
..Default::default()
}),
);
+1
View File
@@ -109,6 +109,7 @@ mod shaders;
mod game_tip;
mod name_persist;
mod opaque2d;
/// Where "the client is going down" may be observed, and why that is `Last` and not `Update`
/// (decision 1528). Every system that persists state on the way out registers through it.
mod shutdown;
+128
View File
@@ -0,0 +1,128 @@
//! **The 2D opaque pass, skipped when it has nothing to draw** (decision 2225).
//!
//! bevy's `MainOpaquePass2dNode` opens a command encoder and a render pass whether or not its two
//! phases hold an item, and on every camera of ours they never do: each player-UI quad is in the
//! transparent phase (`AlphaMode2d::Blend`, decision 0254), Bevy UI paints through its own node,
//! egui through its own. 2197 parked the pass as "price it on an immediate-mode GPU"; 2205 priced
//! its GPU at zero; the crowd profile of 2225 priced its CPU at ~0.2 ms a frame, parked, traced —
//! an encoder and a pass that wgpu sizes and clears its usage trackers for, empty or not (the
//! residency term `perf::gpu`'s census names).
//!
//! The pass exists in bevy for the clear, and the clear does not need it: whichever pass touches
//! the view target first clears it (`ColorAttachment`'s first-call semantics, the same rule
//! `static_gx` and the FFX combine already lean on), and bevy's transparent 2D node opens its pass
//! unconditionally for exactly that reason. So an empty opaque pass has nothing to do that the
//! pass after it does not.
//!
//! This is bevy's node with one early return, registered under bevy's own label:
//! `RenderGraph::add_node` is a map insert, so the later registration replaces the node and the
//! graph's edges — keyed by label — carry over untouched.
use bevy::core_pipeline::core_2d::graph::{Core2d, Node2d};
use bevy::core_pipeline::core_2d::{AlphaMask2d, Opaque2d};
use bevy::ecs::query::QueryItem;
use bevy::prelude::*;
use bevy::render::camera::ExtractedCamera;
use bevy::render::diagnostic::RecordDiagnostics;
use bevy::render::render_graph::{
NodeRunError, RenderGraphContext, RenderGraphExt, ViewNode, ViewNodeRunner,
};
use bevy::render::render_phase::{TrackedRenderPass, ViewBinnedRenderPhases};
use bevy::render::render_resource::{CommandEncoderDescriptor, RenderPassDescriptor, StoreOp};
use bevy::render::renderer::RenderContext;
use bevy::render::view::{ExtractedView, ViewDepthTexture, ViewTarget};
use bevy::render::RenderApp;
#[derive(Default)]
struct SkipEmptyOpaque2dNode;
impl ViewNode for SkipEmptyOpaque2dNode {
type ViewQuery = (
&'static ExtractedCamera,
&'static ExtractedView,
&'static ViewTarget,
&'static ViewDepthTexture,
);
fn run<'w>(
&self,
graph: &mut RenderGraphContext,
render_context: &mut RenderContext<'w>,
(camera, view, target, depth): QueryItem<'w, '_, Self::ViewQuery>,
world: &'w World,
) -> Result<(), NodeRunError> {
let (Some(opaque_phases), Some(alpha_mask_phases)) = (
world.get_resource::<ViewBinnedRenderPhases<Opaque2d>>(),
world.get_resource::<ViewBinnedRenderPhases<AlphaMask2d>>(),
) else {
return Ok(());
};
let view_entity = graph.view_entity();
let (Some(opaque_phase), Some(alpha_mask_phase)) = (
opaque_phases.get(&view.retained_view_entity),
alpha_mask_phases.get(&view.retained_view_entity),
) else {
return Ok(());
};
// The one line bevy's node does not have. Nothing to draw means nothing to encode: the
// transparent pass that follows opens its own pass unconditionally and takes the target's
// first-call clear.
if opaque_phase.is_empty() && alpha_mask_phase.is_empty() {
return Ok(());
}
let diagnostics = render_context.diagnostic_recorder();
let color_attachments = [Some(target.get_color_attachment())];
let depth_stencil_attachment = Some(depth.get_attachment(StoreOp::Store));
render_context.add_command_buffer_generation_task(move |render_device| {
let mut command_encoder =
render_device.create_command_encoder(&CommandEncoderDescriptor {
label: Some("main_opaque_pass_2d_command_encoder"),
});
let render_pass = command_encoder.begin_render_pass(&RenderPassDescriptor {
label: Some("main_opaque_pass_2d"),
color_attachments: &color_attachments,
depth_stencil_attachment,
timestamp_writes: None,
occlusion_query_set: None,
});
let mut render_pass = TrackedRenderPass::new(&render_device, render_pass);
let pass_span = diagnostics.pass_span(&mut render_pass, "main_opaque_pass_2d");
if let Some(viewport) = camera.viewport.as_ref() {
render_pass.set_camera_viewport(viewport);
}
if !opaque_phase.is_empty() {
if let Err(err) = opaque_phase.render(&mut render_pass, world, view_entity) {
error!("Error encountered while rendering the 2d opaque phase {err:?}");
}
}
if !alpha_mask_phase.is_empty() {
if let Err(err) = alpha_mask_phase.render(&mut render_pass, world, view_entity) {
error!("Error encountered while rendering the 2d alpha mask phase {err:?}");
}
}
pass_span.end(&mut render_pass);
drop(render_pass);
command_encoder.finish()
});
Ok(())
}
}
/// Replaces bevy's 2D opaque node under its own label. Added by [`crate::ui_pass::PlayerUiPlugin`],
/// which owns every camera that runs the `Core2d` graph, after `DefaultPlugins` has registered the
/// node it replaces.
pub(crate) struct SkipEmptyOpaque2dPlugin;
impl Plugin for SkipEmptyOpaque2dPlugin {
fn build(&self, app: &mut App) {
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app.add_render_graph_node::<ViewNodeRunner<SkipEmptyOpaque2dNode>>(
Core2d,
Node2d::MainOpaquePass,
);
}
}
+118 -16
View File
@@ -30,8 +30,10 @@ use super::*;
/// app's cursor-transition watcher (`crate::sound`), matching the ref's `ClearCursor` play.
pub(super) fn world_right_click_payload(
mut right_clicks: MessageReader<WorldRightClick>,
hovered: Res<Hovered>,
hovered_object: Res<HoveredObject>,
// The **press** pick, not the live hover (decision 2230) — this leg and [`act_on_right_click`]
// are two halves of one click's routing, so they must classify the same pick or a right-click
// can dismiss the cursor payload *and* interact with the thing it was held over.
press: Res<PressPick>,
script: Option<NonSendMut<UiScript>>,
) {
if right_clicks.read().last().is_none() {
@@ -40,7 +42,7 @@ pub(super) fn world_right_click_payload(
let Some(mut script) = script else {
return;
};
if hovered.target.is_some() || hovered_object.target.is_some() {
if press.hovered.target.is_some() || press.object.target.is_some() {
return;
}
script.clear_cursor_payload();
@@ -56,16 +58,21 @@ pub(super) fn world_right_click_payload(
/// replayed a [`WorldClick`] and was guarded on the press pick naming the same unit; the live probe
/// caught it — the scripted click selected nothing.
///
/// The RIGHT button is the other way round, deliberately. [`act_on_right_click`] acts on the LIVE
/// hover across many legs (interact, attack, loot, GameObject), so feeding it a unit the pointer is
/// not on could attack the wrong thing; it is replayed only when the hover already names the
/// plate's unit — the physical case. A scripted right-click on a plate does nothing yet, which is
/// a stated gap rather than a guess about which leg it should take.
/// The RIGHT button is the other way round, deliberately. [`act_on_right_click`] acts on the
/// frame's [`PressPick`] across many legs (interact, attack, loot, GameObject), so feeding it a
/// unit that pick does not name could attack the wrong thing; it is replayed only when the press
/// pick already names the plate's unit. A scripted right-click on a plate does nothing yet, which
/// is a stated gap rather than a guess about which leg it should take.
///
/// The **physical** right-click on a plate does not come through here at all and never did: the
/// press engages freelook (the camera looks through a plate — 2159), freelook hands the plates'
/// mouse back (`0x60f830`), and the release therefore hit-tests off the plate, so its `OnClick`
/// never fires. That gesture is the camera arbiter's own [`WorldRightClick`], acting on the pick
/// the press latched — which is exactly what decision 2230 made it read.
#[allow(clippy::too_many_arguments)]
pub(super) fn select_on_plate_click(
mut plate: ResMut<crate::vplates::PlateClicks>,
press: Res<PressPick>,
hovered: Res<Hovered>,
ground: Res<crate::ui_action::SpellTargeting>,
mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
@@ -95,7 +102,7 @@ pub(super) fn select_on_plate_click(
greeting.write(crate::sound::NpcGreetingRequest { npc: entity });
// The attack classification is the press pick's, and only when the press was actually on
// this unit: a scripted click has no cursor behind it, so it is not an attack-cursor click.
let attack = press.attack && press.hovered.target == Some(entity);
let attack = press.attack() && press.hovered.target == Some(entity);
scan::commit(
&mut selection,
&mut seam,
@@ -107,7 +114,7 @@ pub(super) fn select_on_plate_click(
attack,
);
}
if right.into_iter().any(|e| hovered.target == Some(e)) {
if right.into_iter().any(|e| press.hovered.target == Some(e)) {
right_clicks.write(WorldRightClick);
}
}
@@ -179,7 +186,7 @@ pub(super) fn select_on_click(
stores.get(entity).ok(),
engaged,
self_guid,
press.attack,
press.attack(),
);
}
// Clicked nothing targetable → deselect (only sends the clear if we actually had a
@@ -288,9 +295,16 @@ fn interaction_already_open_on(target: u64, interact: &crate::ui_session::Intera
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub(super) fn act_on_right_click(
mut clicks: MessageReader<WorldRightClick>,
hovered: Res<Hovered>,
hovered_object: Res<HoveredObject>,
cursor: Res<WorldCursor>,
// **The press pick, not the live hover** (decision 2230) — the same latch the left button has
// read since 1122, and for the same reason: the reference picks exactly once, on the down edge
// (`0x481f00`, one caller image-wide), into the WorldFrame's own slots, and nothing re-picks on
// move or on release. Reading the live hover here only looked right because the release frame
// re-picks at the restored cursor and, for a body click that moved nothing, lands back on the
// same unit. A V-plate has no body under the cursor to land back on: the plate published the
// mouseover at the press (`0x7cb850` → `0x492890`), freelook then took the plates' mouse away
// (`0x60f830`), and the live hover at the release was empty — so a right-click on a plate
// reached every branch below with nothing hovered and did nothing at all.
press: Res<PressPick>,
mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
self_player: Query<(Entity, &Guid, Has<Engaged>), With<SelfPlayer>>,
@@ -333,6 +347,8 @@ pub(super) fn act_on_right_click(
if clicks.read().last().is_none() {
return;
}
// The whole ladder below reads ONE pick — the gesture's own, frozen at the press.
let (hovered, hovered_object, cursor) = (&press.hovered, &press.object, &press.cursor);
// ── The interact family's ACTOR gate: am I in the saddle? ────────────────────────────────
// One predicate, the reference's own — the PLAYER's `UNIT_FIELD_MOUNTDISPLAYID` (decision
// 0481's "one mounted predicate"; wow-re `mounted-action-gate.md`: no aura, no taxi
@@ -359,7 +375,7 @@ pub(super) fn act_on_right_click(
// suppresses the send with no toast (the reference auto-walks there instead — `0x610300`, also
// no packet). The lock arm runs first in `0x5f3130`, and it does here too: a `Refuse` toasts
// even when we are also out of range.
if go_is_nearest(&hovered, &hovered_object) {
if go_is_nearest(hovered, hovered_object) {
// **The interact chain's first link** (tag `use`). "I clicked it and nothing happened" spans
// three systems — this decision, the `CMSG_GAMEOBJ_USE` it sends, and the `SMSG_SPELL_GO`
// the server answers with — and until this line existed only the *taken* branches said
@@ -2246,4 +2262,90 @@ mod tests {
set(&mut world, None);
assert_eq!(run(&mut world, "TargetLastEnemy()"), None);
}
use crate::net::{ClientCommand, NetCommands, ObjectStore};
use bevy::ecs::system::RunSystemOnce;
const F_HEALTH: u16 = 22;
const F_MAXHEALTH: u16 = 28;
const BOAR: u64 = 0xB0A2;
const ME: u64 = 0x5E1F;
fn store(pairs: &[(u16, u32)]) -> ObjectStore {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(pairs))
}
/// Everything [`act_on_right_click`] and the commit under it reach for, and nothing else.
fn right_click_world() -> (World, Entity) {
let (tx, _rx) = crossbeam_channel::unbounded::<ClientCommand>();
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<Messages<WorldRightClick>>();
world.init_resource::<PressPick>();
world.init_resource::<Selection>();
world.init_resource::<crate::ui_cast::QueuedMeleeSpell>();
world.init_resource::<crate::ui_action::AutoRepeatActive>();
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
world.init_resource::<Messages<crate::player::StandStateRequest>>();
world.init_resource::<crate::creature_anim::GestureQueue>();
world.init_resource::<crate::go_templates::GameObjectTemplates>();
world.init_resource::<crate::items::Items>();
world.init_resource::<crate::ui_action::PlayerActions>();
world.init_resource::<crate::ui_action::LearnedAbilities>();
world.init_resource::<crate::ui_quest::QuestGiver>();
world.init_resource::<crate::ui_binder::BinderState>();
world.init_resource::<crate::death::DeathNet>();
world.init_resource::<crate::ui_session::InteractNpc>();
world.init_resource::<crate::ui_action::UiErrorKeys>();
world.init_resource::<crate::ui_action::CastErrors>();
world.init_resource::<crate::ui_loot::LootLatch>();
world.init_resource::<crate::ui_mail::MailOpen>();
world.init_resource::<crate::ui_item_text::ItemTextOpen>();
world.init_resource::<crate::ui_action::GoOpenerCasts>();
world.spawn((SelfPlayer, Guid(ME)));
let boar = world
.spawn((Guid(BOAR), store(&[(F_HEALTH, 100), (F_MAXHEALTH, 100)])))
.id();
(world, boar)
}
/// **A right-click that began on a V-plate acts on that plate's unit** — the bug the director
/// reported as "right click on nameplates no longer works", and the reason
/// [`act_on_right_click`] reads [`PressPick`] (decision 2230).
///
/// The gesture it reconstructs is the whole of it: the plate published the mouseover at the
/// press, the press engaged freelook (the camera looks *through* a plate — 2159), freelook
/// handed the plates' mouse back (`0x60f830`) so the plate's own `OnClick` never fires and its
/// hover goes dark, and `update_hover` blanks the live [`Hovered`] for the whole session. What
/// survives all of that is the press latch — and a plate hover leaves **no body under the
/// cursor** for the release frame's re-p