- a player build ships beside the dev one — dev chords and probe surfaces go dark, and a gate enforces what a player can still reach, by medium not by module - the binary carries its own shaders and its shipped UI, finds the WoW folder by itself, and keeps its local state in one benilla-config folder - the glue screens get a capture shutter, and the engine stops depending on the protocol crate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
224 lines
11 KiB
Rust
224 lines
11 KiB
Rust
//! Area triggers — the client's half of every portal, instance entrance and "explore here" quest.
|
|
//!
|
|
//! `AreaTrigger.dbc` is a table of invisible volumes. The client's *entire* job is geometry: notice
|
|
//! that the player has walked into one, and send `CMSG_AREATRIGGER` naming its id. The **server**
|
|
//! decides what the trigger means — a teleport (`areatrigger_teleport`: the Darnassus/Rut'theran
|
|
//! portals, every dungeon and raid entrance), a quest's explore objective
|
|
//! (`areatrigger_involvedrelation`), the inn's rested state, a battleground's entrance list. The
|
|
//! client never knows and never needs to.
|
|
//!
|
|
//! Until this module existed, benilla sent that opcode from nowhere: a portal did nothing, and an
|
|
//! instance entrance did nothing, because the server was never told we were standing in one (ledger
|
|
//! **B70**, **N02**). The teleport that answers it has worked since decision 0455.
|
|
//!
|
|
//! ## The law, from the reference
|
|
//!
|
|
//! **VERIFIED** against `0x5e2110` (the per-frame check) and `0x5e22d0` (containment), wow-5875-re
|
|
//! `object-layer/scratch/w2b1-decomp.c`; the geometry itself is
|
|
//! [`AreaTriggerRow::contains`](benilla_formats::AreaTriggerRow::contains).
|
|
//!
|
|
//! - The map's rows are the only candidates (`0x5e2080` narrows the map-sorted table to a
|
|
//! `[first, end)` window before any test).
|
|
//! - **One latch, and it is an *exit* latch.** The check holds the trigger it is currently inside;
|
|
//! while the player is still inside *that* volume it returns immediately and sends nothing. Only
|
|
//! once they have left does it scan for a new one — so standing in a portal fires it exactly once,
|
|
//! and overlapping volumes never fight.
|
|
//! - The **first** containing row in file order wins.
|
|
//! - A map change clears the latch.
|
|
//!
|
|
//! Two things the reference's shape buys us, worth stating because they are load-bearing: a trigger
|
|
//! cannot re-fire while you stand in it (a portal would otherwise re-teleport you every frame), and
|
|
//! the *destination* of a portal pair is authored to land outside the return trigger (Darnassus's
|
|
//! exit lands 13.9 yd from the entrance's 10-yd sphere), so a round trip is not a loop.
|
|
//!
|
|
//! The server re-checks our claim with 5 yd of slop and ignores it while taxi-flying
|
|
//! (`HandleAreaTriggerOpcode`, vmangos `Handlers/MiscHandler.cpp:622`), so a wrong id is refused
|
|
//! rather than obeyed.
|
|
//!
|
|
//! ## ⚠ Probing this with `.go` is not the same as walking in
|
|
//!
|
|
//! A GM teleport that drops you *inside* a volume sends this message in the same millisecond as the
|
|
//! teleport ack, and the server re-checks the claim against the position it has stored — which is
|
|
//! sometimes still the pre-teleport one. Measured: five such teleport-ins were obeyed and one
|
|
//! (Darnassus's trigger 527) was silently ignored, while drifting into the same trigger under
|
|
//! movement fired it and teleported 40 ms later. So an ignored `.go` probe is the harness racing
|
|
//! the server, not this check failing — and the latch, faithfully, does not retry (it latches on
|
|
//! the *send*; the client never learns whether the server obeyed). Step out and back in, or better,
|
|
//! park outside and move in.
|
|
|
|
use benilla_assets::coords::bevy_to_wow;
|
|
use benilla_formats::AreaTriggerCatalog;
|
|
use bevy::prelude::*;
|
|
|
|
use crate::net::{ClientCommand, NetCommands};
|
|
use crate::player::Player;
|
|
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
|
|
use benilla_world::schedule::WorldStage;
|
|
use benilla_world::world_map::CurrentMap;
|
|
|
|
/// The `AreaTrigger.dbc` catalog, bucketed by map. Absent when the client data didn't load — the
|
|
/// check then does nothing, like every other data-driven system here.
|
|
#[derive(Resource)]
|
|
pub(crate) struct AreaTriggers(pub(crate) AreaTriggerCatalog);
|
|
|
|
/// The trigger we are currently standing **inside**, with the map it is on — the reference's single
|
|
/// "current trigger" pointer (`DAT_00c4d73c`).
|
|
///
|
|
/// Keyed by map so it can never leak across a worldport (the reference clears it on the map change;
|
|
/// carrying the map id makes the same guarantee without a hook). It deliberately survives a
|
|
/// reconnect on the same map, which is also what the reference does — its clear runs off the map
|
|
/// change, not the socket — and it is the safer half of that coin: reconnecting inside a portal
|
|
/// does not teleport you.
|
|
#[derive(Resource, Default)]
|
|
pub(crate) struct InsideTrigger(Option<(u32, u32)>);
|
|
|
|
impl InsideTrigger {
|
|
/// One check, as the reference orders it (`0x5e2110`): if we are still inside the latched
|
|
/// volume, report nothing; otherwise unlatch, and latch + report the **first** trigger on this
|
|
/// map containing `p`, if any. `Some(id)` means "send `CMSG_AREATRIGGER` for this one".
|
|
///
|
|
/// Pure, and separate from the system, because the property that matters is a state machine
|
|
/// rather than a query: it must fire **once** per entry. Firing per frame would be a packet
|
|
/// flood at best and, on a teleport trigger, an infinite loop — arrive, fire, arrive.
|
|
fn step(&mut self, triggers: &AreaTriggerCatalog, map_id: u32, p: [f32; 3]) -> Option<u32> {
|
|
if let Some((latched_map, id)) = self.0 {
|
|
let still_in = latched_map == map_id
|
|
&& triggers
|
|
.on_map(map_id)
|
|
.iter()
|
|
.find(|t| t.id == id)
|
|
.is_some_and(|t| t.contains(p));
|
|
if still_in {
|
|
return None;
|
|
}
|
|
self.0 = None;
|
|
}
|
|
let entered = triggers.first_containing(map_id, p)?;
|
|
self.0 = Some((map_id, entered.id));
|
|
Some(entered.id)
|
|
}
|
|
}
|
|
|
|
pub(crate) struct AreaTriggerPlugin;
|
|
|
|
impl Plugin for AreaTriggerPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<InsideTrigger>()
|
|
.add_systems(Startup, load_area_triggers.after(AssetSet::Open))
|
|
// In the Stream band: after Input has moved the avatar (and applied any teleport
|
|
// snap), so the position tested is the one this frame ends at — the same one the
|
|
// movement stream reports, which is what the server re-checks us against.
|
|
.add_systems(
|
|
Update,
|
|
check_area_triggers
|
|
.in_set(WorldStage::Stream)
|
|
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
|
);
|
|
}
|
|
}
|
|
|
|
fn load_area_triggers(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
|
let Some(assets) = assets else { return };
|
|
let mut chain = assets.chain.lock_recover();
|
|
match benilla_formats::load_area_trigger_catalog(&mut chain) {
|
|
Ok(cat) => {
|
|
info!("area_trigger: {} triggers", cat.len());
|
|
commands.insert_resource(AreaTriggers(cat));
|
|
}
|
|
Err(e) => warn!("area_trigger: AreaTrigger.dbc failed to load: {e:#}"),
|
|
}
|
|
}
|
|
|
|
/// The per-frame check — the reference's `0x5e2110`, in the same order it runs.
|
|
fn check_area_triggers(
|
|
triggers: Option<Res<AreaTriggers>>,
|
|
map: Option<Res<CurrentMap>>,
|
|
player: Res<Player>,
|
|
mut inside: ResMut<InsideTrigger>,
|
|
net: Res<NetCommands>,
|
|
) {
|
|
let (Some(triggers), Some(map)) = (triggers, map) else {
|
|
return;
|
|
};
|
|
// Before the server has placed us, `pos` is wherever the free-flying camera left the avatar —
|
|
// not a position the server would recognise, and not one worth reporting.
|
|
if !player.active {
|
|
return;
|
|
}
|
|
let here = bevy_to_wow(player.pos);
|
|
let Some(trigger_id) = inside.step(&triggers.0, map.0, here) else {
|
|
return;
|
|
};
|
|
// Fire-and-forget, like every other send: a down write thread drops it.
|
|
let _ = net.0.send(ClientCommand::AreaTrigger { trigger_id });
|
|
// At `info`, deliberately: entering a trigger is rare (you must cross a volume boundary), and
|
|
// when a portal or instance entrance "does nothing", the first question is whether the client
|
|
// saw the volume at all. One line answers it.
|
|
info!(
|
|
"area_trigger: entered {trigger_id} on map {} at [{:.2}, {:.2}, {:.2}]",
|
|
map.0, here[0], here[1], here[2]
|
|
);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn real_catalog() -> Option<AreaTriggerCatalog> {
|
|
let data = benilla_formats::wow_data_or_skip!(None);
|
|
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
|
Some(benilla_formats::load_area_trigger_catalog(&mut chain).expect("AreaTrigger.dbc"))
|
|
}
|
|
|
|
/// The whole state machine over the **real** table, walking the exact route the live probe
|
|
/// walked: the Darnassus portal pair (B70) and the Southshore inn's box (the shape the sphere
|
|
/// tests can't reach). Skips without client data.
|
|
///
|
|
/// The second assertion is the load-bearing one — a portal that reported every frame would
|
|
/// teleport you the instant you arrived, forever.
|
|
#[test]
|
|
fn a_trigger_fires_once_per_entry_and_re_arms_on_leaving() {
|
|
let Some(cat) = real_catalog() else { return };
|
|
let mut inside = InsideTrigger::default();
|
|
|
|
// Rut'theran Village's portal (542, a 10-yd sphere): step in, and it reports once.
|
|
let ruttheran = [8799.41, 969.787, 30.2409];
|
|
assert_eq!(inside.step(&cat, 1, ruttheran), Some(542));
|
|
assert_eq!(
|
|
inside.step(&cat, 1, ruttheran),
|
|
None,
|
|
"standing in a portal must not re-report it — that is the teleport loop"
|
|
);
|
|
|
|
// The server answers by putting us at the Darnassus end. That landing spot is deliberately
|
|
// OUTSIDE the return trigger (527, also 10 yd) — 17.2 yd from its centre, and 13.9 yd the
|
|
// other direction — which is why a round trip is not a loop. Nothing fires, and the latch
|
|
// re-arms.
|
|
let darnassus_arrival = [9946.25, 2612.97, 1316.49];
|
|
assert_eq!(inside.step(&cat, 1, darnassus_arrival), None);
|
|
|
|
// Walk into the return portal proper: it reports, having re-armed.
|
|
assert_eq!(inside.step(&cat, 1, [9947.48, 2630.04, 1318.6]), Some(527));
|
|
|
|
// A map change cannot leak the latch: the Southshore inn's BOX trigger (708) is on map 0
|
|
// and fires immediately, even though a trigger is still latched from map 1.
|
|
assert_eq!(
|
|
inside.step(&cat, 0, [-854.547, -576.314, 18.4659]),
|
|
Some(708)
|
|
);
|
|
assert_eq!(inside.step(&cat, 0, [-854.593, -576.207, 18.5563]), None);
|
|
|
|
// Deadmines' entrance (78) — the instance-portal case (N02), a 7-yd sphere on map 0.
|
|
assert_eq!(inside.step(&cat, 0, [-11208.5, 1685.34, 25.7612]), Some(78));
|
|
}
|
|
|
|
/// Empty air reports nothing, and an unknown map is not an error.
|
|
#[test]
|
|
fn open_ground_and_unknown_maps_are_silent() {
|
|
let Some(cat) = real_catalog() else { return };
|
|
let mut inside = InsideTrigger::default();
|
|
assert_eq!(inside.step(&cat, 0, [0.0, 0.0, 0.0]), None);
|
|
assert_eq!(inside.step(&cat, 9999, [8799.41, 969.787, 30.2409]), None);
|
|
}
|
|
}
|