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
+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());
}