diff --git a/crates/benilla-app/src/area.rs b/crates/benilla-app/src/area.rs index 048060d39..c1171951b 100644 --- a/crates/benilla-app/src/area.rs +++ b/crates/benilla-app/src/area.rs @@ -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. diff --git a/crates/benilla-assets/src/wdt.rs b/crates/benilla-assets/src/wdt.rs index 519f0519b..31114056b 100644 --- a/crates/benilla-assets/src/wdt.rs +++ b/crates/benilla-assets/src/wdt.rs @@ -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() diff --git a/crates/benilla-formats/src/maps.rs b/crates/benilla-formats/src/maps.rs index 8bb8f9871..60af451fe 100644 --- a/crates/benilla-formats/src/maps.rs +++ b/crates/benilla-formats/src/maps.rs @@ -28,6 +28,9 @@ pub struct MapCatalog { instance_types: HashMap, /// The battleground columns of every row, since the client resolves map 0's too. battleground: HashMap, + /// 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, } /// 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) -> 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 { + 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 { 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 { 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 { 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"); + } } diff --git a/crates/benilla-formats/src/wmo_area.rs b/crates/benilla-formats/src/wmo_area.rs index 2e100d2bf..299db3f65 100644 --- a/crates/benilla-formats/src/wmo_area.rs +++ b/crates/benilla-formats/src/wmo_area.rs @@ -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) -> 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. diff --git a/crates/benilla-wdt/src/lib.rs b/crates/benilla-wdt/src/lib.rs index dc9283511..979209c7f 100644 --- a/crates/benilla-wdt/src/lib.rs +++ b/crates/benilla-wdt/src/lib.rs @@ -88,6 +88,9 @@ pub struct WdtFile { /// `has_adt` per tile, indexed `y * 64 + x` (the on-disk MAIN order). has_adt: Vec, global_wmo: Option, + /// `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 WdtReader { 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 WdtReader { 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] diff --git a/crates/benilla-world/src/terrain_stream.rs b/crates/benilla-world/src/terrain_stream.rs index e91f46b54..ef63d4fc7 100644 --- a/crates/benilla-world/src/terrain_stream.rs +++ b/crates/benilla-world/src/terrain_stream.rs @@ -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::() .init_resource::() .init_resource::() + .init_resource::() // 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, diff --git a/crates/benilla-world/src/terrain_stream/queries.rs b/crates/benilla-world/src/terrain_stream/queries.rs index 68f79d28c..c03dc5408 100644 --- a/crates/benilla-world/src/terrain_stream/queries.rs +++ b/crates/benilla-world/src/terrain_stream/queries.rs @@ -14,6 +14,10 @@ use super::TerrainStreamer; #[derive(Resource, Default, PartialEq, Eq)] pub struct CurrentArea(pub Option); +/// The map [`CurrentArea`] was last answered on, so a worldport drops the old map's zone. +#[derive(Resource, Default)] +pub(super) struct AreaMap(Option); + /// 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, @@ -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, focus: Res, @@ -118,6 +127,9 @@ pub(super) fn update_current_area( adt_tiles: Res>, interior: Res, wmo_areas: Option>, + mut area_map: ResMut, + current_map: Option>, + maps: Option>, ) { 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::(); + w.init_resource::(); w.init_resource::(); w.init_resource::(); w.init_resource::>(); @@ -220,4 +252,149 @@ mod tests { w.run_system_once(update_current_area).unwrap(); assert_eq!(w.resource::().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::(); + 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::().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::().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::().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::().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::().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::().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::().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::().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::().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::().0, None); + + // The WDT arrives and the world settles: the new map's own answer. + let mut streamer = w.resource_mut::(); + 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::().0, Some(WAILING_CAVERNS_AREA)); + } } diff --git a/crates/benilla-world/src/wmo_portal/seed.rs b/crates/benilla-world/src/wmo_portal/seed.rs index afd190ada..bb24e96ad 100644 --- a/crates/benilla-world/src/wmo_portal/seed.rs +++ b/crates/benilla-world/src/wmo_portal/seed.rs @@ -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 diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 42b25e59e..c9291c54a 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -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