diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index b749f5ea..de6fb98b 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -618,7 +618,7 @@
"RowEnableAll", "RowMaster", "RowSound", "RowMusic", "RowAmbience",
"RowEnableMusic", "RowEnableAmbience",
},
- Graphics = { "RowUiScale", "RowWorldDetail", "RowVerticalSync" },
+ Graphics = { "RowUiScale", "RowFarclip", "RowWorldDetail", "RowVerticalSync" },
Nameplates = { "RowPlayerNames", "RowNpcNames", "RowOwnName" },
-- The stock globals that were already defined AND consumed when 1134 opened the store
-- (1136; Buff Durations 1139), plus the two equipment-display toggles (1472), in the
@@ -2157,18 +2157,22 @@
-
+ so the checkbox doesn't ship. Terrain Distance: farclip
+ 177..777 step 60 = (max-min)/10 (FARCLIP "Terrain Distance",
+ raw-yards readout — era's law when a slider has no formatter);
+ retired by the director in 0961 and back by their call in
+ 1513, now that the one number moves the far-clip wall AND how
+ much terrain is resident, as it does in the reference.
+ Environment Detail (0992) is the same table's WorldDetail row
+ (0..2 step 1, label ENVIRONMENT_DETAIL) over the clutter-density
+ knob, worn as the dropdown row — the shape call is the
+ template's comment. -->
@@ -2191,11 +2195,22 @@
self.deferred = 1
-
+
+
+ OptionsRow_OnLoad(self, "farclip", "Terrain Distance",
+ "OPTION_TOOLTIP_FARCLIP")
+ OptionsSliderRow_Setup(self, 177, 777, 60, "raw")
+
+
+
+
+
+
+
OptionsRow_OnLoad(self, "WorldDetail", "Environment Detail",
"OPTION_TOOLTIP_ENVIRONMENT_DETAIL")
diff --git a/crates/benilla-app/assets/ui/WorldMapFrame.xml b/crates/benilla-app/assets/ui/WorldMapFrame.xml
index 2bc16e1a..7dc02514 100644
--- a/crates/benilla-app/assets/ui/WorldMapFrame.xml
+++ b/crates/benilla-app/assets/ui/WorldMapFrame.xml
@@ -20,8 +20,9 @@
art spun by SetRotation(GetPlayerFacing()) — the reference renders an engine arrow model;
0203 flags the stand-in).
- OUT (later slices, per 0203): POI landmarks;
- party/raid/battlefield/corpse/ping children (no group wire); the magnifying-glass zoom-out
+ OUT (later slices, per 0203): the AreaPOI.dbc landmarks (the POI pool IS here — the guard's
+ directions marker fills it today, and the DBC rows join through the same GetNumMapLandmarks);
+ party/raid/battlefield/ping children (no group wire); the magnifying-glass zoom-out
shortcut (redundant with the button); SetupFullscreenScale (the map renders at its native
1024×768 centered; a scale pass is a taste knob); WorldMapTooltip (only unit blips used it).
@@ -35,6 +36,7 @@
+
@@ -553,7 +632,8 @@
(WorldMapFrameTemplates.xml l.55-88) inlined: 16x16, the POIIcons skull
cell (0.875-1.0 / 0-0.125), hover names it CORPSE_RED. The ref anchors its
tooltip to a WorldMapTooltip; ours rides the shared GameTooltip (this
- map ships no separate tooltip — header note), same side-pick law. -->
+ map ships no separate tooltip — header note) through the side-pick every
+ marker on the sheet shares. -->
@@ -566,15 +646,7 @@
- local x = this:GetCenter()
- local parentX = this:GetParent():GetCenter()
- if x > parentX then
- GameTooltip:SetOwner(this, "ANCHOR_LEFT")
- else
- GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
- end
- GameTooltip:SetText("|cffff2020Corpse|r")
- GameTooltip:Show()
+ WorldMapMarker_ShowTooltip(this, "|cffff2020Corpse|r")
GameTooltip:Hide()
diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs
index dc1e3bb3..00141b44 100644
--- a/crates/benilla-app/src/capture/mod.rs
+++ b/crates/benilla-app/src/capture/mod.rs
@@ -88,6 +88,7 @@ mod probe_castcancel;
mod probe_charcreate;
mod probe_chest;
mod probe_crossing;
+mod probe_guard_poi;
mod probe_mail;
mod probe_melee;
mod probe_partner;
@@ -108,6 +109,7 @@ pub(crate) use probe_castcancel::ProbeCastCancelPlugin;
pub(crate) use probe_charcreate::ProbeCharCreatePlugin;
pub(crate) use probe_chest::ProbeChestPlugin;
pub(crate) use probe_crossing::ProbeCrossingPlugin;
+pub(crate) use probe_guard_poi::ProbeGuardPoiPlugin;
pub(crate) use probe_mail::ProbeMailPlugin;
pub(crate) use probe_melee::ProbeMeleePlugin;
pub(crate) use probe_partner::ProbePartnerPlugin;
diff --git a/crates/benilla-app/src/capture/probe_guard_poi.rs b/crates/benilla-app/src/capture/probe_guard_poi.rs
new file mode 100644
index 00000000..5320f781
--- /dev/null
+++ b/crates/benilla-app/src/capture/probe_guard_poi.rs
@@ -0,0 +1,219 @@
+//! The guard-directions live probe (`WOW_PROBE=guardpoi`) — the end-to-end instrument for
+//! [`crate::poi_marker`], inert without the env: once in-world, GM-hop to a Stormwind City Guard,
+//! open his gossip on the real wire (`CMSG_GOSSIP_HELLO` → `SMSG_GOSSIP_MESSAGE`), click the
+//! directions option, and report what `SMSG_GOSSIP_POI` actually put on the map.
+//!
+//! The verdict is machine-checked against the server's own row, so a wrong parse cannot pass: the
+//! guard's "Weapons Trainer" option carries `action_poi_id = 808`, whose `points_of_interest` row
+//! is `("Woo Ping", -8796.2, 613.098, icon 6, flags 99)`. The probe asserts the name, the position,
+//! the icon and the flags, then classifies the marker exactly as the minimap's landmark pass would
+//! — distance, view radius, and which of the two draws it takes — so the run says *what would be
+//! on screen*, not merely that a packet arrived.
+//!
+//! Non-combat. Pair with the SLOT-KEYED probe identity (`WOW_USER=probeN WOW_PASS=pprobeN
+//! WOW_CHAR=Probe`; `method.md`, "The local vmangos server"), and `WOW_NOSOUND=1` when
+//! it runs unattended. One `timeout`'d run plus a grep for `PROBE guardpoi:` is the whole harness.
+
+use bevy::prelude::*;
+
+use super::probes::ProbeClock;
+use crate::net::{ClientCommand, Guid, NetEntity, ObjectStore, SelfPlayer};
+use crate::player::Player;
+use crate::poi_marker::PoiMarker;
+use crate::ui_gossip::GossipState;
+
+/// A Stormwind City Guard's spawn (vmangos `creature` guid 79664, entry 68, map 0) — the `.go xyz`
+/// target. The guard himself is then found in the streamed world by his gossip flag, never by a
+/// hardcoded guid.
+const GUARD_AT: [f32; 3] = [-8854.14, 541.299, 105.984];
+/// `UNIT_NPC_FLAG_GOSSIP` (bit 0) — every direction-giving guard carries it.
+const NPC_FLAG_GOSSIP: u32 = 0x1;
+/// The option we click, by its label. Menu 435 lists it 9th ("Weapons Trainer", `action_poi_id`
+/// 808); matching on the text rather than the index keeps the probe honest if the row order moves.
+const OPTION_LABEL: &str = "Weapons Trainer";
+/// What `points_of_interest` row 808 says the answer must be.
+const EXPECT_NAME: &str = "Woo Ping";
+const EXPECT_POS: [f32; 2] = [-8796.2, 613.098];
+const EXPECT_ICON: u32 = 6; // ICON_POI_REDFLAG
+const EXPECT_FLAGS: u32 = 99; // candidate | draw-the-in-range-icon
+/// The in/out-of-range split the minimap's landmark pass applies (`0x811730`, VERIFIED).
+const BLIP_EDGE_RATIO: f32 = 0.8;
+
+pub(crate) struct ProbeGuardPoiPlugin;
+
+impl Plugin for ProbeGuardPoiPlugin {
+ fn build(&self, app: &mut App) {
+ app.init_resource::()
+ .add_systems(Update, guard_poi_probe);
+ }
+}
+
+#[derive(Resource, Default)]
+struct GuardPoiProbe {
+ phase: Phase,
+}
+
+/// `Wait` → (GM hop sent) `Hopped` → (guard found, hello sent) `Greeted` → (option clicked)
+/// `Asked` → verdict → `Done`.
+#[derive(Default, PartialEq)]
+enum Phase {
+ #[default]
+ Wait,
+ Hopped {
+ sent_at: f64,
+ },
+ Greeted {
+ guard: u64,
+ sent_at: f64,
+ },
+ Asked {
+ sent_at: f64,
+ },
+ Done,
+}
+
+// One Bevy system's full input set (the taxi-probe shape).
+#[allow(clippy::too_many_arguments)]
+fn guard_poi_probe(
+ time: ProbeClock,
+ mut probe: ResMut,
+ self_player: Query<(), With>,
+ player: Res,
+ gossip: Res,
+ marker: Res,
+ units: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without>,
+ net: Res,
+) {
+ if self_player.single().is_err() {
+ return;
+ }
+ let now = time.elapsed_secs_f64();
+ match probe.phase {
+ Phase::Wait => {
+ let [x, y, z] = GUARD_AT;
+ info!("PROBE guardpoi: hopping to a Stormwind City Guard at ({x}, {y}, {z})");
+ let _ = net.0.send(ClientCommand::Chat {
+ kind: crate::net::ChatKind::Say,
+ target: None,
+ text: format!(".go xyz {x} {y} {z} 0"),
+ });
+ probe.phase = Phase::Hopped { sent_at: now };
+ }
+ Phase::Hopped { sent_at } => {
+ if now - sent_at < 3.0 {
+ return; // post-teleport settle: let the guard stream in
+ }
+ let me = player.pos;
+ let guard = units.iter().find(|(_, net_e, store, tf)| {
+ net_e.kind == benilla_protocol::EntityKind::Unit
+ && store.0.unit_npc_flags() & NPC_FLAG_GOSSIP != 0
+ && tf.translation.distance(me) < 15.0
+ });
+ if let Some((guid, ..)) = guard {
+ info!(
+ "PROBE guardpoi: guard {:#x} in range — asking for directions",
+ guid.0
+ );
+ let _ = net.0.send(ClientCommand::GossipHello { guid: guid.0 });
+ probe.phase = Phase::Greeted {
+ guard: guid.0,
+ sent_at: now,
+ };
+ } else if now - sent_at > 15.0 {
+ error!("PROBE guardpoi: FAILURE — no gossip NPC streamed in within 15 s");
+ probe.phase = Phase::Done;
+ }
+ }
+ Phase::Greeted { guard, sent_at } => {
+ if gossip.npc == Some(guard) && !gossip.options.is_empty() {
+ let labels: Vec<&str> = gossip.options.iter().map(|o| o.message.as_str()).collect();
+ info!(
+ "PROBE guardpoi: menu open — {} options: {labels:?}",
+ labels.len()
+ );
+ let Some(option) = gossip
+ .options
+ .iter()
+ .find(|o| o.message == OPTION_LABEL)
+ .map(|o| o.index)
+ else {
+ error!("PROBE guardpoi: FAILURE — no \"{OPTION_LABEL}\" option on this menu");
+ probe.phase = Phase::Done;
+ return;
+ };
+ info!("PROBE guardpoi: clicking \"{OPTION_LABEL}\" (option {option})");
+ let _ = net.0.send(ClientCommand::GossipSelectOption {
+ guid: guard,
+ option,
+ });
+ probe.phase = Phase::Asked { sent_at: now };
+ } else if now - sent_at > 8.0 {
+ error!("PROBE guardpoi: FAILURE — SMSG_GOSSIP_MESSAGE never arrived");
+ probe.phase = Phase::Done;
+ }
+ }
+ Phase::Asked { sent_at } => {
+ let Some(poi) = &marker.poi else {
+ if now - sent_at > 8.0 {
+ error!(
+ "PROBE guardpoi: FAILURE — no marker 8 s after the click (no \
+ SMSG_GOSSIP_POI, or it was cleared on arrival)"
+ );
+ probe.phase = Phase::Done;
+ }
+ return;
+ };
+ // Every field against the server's own row — a mis-ordered parse cannot survive this.
+ let mut wrong: Vec = Vec::new();
+ if poi.name != EXPECT_NAME {
+ wrong.push(format!("name {:?} != {EXPECT_NAME:?}", poi.name));
+ }
+ let off = ((poi.pos[0] - EXPECT_POS[0]).powi(2) + (poi.pos[1] - EXPECT_POS[1]).powi(2))
+ .sqrt();
+ if off > 0.5 {
+ wrong.push(format!(
+ "pos ({:.2}, {:.2}) is {off:.2} yd off ({}, {})",
+ poi.pos[0], poi.pos[1], EXPECT_POS[0], EXPECT_POS[1]
+ ));
+ }
+ if poi.icon != EXPECT_ICON {
+ wrong.push(format!("icon {} != {EXPECT_ICON}", poi.icon));
+ }
+ if poi.flags != EXPECT_FLAGS {
+ wrong.push(format!("flags {} != {EXPECT_FLAGS}", poi.flags));
+ }
+
+ // What the minimap's landmark pass would do with it, from here (the default zoom's
+ // 133.3-yd view radius — the probe never opens the zoom dial, so this IS the live one).
+ let w = benilla_assets::coords::bevy_to_wow(player.pos);
+ let d = ((poi.pos[0] - w[0]).powi(2) + (poi.pos[1] - w[1]).powi(2)).sqrt();
+ let radius = 133.3;
+ let draw = match d / radius <= BLIP_EDGE_RATIO {
+ true if poi.flags & 0x2 != 0 => {
+ format!("POIIcons cell {} at its true spot", poi.icon)
+ }
+ true => "nothing (in range, no Flags&2)".to_string(),
+ false => "a rim arrow pointing at it".to_string(),
+ };
+ info!(
+ "PROBE guardpoi: marker \"{}\" at ({:.1}, {:.1}) icon {} flags {} — {d:.1} yd away \
+ (ratio {:.2} of the {radius:.0}-yd view) → minimap draws {draw}",
+ poi.name,
+ poi.pos[0],
+ poi.pos[1],
+ poi.icon,
+ poi.flags,
+ d / radius,
+ );
+ if wrong.is_empty() {
+ info!(
+ "PROBE guardpoi: SUCCESS — the guard's directions match points_of_interest 808"
+ );
+ } else {
+ error!("PROBE guardpoi: FAILURE — {}", wrong.join("; "));
+ }
+ probe.phase = Phase::Done;
+ }
+ Phase::Done => {}
+ }
+}
diff --git a/crates/benilla-app/src/debug_panel/mod.rs b/crates/benilla-app/src/debug_panel/mod.rs
index 23d7d887..f6739486 100644
--- a/crates/benilla-app/src/debug_panel/mod.rs
+++ b/crates/benilla-app/src/debug_panel/mod.rs
@@ -40,7 +40,6 @@ use bevy_egui::{
use benilla_world::lighting::{ClockSource, GameClock, WowLighting};
use benilla_world::model_render::{ModelKind, ModelPart};
use benilla_world::modkeys::{dev_chord, DEV_CHORD};
-use benilla_world::view::ViewDistance;
/// The egui half of the pointer arbitration: this panel *writes* it, `ui_script` owns it —
/// so a build without these overlays still compiles gameplay's reads (decision 1174; the
@@ -332,7 +331,6 @@ fn debug_panel_ui(
mut contexts: EguiContexts,
stamp: Res,
mut debug: ResMut,
- mut view: ResMut,
clock: Res,
lighting: Res,
parts: Query<&ModelPart>,
@@ -461,16 +459,6 @@ fn debug_panel_ui(
if ui.button("dump WMO cull trace").clicked() {
cull_probe.dump_requested = true;
}
-
- ui.add_space(6.0);
- // Faithful `farclip`: doodads/WMOs hard-pop out past this (no fade). The
- // range (and why it runs past vanilla's 777) is `view::FARCLIP_RANGE`,
- // shared with the `$WOW_FARCLIP` capture knob so a setting seen here can
- // be reproduced headlessly.
- ui.add(
- egui::Slider::new(&mut view.farclip, benilla_world::view::FARCLIP_RANGE)
- .text("view distance / farclip (yd)"),
- );
});
egui::CollapsingHeader::new("Lighting")
diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs
index 63f6fba3..5ee49298 100644
--- a/crates/benilla-app/src/dev.rs
+++ b/crates/benilla-app/src/dev.rs
@@ -242,6 +242,12 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_PROBE").as_deref() == Ok("taxi") {
app.add_plugins(crate::capture::ProbeTaxiPlugin);
}
+ // The guard-directions live probe: `WOW_PROBE=guardpoi` asks a Stormwind guard where the
+ // weapons trainer is and reports the marker `SMSG_GOSSIP_POI` left on the map, checked
+ // field by field against the server's own row (see `capture::ProbeGuardPoiPlugin`).
+ if std::env::var("WOW_PROBE").as_deref() == Ok("guardpoi") {
+ app.add_plugins(crate::capture::ProbeGuardPoiPlugin);
+ }
// The mail-arc live probe: `WOW_PROBE_MAIL=1` GM-mails the probe's own character, opens the
// Goldshire mailbox on the real wire, and drives the inbox/take/send/delete surface through
// the live Lua VM — decisions 0544/0548's end-to-end instrument (see `capture::ProbeMailPlugin`).
diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs
index 7b561deb..56acdd12 100644
--- a/crates/benilla-app/src/lib.rs
+++ b/crates/benilla-app/src/lib.rs
@@ -80,6 +80,7 @@ mod pending_item_ops;
mod perf;
mod pipe_warm;
mod player;
+mod poi_marker;
mod portrait;
#[cfg(feature = "dev")]
mod preflight;
@@ -524,6 +525,9 @@ pub fn run(build: BuildId) -> AppExit {
// explore objectives; the server owns what each trigger means.
.add_plugins(area_trigger::AreaTriggerPlugin)
.add_plugins(ui_world_map::WorldMapUiPlugin)
+ // The guard's directions marker (`SMSG_GOSSIP_POI`) — one landmark record, drawn by the
+ // minimap's landmark pass and the world map's POI child, cleared by arriving at it.
+ .add_plugins(poi_marker::PoiMarkerPlugin)
// The glyph atlas (client TTFs -> baked bitmap) `ui_script`'s extraction draws `FontString`
// regions through. Loads at Startup, after the asset chain opens (decision 0068 §2).
.add_plugins(UiTextPlugin)
diff --git a/crates/benilla-app/src/minimap/blips/mod.rs b/crates/benilla-app/src/minimap/blips/mod.rs
index 3e92bef3..7920bd2c 100644
--- a/crates/benilla-app/src/minimap/blips/mod.rs
+++ b/crates/benilla-app/src/minimap/blips/mod.rs
@@ -42,7 +42,15 @@
//! are plain textured quads (measured from the real M2s), so the flat-sprite stand-in is
//! geometry-exact at the model's own quad size — no uv crop, the padding is authored.
//!
-//! Remaining residue: the rim arrow model stacks SIX copies of its quad (an alpha-layering
+//! **The guard's directions marker** ([`crate::poi_marker`], decisions 1514/1516) is a landmark
+//! candidate like any other, appended after the DBC filter the way the reference appends its
+//! static blip slot — everything above applies to it unchanged.
+//!
+//! Remaining residue: two gates the landmark selection here does NOT yet implement, both found by
+//! the 1516 §5 and both belonging to the DBC rows rather than the marker — a `WorldStateID` gate
+//! at `0x6d9b27`, and a different rim-arrow model id per source (`0xa6` landmark, `0xa7` corpse,
+//! `0xa8` the guard marker, `0x4ed349`-`0x4ed37b`) where we draw one arrow for all three. Also:
+//! the rim arrow model stacks SIX copies of its quad (an alpha-layering
//! trick that solidifies the soft texture edges) — we draw one, a brightness nuance for the
//! director's eye; dots re-project every frame where the reference draws ~1 Hz-stale
//! snapshot coords verbatim (a throttle quirk, deliberately not aped — positions agree for
@@ -127,7 +135,8 @@ pub(crate) enum MinimapBlipHover {
/// ceiling: the quest-status store, the guid→entity index + unit positions, the cursor's
/// window, the hover-out slot, the party state, and the tracking-dot inputs (candidates,
/// our own descriptor's masks, the creature/GO template caches, `Lock.dbc`), plus the
-/// `uiScale` dial the tooltip's cursor seat converts through.
+/// `uiScale` dial the tooltip's cursor seat converts through, and the guard-directions marker
+/// the landmark pass draws as a candidate.
pub(super) type BlipInputs<'w, 's> = (
Res<'w, crate::ui_quest::QuestGiver>,
Res<'w, GuidIndex>,
@@ -141,6 +150,7 @@ pub(super) type BlipInputs<'w, 's> = (
Res<'w, NameCache>,
Res<'w, GameObjectTemplates>,
Option>,
+ Res<'w, crate::poi_marker::PoiMarker>,
);
/// Every streamed object the tracking classifier considers (our own avatar excluded — the
@@ -201,8 +211,15 @@ pub(super) struct LandmarkSelection<'a> {
/// The byte-verified landmark selection: candidacy (`ContinentID` + `Flags&1`), the 0.8
/// in/out split against the live view radius, and the out-of-range nearest-3 ranked by
/// (`Importance` signed asc, dist asc) within 694.444 yd.
+///
+/// `marker` is the guard-directions POI ([`crate::poi_marker`]) — the reference appends its
+/// static blip slot to the candidate list *after* the DBC scan, so it bypasses the candidacy
+/// gate and then competes as an equal for the rim slots. Equal, and no more: it gets **no**
+/// exemption from the 694.444-yd rank cut (that belongs to the corpse slot `0xcea848`, the only
+/// candidate `0x6d9cc2` spares — wow-re `gossip-poi-marker.md`).
pub(super) fn select_landmarks<'a>(
pois: impl Iterator,
+ marker: Option<&'a AreaPoi>,
map_id: u32,
wx: f32,
wy: f32,
@@ -210,7 +227,10 @@ pub(super) fn select_landmarks<'a>(
) -> LandmarkSelection<'a> {
let mut icons = Vec::new();
let mut arrows: Vec<(f32, &AreaPoi)> = Vec::new();
- for p in pois.filter(|p| p.continent_id == map_id && p.flags & FLAG_CANDIDATE != 0) {
+ let candidates = pois
+ .filter(|p| p.continent_id == map_id && p.flags & FLAG_CANDIDATE != 0)
+ .chain(marker);
+ for p in candidates {
let d = ((p.pos[0] - wx).powi(2) + (p.pos[1] - wy).powi(2)).sqrt();
if d / radius_yd <= BLIP_EDGE_RATIO {
if p.flags & FLAG_IN_RANGE_ICON != 0 {
@@ -245,7 +265,8 @@ fn poi_icon_cell(icon: u32) -> Option<[f32; 4]> {
#[allow(clippy::too_many_arguments)]
pub(super) fn emit_landmarks(
ctx: &BlipCtx,
- cat: &AreaPoiCatalog,
+ cat: Option<&AreaPoiCatalog>,
+ marker: Option<&AreaPoi>,
map_id: u32,
arrow_tex: &Handle,
poi_icons: Option<&Handle>,
@@ -253,7 +274,8 @@ pub(super) fn emit_landmarks(
hover: &mut MinimapBlipHover,
) {
let sel = select_landmarks(
- cat.rows().map(|(_, p)| p),
+ cat.into_iter().flat_map(|c| c.rows().map(|(_, p)| p)),
+ marker,
map_id,
ctx.wx,
ctx.wy,
@@ -450,7 +472,7 @@ mod tests {
poi(3, 0x1d, 556.0, "Stormwind"),
poi(3, 0x5, 601.0, "Goldshire"),
];
- let sel = select_landmarks(rows.iter(), 0, 0.0, 0.0, 133.0);
+ let sel = select_landmarks(rows.iter(), None, 0, 0.0, 0.0, 133.0);
assert!(sel.icons.is_empty(), "no Flags&2 row is in range");
let names: Vec<&str> = sel.arrows.iter().map(|(_, p)| p.name.as_str()).collect();
assert_eq!(names, ["Stormwind", "Goldshire"]);
@@ -468,11 +490,59 @@ mod tests {
poi(3, 1, 400.0, "city-mid"),
poi(0, 1, 695.0, "beyond-rank"),
];
- let sel = select_landmarks(rows.iter(), 0, 0.0, 0.0, 100.0);
+ let sel = select_landmarks(rows.iter(), None, 0, 0.0, 0.0, 100.0);
let names: Vec<&str> = sel.arrows.iter().map(|(_, p)| p.name.as_str()).collect();
assert_eq!(names, ["minor-near", "minor-far", "city-near"]);
}
+ /// The guard's directions marker ([`crate::poi_marker`]) is a landmark candidate like any
+ /// other — and, carrying the reference's `Importance 0`, it takes a rim slot ahead of the
+ /// town/city rows a capital is thick with. Out of the four here only three arrows draw, and
+ /// the marker is one of them despite being the farthest.
+ #[test]
+ fn the_guard_marker_competes_for_a_rim_slot_and_outranks_the_cities() {
+ let cities = [
+ poi(3, 0x1d, 200.0, "Stormwind"),
+ poi(3, 0x5, 300.0, "Goldshire"),
+ poi(3, 0x5, 400.0, "Northshire Abbey"),
+ ];
+ let marker = poi(0, 0x63, 500.0, "Stormwind Warrior Trainer");
+ let sel = select_landmarks(cities.iter(), Some(&marker), 0, 0.0, 0.0, 133.0);
+ let names: Vec<&str> = sel.arrows.iter().map(|(_, p)| p.name.as_str()).collect();
+ assert_eq!(
+ names,
+ ["Stormwind Warrior Trainer", "Stormwind", "Goldshire"],
+ "Importance 0 ranks the marker first; the third city is crowded out"
+ );
+ }
+
+ /// In range, the marker draws its own `POIIcons` cell at its true position — `Flags & 2` is
+ /// set on every 5875-era `points_of_interest` row (99 = 0x63), so unlike an ordinary town it
+ /// keeps drawing once you are close.
+ #[test]
+ fn the_guard_marker_draws_its_icon_in_range() {
+ let marker = poi(0, 0x63, 50.0, "The Bank");
+ let sel = select_landmarks(std::iter::empty(), Some(&marker), 0, 0.0, 0.0, 133.0);
+ assert!(sel.arrows.is_empty(), "in range — no rim arrow");
+ assert_eq!(sel.icons.len(), 1);
+ assert_eq!(
+ poi_icon_cell(sel.icons[0].icon),
+ Some([0.75, 0.875, 0.0, 0.125]),
+ "icon 6 = ICON_POI_REDFLAG, col 6 row 0 of the 8x8 atlas"
+ );
+ }
+
+ /// The marker bypasses the candidacy gate the DBC rows pass through — the reference appends
+ /// its static blip slot *after* the scan. It is the caller ([`crate::poi_marker::PoiMarker`]'s
+ /// `on_map`) that decides the marker belongs to this map, not this filter.
+ #[test]
+ fn the_guard_marker_skips_the_candidacy_filter() {
+ let mut marker = poi(0, 0, 300.0, "The Inn"); // Flags bit 0 CLEAR — a DBC row would drop
+ marker.continent_id = 571; // and a continent that isn't the displayed one
+ let sel = select_landmarks(std::iter::empty(), Some(&marker), 0, 0.0, 0.0, 133.0);
+ assert_eq!(sel.arrows.len(), 1, "appended unconditionally");
+ }
+
/// An in-range row draws the icon only with Flags bit 1; the `Icon < 64` gate and the
/// 8×8 cell math match the client's table build.
#[test]
@@ -481,7 +551,7 @@ mod tests {
tower.icon = 9; // col 1, row 1
let plain = poi(3, 0x5, 50.0, "Northshire Abbey");
let rows = [tower, plain];
- let sel = select_landmarks(rows.iter(), 0, 0.0, 0.0, 133.0);
+ let sel = select_landmarks(rows.iter(), None, 0, 0.0, 0.0, 133.0);
assert_eq!(sel.arrows.len(), 0);
assert_eq!(sel.icons.len(), 1, "only the Flags&2 tower draws in range");
assert_eq!(
diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs
index 99736243..d1aed70b 100644
--- a/crates/benilla-app/src/minimap/mod.rs
+++ b/crates/benilla-app/src/minimap/mod.rs
@@ -417,6 +417,7 @@ fn emit_minimap(
names,
go_templates,
locks,
+ poi_marker,
) = blip_inputs;
// Hover resets every frame; the blip pass below re-establishes it while the map draws.
*blip_hover = blips::MinimapBlipHover::None;
@@ -749,10 +750,14 @@ fn emit_minimap(
});
let mut hover = blips::MinimapBlipHover::None;
if let Some(ctx) = &blip_ctx {
- if let (Some(tex), Some(pois)) = (&assets.landmark, &assets.pois) {
+ // The guard-directions marker rides this pass as a landmark candidate, the way the
+ // reference appends its static blip slot after the DBC scan — so it draws even when
+ // `AreaPOI.dbc` failed to load, and the pass runs on the arrow art alone.
+ if let Some(tex) = &assets.landmark {
blips::emit_landmarks(
ctx,
- pois,
+ assets.pois.as_ref(),
+ poi_marker.on_map(map.0),
map.0,
tex,
assets.poi.as_ref(),
diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs
index e39f4a22..783f5d76 100644
--- a/crates/benilla-app/src/net/apply.rs
+++ b/crates/benilla-app/src/net/apply.rs
@@ -207,6 +207,11 @@ pub(super) fn apply_net_updates(
// parks the innkeeper's guid here and `crate::ui_binder` turns it into the
// CONFIRM_BINDER dialog, whose Accept is the only thing that binds anything.
ResMut,
+ // The guard's directions marker (`SMSG_GOSSIP_POI`) and the map id it has to be
+ // stamped with — the wire carries no map field, so "where you were standing when
+ // the guard told you" is the client's to remember (`crate::poi_marker`).
+ ResMut,
+ Option>,
),
),
),
@@ -356,7 +361,14 @@ pub(super) fn apply_net_updates(
mut mirror_timers,
mut pet_bar,
mut ui_error_keys,
- (mut page_texts, mut played_time_answer, mut guild, mut binder),
+ (
+ mut page_texts,
+ mut played_time_answer,
+ mut guild,
+ mut binder,
+ mut poi_marker,
+ current_map,
+ ),
),
) = caches;
let (
@@ -1279,6 +1291,12 @@ pub(super) fn apply_net_updates(
npc::npc_greeting(text_id, blocks, &mut gossip, &index, &stores)
}
SessionEvent::GossipComplete => npc::gossip_complete(&mut gossip, &mut quest),
+ SessionEvent::GossipPoi(poi) => npc::gossip_poi(
+ &poi,
+ &mut poi_marker,
+ current_map.as_ref().map_or(0, |m| m.0),
+ aura.1.elapsed_secs_f64(),
+ ),
// Questgiver panels (decision 0088): fill the `QuestGiver` the quest feed
// (`crate::ui_quest`) reads. Each panel packet replaces the open view; the greeting/gossip
// quest-row clicks and the panel buttons flow back out through the quest/gossip drains.
diff --git a/crates/benilla-app/src/net/apply/npc.rs b/crates/benilla-app/src/net/apply/npc.rs
index 5aaa6448..d5c47748 100644
--- a/crates/benilla-app/src/net/apply/npc.rs
+++ b/crates/benilla-app/src/net/apply/npc.rs
@@ -85,6 +85,29 @@ pub(super) fn gossip_complete(gossip: &mut GossipState, quest: &mut QuestGiver)
quest.clear();
}
+/// The guard's directions (`SMSG_GOSSIP_POI`): drop the marker at that spot. Volunteered by the
+/// server for a gossip option carrying an `action_poi_id` — it answers nothing we asked for, and
+/// it does **not** end the gossip session on its own (vmangos `Player::OnGossipSelect`'s
+/// `GOSSIP_OPTION_GOSSIP` arm sends the POI *before* it decides whether to move the menu on, leave
+/// it, or close it).
+///
+/// `map_id` is the map the player is standing on, which is the only place the marker can mean
+/// anything — the wire carries no map field, and the reference reads its own current-map global at
+/// exactly this point. `now_secs` starts the marker's 8-minute clock ([`crate::poi_marker`]); it
+/// is the same real clock the corpse reclaim delay is stamped against (decision 0846).
+pub(super) fn gossip_poi(
+ poi: &benilla_protocol::messages::GossipPoi,
+ marker: &mut crate::poi_marker::PoiMarker,
+ map_id: u32,
+ now_secs: f64,
+) {
+ debug!(
+ "net: directions to \"{}\" at ({:.1}, {:.1}) — icon {}, flags {:#x}",
+ poi.name, poi.pos[0], poi.pos[1], poi.icon, poi.flags
+ );
+ marker.set(poi, map_id, now_secs);
+}
+
/// A vendor's stock (`SMSG_LIST_INVENTORY`): fill the [`MerchantOpen`] the merchant feed
/// (`crate::ui_merchant`) reads. A successful buy updates the stock display via
/// [`vendor_buy_result`] (the item itself lands via item-create); a successful sell is silent
diff --git a/crates/benilla-app/src/player/setup.rs b/crates/benilla-app/src/player/setup.rs
index 6167c736..e5324b8e 100644
--- a/crates/benilla-app/src/player/setup.rs
+++ b/crates/benilla-app/src/player/setup.rs
@@ -11,9 +11,8 @@ use bevy::render::view::Hdr;
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::{RenderConfig, WorldAssets};
-use benilla_world::lighting::fog_range;
use benilla_world::terrain_stream::SPAWN_XY;
-use benilla_world::view::{WorldCamera, CAM_FOVY, CAM_NEAR};
+use benilla_world::view::{WorldCamera, CAM_FAR, CAM_FOVY, CAM_NEAR};
use super::{
CameraControl, CameraProbe, FlyCam, MoveSpeed, Player, PlayerCapsule, CAM_COLLISION_RADIUS,
@@ -75,16 +74,16 @@ pub(super) fn setup_player(
});
// No client data → free-fly an empty scene.
- let (Some(config), Some(_)) = (config, world_assets) else {
+ let (Some(_), Some(_)) = (config, world_assets) else {
spawn_fallback_camera(&mut commands);
return;
};
// Camera starts above the spawn (terrain streams in around it); `control` repositions it
- // third-person once we're in the world. The far plane is sized from the load radius (the
- // radius-derived view range); distance fog itself is added back in the lighting rebuild (step 5).
+ // third-person once we're in the world. The projection far is the horizon plane
+ // (`view::CAM_FAR`); the detailed world ends at `farclip`, by the wall, not by this plane.
let spawn = wow_to_bevy([SPAWN_XY.0, SPAWN_XY.1, 100.0]);
- let cam_far = (fog_range(config.tile_radius).1 + 800.0).max(3000.0);
+ let cam_far = CAM_FAR;
let mut world_cam = commands.spawn((
Camera3d::default(),
// THE world camera (the portrait booths are further `Camera3d`s — every "where is the viewer"
diff --git a/crates/benilla-app/src/poi_marker.rs b/crates/benilla-app/src/poi_marker.rs
new file mode 100644
index 00000000..c147136e
--- /dev/null
+++ b/crates/benilla-app/src/poi_marker.rs
@@ -0,0 +1,235 @@
+//! **The guard's directions** — the marker `SMSG_GOSSIP_POI` drops on the minimap and the world
+//! map when you ask a city guard where the warrior trainer is (or the bank, or the inn).
+//!
+//! The wire is volunteered, never requested: a gossip option carrying an `action_poi_id` makes the
+//! server send `{flags, x, y, icon, data, name}` (vmangos `Player::OnGossipSelect` →
+//! `PlayerMenu::SendPointOfInterest`, `GossipDef.cpp:253`). Every 5875-era row ships
+//! `icon = 6` (`ICON_POI_REDFLAG` — the red flag with the yellow `!`) and `flags = 99`.
+//!
+//! **The reference does NOT give this its own drawing code.** It builds a synthetic `AreaPOI`
+//! record out of the packet and appends it to the minimap's landmark candidate list — one fixed
+//! static slot, so a new marker overwrites the old. Byte law and packet→record map:
+//! `wow-5875-re` `system/ui/scratch/gossip-poi-marker.md` (the §5 for this feature, folded back as 1516) — handler
+//! `0x4e2840`, `set_blip 0x6dac10` writing static slot **1** at `0xcea7d4`, and
+//! `minimap-poi-questdot.md` §A3's candidate array, which appends the static slots
+//! *unconditionally*, bypassing the DBC scan's `ContinentID`/`Flags & 1` gate. So it is a landmark
+//! in every way that follows — the 0.8 in/out split, the `POIIcons` cell picked by `Icon`, the rim
+//! arrow, the nearest-3 `Importance` rank, the 694.444-yd rank cut (the marker gets **no**
+//! exemption from it — that belongs to the corpse slot `0xcea848`), the hover tooltip on both the
+//! icon and the arrow. This module holds only what is *specific* to the marker: the record, and
+//! its lifetime. The drawing is [`crate::minimap::blips`] and the world map's POI pool.
+//!
+//! **The lifetime is four ways to lose it, whichever comes first** (all VERIFIED, §5):
+//! - **8 minutes.** `set_blip` stamps a deadline of `time() + 480` — *seconds*, not the
+//! milliseconds a `GetTickCount` reading would suggest (`0x429580` is a cached `time()`; the
+//! tick count is only its 500 ms cache check).
+//! - **Arriving.** `minimap_update 0x6d93a0` clears it once `(player − marker)² < 100`
+//! (`0x806b10`) — strictly inside 10 yards, the boundary excluded.
+//! - **A replacing packet.** One slot; the next set of directions overwrites this one.
+//! - **World entry.** The world-enter path runs `zone_rebuild`, which stamps the slot clear. Ours
+//! maps that to a worldport and to logging out — the two ways a benilla session re-enters.
+//!
+//! The record keeps the map it was given on, because the wire has no map field and the world map
+//! must project the marker through *some* map's rect. The reference re-derives the same thing from
+//! its live current-map global at `set_blip` time (`+0x1c ContinentID ← [0x86f694]`).
+
+use benilla_formats::AreaPoi;
+use bevy::prelude::*;
+use bevy::time::Real;
+
+use benilla_assets::coords::bevy_to_wow;
+
+use crate::net::{LoggedOutMessage, WorldportMessage};
+use crate::player::Player;
+
+/// The arrival clear's radius, squared, in yards² — `0x806b10` = 100 = (10 yd)², VERIFIED, and the
+/// compare is strict (`<`), so a marker exactly 10 yd away survives.
+const ARRIVE_CLEAR_YD_SQ: f32 = 100.0;
+/// How long a set of directions lasts before it drops itself: `time() + 480` **seconds**, VERIFIED
+/// (`set_blip 0x6dac10`'s deadline stamp; the §5 corrected the unit — 480 ms would be a marker you
+/// could never walk to).
+const MARKER_TTL_SECS: f64 = 480.0;
+
+/// The one point of interest a guard's directions left on the map — a synthetic `AreaPOI` record,
+/// exactly as the reference builds it, or `None` when no directions are live.
+///
+/// Read by the minimap's landmark pass ([`crate::minimap::blips`]) and the world map's feed
+/// ([`crate::ui_world_map`]); written only by the `SMSG_GOSSIP_POI` arm and the clears below.
+#[derive(Resource, Default)]
+pub(crate) struct PoiMarker {
+ /// The record. Its `continent_id` is the map the directions were given on, and its `pos` z is
+ /// `0.0`: the wire carries x/y only, and every law that reads it measures a 2-D distance (the
+ /// reference leaves `+0x18 Z` unwritten for the same reason).
+ pub(crate) poi: Option,
+ /// `Time` seconds at which the marker expires — meaningless while `poi` is `None`. Real,
+ /// not virtual: this is a wall-clock span like the corpse reclaim delay (decision 0846).
+ expires_at: f64,
+}
+
+impl PoiMarker {
+ /// Take the guard's directions: replace whatever marker was live (the reference's single
+ /// slot), and start its 8-minute clock. `map_id` is the map the player is standing on — the
+ /// wire has no map field, and the reference reads its own current-map global here.
+ pub(crate) fn set(
+ &mut self,
+ wire: &benilla_protocol::messages::GossipPoi,
+ map_id: u32,
+ now_secs: f64,
+ ) {
+ self.expires_at = now_secs + MARKER_TTL_SECS;
+ self.poi = Some(AreaPoi {
+ // The nearest-3 rim rank key is the packet's own `data` field, verbatim (`0x6dac4e`
+ // writes it to `+0x04 Importance` — the §5's P4 correction (1516); we had guessed a constant
+ // `0`). Shipped server data sends `0` in every row, which lands the marker in the
+ // first rank band: it out-ranks 28 of Kalimdor's 29 possible competitors outright and
+ // ties Eastern Kingdoms' Importance-0 landmarks, winning those on distance.
+ importance: wire.data,
+ icon: wire.icon,
+ faction_id: 0,
+ pos: [wire.pos[0], wire.pos[1], 0.0],
+ continent_id: map_id,
+ flags: wire.flags,
+ area_id: 0,
+ name: wire.name.clone(),
+ // Never written by the handler — and the reference's `GetMapLandmarkInfo` returns nil
+ // for this landmark's description because of it.
+ description: String::new(),
+ world_state_id: 0,
+ });
+ }
+
+ /// The marker, if it is on `map_id` — the one form both draws want.
+ pub(crate) fn on_map(&self, map_id: u32) -> Option<&AreaPoi> {
+ self.poi.as_ref().filter(|p| p.continent_id == map_id)
+ }
+}
+
+/// The two clocks that end a marker on their own: **arriving** (strictly inside 10 yd) and the
+/// **8-minute deadline**. Both are the reference's, and both are checked where it checks them —
+/// once a frame, in the minimap driver's place.
+fn expire_marker(mut marker: ResMut, player: Res, time: Res