Instances built as one WMO take their zone from Map.dbc (#1670)
In the 17 instances built as one WMO (Wailing Caverns, Blackrock Depths, Molten Core, Dire Maul, the Deeprun Tram and others) benilla had no area: entering by portal kept the outside zone, no `ZONE_CHANGED_NEW_AREA` fired, and logging in inside left the zone texts empty. On a map whose WDT carries MPHD bit 0 (`0x694904`), 1.12.1's area query `0x6703a0` answers the map's `Map.dbc` field 19 for every entity, ahead of any WMO or terrain read; a 0 there fails the query. `MapCatalog` now reads field 19, the WDT exposes the bit, and `update_current_area` takes that leg first on such a map. A worldport also drops the old map's held area, which otherwise survived the transfer. Tests cover the leg, the flag's map, a zero field and the worldport; a live portal into Wailing Caverns fired `ZONE_CHANGED_NEW_AREA` with "Wailing Caverns". Fixes #1636 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,10 @@
|
||||
//! - A zone id change fires `ZONE_CHANGED_NEW_AREA` alone; else a text change fires
|
||||
//! `ZONE_CHANGED_INDOORS` or `ZONE_CHANGED` by the indoor bit. The first world-enter is a zone
|
||||
//! id change from the zeroed cache.
|
||||
//! - The leaf is the area query's answer (`0x6703a0`): on a map whose WDT holds one global WMO,
|
||||
//! `Map.dbc` field 19 for every entity; elsewhere the down-ray's WMO row, else the terrain. The
|
||||
//! indoor naming below still applies on such a map, as `0x67e510` takes the indoor arm by the
|
||||
//! indoor bit alone.
|
||||
//! - The zone is the leaf's single-hop parent (AreaTable field 2, itself when 0).
|
||||
//! - The indoor bit is the faces-only down-ray (`0x6a87f0`): the surface below is a WMO face whose
|
||||
//! group lacks MOGP `0x8` EXTERIOR.
|
||||
|
||||
@@ -20,6 +20,11 @@ impl WdtIndex {
|
||||
.is_some_and(|t| t.has_adt)
|
||||
}
|
||||
|
||||
/// `MPHD` bit 0, the client's map-is-one-object flag (`[0xc9e384]`).
|
||||
pub fn is_one_object(&self) -> bool {
|
||||
self.0.is_one_object()
|
||||
}
|
||||
|
||||
/// The single global building of a map with no terrain (`MPHD` bit 0); `None` on an ADT map.
|
||||
pub fn global_wmo(&self) -> Option<&GlobalWmo> {
|
||||
self.0.global_wmo()
|
||||
|
||||
@@ -28,6 +28,9 @@ pub struct MapCatalog {
|
||||
instance_types: HashMap<u32, u32>,
|
||||
/// The battleground columns of every row, since the client resolves map 0's too.
|
||||
battleground: HashMap<u32, MapBattlegroundColumns>,
|
||||
/// Field 19 (`+0x4c`), the `AreaTable` id of a map built as one WMO, of every row; 0 on the
|
||||
/// ordinary maps (`0x6703d1`).
|
||||
area_table_ids: HashMap<u32, u32>,
|
||||
}
|
||||
|
||||
/// The `Map.dbc` columns the battleground verbs read by row offset (`GetBattlefieldInfo`
|
||||
@@ -71,6 +74,23 @@ impl MapBattlegroundColumns {
|
||||
}
|
||||
|
||||
impl MapCatalog {
|
||||
/// A catalog of `(id, directory, AreaTableID)` rows, for tests that run without an install.
|
||||
pub fn from_rows<'a>(rows: impl IntoIterator<Item = (u32, &'a str, u32)>) -> Self {
|
||||
let mut c = Self {
|
||||
dirs: HashMap::new(),
|
||||
names: HashMap::new(),
|
||||
loading_screens: HashMap::new(),
|
||||
instance_types: HashMap::new(),
|
||||
battleground: HashMap::new(),
|
||||
area_table_ids: HashMap::new(),
|
||||
};
|
||||
for (id, dir, area) in rows {
|
||||
c.dirs.insert(id, dir.to_string());
|
||||
c.area_table_ids.insert(id, area);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// The MPQ directory name (`Directory`) for `map_id`, as `MapTiles::load(chain, dir)` takes it.
|
||||
pub fn directory(&self, map_id: u32) -> Option<&str> {
|
||||
self.dirs.get(&map_id).map(String::as_str)
|
||||
@@ -102,6 +122,13 @@ impl MapCatalog {
|
||||
self.battleground.get(&map_id)
|
||||
}
|
||||
|
||||
/// Field 19 (`+0x4c`) for `map_id`: the area the whole map answers when its WDT holds one
|
||||
/// global WMO (`0x6703a0` reads it at `0x6703d1`, and a 0 fails the query). `None` only for an
|
||||
/// id with no row; the ordinary maps carry 0.
|
||||
pub fn area_table_id(&self, map_id: u32) -> Option<u32> {
|
||||
self.area_table_ids.get(&map_id).copied()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.dirs.len()
|
||||
}
|
||||
@@ -121,6 +148,7 @@ const MAX_PLAYERS_FIELD: usize = 15;
|
||||
const FIELD_16: usize = 16;
|
||||
const FIELD_17: usize = 17;
|
||||
const FIELD_18: usize = 18;
|
||||
const AREA_TABLE_FIELD: usize = 19;
|
||||
const DESCRIPTION_0_FIELD: usize = 20;
|
||||
const DESCRIPTION_1_FIELD: usize = 29;
|
||||
const BRACKET_SPAN_FIELD: usize = 39;
|
||||
@@ -148,6 +176,7 @@ fn map_schema() -> Schema {
|
||||
LOADING_SCREEN_FIELD => {
|
||||
s.add_field(SchemaField::new("LoadingScreenID", FieldType::UInt32))
|
||||
}
|
||||
AREA_TABLE_FIELD => s.add_field(SchemaField::new("AreaTableID", FieldType::UInt32)),
|
||||
_ => s.add_field(SchemaField::new(format!("_pad{i}"), FieldType::UInt32)),
|
||||
}
|
||||
}
|
||||
@@ -166,6 +195,7 @@ pub fn load_map_catalog(chain: &mut Chain) -> Result<MapCatalog> {
|
||||
let mut loading_screens = HashMap::new();
|
||||
let mut instance_types = HashMap::with_capacity(rs.records().len());
|
||||
let mut battleground = HashMap::with_capacity(rs.records().len());
|
||||
let mut area_table_ids = HashMap::with_capacity(rs.records().len());
|
||||
for r in rs.records() {
|
||||
let Some(id) = u32_at(r, 0) else { continue };
|
||||
battleground.insert(
|
||||
@@ -186,6 +216,7 @@ pub fn load_map_catalog(chain: &mut Chain) -> Result<MapCatalog> {
|
||||
minimap_icon_scale: f32_at(r, MINIMAP_ICON_SCALE_FIELD).unwrap_or(1.0),
|
||||
},
|
||||
);
|
||||
area_table_ids.insert(id, u32_at(r, AREA_TABLE_FIELD).unwrap_or(0));
|
||||
if let Some(dir) = str_at(&rs, r, 1) {
|
||||
dirs.insert(id, dir);
|
||||
}
|
||||
@@ -206,6 +237,7 @@ pub fn load_map_catalog(chain: &mut Chain) -> Result<MapCatalog> {
|
||||
loading_screens,
|
||||
instance_types,
|
||||
battleground,
|
||||
area_table_ids,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -255,4 +287,32 @@ mod tests {
|
||||
"every row carries the columns — the client resolves map 0's when nothing was listed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Field 19 (`+0x4c`) is a one-object map's whole area (`0x6703d1`); a continent carries 0.
|
||||
/// The query reads it only where the WDT sets `MPHD` bit 0, and Zul'Gurub and Blackwing Lair
|
||||
/// carry the field without the flag.
|
||||
#[test]
|
||||
fn field_19_is_the_area_of_a_map_built_as_one_object() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let catalog = load_map_catalog(&mut chain).expect("Map.dbc");
|
||||
assert_eq!(catalog.area_table_id(43), Some(718), "Wailing Caverns");
|
||||
assert_eq!(catalog.area_table_id(230), Some(1584), "Blackrock Depths");
|
||||
assert_eq!(catalog.area_table_id(0), Some(0), "Eastern Kingdoms");
|
||||
assert_eq!(catalog.area_table_id(1), Some(0), "Kalimdor");
|
||||
assert_eq!(catalog.area_table_id(9999), None);
|
||||
let mut flag = |map: u32| {
|
||||
let dir = catalog.directory(map).expect("row").to_string();
|
||||
let bytes = chain
|
||||
.read_file(&format!("World\\Maps\\{dir}\\{dir}.wdt"))
|
||||
.expect("wdt");
|
||||
crate::WdtReader::new(std::io::Cursor::new(bytes), crate::WowVersion::Classic)
|
||||
.read()
|
||||
.expect("parses")
|
||||
.is_one_object()
|
||||
};
|
||||
assert!(flag(43), "Wailing Caverns");
|
||||
assert!(!flag(0) && !flag(1));
|
||||
assert!(!flag(309), "Zul'Gurub has a field 19 and terrain");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ pub struct WmoArea {
|
||||
pub zone_music: u32,
|
||||
/// `ZoneIntroMusicTable.dbc` id, the entry fanfare.
|
||||
pub intro_sound: u32,
|
||||
/// The `AreaTable.dbc` area this group counts as (0 = none), read by the area resolver
|
||||
/// `0x670250` when the down-ray keeps the WMO over the terrain: Ironforge reports its own area.
|
||||
/// The `AreaTable.dbc` area this group counts as (0 = none), read by the area query
|
||||
/// `0x6703a0` (`0x670437`) when the down-ray keeps the WMO over the terrain: Ironforge reports
|
||||
/// its own area.
|
||||
pub area_table_id: u32,
|
||||
/// The interior's display name in the locale's column, empty for an unnamed row.
|
||||
pub name: String,
|
||||
@@ -41,6 +42,20 @@ pub struct WmoAreaCatalog {
|
||||
}
|
||||
|
||||
impl WmoAreaCatalog {
|
||||
/// A catalog of `(WMOID, NameSetID, WMOGroupID, row)` rows, `u32::MAX` the whole-WMO row, for
|
||||
/// tests that run without an install.
|
||||
pub fn from_rows(rows: impl IntoIterator<Item = (u32, u32, u32, WmoArea)>) -> Self {
|
||||
let (mut groups, mut defaults) = (HashMap::new(), HashMap::new());
|
||||
for (wmo_id, name_set, group, area) in rows {
|
||||
if group == u32::MAX {
|
||||
defaults.insert((wmo_id, name_set), area);
|
||||
} else {
|
||||
groups.insert((wmo_id, name_set, group), area);
|
||||
}
|
||||
}
|
||||
Self { groups, defaults }
|
||||
}
|
||||
|
||||
/// The hit group's own row by exact key, the zone-text chain's second query (`0x69d8f0`, one
|
||||
/// bsearch at `0x7ccd30` with no name-set retry, default fallback or overlay): its name, if
|
||||
/// any, refills the subzone slot.
|
||||
|
||||
@@ -88,6 +88,9 @@ pub struct WdtFile {
|
||||
/// `has_adt` per tile, indexed `y * 64 + x` (the on-disk MAIN order).
|
||||
has_adt: Vec<bool>,
|
||||
global_wmo: Option<GlobalWmo>,
|
||||
/// `MPHD` dword 0 bit 0 alone: the client's map-is-one-object flag (`[0xc9e384]`, `0x694810`
|
||||
/// to `0x694904`), set with or without a usable placement.
|
||||
one_object: bool,
|
||||
}
|
||||
|
||||
impl WdtFile {
|
||||
@@ -102,6 +105,12 @@ impl WdtFile {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `MPHD` bit 0 is set: the flag the client's area query tests (`0x6703a4`), which
|
||||
/// makes the map's `Map.dbc` area the answer for every entity.
|
||||
pub fn is_one_object(&self) -> bool {
|
||||
self.one_object
|
||||
}
|
||||
|
||||
/// The map's one building, on a WMO-only map (`MPHD` bit 0).
|
||||
pub fn global_wmo(&self) -> Option<&GlobalWmo> {
|
||||
self.global_wmo.as_ref()
|
||||
@@ -199,6 +208,7 @@ impl<R: Read + Seek> WdtReader<R> {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, "WDT missing MAIN chunk")
|
||||
})?;
|
||||
// The flag and a placement, or no global WMO: a stray header bit must not hide the grid.
|
||||
let one_object = wmo_only;
|
||||
let global_wmo = match (wmo_only, wmo_path, modf) {
|
||||
(true, Some(model), Some(e)) => Some(GlobalWmo {
|
||||
model,
|
||||
@@ -214,6 +224,7 @@ impl<R: Read + Seek> WdtReader<R> {
|
||||
Ok(WdtFile {
|
||||
has_adt,
|
||||
global_wmo,
|
||||
one_object,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -342,9 +353,12 @@ mod tests {
|
||||
// Nor a file with the bit but no placement,
|
||||
let half = parse(synth_wmo_only_wdt(true, false)).expect("parses");
|
||||
assert!(half.global_wmo().is_none());
|
||||
assert!(half.is_one_object(), "the client's flag is the bit alone");
|
||||
// nor one with the placement but no bit.
|
||||
let unflagged = parse(synth_wmo_only_wdt(false, true)).expect("parses");
|
||||
assert!(unflagged.global_wmo().is_none());
|
||||
assert!(!unflagged.is_one_object());
|
||||
assert!(!wdt.is_one_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -75,6 +75,8 @@ pub struct TerrainStreamer {
|
||||
wdt_ungated: bool,
|
||||
/// This WMO-only map's one building is registered as a placement.
|
||||
global_wmo: bool,
|
||||
/// The loaded WDT sets `MPHD` bit 0, the client's map-is-one-object flag (`[0xc9e384]`).
|
||||
one_object: bool,
|
||||
/// [`ViewFocus::paced`] last frame: the residency latch re-arms on the edge into a load, since
|
||||
/// a viewer with no avatar is unpaced forever and the level would re-arm it every frame.
|
||||
was_paced: bool,
|
||||
@@ -90,6 +92,12 @@ pub struct TerrainStreamer {
|
||||
const GLOBAL_WMO_UID: u32 = u32::MAX;
|
||||
|
||||
impl TerrainStreamer {
|
||||
/// Whether the loaded map, whose `Map.dbc` directory is `dir`, has its WDT's one-object flag
|
||||
/// set; false until that WDT has loaded.
|
||||
pub(crate) fn is_one_object_map(&self, dir: &str) -> bool {
|
||||
self.one_object && self.map_dir.as_deref() == Some(dir)
|
||||
}
|
||||
|
||||
/// `(spawned, requested)` tiles for the debug panel: furnished, and all in the window.
|
||||
pub fn residency(&self) -> (usize, usize) {
|
||||
let spawned = self.tiles.values().filter(|t| t.furnished).count();
|
||||
@@ -381,6 +389,7 @@ impl Plugin for TerrainPlugin {
|
||||
.init_resource::<StaticMerge>()
|
||||
.init_resource::<crate::model_forms::ModelForms>()
|
||||
.init_resource::<CurrentArea>()
|
||||
.init_resource::<queries::AreaMap>()
|
||||
// In `WorldStage::Stream`, between the teleport snap (Input) and the loading cover
|
||||
// (Present), so a swap never renders uncovered. `finish_colliders` heads the chain so
|
||||
// the collider queue read downstream is this frame's.
|
||||
@@ -515,6 +524,10 @@ fn stream_terrain(
|
||||
}
|
||||
// A failed WDT falls back to ungated probing, warned once per map, never to no world.
|
||||
let wdt_index = state.wdt.as_ref().and_then(|h| wdts.get(h));
|
||||
let one_object = wdt_index.is_some_and(|w| w.is_one_object());
|
||||
if state.one_object != one_object {
|
||||
state.one_object = one_object;
|
||||
}
|
||||
if wdt_index.is_none() && !state.wdt_ungated {
|
||||
if let Some(h) = &state.wdt {
|
||||
if matches!(
|
||||
@@ -980,6 +993,7 @@ fn drop_streamed_world(
|
||||
if std::mem::take(&mut state.global_wmo) {
|
||||
release_placement(commands, placements, GLOBAL_WMO_UID, activity);
|
||||
}
|
||||
state.one_object = false;
|
||||
placements.materials.clear();
|
||||
// Cleared here, not by the flush's dead-key discard: tile keys repeat across maps, and the
|
||||
// new map's tiles are inserted this same frame. The same holds for the merge accumulators,
|
||||
|
||||
@@ -14,6 +14,10 @@ use super::TerrainStreamer;
|
||||
#[derive(Resource, Default, PartialEq, Eq)]
|
||||
pub struct CurrentArea(pub Option<u32>);
|
||||
|
||||
/// The map [`CurrentArea`] was last answered on, so a worldport drops the old map's zone.
|
||||
#[derive(Resource, Default)]
|
||||
pub(super) struct AreaMap(Option<u32>);
|
||||
|
||||
/// The set of [`update_current_area`]. Every consumer that acts on the area orders after it, so
|
||||
/// leaf, indoor bit and names come from one frame (the reference resolves them in one pass).
|
||||
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -46,8 +50,9 @@ pub fn doodad_ground_shade(
|
||||
}
|
||||
}
|
||||
|
||||
/// The MCNK `areaId` under a Bevy-space position: the outdoor leg of the reference's `GetAreaID`
|
||||
/// (`0x670250`), for any unit, with no interior claim. An `areaId` of 0 (unassigned) is a miss.
|
||||
/// The MCNK `areaId` under a Bevy-space position: the outdoor leg of the reference's area query
|
||||
/// (`0x6703a0` → `0x69b120`), for any unit, with no interior claim. An `areaId` of 0 (unassigned)
|
||||
/// is a miss.
|
||||
pub fn area_id_under(
|
||||
streamer: &TerrainStreamer,
|
||||
adt_tiles: &Assets<AdtTile>,
|
||||
@@ -74,7 +79,7 @@ pub fn ground_effect_under(
|
||||
}
|
||||
|
||||
/// The terrain height (WoW `z`) under a Bevy-space position: the terrain leg of the reference's
|
||||
/// down-ray arbitration (`0x6821f0`'s `0x69c320` probe, also in `GetAreaID` `0x670250`), which
|
||||
/// down-ray arbitration (`0x6821f0`'s `0x69c320` probe, also in the unreached point query `0x670250`), which
|
||||
/// wins the column when the ground is strictly nearer than the WMO. `None` is no terrain in the
|
||||
/// column (off the ring, decoding, or an MCNK hole over a cave mouth), never ground at 0.
|
||||
pub fn terrain_height_under(
|
||||
@@ -107,10 +112,14 @@ pub fn terrain_height_under_cached<'a>(
|
||||
benilla_formats::terrain_height_at(&adt.chunks, wow)
|
||||
}
|
||||
|
||||
/// Tracks the `AreaTable` id under the player's feet as the reference's `GetAreaID` (`0x670250`)
|
||||
/// does: a WMO interior wins when the faces-only down-ray (`0x6a8a20`) finds it nearer, by its
|
||||
/// `WMOAreaTable.AreaTableID`; outdoors it is the chunk's MCNK `areaId`. Holds the last value
|
||||
/// while a tile decodes.
|
||||
/// Tracks the `AreaTable` id under the player's feet as the reference's area query (`0x6703a0`)
|
||||
/// does. On a map whose WDT sets the one-object flag (`MPHD` bit 0, `[0xc9e384]`) it is that map's
|
||||
/// `Map.dbc` field 19 for any entity, read ahead of every other leg (`0x6703a4` to `0x6703d1`),
|
||||
/// and a 0 there fails the query rather than falling through. Elsewhere a WMO interior wins when
|
||||
/// the faces-only down-ray (`0x6a8a20`) finds it nearer, by its `WMOAreaTable.AreaTableID`;
|
||||
/// outdoors it is the chunk's MCNK `areaId`. Within a map it holds the last value while a tile
|
||||
/// decodes, and on a failed query (the zone caches keep their state in the reference, `0x67e54d`);
|
||||
/// a worldport drops it, since it is the old map's.
|
||||
pub(super) fn update_current_area(
|
||||
mut area: ResMut<CurrentArea>,
|
||||
focus: Res<crate::terrain_stream::ViewFocus>,
|
||||
@@ -118,6 +127,9 @@ pub(super) fn update_current_area(
|
||||
adt_tiles: Res<Assets<AdtTile>>,
|
||||
interior: Res<crate::wmo_portal::CurrentAreaInterior>,
|
||||
wmo_areas: Option<Res<crate::wmo_portal::WmoAreas>>,
|
||||
mut area_map: ResMut<AreaMap>,
|
||||
current_map: Option<Res<crate::world_map::CurrentMap>>,
|
||||
maps: Option<Res<benilla_assets::MapCatalogRes>>,
|
||||
) {
|
||||
let Some(wow) = focus.body_pos() else {
|
||||
// No avatar: the area dies with the character session, or the next login reads this
|
||||
@@ -128,11 +140,30 @@ pub(super) fn update_current_area(
|
||||
}
|
||||
return;
|
||||
};
|
||||
// The held answer is the previous map's after a worldport: the reference resolves each entity
|
||||
// on its own map (`0x6703a0`), so no zone of the old map is ever read on the new one.
|
||||
let map_id = focus.map(current_map.as_ref().map(|m| m.0));
|
||||
if area_map.0.replace(map_id).is_some_and(|m| m != map_id) && area.0.is_some() {
|
||||
*area = CurrentArea(None);
|
||||
}
|
||||
if !focus.body_settled() {
|
||||
// A body whose world is still arriving holds the last answer: the reference is behind a
|
||||
// loading screen for this window.
|
||||
return;
|
||||
}
|
||||
// A one-object map answers its own `Map.dbc` area, with no WMO row and no terrain read.
|
||||
let one_object_area = maps.as_ref().and_then(|maps| {
|
||||
let dir = maps.0.directory(map_id)?;
|
||||
streamer
|
||||
.is_one_object_map(dir)
|
||||
.then(|| maps.0.area_table_id(map_id).unwrap_or(0))
|
||||
});
|
||||
if let Some(id) = one_object_area {
|
||||
if id != 0 && *area != CurrentArea(Some(id)) {
|
||||
*area = CurrentArea(Some(id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// WMO interior first: the player's down-ray group resolved to its WMOAreaTable world area.
|
||||
let interior_area = interior.0.zip(wmo_areas.as_ref()).and_then(|(k, cat)| {
|
||||
cat.0
|
||||
@@ -164,6 +195,7 @@ mod tests {
|
||||
fn bare_world() -> World {
|
||||
let mut w = World::new();
|
||||
w.init_resource::<CurrentArea>();
|
||||
w.init_resource::<AreaMap>();
|
||||
w.init_resource::<crate::terrain_stream::ViewFocus>();
|
||||
w.init_resource::<TerrainStreamer>();
|
||||
w.init_resource::<Assets<AdtTile>>();
|
||||
@@ -220,4 +252,149 @@ mod tests {
|
||||
w.run_system_once(update_current_area).unwrap();
|
||||
assert_eq!(w.resource::<CurrentArea>().0, Some(12));
|
||||
}
|
||||
|
||||
const WAILING_CAVERNS: u32 = 43;
|
||||
const WAILING_CAVERNS_AREA: u32 = 718;
|
||||
|
||||
/// A world on `map` with the catalog rows `rows`, a settled body in the open, and a streamer
|
||||
/// that has loaded `dir`'s WDT with the one-object flag as `one_object` says.
|
||||
fn map_world(map: u32, rows: &[(u32, &str, u32)], dir: &str, one_object: bool) -> World {
|
||||
let mut w = bare_world();
|
||||
w.insert_resource(crate::world_map::CurrentMap(map));
|
||||
w.insert_resource(benilla_assets::MapCatalogRes(
|
||||
benilla_formats::MapCatalog::from_rows(rows.iter().copied()),
|
||||
));
|
||||
w.insert_resource(crate::terrain_stream::ViewFocus::body(
|
||||
[-10.0, 20.0, 30.0],
|
||||
true,
|
||||
));
|
||||
let mut streamer = w.resource_mut::<TerrainStreamer>();
|
||||
streamer.map_dir = Some(dir.to_string());
|
||||
streamer.one_object = one_object;
|
||||
w
|
||||
}
|
||||
|
||||
fn wc_rows() -> [(u32, &'static str, u32); 2] {
|
||||
[
|
||||
(WAILING_CAVERNS, "WailingCaverns", WAILING_CAVERNS_AREA),
|
||||
(1, "Kalimdor", 0),
|
||||
]
|
||||
}
|
||||
|
||||
/// `0x6703a0` answers the map record's field 19 for a one-object map (`0x6703a4`, `0x6703d1`),
|
||||
/// with no WMO row and no terrain read: here the down-ray claims a group whose row names
|
||||
/// another area, and no tile is resident.
|
||||
#[test]
|
||||
fn a_one_object_map_answers_its_map_record_area() {
|
||||
let mut w = map_world(WAILING_CAVERNS, &wc_rows(), "WailingCaverns", true);
|
||||
let keys = crate::wmo_portal::WmoInteriorKeys {
|
||||
wmo_id: 7,
|
||||
name_set: 0,
|
||||
group_area_id: 9,
|
||||
};
|
||||
w.insert_resource(crate::wmo_portal::CurrentAreaInterior(Some(keys)));
|
||||
w.insert_resource(crate::wmo_portal::WmoAreas(
|
||||
benilla_formats::WmoAreaCatalog::from_rows([(
|
||||
7,
|
||||
0,
|
||||
9,
|
||||
benilla_formats::WmoArea {
|
||||
id: 1,
|
||||
sound_provider: [0; 2],
|
||||
ambience: 0,
|
||||
zone_music: 0,
|
||||
intro_sound: 0,
|
||||
area_table_id: 1519,
|
||||
name: String::new(),
|
||||
},
|
||||
)]),
|
||||
));
|
||||
// Login inside: no earlier area.
|
||||
w.run_system_once(update_current_area).unwrap();
|
||||
assert_eq!(
|
||||
w.resource::<CurrentArea>().0,
|
||||
Some(WAILING_CAVERNS_AREA),
|
||||
"field 19, not the group row's 1519"
|
||||
);
|
||||
|
||||
// Entry by portal: the outside zone is replaced on the first settled frame.
|
||||
w.insert_resource(CurrentArea(Some(40))); // Westfall
|
||||
w.run_system_once(update_current_area).unwrap();
|
||||
assert_eq!(w.resource::<CurrentArea>().0, Some(WAILING_CAVERNS_AREA));
|
||||
|
||||
// A later login on the same map: the area dies with the avatar and comes back.
|
||||
w.insert_resource(crate::terrain_stream::ViewFocus::camera());
|
||||
w.run_system_once(update_current_area).unwrap();
|
||||
assert_eq!(w.resource::<CurrentArea>().0, None);
|
||||
w.insert_resource(crate::terrain_stream::ViewFocus::body([0.0; 3], true));
|
||||
w.run_system_once(update_current_area).unwrap();
|
||||
assert_eq!(w.resource::<CurrentArea>().0, Some(WAILING_CAVERNS_AREA));
|
||||
}
|
||||
|
||||
/// The flag, not the field, gates the leg: Zul'Gurub-like maps carry a field 19 and are read
|
||||
/// off their terrain (`0x6703a4` falls through to the entity's node).
|
||||
#[test]
|
||||
fn a_map_without_the_flag_ignores_its_record_area() {
|
||||
let rows = [(309, "Zul'gurub", 1977)];
|
||||
let mut w = map_world(309, &rows, "Zul'gurub", false);
|
||||
w.run_system_once(update_current_area).unwrap();
|
||||
assert_eq!(w.resource::<CurrentArea>().0, None, "no tile, no area");
|
||||
}
|
||||
|
||||
/// The streamer's flag is for the map it loaded: the previous map's WDT never answers for the
|
||||
/// new map's record in the frames after a worldport.
|
||||
#[test]
|
||||
fn the_flag_belongs_to_the_loaded_map() {
|
||||
let mut w = map_world(WAILING_CAVERNS, &wc_rows(), "Kalimdor", true);
|
||||
w.run_system_once(update_current_area).unwrap();
|
||||
assert_eq!(w.resource::<CurrentArea>().0, None);
|
||||
}
|
||||
|
||||
/// A failed query (field 19 of 0, the dev maps) leaves the state alone, and never falls
|
||||
/// through to the WMO and terrain legs (`0x6703d4`).
|
||||
#[test]
|
||||
fn a_one_object_map_with_no_record_area_fails_the_query() {
|
||||
let rows = [(13, "test", 0)];
|
||||
let mut w = map_world(13, &rows, "test", true);
|
||||
w.insert_resource(CurrentArea(Some(40)));
|
||||
w.run_system_once(update_current_area).unwrap();
|
||||
assert_eq!(w.resource::<CurrentArea>().0, Some(40));
|
||||
}
|
||||
|
||||
/// The settled gate stands ahead of the leg: a body whose world is still arriving holds.
|
||||
#[test]
|
||||
fn a_one_object_map_waits_for_the_settled_world() {
|
||||
let mut w = map_world(WAILING_CAVERNS, &wc_rows(), "WailingCaverns", true);
|
||||
w.insert_resource(crate::terrain_stream::ViewFocus::body([0.0; 3], false));
|
||||
w.run_system_once(update_current_area).unwrap();
|
||||
assert_eq!(w.resource::<CurrentArea>().0, None);
|
||||
}
|
||||
|
||||
/// The held area is the old map's after a worldport. Kept, it fed the zone text a stale leaf
|
||||
/// with the new map's building claim, and the indoor dedup then skipped the real zone.
|
||||
#[test]
|
||||
fn a_worldport_drops_the_old_maps_area() {
|
||||
let rows = [(0, "Azeroth", 0), (WAILING_CAVERNS, "WailingCaverns", 718)];
|
||||
let mut w = map_world(0, &rows, "Azeroth", false);
|
||||
w.insert_resource(CurrentArea(Some(1519)));
|
||||
w.run_system_once(update_current_area).unwrap();
|
||||
assert_eq!(
|
||||
w.resource::<CurrentArea>().0,
|
||||
Some(1519),
|
||||
"within the map the held value stands while the tile decodes"
|
||||
);
|
||||
|
||||
w.insert_resource(crate::world_map::CurrentMap(WAILING_CAVERNS));
|
||||
w.insert_resource(crate::terrain_stream::ViewFocus::body([0.0; 3], false));
|
||||
w.run_system_once(update_current_area).unwrap();
|
||||
assert_eq!(w.resource::<CurrentArea>().0, None);
|
||||
|
||||
// The WDT arrives and the world settles: the new map's own answer.
|
||||
let mut streamer = w.resource_mut::<TerrainStreamer>();
|
||||
streamer.map_dir = Some("WailingCaverns".to_string());
|
||||
streamer.one_object = true;
|
||||
w.insert_resource(crate::terrain_stream::ViewFocus::body([0.0; 3], true));
|
||||
w.run_system_once(update_current_area).unwrap();
|
||||
assert_eq!(w.resource::<CurrentArea>().0, Some(WAILING_CAVERNS_AREA));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
//! counts only within the 0.1 yd snap of its plane (`0x7c22b0`), and a crossing within about
|
||||
//! 1e-4 of the nearest face hit still wins (`0x80c4f4`).
|
||||
//! - The terrain race: the WMO hit is dropped only when terrain on the same segment is strictly
|
||||
//! nearer (`0x6822a2`; `GetAreaID` `0x670250` arbitrates alike at `0x670345`). Terrain above the
|
||||
//! nearer (`0x6822a2`; the unreached point query `0x670250` arbitrates alike at `0x670345`, and
|
||||
//! the live entity query `0x6703a0` attaches by the same ray, `0x6a8a20`). Terrain above the
|
||||
//! eye is off the segment, so a tunnel under a hill stays inside.
|
||||
//!
|
||||
//! The verdict is the in-group plus, for a portal win, the across-group: the containing-group set
|
||||
|
||||
+3
-3
@@ -43,7 +43,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
|
||||
|
||||
#### area
|
||||
|
||||
- The indoor dedup keys `(WMOID, MOGP uniqueID)`, not the client's raw per-WMO group index, because the raw index can alias across adjacent buildings. ([`src/area.rs:26`](../crates/benilla-app/src/area.rs#L26))
|
||||
- The indoor dedup keys `(WMOID, MOGP uniqueID)`, not the client's raw per-WMO group index, because the raw index can alias across adjacent buildings. ([`src/area.rs:30`](../crates/benilla-app/src/area.rs#L30))
|
||||
|
||||
#### assets/ui
|
||||
|
||||
@@ -798,7 +798,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
|
||||
|
||||
#### terrain_stream
|
||||
|
||||
- A tile is released only one chunk past the outer window (`StreamWindow::keeps`), where the reference releases by window membership (`0x6984f0`), because our reload is a re-decode and re-spawn, not a cheap async read. ([`src/terrain_stream.rs:617`](../crates/benilla-world/src/terrain_stream.rs#L617))
|
||||
- A tile is released only one chunk past the outer window (`StreamWindow::keeps`), where the reference releases by window membership (`0x6984f0`), because our reload is a re-decode and re-spawn, not a cheap async read. ([`src/terrain_stream.rs:630`](../crates/benilla-world/src/terrain_stream.rs#L630))
|
||||
- Tagged per submesh, where the reference tests the whole object, because a placement has no root entity to carry the bound. ([`src/terrain_stream/spawn/mod.rs:229`](../crates/benilla-world/src/terrain_stream/spawn/mod.rs#L229))
|
||||
- The box, where the reference tests its circumsphere (`0x683700` → `0x682ef0` → `0x686b80` on the `[rec+0x5c]` centre and `[rec+0x68]` radius × scale), because Bevy culls by box and the box is tighter. ([`src/terrain_stream/spawn/mod.rs:896`](../crates/benilla-world/src/terrain_stream/spawn/mod.rs#L896))
|
||||
- Whole tiles, because benilla loads, spawns and releases ADT tiles, not chunks: a tile is wanted when any of its chunks lies in the outer window, so the resident edge is a tile line where the reference's is a chunk line. ([`src/terrain_stream/window.rs:7`](../crates/benilla-world/src/terrain_stream/window.rs#L7))
|
||||
@@ -827,7 +827,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
|
||||
|
||||
#### wmo_portal
|
||||
|
||||
- When legs A and B both miss and no terrain lies at or below the eye, Leg C runs the same race over the camera-only faces (DETAIL without NOCAMCOLLIDE), because the 1.12 client blanks the whole building from a pocket floored only with DETAIL faces, as at the Deadmines entrance. ([`src/wmo_portal/seed.rs:20`](../crates/benilla-world/src/wmo_portal/seed.rs#L20))
|
||||
- When legs A and B both miss and no terrain lies at or below the eye, Leg C runs the same race over the camera-only faces (DETAIL without NOCAMCOLLIDE), because the 1.12 client blanks the whole building from a pocket floored only with DETAIL faces, as at the Deadmines entrance. ([`src/wmo_portal/seed.rs:21`](../crates/benilla-world/src/wmo_portal/seed.rs#L21))
|
||||
|
||||
#### zfill
|
||||
|
||||
|
||||
Reference in New Issue
Block a user