Terrain Distance returns to Graphics, and guards flag your map

- the Terrain Distance slider is back on Options → Graphics, in its 1.12 seat and range (177–777, live, no Apply) — and it now drives how much world is resident, not just how far you see: the streamer derives its resident and prefetch windows from farclip in the reference's own chunk units, pulling the ring in as you drag left and streaming it back one tile per frame as you drag right; the `WOW_TILE_RADIUS` env var, the debug panel's farclip slider and a dressed-up fog constant all retire into it
- ask a city guard for directions and the destination lands on your maps: `SMSG_GOSSIP_POI` becomes a red flag drawn through the same landmark path as towns and inns — an icon in place when near, competing for a rim arrow when off the edge — and the world map's landmark enumeration answers now, so the flag renders there too and names itself on hover
- the flag is one slot (new directions replace the last), drops itself within 10 yards of the destination, expires after the reference's own 480 seconds, and clears on any world entry; the rim-arrow rank rides the packet's own field, not a guessed constant

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-22 18:18:30 +07:00
co-authored by Claude Fable 5
parent 312e123fb7
commit da786b9e59
39 changed files with 1620 additions and 209 deletions
+28 -13
View File
@@ -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 @@
</Frame>
</Frames>
</Frame>
<!-- The Graphics page (0959; farclip row retired 0961 — the
director's call, standing: the farclip CVar/knob/env/
debug-panel plumbing all stay, only the row is off the page).
UI Scale is the 1.12 OptionsFrameSliders table verbatim
(OptionsFrame.lua l.25): uiscale 0.64..1.0 step 0.01 (UI_SCALE
"UI Scale", percent readout — era's own
FormatPercentageRounded on its uiscale row). 1.12's useUiScale
<!-- The Graphics page (0959): the 1.12 OptionsFrameSliders rows
that have a benilla knob behind them, in the table's own order
(OptionsFrame.lua l.25-27). UI Scale: uiscale 0.64..1.0 step
0.01 (UI_SCALE "UI Scale", percent readout — era's own
FormatPercentageRounded on its uiscale row); 1.12's useUiScale
gate has no benilla knob (our scale always applies; off ≡ 1.0)
so the checkbox doesn't ship. Environment Detail (0992) is the
same table's WorldDetail row (l.26: 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. -->
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. -->
<Frame name="$parentGraphics" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
@@ -2191,11 +2195,22 @@
self.deferred = 1
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowWorldDetail" inherits="OptionsDropdownRowTemplate">
<Frame name="$parentRowFarclip" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowUiScale" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowUiScale" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "farclip", "Terrain Distance",
"OPTION_TOOLTIP_FARCLIP")
OptionsSliderRow_Setup(self, 177, 777, 60, "raw")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowWorldDetail" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowFarclip" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowFarclip" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "WorldDetail", "Environment Detail",
"OPTION_TOOLTIP_ENVIRONMENT_DETAIL")
+84 -12
View File
@@ -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 @@
<Script><![CDATA[
NUM_WORLDMAP_DETAIL_TILES = 12;
NUM_WORLDMAP_OVERLAYS = 0;
NUM_WORLDMAP_POIS = 0;
-- ref WorldMapFrame.lua l.509-515 is the spec. Through the PANEL MANAGER, not a bare
-- Show/Hide: the map is registered `area = "full"` (UiPanels.xml), which is what makes it
@@ -138,6 +140,35 @@
getglobal("WorldMapOverlay"..slot):Hide();
end
-- The POI icons (spec: ref WorldMapFrame.lua's landmark pass). One frame per landmark
-- the host reports for the displayed map, grown on demand from WorldMapPOITemplate and
-- never shrunk — the tail from a busier map is parked hidden, like the overlay pool
-- above. Today the host reports exactly one kind, the guard-directions marker
-- (SMSG_GOSSIP_POI); the AreaPOI.dbc landmarks are 0203's deferred slice and arrive
-- through this same list.
local numPOIs = GetNumMapLandmarks();
if ( numPOIs > NUM_WORLDMAP_POIS ) then
for slot = NUM_WORLDMAP_POIS + 1, numPOIs do
CreateFrame("Button", "WorldMapFramePOI"..slot, WorldMapButton, "WorldMapPOITemplate");
end
NUM_WORLDMAP_POIS = numPOIs;
end
for slot = 1, NUM_WORLDMAP_POIS do
local poi = getglobal("WorldMapFramePOI"..slot);
if ( slot > numPOIs ) then
poi:Hide();
else
local name, description, textureIndex, x, y = GetMapLandmarkInfo(slot);
WorldMapPOI_SetTexture(poi, textureIndex);
poi.name = name;
poi.description = description;
poi:ClearAllPoints();
poi:SetPoint("CENTER", "WorldMapDetailFrame", "TOPLEFT",
x * WorldMapDetailFrame:GetWidth(), -y * WorldMapDetailFrame:GetHeight());
poi:Show();
end
end
if ( GetCurrentMapContinent() == 0 ) then
WorldMapZoomOutButton:Disable();
else
@@ -145,6 +176,35 @@
end
end
-- Point a POI frame's texture at its cell of Interface\Minimap\POIIcons — an 8x8 grid of
-- icons indexed across then down, the same atlas and the same indexing the minimap's
-- landmark blips use (the corpse marker below hardcodes its own cell of it).
function WorldMapPOI_SetTexture(poi, textureIndex)
local column = mod(textureIndex, 8);
local row = floor(textureIndex / 8);
getglobal(poi:GetName().."Texture"):SetTexCoord(
column * 0.125, (column + 1) * 0.125, row * 0.125, (row + 1) * 0.125);
end
-- Every marker on the sheet — the corpse, a POI — names itself in the shared GameTooltip,
-- and every one of them wants the plate opening AWAY from the edge it sits nearest, so the
-- side pick lives here once instead of in each OnEnter. `subtitle` is the second line the
-- landmarks that carry a live status use; the rest pass nothing.
function WorldMapMarker_ShowTooltip(marker, title, subtitle)
local markerX = marker:GetCenter();
local sheetX = marker:GetParent():GetCenter();
if ( markerX > sheetX ) then
GameTooltip:SetOwner(marker, "ANCHOR_LEFT");
else
GameTooltip:SetOwner(marker, "ANCHOR_RIGHT");
end
GameTooltip:SetText(title);
if ( subtitle and subtitle ~= "" ) then
GameTooltip:AddLine(subtitle, 1, 1, 1);
end
GameTooltip:Show();
end
-- The reference's rule: zone view → its continent; continent view → the world.
function WorldMapZoomOutButton_OnClick()
if ( GetCurrentMapZone() ~= 0 ) then
@@ -318,6 +378,25 @@
</Layer>
</Layers>
</Frame>
<!-- One POI icon on the map (spec: ref WorldMapPOITemplate, WorldMapFrameTemplates.xml): a
16x16 cell of Interface\Minimap\POIIcons picked by the landmark's own textureIndex, with a
name/description tooltip. Grown on demand by WorldMapFrame_Update into WorldMapButton. -->
<Button name="WorldMapPOITemplate" virtual="true" enableMouse="true">
<Size><AbsDimension x="16" y="16"/></Size>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentTexture" file="Interface\Minimap\POIIcons" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnEnter>
WorldMapMarker_ShowTooltip(this, this.name, this.description);
</OnEnter>
<OnLeave>
GameTooltip:Hide();
</OnLeave>
</Scripts>
</Button>
<Frame name="WorldMapFrame" frameStrata="FULLSCREEN_DIALOG" hidden="true" enableMouse="true" parent="UIParent">
<Size><AbsDimension x="1024" y="768"/></Size>
<Anchors>
@@ -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. -->
<Frame name="WorldMapCorpse" hidden="true" enableMouse="true">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
@@ -566,15 +646,7 @@
</Layers>
<Scripts>
<OnEnter>
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")
</OnEnter>
<OnLeave>
GameTooltip:Hide()
+2
View File
@@ -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;
@@ -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<N-spelled>`; `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::<GuardPoiProbe>()
.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<GuardPoiProbe>,
self_player: Query<(), With<SelfPlayer>>,
player: Res<Player>,
gossip: Res<GossipState>,
marker: Res<PoiMarker>,
units: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without<SelfPlayer>>,
net: Res<crate::net::NetCommands>,
) {
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<String> = 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 => {}
}
}
-12
View File
@@ -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<benilla_world::build_id::BuildId>,
mut debug: ResMut<DebugState>,
mut view: ResMut<ViewDistance>,
clock: Res<GameClock>,
lighting: Res<WowLighting>,
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")
+6
View File
@@ -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`).
+4
View File
@@ -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)
+78 -8
View File
@@ -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::go_templates::Locks>>,
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<Item = &'a AreaPoi>,
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<Image>,
poi_icons: Option<&Handle<Image>>,
@@ -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!(
+7 -2
View File
@@ -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(),
+19 -1
View File
@@ -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<crate::ui_binder::BinderState>,
// 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<crate::poi_marker::PoiMarker>,
Option<Res<benilla_world::world_map::CurrentMap>>,
),
),
),
@@ -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.
+23
View File
@@ -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
+5 -6
View File
@@ -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"
+235
View File
@@ -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<AreaPoi>,
/// `Time<Real>` 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<PoiMarker>, player: Res<Player>, time: Res<Time<Real>>) {
let Some(poi) = &marker.poi else {
return;
};
if time.elapsed_secs_f64() >= marker.expires_at {
debug!("poi: \"{}\" expired — clearing the marker", poi.name);
marker.poi = None;
return;
}
let w = bevy_to_wow(player.pos);
let d2 = (poi.pos[0] - w[0]).powi(2) + (poi.pos[1] - w[1]).powi(2);
if d2 < ARRIVE_CLEAR_YD_SQ {
debug!("poi: arrived at \"{}\" — clearing the marker", poi.name);
marker.poi = None;
}
}
/// **World entry clears it** — the reference's fourth exit, whose path stamps the slot clear
/// through `zone_rebuild`. Benilla re-enters the world in two ways: a worldport (the cross-map
/// teleport that IS a new world entry) and a logout, after which the next login is somebody else's
/// session.
fn clear_on_world_entry(
mut marker: ResMut<PoiMarker>,
mut worldports: MessageReader<WorldportMessage>,
mut logged_out: MessageReader<LoggedOutMessage>,
) {
if worldports.read().next().is_some() || logged_out.read().next().is_some() {
marker.poi = None;
}
}
/// The guard's directions marker — see the module doc.
pub(crate) struct PoiMarkerPlugin;
impl Plugin for PoiMarkerPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<PoiMarker>()
.add_systems(Update, (expire_marker, clear_on_world_entry));
}
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::messages::GossipPoi;
/// The `POIIcons.blp` cell every 5875-era `points_of_interest` row ships: `ICON_POI_REDFLAG`,
/// which vmangos names "red flag w/ yellow !" (`GossipDef.h:113`) — and the real atlas agrees,
/// measured rather than taken on the enum's word: decoded off the install's own 128² DXT3
/// `POIIcons.blp`, cell 6's lit pixels mean **RGB (140, 38, 17)**, against the corpse skull's
/// (98, 70, 67) in cell 7 and the grey AV mine's (115, 113, 115) in cell 0 as controls.
/// Nothing hardcodes the number — the packet's own `icon` is what draws — so it lives here,
/// naming what the data actually sends.
const ICON_POI_REDFLAG: u32 = 6;
fn wire(name: &str, x: f32, y: f32) -> GossipPoi {
GossipPoi {
flags: 99,
pos: [x, y],
icon: ICON_POI_REDFLAG,
data: 0,
name: name.into(),
}
}
/// The packet becomes the synthetic AreaPOI record the landmark pipeline reads: the wire's
/// own `flags`/`icon` (the two columns the draw laws consult), `data` as the rim rank key,
/// z zeroed, the map stamped.
#[test]
fn the_wire_becomes_a_landmark_record() {
let mut m = PoiMarker::default();
m.set(&wire("Stormwind Warrior Trainer", -8900.0, 600.0), 0, 0.0);
let poi = m.poi.as_ref().expect("a marker");
assert_eq!(poi.flags & 0x1, 0x1, "a candidate (Flags bit 0)");
assert_eq!(
poi.flags & 0x2,
0x2,
"draws the in-range icon (Flags bit 1)"
);
assert_eq!(poi.icon, ICON_POI_REDFLAG);
assert_eq!(poi.pos, [-8900.0, 600.0, 0.0], "the wire carries no z");
assert_eq!(poi.name, "Stormwind Warrior Trainer");
assert_eq!(poi.continent_id, 0, "the map it was given on");
}
/// The rim rank key is the packet's `data`, verbatim — not a constant of ours (§5 P4).
#[test]
fn the_rank_key_is_the_packets_data_field() {
let mut w = wire("The Bank", -8900.0, 600.0);
w.data = 7;
let mut m = PoiMarker::default();
m.set(&w, 0, 0.0);
assert_eq!(m.poi.as_ref().unwrap().importance, 7);
}
/// One slot: the next set of directions replaces the last, it never stacks — and restarts the
/// 8-minute clock.
#[test]
fn a_second_set_of_directions_replaces_the_first() {
let mut m = PoiMarker::default();
m.set(&wire("The Bank", -8900.0, 600.0), 0, 0.0);
m.set(&wire("The Inn", -8800.0, 500.0), 0, 100.0);
let poi = m.poi.as_ref().expect("a marker");
assert_eq!(poi.name, "The Inn");
assert_eq!(poi.pos, [-8800.0, 500.0, 0.0]);
assert_eq!(m.expires_at, 100.0 + MARKER_TTL_SECS);
}
/// The deadline is 480 **seconds** — long enough to walk across a capital, which is the whole
/// point of the §5's unit correction.
#[test]
fn the_deadline_is_eight_minutes_from_when_it_was_given() {
let mut m = PoiMarker::default();
m.set(&wire("The Inn", 0.0, 0.0), 0, 12.5);
assert_eq!(m.expires_at, 492.5);
}
/// The marker is drawn only on the map it was given on.
#[test]
fn a_marker_is_off_the_map_it_was_not_given_on() {
let mut m = PoiMarker::default();
m.set(&wire("The Bank", -8900.0, 600.0), 0, 0.0);
assert!(m.on_map(0).is_some());
assert!(m.on_map(1).is_none(), "Kalimdor sees no Stormwind flag");
}
}
+4
View File
@@ -880,6 +880,10 @@ mod quiver_tests;
#[cfg(test)]
mod world_entry_tests;
/// The world map's POI pool — the guard's directions marker today, the AreaPOI landmarks later.
#[cfg(test)]
mod world_map_tests;
#[cfg(test)]
mod seam_scale_tests {
use super::seam_scale;
@@ -857,13 +857,15 @@ fn defaults_resets_the_audio_page_to_registered_defaults() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Selecting Graphics shows ITS page body (0959; one row since the farclip retirement, 0961)
/// with the uiScale slider reading the table on the 0.64..1.0 panel range with the percent
/// readout. The swap works both ways — Audio's body takes over when clicked.
/// Selecting Graphics shows ITS page body (0959) with both 1.12 sliders reading the table:
/// uiScale on the 0.64..1.0 panel range with the percent readout, farclip (Terrain Distance —
/// retired 0961, back 1513) on 177..777 with the raw-yards readout. The swap works both ways —
/// Audio's body takes over when clicked.
#[test]
fn the_graphics_page_reads_the_cvar_table_on_select() {
let mut s = audio_harness();
s.set_cvar_host("uiScale", "0.8");
s.set_cvar_host("farclip", "297");
let s = harness_on(s);
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowGraphics:Click()")
@@ -887,12 +889,29 @@ fn the_graphics_page_reads_the_cvar_table_on_select() {
.unwrap(),
"80%"
);
// The label is the 1.12 GlobalStrings' own.
assert!(s
.eval::<bool>(
"return math.abs(OptionsFrameContainerBodyGraphicsRowFarclipControlSlider:GetValue() - 297) < 0.001"
)
.unwrap());
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyGraphicsRowFarclipControlValue:GetText()"
)
.unwrap(),
"297"
);
// The labels are the 1.12 GlobalStrings' own.
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyGraphicsRowUiScaleLabel:GetText()")
.unwrap(),
"UI Scale"
);
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyGraphicsRowFarclipLabel:GetText()")
.unwrap(),
"Terrain Distance"
);
// The swap, the other way: Audio in, Graphics out.
s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap();
@@ -1214,13 +1233,55 @@ fn a_pending_ui_scale_survives_the_page_switch_and_dies_on_hide() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Defaults on the Graphics page: uiScale back to its registered default (0.9), the row
/// following, ONLY the moved value queuing — and a pending edit dies with it (the default
/// write supersedes what Apply would have committed).
/// The Terrain Distance slider (1513) is a LIVE row, unlike uiScale: a move snaps to the 1.12
/// panel grid — 177+n·60, ANCHORED AT THE MINIMUM, so 300 lands on 297, not the multiple-of-60
/// 300 — writes the CVar on the move as a clean short string, and raises no Apply button. The
/// write is what moves the far-clip wall and the residency window together.
#[test]
fn the_terrain_distance_slider_snaps_to_the_1_12_grid_and_writes_live() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert!(
s.take_cvar_changes().is_empty(),
"reading the table on select must not write it back"
);
s.run("OptionsFrameContainerBodyGraphicsRowFarclipControlSlider:SetValue(300)")
.unwrap();
assert!(s
.eval::<bool>(
"return math.abs(OptionsFrameContainerBodyGraphicsRowFarclipControlSlider:GetValue() - 297) < 0.001"
)
.unwrap());
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyGraphicsRowFarclipControlValue:GetText()"
)
.unwrap(),
"297"
);
assert_eq!(
s.take_cvar_changes(),
vec![("farclip".to_string(), "297".to_string())]
);
assert!(
!s.eval::<bool>("return OptionsFrameApplyButton:IsVisible()")
.unwrap(),
"a live row stages nothing"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Defaults on the Graphics page: uiScale back to its registered default (0.9) and farclip to
/// its (777), the rows following, ONLY the moved values queuing — and a pending uiScale edit
/// dies with it (the default write supersedes what Apply would have committed).
#[test]
fn defaults_resets_the_graphics_page_to_registered_defaults() {
let mut s = audio_harness();
s.set_cvar_host("uiScale", "0.8");
s.set_cvar_host("farclip", "297");
let mut s = harness_on(s);
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowGraphics:Click()")
@@ -1232,10 +1293,25 @@ fn defaults_resets_the_graphics_page_to_registered_defaults() {
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
let changes = s.take_cvar_changes();
assert!(
changes.contains(&("uiScale".to_string(), "0.9".to_string())),
"{changes:?}"
);
assert!(
changes.contains(&("farclip".to_string(), "777".to_string())),
"{changes:?}"
);
assert_eq!(
changes,
vec![("uiScale".to_string(), "0.9".to_string())],
"only the default write queues — never the dead pending"
changes.len(),
2,
"only the default writes queue — never the dead pending: {changes:?}"
);
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyGraphicsRowFarclipControlValue:GetText()"
)
.unwrap(),
"777"
);
assert_eq!(
s.eval::<String>(
@@ -1780,16 +1856,16 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
);
checked += 1;
}
// 21 CVar rows (Social's two bubble switches are 1139's; Status Bar Text, Mouse Sensitivity
// 22 CVar rows (Social's two bubble switches are 1139's; Status Bar Text, Mouse Sensitivity
// and Max Camera Distance 1140's; Graphics' Vertical Sync is 1394's; Camera Following Style
// 1493's) + the Combat page's 14 saved-variable rows (1134) + the Interface page's 4 (3 from
// 1493's; Terrain Distance 1513's) + the Combat page's 14 saved-variable rows (1134) + the Interface page's 4 (3 from
// 1136, Buff Durations 1139) and the Action Bars page's 2 (the lock 1136, Always Show
// ActionBars 1500) + 6 API rows (the Interface page's Show Cloak / Show Helm, 1472; the Action
// Bars page's four multibar switches, 1500) — which is the point of counting here rather than
// per page: the third store's rows are held to the same "the key is 1.12's own and it resolves"
// bar as the other two. Camera Following Style is counted on the key it wears at rest (Smart's
// OPTION_TOOLTIP_CAMERA1); the other two ride the same census as the selection moves.
assert_eq!(checked, 47, "every row but one carries a live 1.12 key");
assert_eq!(checked, 48, "every row but one carries a live 1.12 key");
assert_eq!(
untipped,
vec!["ControlsRowAutoLoot".to_string()],
@@ -1878,12 +1954,13 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
s.errors()
);
}
// 21 of the 22 CVar rows (Social's two are 1139's; Status Bar Text, Mouse Sensitivity and
// Max Camera Distance 1140's; Vertical Sync 1394's; Camera Following Style 1493's), plus the
// Combat page's 14 saved-variable rows (1134), the Interface page's 4 (1136, + Buff Durations
// 1139), Action Bars' 2 (the lock 1136, Always Show ActionBars 1500) and 6 API rows (Show
// Cloak / Show Helm, 1472; the four multibar switches, 1500).
assert_eq!(raised, 47, "every row but Auto Loot has a 1.12 description");
// 22 of the 23 CVar rows (Social's two are 1139's; Status Bar Text, Mouse Sensitivity and
// Max Camera Distance 1140's; Vertical Sync 1394's; Camera Following Style 1493's; Terrain
// Distance 1513's), plus the Combat page's 14 saved-variable rows (1134), the Interface
// page's 4 (1136, + Buff Durations 1139), Action Bars' 2 (the lock 1136, Always Show
// ActionBars 1500) and 6 API rows (Show Cloak / Show Helm, 1472; the four multibar switches,
// 1500).
assert_eq!(raised, 48, "every row but Auto Loot has a 1.12 description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
@@ -0,0 +1,210 @@
//! The world map's **POI layer** — the icons `GetNumMapLandmarks`/`GetMapLandmarkInfo` feed into
//! `WorldMapFrame_Update`'s frame pool. Today the only landmark is the guard's directions marker
//! (`SMSG_GOSSIP_POI` → [`crate::poi_marker`]); the `AreaPOI.dbc` rows are 0203's deferred slice
//! and arrive through the same list, so these tests pin the *pool*, not the marker.
//!
//! Driven through the shipped `assets/ui/WorldMapFrame.xml` in a bare engine (no Bevy) — the
//! panel-test idiom: push host state, run the repaint, read the frames back.
use benilla_ui::script::{UiScript, WorldMapLandmarkView};
/// The map plus everything its OnLoad touches, in the shipped list's own order.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"UIPanelTemplates.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml", // the map's continent/zone pickers initialize into it at OnLoad
"ScrollTemplates.xml",
"WorldMapFrame.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
}
fn landmark(name: &str, icon: u32, uv: (f32, f32)) -> WorldMapLandmarkView {
WorldMapLandmarkView {
name: name.into(),
description: String::new(),
texture_index: icon,
uv,
}
}
/// A pushed landmark becomes a shown POI frame at its UV, wearing its own cell of the 8×8
/// `POIIcons` atlas. Icon **6** is `ICON_POI_REDFLAG` — the red flag every 5875-era
/// `points_of_interest` row ships, so this is the guard-directions case exactly.
#[test]
fn a_landmark_draws_its_poi_icon_at_its_map_position() {
let mut s = harness();
s.set_world_map_landmarks(vec![landmark("Stormwind Warrior Trainer", 6, (0.25, 0.5))]);
s.run("WorldMapFrame_Update()").unwrap();
assert_eq!(
s.eval::<i64>("return GetNumMapLandmarks()").unwrap(),
1,
"the host reports the one landmark"
);
assert!(
s.eval::<bool>("return WorldMapFramePOI1:IsShown()")
.unwrap(),
"the pool grew a frame for it and showed it"
);
// Cell 6 of the 8×8 grid = column 6, row 0.
let (l, r, t, b): (f32, f32, f32, f32) = s
.eval("return WorldMapFramePOI1Texture:GetTexCoord()")
.unwrap();
assert_eq!(
(l, r, t, b),
(0.75, 0.875, 0.0, 0.125),
"POIIcons cell 6 — the red flag"
);
// Seated at UV × the 1002×668 detail frame, from its TOPLEFT (y down).
let (x, y): (f32, f32) = s
.eval(
"local _, _, _, ox, oy = WorldMapFramePOI1:GetPoint(1) \
return ox, oy",
)
.unwrap();
assert_eq!((x, y), (0.25 * 1002.0, -0.5 * 668.0));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The pool grows and is reused, never shrunk: a busier map's tail parks hidden rather than being
/// destroyed, and the same frame is re-seated when the list shrinks back.
#[test]
fn the_poi_pool_grows_and_parks_its_tail() {
let mut s = harness();
s.set_world_map_landmarks(vec![
landmark("The Bank", 6, (0.1, 0.1)),
landmark("The Inn", 6, (0.2, 0.2)),
landmark("The Auction House", 6, (0.3, 0.3)),
]);
s.run("WorldMapFrame_Update()").unwrap();
assert_eq!(s.eval::<i64>("return NUM_WORLDMAP_POIS").unwrap(), 3);
assert!(s
.eval::<bool>("return WorldMapFramePOI3:IsShown()")
.unwrap());
s.set_world_map_landmarks(vec![landmark("The Inn", 6, (0.2, 0.2))]);
s.run("WorldMapFrame_Update()").unwrap();
assert_eq!(
s.eval::<i64>("return NUM_WORLDMAP_POIS").unwrap(),
3,
"the pool never shrinks"
);
assert!(s
.eval::<bool>("return WorldMapFramePOI1:IsShown()")
.unwrap());
assert!(
!s.eval::<bool>("return WorldMapFramePOI2:IsShown()")
.unwrap(),
"slot 2 parked hidden"
);
assert!(!s
.eval::<bool>("return WorldMapFramePOI3:IsShown()")
.unwrap());
assert_eq!(
s.eval::<String>("return WorldMapFramePOI1.name").unwrap(),
"The Inn",
"slot 1 was re-seated, not left on the stale landmark"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Hovering a POI names it. The description line only appears on landmarks that carry one — the
/// guard's directions never do, a battleground node's "In Conflict" would.
#[test]
fn hovering_a_poi_names_it_and_adds_a_status_line_only_when_there_is_one() {
let mut s = harness();
s.set_world_map_landmarks(vec![landmark("Lion's Pride Inn", 6, (0.5, 0.5))]);
s.run("WorldMapFrame_Update()").unwrap();
s.run("this = WorldMapFramePOI1 this:GetScript(\"OnEnter\")() this = nil")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"Lion's Pride Inn"
);
assert!(
s.eval::<bool>("return GameTooltipTextLeft2:GetText() == nil or GameTooltipTextLeft2:GetText() == \"\"")
.unwrap(),
"no description → no second line"
);
let mut with_status = landmark("Stables", 6, (0.5, 0.5));
with_status.description = "In Conflict".into();
s.set_world_map_landmarks(vec![with_status]);
s.run("WorldMapFrame_Update()").unwrap();
s.run("this = WorldMapFramePOI1 this:GetScript(\"OnEnter\")() this = nil")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft2:GetText()")
.unwrap(),
"In Conflict"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `GetMapLandmarkInfo` hands back the reference's own five values, and a landmark with no
/// description answers **nil** there rather than an empty string (VERIFIED, `0x4a8740`) — the
/// guard's marker never carries one.
#[test]
fn the_landmark_getter_returns_the_references_five_values() {
let mut s = harness();
let mut with_status = landmark("Stables", 9, (0.25, 0.75));
with_status.description = "In Conflict".into();
s.set_world_map_landmarks(vec![landmark("Woo Ping", 6, (0.5, 0.5)), with_status]);
let (name, desc_is_nil, icon, x, y): (String, bool, i64, f32, f32) = s
.eval("local n, d, t, x, y = GetMapLandmarkInfo(1) return n, d == nil, t, x, y")
.unwrap();
assert_eq!(
(name.as_str(), desc_is_nil, icon, x, y),
("Woo Ping", true, 6, 0.5, 0.5)
);
let desc: String = s
.eval("local _, d = GetMapLandmarkInfo(2) return d")
.unwrap();
assert_eq!(
desc, "In Conflict",
"a landmark that HAS one still answers it"
);
assert!(
s.eval::<bool>("return GetMapLandmarkInfo(3) == nil")
.unwrap(),
"past the end is nil"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// No landmarks → no frames, and the repaint stays quiet. This is the common case by far: the map
/// is open far more often than a guard has just given directions.
#[test]
fn no_landmarks_draws_nothing() {
let s = harness();
s.run("WorldMapFrame_Update()").unwrap();
assert_eq!(s.eval::<i64>("return GetNumMapLandmarks()").unwrap(), 0);
assert_eq!(s.eval::<i64>("return NUM_WORLDMAP_POIS").unwrap(), 0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+27 -1
View File
@@ -29,7 +29,9 @@ use benilla_formats::{
load_world_map_area_catalog, load_world_map_continent_catalog, load_world_map_overlay_catalog,
load_zone_map, WorldMapArea,
};
use benilla_ui::script::{UiScript, WorldMapContinentView, WorldMapOverlayView, WorldMapZoneView};
use benilla_ui::script::{
UiScript, WorldMapContinentView, WorldMapLandmarkView, WorldMapOverlayView, WorldMapZoneView,
};
use crate::net::{ObjectStore, SelfPlayer};
use crate::player::Player;
@@ -254,6 +256,7 @@ fn feed_world_map(
self_q: Query<&ObjectStore, With<SelfPlayer>>,
areas: Option<Res<crate::area::AreaTableRes>>,
death_net: Res<crate::death::DeathNet>,
poi_marker: Res<crate::poi_marker::PoiMarker>,
mut last_explored: Local<crate::ui_script::VmMemo<Option<Vec<u32>>>>,
) {
let (Some(mut script), Some(data), Some(map), Some(areas)) = (script, data, map, areas) else {
@@ -326,6 +329,29 @@ fn feed_world_map(
)
});
// The map's POI icons (`GetNumMapLandmarks`) — today just the guard-directions marker
// (`crate::poi_marker`), projected through the same law as the two blips above. A landmark
// list carries no `(0,0)` hide sentinel for its Lua to read (unlike the blips), so off-map is
// dropped here instead — including the `(0,0)` `zone_uv` answers with for a position outside
// the displayed rect. That last one is a deliberate divergence: the reference hands its
// landmark position back unclamped and filters only on the RECORD's position being non-zero,
// so a marker in the next zone over lands a flag on the sheet's top-left corner (wow-re
// `gossip-poi-marker.md`, `0x4a8740`). Dropping it is the same call as 1514's map stamp.
let landmarks: Vec<WorldMapLandmarkView> = poi_marker
.poi
.iter()
.filter_map(|poi| {
let uv = project(poi.continent_id, poi.pos[0], poi.pos[1])?;
(uv != (0.0, 0.0)).then(|| WorldMapLandmarkView {
name: poi.name.clone(),
description: poi.description.clone(),
texture_index: poi.icon,
uv,
})
})
.collect();
script.set_world_map_landmarks(landmarks);
script.set_world_map_feed(player_zone, uv, player.facing(), corpse_uv);
}
+7 -10
View File
@@ -597,18 +597,15 @@ impl WorldAssets {
}
/// World/render configuration read once at startup from the environment, shared by the subsystems
/// that need it: terrain reads `tile_radius` (streaming) + `tex_tiles` (splat tiling); lighting reads
/// `tile_radius` for the fog range. Inserted by [`AssetPlugin`] alongside [`WorldAssets`].
/// that need it: terrain reads `tex_tiles` (splat tiling) + `unload_budget` (the release lane).
/// Inserted by [`AssetPlugin`] alongside [`WorldAssets`]; its *presence* is also the "there is a
/// client install" gate the world-side setups key on.
///
/// How far terrain streams is deliberately **not** here: the residency window derives from the
/// live `farclip` view distance (`benilla_world::view::ViewDistance` — the player's Terrain
/// Distance setting), as the reference derives it (decision 1513).
#[derive(Resource, Clone, Copy)]
pub struct RenderConfig {
/// Tiles loaded in each direction around the player (`$WOW_TILE_RADIUS`, default **2** — the
/// 5×5 block whose *worst* case (standing on a tile line) still reaches two full tiles
/// ≈ 1066 yd, clear of the 777 yd `farclip` wall, so terrain is resident before the wall
/// reveals it). `1` is the perf knob: a 3×3 block, ~2.8× smaller working set, reaching only
/// 533 yd in the worst case — the band from there to the wall has neither detailed terrain nor
/// the WDL backdrop (whose near plane sits at `farclip − 33`), so it shows only where the
/// zone's own fog end does not already hide it.
pub tile_radius: u32,
/// Ground-texture repeats per chunk (`$WOW_TEX_TILES`, default 8).
pub tex_tiles: f32,
/// Stale tiles released per frame on a within-map window shift (`$WOW_TILE_UNLOAD`,
+3 -1
View File
@@ -222,7 +222,9 @@ mod wdl;
/// World (x, y) ↔ ADT tile `(col, row)` — the same mapping the streamer uses to pick tiles
/// (and the minimap uses to place its tile art, decision 0203: minimap `map<X>_<Y>.blp` names
/// share the ADT index order, chain-verified — `AhnQiraj_27_46.adt` exists, the swap doesn't).
pub use benilla_wdt::{tile_to_world, world_to_tile, GlobalWmo, WdtFile, WdtReader, WowVersion};
pub use benilla_wdt::{
tile_to_world, world_to_chunk, world_to_tile, GlobalWmo, WdtFile, WdtReader, WowVersion,
};
pub use wdl::{WdlFile, WdlTileMesh};
// The map arc (decision 0203), phase 0: the minimap tile hash catalog + the world-map DBCs.
+3
View File
@@ -761,6 +761,9 @@ pub enum SessionEvent {
QuestGiverFailed { quest_id: u32, reason: u32 },
/// The gossip window closes (`SMSG_GOSSIP_COMPLETE`) — no menu is open server-side any more.
GossipComplete,
/// A guard's directions (`SMSG_GOSSIP_POI`): drop a marker at that spot on the minimap and the
/// world map. Volunteered by a gossip option carrying an `action_poi_id` — never requested.
GossipPoi(crate::messages::GossipPoi),
/// The greeting record for a gossip menu (`SMSG_NPC_TEXT_UPDATE`, answering our
/// `CMSG_NPC_TEXT_QUERY`) — all 8 blocks, **undrawn**: which line greets you depends on the
/// NPC's gender and a die roll, so the app draws it when the frame opens
@@ -303,6 +303,7 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
vec![SessionEvent::QuestGiverFailed { quest_id, reason }]
}
ServerPacket::GossipComplete => vec![SessionEvent::GossipComplete],
ServerPacket::GossipPoi(poi) => vec![SessionEvent::GossipPoi(poi)],
ServerPacket::NpcText { text_id, blocks } => {
vec![SessionEvent::NpcGreeting { text_id, blocks }]
}
@@ -158,6 +158,49 @@ pub(super) fn read_npc_text_update(r: &mut &[u8]) -> io::Result<(u32, Vec<NpcTex
Ok((text_id, blocks))
}
/// A point-of-interest marker (`SMSG_GOSSIP_POI`) — the flag a guard drops on your map when you
/// ask where the warrior trainer is. Volunteered by the server when a gossip option carries an
/// `action_poi_id` (vmangos `Player::OnGossipSelect` → `PlayerMenu::SendPointOfInterest`), so it
/// answers no request of ours and can arrive with or without the menu staying open.
///
/// The reference client feeds it into the minimap's **AreaPOI landmark** pipeline as a synthetic
/// record (`system/minimap/scratch/minimap-re.md`: blip slot 1, `0xcea7d4`, written by
/// `set_blip 0x6dac10` from the 0x224 handler `0x4e2840`) — which is why the field names below are
/// the DBC's: `flags` and `icon` are read by exactly the laws that read `AreaPOI.dbc`'s columns.
#[derive(Debug, Clone, PartialEq)]
pub struct GossipPoi {
/// The `AreaPOI.dbc` `Flags` column's role: bit 0 = a candidate at all, bit 1 = draw the
/// in-range icon. 5875-era `points_of_interest` rows all ship `99` (0x63 — both bits set).
pub flags: u32,
/// World position — **x, y only**; the wire carries no z (the marker is a map pin, and every
/// law that consumes it measures a 2-D distance).
pub pos: [f32; 2],
/// The `POIIcons.blp` cell, 8×8 grid, gated `< 64` by the draw. All 250 5875-era rows use
/// `6` = `ICON_POI_REDFLAG`, "red flag w/ yellow !" (vmangos `GossipDef.h:113`).
pub icon: u32,
/// vmangos `points_of_interest.data` — `0` in every 5875-era row (the `.debug send poi`
/// command sends `30`). Carried for the wire's sake; see the app-side marker for what the
/// reference does with it.
pub data: u32,
/// The destination's name ("Stormwind Warrior Trainer") — the marker's hover tooltip.
pub name: String,
}
/// Read `SMSG_GOSSIP_POI` (vmangos `GossipDef.cpp:239-295` — both overloads write the same
/// shape): `u32 flags, f32 x, f32 y, u32 icon, u32 data, cstr name`.
pub(super) fn read_gossip_poi(r: &mut &[u8]) -> io::Result<GossipPoi> {
let flags = read_u32_le(r)?;
let x = read_f32_le(r)?;
let y = read_f32_le(r)?;
Ok(GossipPoi {
flags,
pos: [x, y],
icon: read_u32_le(r)?,
data: read_u32_le(r)?,
name: read_cstring(r)?,
})
}
/// Pick the greeting line the way the reference does (`0x4e2010`, wow-re
/// `system/ui/scratch/gossip-npctext-law.md`) — a **weighted random draw**, not the highest weight:
///
+1 -1
View File
@@ -102,7 +102,7 @@ pub use duel::{
};
pub use gameobject::{gameobj_use, gameobject_query, GameObjectQueryInfo};
pub use gossip::{
gossip_hello, gossip_select_option, npc_text_query, select_greeting, GossipOption,
gossip_hello, gossip_select_option, npc_text_query, select_greeting, GossipOption, GossipPoi,
NpcTextBlock, QuestOption, NPC_TEXT_BLOCKS,
};
pub use group::{
@@ -614,8 +614,15 @@ pub const SMSG_GOSSIP_MESSAGE: u16 = 0x017D; // 381
pub const SMSG_GOSSIP_COMPLETE: u16 = 0x017E; // 382
pub const CMSG_NPC_TEXT_QUERY: u16 = 0x017F; // 383
pub const SMSG_NPC_TEXT_UPDATE: u16 = 0x0180; // 384
// The questgiver panel set — "accept/turn-in a quest at an NPC" (VERIFIED vmangos
// `Opcodes_1_12_1.h`: 386-402). Bodies + SMSG layouts in [`super::quest`] (decision 0088).
// The guard's directions marker. Volunteered by a gossip option carrying an `action_poi_id`
// (vmangos `Player::OnGossipSelect` → `PlayerMenu::SendPointOfInterest`, `GossipDef.cpp:253`), so
// it answers no request of ours — a gossip-family SMSG with no CMSG beside it, which is why it
// sits out at 548 instead of inside the 379-384 block. Body in [`super::gossip`].
pub const SMSG_GOSSIP_POI: u16 = 0x0224; // 548
// The questgiver panel set — "accept/turn-in a quest at an NPC" (VERIFIED vmangos
// `Opcodes_1_12_1.h`: 386-402). Bodies + SMSG layouts in [`super::quest`] (decision 0088).
pub const CMSG_QUESTGIVER_STATUS_QUERY: u16 = 0x0182; // 386
pub const SMSG_QUESTGIVER_STATUS: u16 = 0x0183; // 387
pub const CMSG_QUESTGIVER_HELLO: u16 = 0x0184; // 388
@@ -7,11 +7,11 @@ use crate::wire::Vector3d;
use super::{
ActionButton, AttackerState, CastOutcome, ChannelNotify, Character, ChatMessage,
CorpseLocation, DamageShield, EnvironmentalDamageLog, ExplorationXp, FriendEntry,
FriendStatusUpdate, GameObjectQueryInfo, GossipOption, GroupLootInfo, GroupMemberEntry,
GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse, GuildRoster,
InitWorldStates, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed, LootItem,
LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart, MoveMode, Object,
PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells, QuestComplete, QuestDetails,
FriendStatusUpdate, GameObjectQueryInfo, GossipOption, GossipPoi, GroupLootInfo,
GroupMemberEntry, GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse,
GuildRoster, InitWorldStates, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed,
LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart, MoveMode,
Object, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells, QuestComplete, QuestDetails,
QuestGiverList, QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate,
ResurrectRequestBody, SpeedKind, SpellChainTargets, SpellCooldown, SpellDamageLog,
SpellEnergizeLog, SpellGo, SpellHealLog, SpellLogMiss, SpellStart, TaxiMask, TradeStatus,
@@ -702,6 +702,9 @@ pub enum ServerPacket {
},
/// `SMSG_GOSSIP_COMPLETE` — the gossip window closes (vmangos `Npc.cpp:90`); an empty body.
GossipComplete,
/// `SMSG_GOSSIP_POI` — the marker a guard drops when you ask for directions (vmangos
/// `GossipDef.cpp:253`). Volunteered, never requested.
GossipPoi(GossipPoi),
/// `SMSG_NPC_TEXT_UPDATE` — answers `CMSG_NPC_TEXT_QUERY`: always 8 weighted text blocks
/// (vmangos `GossipDef.cpp:298-369`), carried here **undecided**. Which line greets you needs
/// the NPC's gender and a die roll, so it is drawn when the frame opens, not when the packet
@@ -1280,6 +1283,7 @@ impl ServerPacket {
ServerPacket::QuestUpdateAddItem { .. } => "SMSG_QUESTUPDATE_ADD_ITEM".into(),
ServerPacket::GossipMessage { .. } => "SMSG_GOSSIP_MESSAGE".into(),
ServerPacket::GossipComplete => "SMSG_GOSSIP_COMPLETE".into(),
ServerPacket::GossipPoi(..) => "SMSG_GOSSIP_POI".into(),
ServerPacket::NpcText { .. } => "SMSG_NPC_TEXT_UPDATE".into(),
ServerPacket::VendorList { .. } => "SMSG_LIST_INVENTORY".into(),
ServerPacket::BuyItem { .. } => "SMSG_BUY_ITEM".into(),
@@ -604,6 +604,7 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
}
}
opcode::SMSG_GOSSIP_COMPLETE => ServerPacket::GossipComplete,
opcode::SMSG_GOSSIP_POI => ServerPacket::GossipPoi(gossip::read_gossip_poi(&mut r)?),
opcode::SMSG_NPC_TEXT_UPDATE => {
let (text_id, blocks) = gossip::read_npc_text_update(&mut r)?;
ServerPacket::NpcText { text_id, blocks }
@@ -184,6 +184,40 @@ fn gossip_message_wire() {
}
}
/// `SMSG_GOSSIP_POI` — the guard's directions marker (vmangos `GossipDef.cpp:239-295`):
/// `u32 flags, f32 x, f32 y, u32 icon, u32 data, cstr name`. The golden body is a REAL row of the
/// 5875-era `points_of_interest` table (entry 658, "Lion's Pride Inn": flags 99, icon 6 =
/// `ICON_POI_REDFLAG`, data 0) — the shape every guard direction actually takes.
#[test]
fn gossip_poi_wire_and_event() {
let body = hx(concat!(
"63000000", // u32 flags = 99 (0x63): candidate | in-range icon
"66cd13c6", // f32 x = -9459.35
"6f522842", // f32 y = 42.0805
"06000000", // u32 icon = 6 (ICON_POI_REDFLAG)
"00000000", // u32 data = 0
"4c696f6e277320507269646520496e6e", // "Lion's Pride Inn"
"00", // … NUL-terminated
));
let expected = messages::GossipPoi {
flags: 99,
pos: [-9459.35, 42.0805],
icon: 6,
data: 0,
name: "Lion's Pride Inn".into(),
};
match messages::parse_server(messages::opcode::SMSG_GOSSIP_POI, &body).unwrap() {
ServerPacket::GossipPoi(poi) => assert_eq!(poi, expected),
other => panic!("gossip poi, got {}", other.name()),
}
let packet = messages::parse_server(messages::opcode::SMSG_GOSSIP_POI, &body).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::GossipPoi(poi) => assert_eq!(poi, expected),
other => panic!("gossip poi event, got {other:?}"),
}
}
#[test]
fn npc_text_update_greeting_extraction() {
// SMSG_NPC_TEXT_UPDATE (vmangos GossipDef.cpp:298-369): always 8 blocks of {f32 probability,
+4 -1
View File
@@ -207,7 +207,10 @@ pub use types::{
pub(crate) use types::{FontExplicit, MeasuredText, RegionData};
pub use unit::{grey_band, level_reads_unknown, power_token, unit_is_grey, UnitState};
pub use weapon_enchant::WeaponEnchant;
pub use worldmap::{WorldMapContinentView, WorldMapOverlayView, WorldMapState, WorldMapZoneView};
pub use worldmap::{
WorldMapContinentView, WorldMapLandmarkView, WorldMapOverlayView, WorldMapState,
WorldMapZoneView,
};
pub use worn_display::WornDisplay;
use mlua::Lua;
+82 -4
View File
@@ -28,7 +28,9 @@
//! Zone maps carry **exploration fog**: the base tiles are the unexplored parchment, and each
//! discovered sub-area's overlay art (`GetNumMapOverlays`/`GetMapOverlayInfo`, filtered by the
//! pushed `PLAYER_EXPLORED_ZONES` bitset) fills it in — the reference's own overlay pool draws
//! the returned pieces. v1 stubs: the landmark (AreaPOI) family (`GetNumMapLandmarks` → 0).
//! the returned pieces. The landmark family (`GetNumMapLandmarks`/`GetMapLandmarkInfo`) answers
//! with the guard-directions marker (decision 1514); the `AreaPOI.dbc` rows that also belong in
//! it are 0203's still-deferred slice.
use mlua::{Lua, MultiValue, Value};
@@ -52,6 +54,28 @@ pub struct WorldMapZoneView {
pub overlays: Vec<WorldMapOverlayView>,
}
/// One **map landmark** — a POI icon on the displayed map (`GetNumMapLandmarks` /
/// `GetMapLandmarkInfo`). The app projects it, so what arrives here is already map UV.
///
/// Today the only landmark is the guard-directions marker (`SMSG_GOSSIP_POI` — the flag a guard
/// drops when you ask where the warrior trainer is), and the reference routes it through this same
/// list: its landmark-list builder `0x4a67a0` appends the marker as the one element with
/// `+0x10 == 1`, exempt from every gate a real AreaPOI must pass (VERIFIED, wow-re
/// `system/ui/scratch/gossip-poi-marker.md`). The `AreaPOI.dbc` rows that also belong here are
/// 0203's still-deferred landmark slice, and land in this list when they do.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct WorldMapLandmarkView {
/// The label its tooltip shows ("Stormwind Warrior Trainer").
pub name: String,
/// The second tooltip line — a live status string on the AreaPOI rows that carry one (a
/// battleground node's "In Conflict"); empty for the guard marker.
pub description: String,
/// The `Interface\Minimap\POIIcons` cell, 8×8 grid (the guard marker's is `6` — the red flag).
pub texture_index: u32,
/// Position on the DISPLAYED map, `[0,1]` UV from its top-left.
pub uv: (f32, f32),
}
/// One discovery overlay: a sub-area's map art, drawn over the base tiles once any of its
/// explore bits is set (decision 0203 phase 3).
#[derive(Clone, Debug, Default, PartialEq)]
@@ -195,6 +219,9 @@ pub struct WorldMapState {
/// The discovery bitset (`PLAYER_EXPLORED_ZONES_1` — 64 u32 = 2048 bits, bit n = AreaTable
/// exploreFlag n), pushed by the app when the descriptor changes. Empty = nothing explored.
pub explored: Vec<u32>,
/// The POI icons on the displayed map (`GetNumMapLandmarks`/`GetMapLandmarkInfo`), already
/// projected into its UV — pushed by the app on change, which repaints an open map.
pub landmarks: Vec<WorldMapLandmarkView>,
}
/// Is explore-bit `n` set in the pushed bitset?
@@ -257,6 +284,19 @@ impl super::UiScript {
}
}
/// Push the displayed map's landmarks (the app projects them; see [`WorldMapLandmarkView`]).
/// On change, queues `WORLD_MAP_UPDATE` — the repaint that re-seats the POI icons, exactly
/// the event the overlay push above uses for the same reason.
pub fn set_world_map_landmarks(&mut self, landmarks: Vec<WorldMapLandmarkView>) {
let mut model = self.model_mut();
if model.worldmap.landmarks != landmarks {
model.worldmap.landmarks = landmarks;
model
.pending_events
.push(("WORLD_MAP_UPDATE".to_string(), Vec::new()));
}
}
/// Push the per-frame feed (player zone/projection/facing — see [`WorldMapState`]).
pub fn set_world_map_feed(
&mut self,
@@ -562,11 +602,49 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// The landmark (AreaPOI) family — a later phase-3 slice. Zero/nil now.
g.set("GetNumMapLandmarks", lua.create_function(|_, ()| Ok(0i64))?)?;
// GetNumMapLandmarks() — how many POI icons the displayed map carries. Today that is the
// guard-directions marker and nothing else; the `AreaPOI.dbc` rows are 0203's deferred
// landmark slice, and they join this same list when it lands.
g.set(
"GetNumMapLandmarks",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.worldmap.landmarks.len() as i64)
})?,
)?;
// GetMapLandmarkInfo(i) → name, description, textureIndex, x, y — the i-th (1-based) landmark
// (return shape VERIFIED at `0x4a8740`, wow-re `system/ui/scratch/gossip-poi-marker.md`).
// `textureIndex` indexes `Interface\Minimap\POIIcons`' 8×8 grid — for the guard's marker it is
// the packet's own `Icon`, verbatim, exempt from the substitution an ordinary landmark gets at
// zone level; x/y are `[0,1]` UV on the displayed map, the same space `GetPlayerMapPosition`
// answers in. A landmark with no description answers **nil** there, as the reference does
// (`0x6f3890` pushes nil on a NULL string) — the guard's marker never carries one.
g.set(
"GetMapLandmarkInfo",
lua.create_function(|_, _i: i64| Ok(Value::Nil))?,
lua.create_function(|lua, i: i64| {
let landmark = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
usize::try_from(i)
.ok()
.and_then(|i| i.checked_sub(1))
.and_then(|i| model.worldmap.landmarks.get(i).cloned())
};
let Some(l) = landmark else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
let description = match l.description.is_empty() {
true => Value::Nil,
false => Value::String(lua.create_string(&l.description)?),
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&l.name)?),
description,
Value::Integer(i64::from(l.texture_index)),
Value::Number(f64::from(l.uv.0)),
Value::Number(f64::from(l.uv.1)),
]))
})?,
)?;
// GetNumMapOverlays() — how many of the displayed zone's overlays are REVEALED by the
+38
View File
@@ -46,6 +46,25 @@ pub fn world_to_tile(world_x: f32, world_y: f32) -> (u32, u32) {
(tile_x.min(63), tile_y.min(63))
}
/// Chunks per tile edge (16×16 per ADT) and per map edge (1024).
const CHUNKS_PER_TILE: u32 = 16;
/// Yards per terrain chunk (1/16 of a tile, 33⅓ yd).
const CHUNK_YARDS: f32 = TILE_YARDS / CHUNKS_PER_TILE as f32;
/// World `(x, y)` → the **chunk** index `(chunk_x, chunk_y)` on the 1024×1024 chunk grid — the same
/// axis swap as [`world_to_tile`], and `chunk >> 4` is exactly that tile. Clamped to `0..=1023`.
///
/// This is the reference's own streaming coordinate: `0x672730` computes
/// `fistp((17066.666 − pos) × 0.03 − 0.5)` per axis — the containing chunk — and sizes its
/// residency windows in these units (wow-re `terrain.md`, "Streaming residency").
pub fn world_to_chunk(world_x: f32, world_y: f32) -> (u32, u32) {
let offset = 32.0 * TILE_YARDS;
let max = MAP_SIZE as u32 * CHUNKS_PER_TILE - 1;
let chunk_x = ((offset - world_y) / CHUNK_YARDS) as u32;
let chunk_y = ((offset - world_x) / CHUNK_YARDS) as u32;
(chunk_x.min(max), chunk_y.min(max))
}
/// ADT tile `(tile_x, tile_y)` → the world `(x, y)` of its max-corner origin (inverse of
/// [`world_to_tile`]).
pub fn tile_to_world(tile_x: u32, tile_y: u32) -> (f32, f32) {
@@ -360,6 +379,25 @@ mod tests {
assert!(unflagged.global_wmo().is_none());
}
/// The chunk grid nests in the tile grid: `chunk >> 4` is the tile `world_to_tile` names, at
/// every chunk centre of a whole tile and at the map's far clamp.
#[test]
fn the_chunk_index_nests_in_the_tile_index() {
let (ox, oy) = tile_to_world(32, 44);
for cx in 0..16u32 {
for cy in 0..16u32 {
// Chunk (cx, cy) of tile (32, 44): tile_x runs along world y, tile_y along world x.
let wy = oy - (cx as f32 + 0.5) * CHUNK_YARDS;
let wx = ox - (cy as f32 + 0.5) * CHUNK_YARDS;
let (chx, chy) = world_to_chunk(wx, wy);
assert_eq!((chx >> 4, chy >> 4), world_to_tile(wx, wy));
assert_eq!((chx, chy), (32 * 16 + cx, 44 * 16 + cy));
}
}
assert_eq!(world_to_chunk(-1.0e6, -1.0e6), (1023, 1023));
assert_eq!(world_to_chunk(1.0e6, 1.0e6), (0, 0));
}
#[test]
fn world_origin_is_map_center_tile() {
// World (0, 0) sits at the 32,32 tile boundary — the documented map center.
+24 -38
View File
@@ -55,29 +55,31 @@ use benilla_formats::TILE_SIZE;
const SWEEP_SECS: f32 = 1.0;
/// Default eviction radius in yards — five ADT tiles (2667 yd). Chosen as the smallest round
/// multiple of the tile grid comfortably clear of [`radius_floor`] at the default
/// `$WOW_TILE_RADIUS` (2 ⇒ 2419 yd): far enough that art still on screen is never evicted, close
/// enough that leaving one city for another recovers it (Stormwind → Booty Bay is ~5500 yd,
/// Ironforge → Stormwind ~4250).
/// multiple of the tile grid comfortably clear of [`radius_floor`] (2560 yd): far enough that art
/// still on screen is never evicted, close enough that leaving one city for another recovers it
/// (Stormwind → Booty Bay is ~5500 yd, Ironforge → Stormwind ~4250).
const DEFAULT_RADIUS_YD: f32 = 5.0 * TILE_SIZE;
/// The smallest radius that cannot evict art the streamer is still holding: the far **corner** of
/// the `(2r+1)²` tile block it keeps resident, plus a tile of margin.
/// the widest tile block the residency window can keep, plus a tile of margin.
///
/// A stamp records where *the viewer* was when the art was last wanted, not where the art is — so an
/// entry can be up to a streaming radius "behind" the art it belongs to. Flooring the radius at the
/// entry can be up to a streaming reach "behind" the art it belongs to. Flooring the radius at the
/// block's circumscribed radius means you have to leave the art's neighbourhood entirely before its
/// dedup expires.
fn radius_floor(tile_radius: u32) -> f32 {
(tile_radius as f32 + 0.5) * TILE_SIZE * std::f32::consts::SQRT_2 + TILE_SIZE
/// dedup expires. The window follows the live `farclip` (1513), so the floor is taken at the clamp's
/// **max** — the widest the window can ever be — rather than tracking the slider: a floor that
/// shrank with the view distance would buy nothing (the default clears it either way) and would
/// have to re-resolve on every drag.
fn radius_floor() -> f32 {
crate::terrain_stream::window::max_resident_reach_yd(*crate::view::FARCLIP_RANGE.end())
}
/// Resolve `$WOW_ART_RADIUS` (yards; `0` or negative ⇒ eviction off, the A/B leg that reproduces the
/// unbounded behaviour on a fixed build) against the streamer's reach. An explicitly-requested radius
/// below [`radius_floor`] is raised **and** warned about, rather than silently obeyed: a knob that
/// quietly means something else than it says is how decision 0789 happened.
fn radius_from_env(tile_radius: u32) -> f32 {
let floor = radius_floor(tile_radius);
fn radius_from_env() -> f32 {
let floor = radius_floor();
match std::env::var("WOW_ART_RADIUS")
.ok()
.and_then(|s| s.parse::<f32>().ok())
@@ -86,7 +88,7 @@ fn radius_from_env(tile_radius: u32) -> f32 {
Some(r) if r < floor => {
warn!(
"art-scope: WOW_ART_RADIUS={r} is inside the streamer's own reach ({floor:.0} yd \
at tile radius {tile_radius}) — using {floor:.0}"
at the widest view distance) — using {floor:.0}"
);
floor
}
@@ -228,13 +230,9 @@ impl ArtScope<'_> {
}
}
/// Resolve the radius once the streamer's configuration exists (it decides the floor).
fn configure_art_scope(
mut state: ResMut<ArtScopeState>,
cfg: Option<Res<benilla_assets::RenderConfig>>,
) {
let tile_radius = cfg.map(|c| c.tile_radius).unwrap_or(2);
state.radius = radius_from_env(tile_radius);
/// Resolve the radius once at startup (the floor is a constant of the view-distance clamp).
fn configure_art_scope(mut state: ResMut<ArtScopeState>) {
state.radius = radius_from_env();
if state.radius > 0.0 {
info!(
"art-scope: within-map art evicts beyond {:.0} yd of the view focus",
@@ -271,10 +269,7 @@ impl Plugin for ArtScopePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ArtCensus>()
.init_resource::<ArtScopeState>()
.add_systems(
Startup,
configure_art_scope.after(benilla_assets::AssetSet::Open),
)
.add_systems(Startup, configure_art_scope)
.add_systems(Update, track_art_scope);
}
}
@@ -288,31 +283,22 @@ mod tests {
// stayed: how wide the sweep reaches, and that an unconfigured run still bounds itself.
/// Eviction must never outrun the streamer: the default radius clears the far corner of the
/// resident tile block at the default `$WOW_TILE_RADIUS`, and the floor rises with it.
/// widest block the window keeps at the clamp's max view distance.
#[test]
fn the_radius_clears_the_streamers_own_reach() {
assert!(
DEFAULT_RADIUS_YD > radius_floor(2),
DEFAULT_RADIUS_YD > radius_floor(),
"default {DEFAULT_RADIUS_YD} must clear the floor {}",
radius_floor(2)
);
// The corner of the 5×5 block at tile radius 2 is ~1886 yd; the floor adds a tile.
assert!(
(radius_floor(2) - 2418.7).abs() < 1.0,
"{}",
radius_floor(2)
);
assert!(
radius_floor(4) > DEFAULT_RADIUS_YD,
"a wider window raises it"
radius_floor()
);
// (26 + 1 + 16) chunks per axis at 777, the corner of that, plus a tile: ~2560 yd.
assert!((radius_floor() - 2560.0).abs() < 1.0, "{}", radius_floor());
}
/// With `$WOW_ART_RADIUS` unset (every ordinary run, and the test binary) the radius is the
/// default, floored — never zero, so a shipped build always bounds itself.
#[test]
fn an_unconfigured_run_still_bounds_itself() {
assert_eq!(radius_from_env(2), DEFAULT_RADIUS_YD.max(radius_floor(2)));
assert!(radius_from_env(8) >= radius_floor(8));
assert_eq!(radius_from_env(), DEFAULT_RADIUS_YD.max(radius_floor()));
}
}
+3 -9
View File
@@ -79,14 +79,9 @@ fn open_world_assets(mut commands: Commands, device: Res<RenderDevice>) {
);
return;
};
// Default 2 (a 5×5 block, ~1066 yd) so loaded terrain extends a buffer past the ~777 yd far-clip
// wall — tiles are resident before the wall reveals them, no streaming pop-in. Geometry beyond the
// wall is clipped/culled anyway. Lower $WOW_TILE_RADIUS to 1 to shrink the working set (less view
// distance / better perf); raise it for more buffer.
let tile_radius = std::env::var("WOW_TILE_RADIUS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2);
// How much terrain is resident is NOT a knob here: the streamer derives its window from the
// live `farclip` (`view::ViewDistance`, the player's Terrain Distance setting) the way the
// reference does — `terrain_stream::window`, decision 1513. `$WOW_TILE_RADIUS` is retired.
// Ground-texture repeats per chunk; tunable live in the panel afterward.
let tex_tiles = std::env::var("WOW_TEX_TILES")
.ok()
@@ -99,7 +94,6 @@ fn open_world_assets(mut commands: Commands, device: Res<RenderDevice>) {
.and_then(|s| s.parse().ok())
.unwrap_or(1);
commands.insert_resource(RenderConfig {
tile_radius,
tex_tiles,
unload_budget,
});
-1
View File
@@ -19,7 +19,6 @@ mod sh; // the model SH light-probe coefficient math
pub use blob::LightBlob;
pub use global_light::{new_shared_light_buffer, LightRooms, SharedLightBuffer};
pub use prop_probes::{PropProbeSlot, PropProbes, MAX_PROP_PROBES};
pub use resolve::fog_range;
// The std430 layout itself — row indices, byte sizes, region offsets and the folds that fill them
// — stays in the crate. Off-world producers state values through `LightBlob` and never a row index
// (see `blob`); `global_light` builds the world's own blob on the frame path.
+3 -11
View File
@@ -1,8 +1,8 @@
//! The per-frame time-of-day RESOLVE: samples `Light.dbc` against the effective game clock (server
//! clock, manual debug-panel scrub, or the pre-connect noon fallback) into a fresh [`super::WowLighting`]
//! — the scene-fog stage, the camera-in-WMO interior-fog crossfade ([`WmoFogRamp`]), and the startup
//! seed ([`setup_lighting`]) all live here, plus the [`fog_range`] helper the player camera's far plane
//! borrows. Split from the resource type + plugin registration in `super`.
//! seed ([`setup_lighting`]) all live here. Split from the resource type + plugin registration in
//! `super`.
use bevy::prelude::*;
@@ -12,18 +12,10 @@ use crate::view::WorldCamera;
use crate::world_map::CurrentMap;
use benilla_assets::coords::bevy_to_wow;
use benilla_assets::{LockRecover, WorldAssets};
use benilla_formats::{Atmosphere, LightCatalog, TILE_SIZE};
use benilla_formats::{Atmosphere, LightCatalog};
use super::{daynight, quantize_glow, ClockSource, GameClock, LightSampler, WowLighting};
/// The default (non-haze) distance-fog range for a given tile load radius: anchored at the radius-1
/// look (350→1100 yd), extended one `TILE_SIZE` per extra ring so a larger radius reveals more terrain
/// before the fade. Shared by the lighting fog baseline and the player camera's far plane / seed fog.
pub fn fog_range(tile_radius: u32) -> (f32, f32) {
let end = 1100.0 + tile_radius.saturating_sub(1) as f32 * TILE_SIZE;
(end - 750.0, end)
}
/// The scene-fog stage (`dn_scene_fog 0x6cee30`, wow-re `rf-weather-fog-veil.md`): resolve the
/// **pushed** `(start, end)` fog pair from the blended bands. `end = min(zone_end, farclip)` — the
/// zone ends in yards (Elwynn 500 clear / 278 storm, decision 0324) exceed only a lowered farclip,
+1 -1
View File
@@ -55,7 +55,7 @@ const SUN_SIZE: f32 = 0.0833;
const GLARE_DIST: f32 = 12.0;
/// Flare occlusion-march tuning: sample count and reach. The reach spans the whole DRAWN horizon —
/// the detailed ADT ring (`tile_radius` 2 ≈ ±1066 units) and the coarse WDL beyond it, out to just
/// the detailed ADT ring (the farclip-derived window, ≈ ±867 units at 777) and the coarse WDL beyond it, out to just
/// inside the far plane — so a distant WDL mountain that visibly hides the body also kills its flare
/// (the moon shone through the Westfall ridge when the march stopped at 1000). 48 quadratic samples
/// ⇒ ≤ ~117-unit spacing at the far end, well under a horizon mountain's footprint.
+47 -30
View File
@@ -19,7 +19,7 @@ use std::time::{Duration, Instant};
use benilla_assets::coords::{bevy_to_wow, placement_rotation, wow_to_bevy};
use benilla_assets::{AdtTile, M2Model, WdtIndex, WmoModel};
use benilla_formats::{world_to_tile, Doodad, WmoInstance};
use benilla_formats::{Doodad, WmoInstance};
use bevy::pbr::ExtendedMaterial;
use bevy::prelude::*;
use bevy::render::render_resource::Face;
@@ -45,6 +45,7 @@ pub mod merge;
mod queries;
mod spawn;
mod weld;
pub(crate) mod window;
use collider::{finish_colliders, impassable_wall_data, terrain_collider_data};
use furnish::furnish_tile_cells;
@@ -52,6 +53,7 @@ use merge::{flush_static_merge, StaticMerge};
use spawn::prop_light::WmoDoodadInst;
use spawn::spawn_loaded_placements;
use weld::{flush_hull_welds, HullWelds};
pub use window::StreamWindow;
// The WMO prop-light machinery lives spawn-side (0830's named carve, executed in 0832); the two
// outside consumers — `crate::interior` and `crate::entities`' `wmo_props` — keep their
// `terrain_stream::X` paths, same as the `queries` items below.
@@ -89,6 +91,9 @@ pub struct TerrainStreamer {
/// The focus tile [`stream_terrain`] last streamed around — the furnisher's nearest-first
/// ordering key, published here so it never recomputes the focus ladder a second time.
focus: (i32, i32),
/// The window's `(inner, outer)` half-widths as of last frame, so the reach is logged once
/// per change (the Terrain Distance slider) rather than per frame.
reach: Option<(i32, i32)>,
/// The current map's WDT tile index (decision 0476), requested with the map: ADT requests wait
/// for it and consult its `MAIN` grid — open ocean authors no tiles to ask for.
wdt: Option<Handle<WdtIndex>>,
@@ -605,10 +610,14 @@ fn stream_terrain(
camera: Query<&Transform, With<WorldCamera>>,
shared_light: Option<Res<SharedLightBuffer>>,
cfg: Option<Res<RenderConfig>>,
// "Where are we" — the server's map, the catalog that names its directory, and the roster row
// of a pick still in flight (the entry window's focus). Bundled for the same param-limit
// reason as `asset_stores`.
location: (Option<Res<CurrentMap>>, Option<Res<MapCatalogRes>>),
// "Where are we, and how far do we look" — the server's map, the catalog that names its
// directory, and the live view distance the residency window derives from (1513). Bundled
// for the same param-limit reason as `asset_stores`.
location: (
Option<Res<CurrentMap>>,
Option<Res<MapCatalogRes>>,
Res<crate::view::ViewDistance>,
),
mut load_progress: Option<ResMut<WorldLoadProgress>>,
// Bundled for the same 16-param reason as `asset_stores`: the two stream-batch accumulators
// the map-change drop must clear.
@@ -622,7 +631,7 @@ fn stream_terrain(
) {
let (mut welds, mut static_merge, mut staticgx) = batchers;
let (mut materials, mut meshes, wdts, _time, mut activity) = asset_stores;
let (current_map, map_catalog) = location;
let (current_map, map_catalog, view) = location;
// The shared light buffer + map catalog are set up by other plugins' startup; until they exist
// there's nothing to stream against, so idle.
let (Some(shared_light), Some(map_catalog)) = (shared_light, map_catalog) else {
@@ -703,27 +712,34 @@ fn stream_terrain(
}
}
// Stream around the *view focus* — see [`ViewFocus`] for the ladder.
// Stream around the *view focus* — see [`ViewFocus`] for the ladder — as far as the view
// distance reaches: the residency window is the reference's own, derived from the live
// `farclip` in chunk units (`window`, decision 1513). The slider that moves the far-clip
// wall moves this with it.
let center = focus.resolve(camera.single().ok().map(|c| c.translation));
let radius = cfg.as_ref().map(|c| c.tile_radius as i32).unwrap_or(2);
let window = StreamWindow::at(view.farclip, center[0], center[1]);
let tiling = cfg.as_ref().map(|c| c.tex_tiles).unwrap_or(8.0);
let (cx, cy) = world_to_tile(center[0], center[1]);
let (cx, cy) = (cx as i32, cy as i32);
let (cx, cy) = window.focus_tile();
state.focus = (cx, cy);
// Desired set: the (2r+1)² square around the focus, clamped to the 64×64 tile grid — and
// filtered to tiles the WDT says exist (open ocean authors none). The filter also fixes the
// loading screen's accounting: `ready == total` is reachable on a coast, and any fallback-era
// entry for a nonexistent tile turns stale and unloads below.
let mut desired: Vec<(i32, i32)> = Vec::new();
for dx in -radius..=radius {
for dy in -radius..=radius {
let (tx, ty) = (cx + dx, cy + dy);
if (0..=63).contains(&tx) && (0..=63).contains(&ty) {
desired.push((tx, ty));
}
}
// One line per change of reach — the slider moved, or the first frame — so a run log says
// what the residency window was when a tile count or a frame cost was read off it.
if state.reach != Some((window.inner, window.outer)) {
state.reach = Some((window.inner, window.outer));
info!(
"terrain: residency window ±{} chunks ({:.0} yd), release past ±{} — farclip {:.0}",
window.outer,
window.outer as f32 * benilla_formats::CHUNK_SIZE,
window.outer + window::KEEP_BAND_CHUNKS,
view.farclip
);
}
// Desired set: every tile the outer window touches, clamped to the 64×64 tile grid — and
// filtered to tiles the WDT says exist (open ocean authors none; the reference's own
// `MAIN` bit-0 test at `0x69863c`). The filter also fixes the loading screen's accounting:
// `ready == total` is reachable on a coast, and any fallback-era entry for a nonexistent
// tile turns stale and unloads below.
let mut desired = window.wanted_tiles();
if let Some(w) = wdt_index {
desired.retain(|&(tx, ty)| w.has_tile(tx as u32, ty as u32));
}
@@ -740,21 +756,22 @@ fn stream_terrain(
// re-cross inside the drain window finds its tile still loaded (no re-decode, no re-spawn).
// A cross-map swap is NOT budgeted (`drop_streamed_world` above): the loading screen covers
// it, and holding a dead map's tiles through a fresh map's IO burst helps nothing.
// **The keep-band** (B181): a tile is requested inside `radius` but released only past
// `radius + 1` — one tile of hysteresis. Without it, a body straddling a tile line cycles a
// whole row per step: the reporter's own minimal repro (`.go` half a yard across, and back)
// re-decoded and re-spawned five tiles each way, and the row's landing — not the streamer's
// **The keep-band** (B181): a tile is wanted inside the outer window but released only past
// it plus one chunk (`StreamWindow::keeps`) — hysteresis. Without it, a body pacing across
// the one chunk line that toggles a far tile cycles that tile per step: the reporter's own
// minimal repro (`.go` half a yard across, and back) re-decoded and re-spawned five tiles
// each way under the old tile-centred window, and the row's landing — not the streamer's
// own budgeted systems — is the frame spike (a fresh Undercity row lands ~3900 placements
// and ~1300 mesh assets). A band tile stays fully resident, so a re-cross finds it and
// streams NOTHING; the band's dedup entries need no art-sweep accommodation (0793's
// `radius_floor` is about art the streamer will re-fetch — a band tile is already spawned
// and never re-consults the caches).
// and never re-consults the caches). The reference releases by window membership alone
// (`0x6984f0`) — its reload is a cheap async read; ours is the spawn wave above.
//
// Ablation switch (`WOW_NO_TILE_DROP=1`): never release stale tiles — a crossing becomes a
// pure asset-ADD event and a re-cross a pure no-op, separating the load lane from the free
// lane when measuring a streaming cost. Residency grows for the life of the run; dev-only.
let unload_budget = cfg.as_ref().map(|c| c.unload_budget).unwrap_or(1);
let keep = radius + 1;
let mut stale: Vec<(i32, i32)> = if tile_drop_disabled() {
Vec::new()
} else {
@@ -762,7 +779,7 @@ fn stream_terrain(
.tiles
.keys()
.copied()
.filter(|&(tx, ty)| (tx - cx).abs().max((ty - cy).abs()) > keep)
.filter(|&tile| !window.keeps(tile))
.collect()
};
if unload_budget > 0 && stale.len() > unload_budget {
@@ -0,0 +1,235 @@
//! **The residency window** — how far around the view focus terrain is kept resident, derived
//! from `farclip` exactly as the reference derives it (decision 1513).
//!
//! The reference has one view-distance setting, the `farclip` CVar (the Video Options "Terrain
//! Distance" slider, 177..777), and streams *from* it: `SetFarClip 0x6725d0` computes
//! `r = 1 + trunc(farclip / 33.333)` **chunks** once, and the per-frame world tick (`0x672730`)
//! centres two nested square windows on the viewer's chunk — the **inner** `idx ± r` (terrain that
//! must be resident: the farclip disc rounded out to whole chunks) and the **outer**
//! `idx ± max(r + 2, 8)` (requested ahead, built under a 5 ms/frame budget; the 8-chunk floor is
//! 266⅔ yd). Eviction is pure membership in the outer window, at tile granularity (`[area+0x88]`
//! vs `bounds >> 4`). Every number here is VERIFIED at the bytes — wow-re `terrain.md`,
//! "Streaming residency", and `scratch/streaming-residency-and-forced-load.md` §1–§2.
//!
//! Two deliberate differences from the reference, both named in 1513:
//! - **Whole tiles.** benilla loads, spawns and releases ADT tiles, not chunks; a tile is wanted iff
//! any of its chunks lies in the outer window. The reference builds chunk-by-chunk inside the
//! same windows, so the edge of its resident world is a chunk line and ours is a tile line.
//! - **A one-chunk keep band** ([`KEEP_BAND_CHUNKS`]). The reference releases by membership alone
//! because its reload is a cheap async read; ours re-decodes and re-spawns a tile (B181's
//! frame spike), so a tile is wanted inside `outer` and released only past `outer + 1` —
//! hysteresis against a body pacing across the one chunk line that toggles a far tile.
use benilla_formats::{world_to_chunk, CHUNK_SIZE, TILE_SIZE};
/// Chunks per tile edge; `chunk >> 4` is the tile.
const CHUNKS_PER_TILE: i32 = 16;
/// Chunk indices run `0..=1023` (64 tiles × 16), matching the reference's `[0, 0x3ff]` clamp.
const CHUNK_MAX: i32 = 64 * CHUNKS_PER_TILE - 1;
/// The outer window's floor in chunks — `max(r + 2, 8)` (`0x672966`–`0x67297b`).
const OUTER_FLOOR_CHUNKS: i32 = 8;
/// Chunks the outer window grows by for the *release* test only (see the module docs).
pub const KEEP_BAND_CHUNKS: i32 = 1;
/// The two nested windows around one focus chunk, in chunk units on the tile-grid axes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct StreamWindow {
/// The focus's chunk index `(chunk_x, chunk_y)` — the same axes as the tile grid.
pub focus: (i32, i32),
/// Inner half-width in chunks: `1 + trunc(farclip / CHUNK_SIZE)`.
pub inner: i32,
/// Outer half-width in chunks: `max(inner + 2, 8)`.
pub outer: i32,
}
impl StreamWindow {
/// The windows `farclip` yards of view distance put around the WoW-space focus `(x, y)`.
pub fn at(farclip: f32, wow_x: f32, wow_y: f32) -> Self {
let (cx, cy) = world_to_chunk(wow_x, wow_y);
let inner = inner_radius(farclip);
Self {
focus: (cx as i32, cy as i32),
inner,
outer: outer_radius(inner),
}
}
/// The tile the focus stands on.
pub fn focus_tile(&self) -> (i32, i32) {
(
self.focus.0 / CHUNKS_PER_TILE,
self.focus.1 / CHUNKS_PER_TILE,
)
}
/// The inclusive tile range `((min_x, min_y), (max_x, max_y))` touched by the outer window
/// grown by `band` chunks, clamped to the 64×64 tile grid.
fn tile_range(&self, band: i32) -> ((i32, i32), (i32, i32)) {
let half = self.outer + band;
let lo = |c: i32| (c - half).clamp(0, CHUNK_MAX) / CHUNKS_PER_TILE;
let hi = |c: i32| (c + half).clamp(0, CHUNK_MAX) / CHUNKS_PER_TILE;
(
(lo(self.focus.0), lo(self.focus.1)),
(hi(self.focus.0), hi(self.focus.1)),
)
}
/// Every tile the outer window touches — the set the streamer wants resident, in grid order.
pub fn wanted_tiles(&self) -> Vec<(i32, i32)> {
let ((x0, y0), (x1, y1)) = self.tile_range(0);
let mut out = Vec::with_capacity(((x1 - x0 + 1) * (y1 - y0 + 1)) as usize);
for tx in x0..=x1 {
for ty in y0..=y1 {
out.push((tx, ty));
}
}
out
}
/// Does the window still *keep* this tile — is it inside the outer window plus the keep band?
/// A resident tile that is not kept is stale and releases.
pub fn keeps(&self, tile: (i32, i32)) -> bool {
let ((x0, y0), (x1, y1)) = self.tile_range(KEEP_BAND_CHUNKS);
(x0..=x1).contains(&tile.0) && (y0..=y1).contains(&tile.1)
}
}
/// The inner half-width in chunks for a view distance — `1 + trunc(farclip / 33.333)`
/// (`0x6725e9 fmul −0.03 · __ftol · 1 − eax`). 777 ⇒ 24, 350 ⇒ 11, 177 ⇒ 6.
pub fn inner_radius(farclip: f32) -> i32 {
1 + (farclip / CHUNK_SIZE).trunc() as i32
}
/// The outer half-width in chunks for an inner one — `max(inner + 2, 8)`.
pub fn outer_radius(inner: i32) -> i32 {
(inner + 2).max(OUTER_FLOOR_CHUNKS)
}
/// How far, in yards, the corner of any tile this window can hold resident may lie from the
/// focus — the bound a within-map art sweep must clear so it never evicts what the streamer is
/// still holding (`art_scope::radius_floor`). A kept tile's far edge lies at most
/// `outer + band + 16` chunks out per axis (the window's edge chunk plus the rest of that whole
/// tile), on both axes at once at the corner.
pub fn max_resident_reach_yd(farclip: f32) -> f32 {
let half = outer_radius(inner_radius(farclip)) + KEEP_BAND_CHUNKS + CHUNKS_PER_TILE;
half as f32 * CHUNK_SIZE * std::f32::consts::SQRT_2 + TILE_SIZE
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_formats::tile_to_world;
/// The radii are the bytes': 777 ⇒ 24/26, the registrar default 350 ⇒ 11/13, and at the
/// 177 floor the outer window sits on its 8-chunk floor, not at inner + 2.
#[test]
fn the_radii_are_the_references() {
assert_eq!(inner_radius(777.0), 24);
assert_eq!(outer_radius(24), 26);
assert_eq!(inner_radius(350.0), 11);
assert_eq!(outer_radius(11), 13);
assert_eq!(inner_radius(177.0), 6);
assert_eq!(outer_radius(6), 8);
// Truncation, not rounding: a hair under a chunk multiple stays on the lower ring.
assert_eq!(inner_radius(CHUNK_SIZE * 3.0 - 0.01), 3);
assert_eq!(inner_radius(CHUNK_SIZE * 3.0 + 0.01), 4);
}
/// A WoW-space point in chunk (5, 9) of tile (32, 44): the chunk index is that chunk.
fn at_chunk(farclip: f32, tile: (u32, u32), chunk: (i32, i32)) -> StreamWindow {
let (ox, oy) = tile_to_world(tile.0, tile.1);
let wy = oy - (chunk.0 as f32 + 0.5) * CHUNK_SIZE;
let wx = ox - (chunk.1 as f32 + 0.5) * CHUNK_SIZE;
StreamWindow::at(farclip, wx, wy)
}
/// At 777 the outer window reaches 26 chunks — 1 tile + 10 chunks — so from a chunk in the
/// tile's middle band (5..=9) it touches two tiles each way (5×5), and from a chunk near an
/// edge only one tile on that side (4×4 at the corner chunk). The old fixed 5×5 was the
/// worst case of this, every time.
#[test]
fn the_wanted_block_follows_the_focus_chunk() {
let w = at_chunk(777.0, (32, 44), (7, 7));
assert_eq!(w.focus, (32 * 16 + 7, 44 * 16 + 7));
assert_eq!(w.focus_tile(), (32, 44));
let tiles = w.wanted_tiles();
assert_eq!(tiles.len(), 25);
assert!(tiles.contains(&(30, 42)) && tiles.contains(&(34, 46)));
let w = at_chunk(777.0, (32, 44), (0, 0));
let tiles = w.wanted_tiles();
// Back: chunk 0 − 26 reaches 26 chunks into the previous tiles ⇒ two tiles. Forward:
// chunk 0 + 26 = chunk 10 of the next tile ⇒ one tile.
assert_eq!(tiles.len(), 16);
assert!(tiles.contains(&(30, 42)) && tiles.contains(&(33, 45)));
assert!(!tiles.contains(&(34, 44)));
}
/// At the registrar default 350 the outer window is 13 chunks (433 yd): a 2×2 to 3×3 block —
/// the working set the slider is for.
#[test]
fn a_shorter_view_distance_wants_fewer_tiles() {
assert_eq!(at_chunk(350.0, (32, 44), (7, 7)).wanted_tiles().len(), 9);
assert_eq!(at_chunk(350.0, (32, 44), (15, 15)).wanted_tiles().len(), 4);
}
/// The keep band is one chunk: a tile the outer window's edge just left is still kept, and
/// one a chunk further out is not.
#[test]
fn the_keep_band_is_one_chunk_of_hysteresis() {
// From chunk 11 the outer window (26) reaches forward to chunk 37 = tile +2, chunk 5.
let w = at_chunk(777.0, (32, 44), (11, 11));
assert!(w.wanted_tiles().contains(&(34, 46)));
// From chunk 5 it reaches chunk 31 = tile +1's last chunk: tile +2 is not wanted…
let w = at_chunk(777.0, (32, 44), (5, 5));
assert!(!w.wanted_tiles().contains(&(34, 46)));
// …but the band (chunk 32 = tile +2, chunk 0) still keeps it.
assert!(w.keeps((34, 46)));
// From chunk 4 even the band falls short: the tile releases.
let w = at_chunk(777.0, (32, 44), (4, 4));
assert!(!w.keeps((34, 46)));
// What is wanted is always kept.
for t in w.wanted_tiles() {
assert!(w.keeps(t));
}
}
/// The windows clamp at the map's edge instead of naming tiles that do not exist.
#[test]
fn the_window_clamps_to_the_grid() {
let w = at_chunk(777.0, (0, 63), (0, 15));
let tiles = w.wanted_tiles();
assert!(tiles
.iter()
.all(|&(x, y)| (0..64).contains(&x) && (0..64).contains(&y)));
assert!(tiles.contains(&(0, 63)) && tiles.contains(&(1, 62)));
assert!(w.keeps((0, 63)) && !w.keeps((-1, 63)) && !w.keeps((0, 64)));
}
/// The art-scope floor clears the far corner of the widest block 777 can hold, and grows
/// with the view distance.
#[test]
fn the_resident_reach_bounds_the_block() {
let reach = max_resident_reach_yd(777.0);
// (26 + 1 + 16) chunks = 43 × 33⅓ = 1433 yd per axis; the corner is √2 of that, plus a tile.
assert!((reach - (43.0 * CHUNK_SIZE * std::f32::consts::SQRT_2 + TILE_SIZE)).abs() < 0.01);
assert!(max_resident_reach_yd(350.0) < reach);
// A wanted tile's far corner is inside the reach (worst case: the focus at a chunk's
// near edge, the tile at the window's far edge).
let w = at_chunk(777.0, (32, 44), (9, 9));
let (fx, fy) = (w.focus.0 as f32, w.focus.1 as f32);
let far = w
.wanted_tiles()
.into_iter()
.map(|(tx, ty)| {
let cx = ((tx + 1) * CHUNKS_PER_TILE) as f32 - fx;
let cy = ((ty + 1) * CHUNKS_PER_TILE) as f32 - fy;
(cx * cx + cy * cy).sqrt() * CHUNK_SIZE
})
.fold(0.0f32, f32::max);
assert!(
far < reach,
"far corner {far} must sit inside the reach {reach}"
);
}
}
+23 -15
View File
@@ -14,11 +14,11 @@
//!
//! Read by: the hard far-clip **wall** (terrain/model/liquid/WDL/particle shaders, pushed as
//! `fog_params.w` by `lighting::apply_wow_lighting`), the per-object **cull**
//! (`debug_panel::apply_model_visibility`), the particle **draw-set gate**
//! (`particles::sim::simulate_particles`) — both through [`within_farclip`] — and
//! — once `terrain_stream` is split — the tile
//! **stream radius** (which should derive its coverage from `farclip`). Live-editable via the debug
//! panel slider (the same A/B lever as before, now writing this resource).
//! (`model_render::apply_model_visibility`), the particle **draw-set gate**
//! (`particles::sim::simulate_particles`) — both through [`within_farclip`] — and the terrain
//! **residency window** (`terrain_stream::window`), which derives its reach from `farclip` the way
//! the reference does (decision 1513). The player's lever is the Terrain Distance row of the
//! options window (the `farclip` CVar); `$WOW_FARCLIP` is the headless one.
use bevy::prelude::*;
@@ -94,23 +94,31 @@ impl Viewer {
}
}
/// View distance in yards. `farclip` = WoW's `farclip` CVar — the projection far plane for the detailed
/// world; geometry beyond it is clipped per-pixel (the wall) and the WDL horizon fills in beyond.
/// Default **777** (the vanilla max-view clamp `[177, 777]`; matches the reference `Config.wtf`).
/// View distance in yards. `farclip` = WoW's `farclip` CVar — the ONE view distance: the far plane of
/// the detailed world (geometry beyond it is clipped per-pixel, the wall, and the WDL horizon fills
/// in beyond) **and** the reach of terrain residency (`terrain_stream::window`, decision 1513).
/// Default **777** (the vanilla clamp's max; the reference registers 350 — 0954's deliberate
/// divergence, matching the reference `Config.wtf` most players ran).
#[derive(Resource, Clone, Copy)]
pub struct ViewDistance {
pub farclip: f32,
}
/// The settable range of [`ViewDistance::farclip`], shared by the debug-panel slider and the
/// `$WOW_FARCLIP` env knob so the two can't drift. The vanilla `farclip` CVar clamp is `[177, 777]`
/// (validate callback `0x688d40`, wow-re `terrain.md` "Camera-distance CVars"; its default is 350, ours
/// is the max) — the upper end here runs past that ONLY as an A/B lever against the old
/// "draw everything in the tile window" look.
pub const FARCLIP_RANGE: std::ops::RangeInclusive<f32> = 177.0..=1200.0;
/// The settable range of [`ViewDistance::farclip`] — the vanilla `farclip` CVar clamp `[177, 777]`
/// (validate callback `0x688d40`, wow-re `terrain.md` "Camera-distance CVars"), shared by the CVar
/// apply, the options row and the `$WOW_FARCLIP` env knob so none can drift. It used to run to 1200
/// as an A/B lever against the pre-wall "draw everything in the tile window" look; that look is
/// gone and the window now follows this number, so the headroom went with it (1513).
pub const FARCLIP_RANGE: std::ops::RangeInclusive<f32> = 177.0..=777.0;
/// The world camera's projection far plane in yards — the **horizon** plane, far beyond `farclip`
/// on purpose so the coarse WDL ring draws behind the wall (decision 0684; the reference's own
/// `horizonfarclip` is a second plane floored at `farclip + 528`, default 2112). One number, not a
/// function of anything: the detailed world ends at `farclip` by the wall, never by this plane.
pub const CAM_FAR: f32 = 3000.0;
impl Default for ViewDistance {
/// `$WOW_FARCLIP` (yd, clamped to [`FARCLIP_RANGE`]) overrides the 777 default. The panel slider is
/// `$WOW_FARCLIP` (yd, clamped to [`FARCLIP_RANGE`]) overrides the 777 default. The options row is
/// the live lever, but a headless capture has no hands — and a horizon or fog report almost always
/// arrives with the director's slider somewhere other than the default (the 0684 gap was invisible
/// at 777 and glaring at 320), so reproducing one must not need a human. Read once at startup, like
+2 -6
View File
@@ -31,10 +31,8 @@ use benilla_assets::coords::wow_to_bevy;
use crate::boot;
use crate::build_id::BuildId;
use crate::lighting::fog_range;
use crate::terrain_stream::SPAWN_XY;
use crate::thread_qos;
use benilla_assets::RenderConfig;
/// Where the viewer opens, in WoW world coords. Northshire, Elwynn — the same anchor the client
/// boots on ([`crate::terrain_stream::SPAWN_XY`]), so the two binaries stream the same tiles and a difference
@@ -312,11 +310,9 @@ struct ViewCam {
speed: f32,
}
fn spawn_view_camera(mut commands: Commands, config: Option<Res<RenderConfig>>) {
fn spawn_view_camera(mut commands: Commands) {
let start = wow_to_bevy([VIEW_START.0, VIEW_START.1, 100.0]);
let far = config
.map(|c| (fog_range(c.tile_radius).1 + 800.0).max(3000.0))
.unwrap_or(3000.0);
let far = crate::view::CAM_FAR;
commands.spawn((
Camera3d::default(),
crate::view::WorldCamera,