GM flight works, and creatures are clickable on the body again

- .cheat fly flies now. The find, read straight out of the 1.12 binary: there
  is no flight mover at all. Flying is the swim mover plus one flag that stops
  dry ground from cancelling it — the client's per-frame water check opens by
  testing LEVITATING and bails before either arm can run. So you float where
  you stand and aim-and-hold-forward to climb or dive, exactly like swimming.
  Jumping breaches you out and you fall until you re-toggle: the original traps
  the very same way, so ours does too.
- Clicking a creature's body selects it again. The skinning move one snapshot
  ago left the picker reading the engine's old joint attributes — absent from
  every mesh since — so only nameplates could select. It reads our attributes
  now, and a test pins the picker to the same constants the meshes are built
  with.
- A run that never connected to a server announces it in the log now. A "clean
  run" in capture mode proves nothing about the packet path, and one of those
  had already stood in for a live check once.
- The network apply loop's last inline handlers moved out beside their
  families — same behaviour, one shape for all of them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-28 21:55:19 +07:00
co-authored by Claude Fable 5
parent 63efec188f
commit 06e9457582
24 changed files with 1073 additions and 440 deletions
@@ -81,6 +81,19 @@ pub(crate) mod move_flags {
/// WALK-mode (vs run). In the client this only scales the rate numerator (walk vs run speed), never
/// the id choice. The net bridge reads it to extrapolate a `/walk`-toggled remote mover at walk speed.
pub const WALK_MODE: u32 = 0x100;
/// MOVEFLAG_LEVITATING — **the free-flight bit, and it works by SUPPRESSION.** It is the very
/// first test in the client's per-frame swim decision `0x6030c0` (`0x6030d2 test ah,4` → bail to
/// `0x6031fa`; VERIFIED, wow-re `collision/scratch/swim-transition.md`): set, and the whole
/// water/depth decision is skipped — neither the ENTER arm nor the STOP arm runs, so liquid can
/// no longer latch *or unlatch* [`SWIMMING`].
///
/// That suppression is the entirety of GM flight. vmangos's `.cheat fly` sends
/// `LEVITATING | SWIMMING | MOVED | FLYING` (`Player::SetFly`): `SWIMMING` puts the mover in the
/// 3-D floating regime (gravity bypassed, vertical from the aim pitch), and `LEVITATING` stops
/// the dry-land depth test from clearing it again the very next frame. `MOVED` (`0x800000`) and
/// `FLYING` (`0x1000000`) carry no behaviour on this build — vmangos's own header marks both
/// doubtful — and we model neither. Decision 0726.
pub const LEVITATING: u32 = 0x400;
/// JUMPING/FALLING — set for the whole airborne arc; drives the jump Special state.
pub const FALLING: u32 = 0x2000;
/// MOVEFLAG_FALLINGFAR — the arc has become a **far fall** (`0x633220`/`0x633240`; wow-re
+3 -3
View File
@@ -33,8 +33,8 @@ pub(super) struct EntityPart {
pub(super) mesh: Handle<Mesh>,
/// The skinned twin of [`Self::mesh`] (decision 0019), present for every M2 part. An animated
/// instance (a creature, or a GameObject that runs the state machine / loops a loader-idle seq)
/// renders this + a `SkinnedMesh` bound to its joints; a truly static instance uses `mesh`. `None`
/// for WMO-display parts (no skeleton).
/// renders this, skinned through its palette slot's rows (decision 0720); a truly static
/// instance uses `mesh`. `None` for WMO-display parts (no skeleton).
pub(super) skinned_mesh: Option<Handle<Mesh>>,
pub(super) material: Handle<WowModelMaterial>,
/// The interior-lit variant (M2 only): the same material in the plain day/night matte
@@ -152,7 +152,7 @@ pub(super) struct DisplayModel {
/// [`BoneAttach`] beside the attachments — the missile launch points ($CSL/$CSR/$CST/$BWR).
pub(super) markers: Vec<benilla_assets::ModelMarker>,
/// The matching inverse bind poses, shared across every instance of this display. `Some` for an M2
/// display, `None` for WMO / model-less. Paired with `skeleton` to build each instance's `SkinnedMesh`.
/// display, `None` for WMO / model-less. Paired with `skeleton` to build each instance's palette rig.
pub(super) inverse_bindposes: Option<Handle<SkinnedMeshInverseBindposes>>,
/// The model's animations (decision 0019), captured with `parts` on load. The creature attach path
/// gives each instance an `AnimationPlayer` driving them (playing Stand). `None` for WMO / static.
+13
View File
@@ -50,9 +50,22 @@ pub(crate) struct NetPlugin {
pub(crate) connect: bool,
}
/// Marker for "this process has **no** IO thread" — inserted only when [`NetPlugin::connect`] is
/// false. Read by `crate::preflight`'s startup notice, which says so out loud, so a run that cannot
/// exercise the wire path is never mistaken for one that did (decision 0728).
///
/// A resource of its own rather than a bool on [`NetStatus`], because it answers a different
/// question: `connected` is *runtime* state that a refused or dropped connection also clears, while
/// this one says the attempt was never made and no packet can ever arrive.
#[derive(Resource)]
pub(crate) struct NetOffline;
impl Plugin for NetPlugin {
fn build(&self, app: &mut App) {
let handles = io::spawn_net(io::NetConfig::from_env(), self.connect);
if !self.connect {
app.insert_resource(NetOffline);
}
app.insert_resource(NetEvents(handles.events))
.insert_resource(NetCommands(handles.commands))
.insert_resource(CharPick(handles.pick))
+129 -390
View File
@@ -9,28 +9,32 @@ use benilla_protocol::{ObjectFields, SessionEvent};
use bevy::prelude::*;
use super::{
AiReactionMessage, CharActionResultMessage, CharListMessage, EmoteKind, EmoteMessage,
EnteredWorldMessage, Guid, GuidIndex, LoggedOutMessage, NetCommands, NetEvents, NetStatus,
ObjectStore, PendingTransfer, RemoteMotion, Reputations, SelfGuid, SelfPlayer, ServerSoundKind,
ServerSoundMessage, ServerTime, TeleportMessage, WeatherMessage, WorldportMessage,
AiReactionMessage, CharActionResultMessage, CharListMessage, EmoteMessage, EnteredWorldMessage,
Guid, GuidIndex, LoggedOutMessage, NetCommands, NetEvents, NetStatus, ObjectStore,
PendingTransfer, RemoteMotion, Reputations, SelfGuid, SelfPlayer, ServerSoundMessage,
ServerTime, TeleportMessage, WeatherMessage, WorldportMessage,
};
mod anim;
mod chat;
mod combat;
mod combat_log;
mod death;
mod group;
mod loot;
mod mail;
mod mount;
mod names;
mod npc;
mod objects;
mod quests;
mod session;
mod spells;
mod trade;
mod world;
// The large arm families, split out of the dispatch match below (each `pub(super)` fn is
// one arm's body; the match stays the dispatcher, one call per arm — see the child modules).
use group::push_group_lines;
// The arm families, split out of the dispatch match below (each `pub(super)` fn is one arm's
// body; the match stays the dispatcher, one call per arm — see the child modules).
use loot::{
inventory_failure, item_push_result, item_template, loot_all_passed, loot_clear_money,
loot_error, loot_money_notify, loot_release_response, loot_removed, loot_response, loot_roll,
@@ -42,9 +46,9 @@ use quests::{
quest_objectives_complete, quest_offer, quest_progress, quest_template,
};
use spells::{
action_buttons, cancel_auto_repeat, cast_result, clear_cooldown, cooldown_cheat,
cooldown_event, item_cooldown, learned_spell, spell_book, spell_cooldowns, spell_delayed,
spell_failed_other, spell_go, spell_start, superceded_spell,
action_buttons, aura_duration, cancel_auto_repeat, cast_result, channel_start, channel_update,
clear_cooldown, cooldown_cheat, cooldown_event, item_cooldown, learned_spell, spell_book,
spell_cooldowns, spell_delayed, spell_failed_other, spell_go, spell_start, superceded_spell,
};
// ── The per-frame bridge systems ─────────────────────────────────────────────────────────────────
@@ -235,7 +239,9 @@ pub(super) fn apply_net_updates(
Res<Time<bevy::time::Real>>,
),
) {
let play_seq = &mut aura.4;
// A `&mut` to the counter itself (deref-coerced through the `ResMut`), so the arms that stamp
// it *conditionally* can take it by reference and only advance it when they emit.
let play_seq: &mut crate::creature_anim::PlaySeq = &mut aura.4;
let (
mut names,
mut items,
@@ -288,25 +294,17 @@ pub(super) fn apply_net_updates(
let mut pending: HashMap<u64, ObjectFields> = HashMap::new();
for ev in events.0.try_iter() {
match ev {
SessionEvent::LoginStage { stage } => {
login_stages.write(super::LoginStageMessage { stage });
}
SessionEvent::LoginStage { stage } => session::login_stage(stage, &mut login_stages),
SessionEvent::LoginFailed {
code,
reason,
terminal,
} => {
login_failures.write(super::LoginFailedMessage {
code,
reason,
terminal,
});
}
} => session::login_failed(code, reason, terminal, &mut login_failures),
SessionEvent::CharacterList { characters, realm } => {
session::character_list(characters, realm, &mut status, &mut char_lists)
}
SessionEvent::CharActionResult { action, code } => {
char_actions.write(CharActionResultMessage { action, code });
session::char_action_result(action, code, &mut char_actions)
}
SessionEvent::CinematicTriggered { cinematic_id } => {
session::cinematic_triggered(cinematic_id, &net_commands)
@@ -332,9 +330,6 @@ pub(super) fn apply_net_updates(
}
SessionEvent::LogoutCancelled => logout.apply_cancelled(),
SessionEvent::Disconnected { reason } => {
disconnects.write(super::DisconnectedMessage {
reason: reason.clone(),
});
session::disconnected(
reason,
&mut commands,
@@ -362,6 +357,7 @@ pub(super) fn apply_net_updates(
&mut duel,
&mut social,
&mut aura.6,
&mut disconnects,
);
}
SessionEvent::ObjectCreate {
@@ -377,14 +373,7 @@ pub(super) fn apply_net_updates(
spline,
fields,
} => {
// OUR corpse streaming into range (a TYPEID_CORPSE create whose owner is us):
// remember its guid for the reclaim send (decision 0308 §5). Corpses classify as
// EntityKind::Other; the owner field is corpse-only, so the filter is exact.
if kind == benilla_protocol::EntityKind::Other
&& fields.corpse_owner() == self_guid.0
{
death_net.corpse_guid = Some(guid);
}
death::note_corpse(guid, kind, &fields, &self_guid, &mut death_net);
objects::object_create(
guid,
kind,
@@ -467,11 +456,7 @@ pub(super) fn apply_net_updates(
objects::object_values(guid, fields, &index, &mut stores, &mut pending, &mut items)
}
SessionEvent::ObjectDestroyed(guid) => {
// The corpse-to-bones swap destroys the corpse object under its guid (0308 §1);
// a stale guid must not ride a later reclaim.
if death_net.corpse_guid == Some(guid) {
death_net.corpse_guid = None;
}
death::forget_corpse(guid, &mut death_net);
objects::object_destroyed(guid, &mut commands, &mut index, &mut items)
}
SessionEvent::ObjectsRemoved(guids) => {
@@ -545,10 +530,7 @@ pub(super) fn apply_net_updates(
session::reputations(standings, &mut reputations)
}
SessionEvent::ReputationDelta { standings } => {
// A standing change is a questgiver-status input (`SatisfyQuestReputation`, and
// the reaction gate): the reference sweeps from this handler too (0654).
session::reputation_delta(standings, &mut reputations);
quest.bump_reask();
session::reputation_delta(standings, &mut reputations, &mut quest)
}
SessionEvent::BindPoint { area } => home_bind.0 = Some(area),
SessionEvent::Proficiency {
@@ -557,13 +539,11 @@ pub(super) fn apply_net_updates(
} => {
proficiencies.0.insert(item_class, subclass_mask);
}
// Name-query answers → the cache (asked by NameCache::resolve; race/gender/class ride
// the player answer but have no consumer yet — the cache stays name-only until one does).
SessionEvent::PlayerName { guid, name, .. } => {
names.insert_player(guid, name);
names::player_name(guid, name, &mut names)
}
SessionEvent::PetName { pet_number, name } => {
names.insert_pet(pet_number, name);
names::pet_name(pet_number, name, &mut names)
}
SessionEvent::CreatureName {
entry,
@@ -574,20 +554,17 @@ pub(super) fn apply_net_updates(
type_flags,
civilian,
racial_leader,
} => {
names.insert_creature(
entry,
name.map(|n| crate::names::CreatureRecord {
name: n,
subname,
creature_type: creature_type.unwrap_or(0),
rank,
type_flags,
civilian,
racial_leader,
}),
);
}
} => names::creature_name(
entry,
name,
subname,
creature_type,
rank,
type_flags,
civilian,
racial_leader,
&mut names,
),
SessionEvent::GameObjectInfo {
entry,
type_id,
@@ -595,59 +572,24 @@ pub(super) fn apply_net_updates(
name,
data,
} => {
// The ask-once GameObject template (decision 0239): cache it and resolve the lockId
// from the type-specific `data[]` slot — the interact routing reads it to choose
// use-vs-cast; the hover tooltip reads the name (decision 0276's GO law).
debug!(
"net: gameobject template {entry} type {type_id} display {display_id} {name:?}"
);
go_templates.insert(entry, type_id, name, &data);
}
SessionEvent::PlaySound { sound_id } => {
audio.0.write(ServerSoundMessage {
kind: ServerSoundKind::Sound2d,
sound_id,
source: None,
});
}
SessionEvent::PlayMusic { music_id } => {
audio.0.write(ServerSoundMessage {
kind: ServerSoundKind::Music,
sound_id: music_id,
source: None,
});
objects::gameobject_info(entry, type_id, display_id, name, &data, &mut go_templates)
}
SessionEvent::PlaySound { sound_id } => world::play_sound(sound_id, &mut audio.0),
SessionEvent::PlayMusic { music_id } => world::play_music(music_id, &mut audio.0),
SessionEvent::PlayObjectSound { sound_id, guid } => {
audio.0.write(ServerSoundMessage {
kind: ServerSoundKind::ObjectSound,
sound_id,
source: index.0.get(&guid).copied(),
});
world::play_object_sound(sound_id, guid, &index, &mut audio.0)
}
SessionEvent::Weather {
weather_type,
grade,
sound_id,
instant,
} => {
audio.1.write(WeatherMessage {
weather_type,
grade,
sound_id,
instant,
});
}
} => world::weather(weather_type, grade, sound_id, instant, &mut audio.1),
SessionEvent::TextEmote { guid, text_emote } => {
audio.2.write(EmoteMessage {
source: index.0.get(&guid).copied(),
kind: EmoteKind::Text(text_emote),
});
anim::text_emote(guid, text_emote, &index, &mut audio.2)
}
SessionEvent::Emote { guid, emote_id } => {
audio.2.write(EmoteMessage {
source: index.0.get(&guid).copied(),
kind: EmoteKind::Anim(emote_id),
});
anim::emote(guid, emote_id, &index, &mut audio.2)
}
// The spell-book/action-bar pair → the action store the UI feed reads
// (`crate::ui_action`), sent once at login (and the bar again on server-side edits).
@@ -696,61 +638,13 @@ pub(super) fn apply_net_updates(
&mut ui_actions.7,
),
SessionEvent::Chat(m) => {
// The chat window (decision 0084): the feed ([`crate::ui_chat`]) formats + colors
// per type, resolves the sender name ask-once, and AddMessages it into ChatFrame1.
// System lines (`CHAT_MSG_SYSTEM` 0x0A, vmangos `SharedDefines.h`) are the SERVER'S
// ANSWER to a GM dot-command — "Premade gear template N applied", "No matching
// premade player template found", "There is no such command". For a headless probe
// that is the only channel the server has to say *why* something did not happen, so
// it rides at `info!`: at `debug!` it was in the log but invisible at the default
// level, and a refused command read exactly like an applied one (decision 0651 —
// the rig's whole batch silently no-op'd on a too-low GM level and nothing said so).
// Ordinary chat stays at `debug!`: conversation, not diagnosis, and high volume.
if m.chat_type == 0x0A {
info!("net: server says — {}", m.text);
// …and as a message, for the senders that must know whether their command
// landed. Server state with no descriptor field (god mode) has no other tell.
server_said.write(super::ServerSaidMessage {
text: m.text.clone(),
});
} else {
debug!("net: chat [{:#04x}] {}", m.chat_type, m.text);
}
// …and on the trace clock too (decision 0624). A GM dot-command is the only way to
// ask the SERVER what it believes — `.gps` reads back the server-side position of a
// mover whose packets may or may not be reaching it — and its answer is only usable
// if it lands on the same timeline as the `snd`/`rly`/`run` lines it must be read
// against. `debug!` timestamps are wall-clock in a different format; this is one
// clock, one file.
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
"sys",
&format!("[{:#04x}] {}", m.chat_type, m.text.replace('\n', " ⏎ ")),
);
}
// The ignore gate (decision 0668): an ignored speaker is dropped SILENTLY —
// no line at all — which is the client's own `FriendList::IsIgnored 0x5ae5a0`
// check, VERIFIED for the sibling text-emote path (wow-re
// `system/ui/scratch/text-emote-composition.md`). A dropped WHISPER additionally
// tells the server, so the sender gets the "is ignoring you" answer: that is what
// `CMSG_CHAT_IGNORED` is for, and only the client can send it.
if social.is_ignored(m.sender_guid) {
if m.chat_type == benilla_protocol::messages::CHAT_MSG_WHISPER {
let _ = net_commands.0.send(crate::net::ClientCommand::ChatIgnored {
guid: m.sender_guid,
});
}
continue;
}
chat_log.push_wire(m);
chat::chat(m, &mut chat_log, &social, &net_commands, &mut server_said)
}
SessionEvent::ChannelNotify {
notice,
channel,
tail,
} => {
chat_log.push_channel_notice(notice, channel, &tail);
}
} => chat_log.push_channel_notice(notice, channel, &tail),
SessionEvent::ChannelList {
channel, members, ..
} => chat::channel_list(channel, &members, &mut chat_log),
@@ -770,59 +664,48 @@ pub(super) fn apply_net_updates(
max,
roll,
guid,
} => {
chat_log.push_roll(min, max, roll, guid);
}
// ── The group/party family (decision 0434 §D2, superseded by 0440): `GroupState`
// mirrors the wire; its composed lines ride CHAT_MSG_SYSTEM, the way the reference's
// engine-side errorId→GlobalStrings display does (mapping byte-verified, decision
// 0440's §5 fold-back) ──
} => chat_log.push_roll(min, max, roll, guid),
// ── The group/party family (decision 0434 §D2, superseded by 0440) — arm bodies in
// `group` ──
SessionEvent::GroupInvite { inviter } => {
push_group_lines(&mut chat_log, group.apply_invited(&inviter));
group::invited(&mut group, &mut chat_log, &inviter)
}
SessionEvent::GroupDecline { name } => {
push_group_lines(&mut chat_log, group.apply_declined(&name));
}
SessionEvent::GroupUninvited => {
push_group_lines(&mut chat_log, group.apply_uninvited());
}
SessionEvent::GroupLeaderChanged { name } => {
// Our own name is cache-seeded at login (session::connected), so this never asks.
let own = self_guid
.0
.and_then(|g| names.resolve(g, &net_commands).map(str::to_string));
push_group_lines(
&mut chat_log,
group.apply_leader_changed(&name, own.as_deref()),
);
}
SessionEvent::GroupDestroyed => {
push_group_lines(&mut chat_log, group.apply_destroyed());
group::declined(&mut group, &mut chat_log, &name)
}
SessionEvent::GroupUninvited => group::uninvited(&mut group, &mut chat_log),
SessionEvent::GroupLeaderChanged { name } => group::leader_changed(
&mut group,
&mut chat_log,
&name,
&self_guid,
&mut names,
&net_commands,
),
SessionEvent::GroupDestroyed => group::destroyed(&mut group, &mut chat_log),
SessionEvent::GroupList {
group_type,
own_flags,
members,
leader,
loot,
} => {
let lines = group.apply_list(group_type, own_flags, members, leader, loot);
push_group_lines(&mut chat_log, lines);
// Roster changes move shared-quest availability — the reference sweeps here (0654).
quest.bump_reask();
}
} => group::list(
&mut group,
&mut chat_log,
&mut quest,
group_type,
own_flags,
members,
leader,
loot,
),
SessionEvent::PartyCommandResult {
operation,
member,
result,
} => {
push_group_lines(
&mut chat_log,
group.apply_command_result(operation, &member, result),
);
}
} => group::command_result(&mut group, &mut chat_log, operation, &member, result),
SessionEvent::PartyMemberStats { guid, full, info } => {
group.apply_stats(guid, full, *info);
group.apply_stats(guid, full, *info)
}
SessionEvent::RaidTargetSet { icon, guid } => group.apply_raid_target(icon, guid),
SessionEvent::RaidTargetList { entries } => group.apply_raid_target_list(&entries),
@@ -894,68 +777,41 @@ pub(super) fn apply_net_updates(
SessionEvent::LootRoll(p) => loot_roll(p, &mut loot_rolls),
SessionEvent::LootRollWon(p) => loot_roll_won(p, &mut loot_rolls),
SessionEvent::LootAllPassed(p) => loot_all_passed(p, &mut loot_rolls),
// ── The death arc (decision 0308) — the wire-fed stores + the controller acks ──────
// ── The death arc (decision 0308) — arm bodies in `death` ─────────────────────────
SessionEvent::CorpseQuery {
found,
display_map,
position,
corpse_map,
} => {
// A not-found (reactive or the unprompted bones-conversion push) drops the marker.
death_net.corpse = found.then_some(crate::death::CorpsePoint {
display_map,
position,
corpse_map,
});
}
} => death::corpse_query(found, display_map, position, corpse_map, &mut death_net),
SessionEvent::CorpseReclaimDelay { delay_ms } => {
death_net.reclaim_at =
Some(aura.1.elapsed_secs_f64() + f64::from(delay_ms) / 1000.0);
// The client's 0x269 handler re-fires the corpse-range events through its latch
// (wow-re death-ui.md §4) — the feed re-announces on this bump.
death_net.reclaim_generation = death_net.reclaim_generation.wrapping_add(1);
death::corpse_reclaim_delay(delay_ms, aura.1.elapsed_secs_f64(), &mut death_net)
}
SessionEvent::ResurrectRequest {
caster,
name,
sickness,
has_timer,
} => {
death_net.resurrect = Some(crate::death::ResurrectOffer {
caster,
name,
sickness,
has_timer,
});
} => death::resurrect_request(caster, name, sickness, has_timer, &mut death_net),
SessionEvent::SpiritHealerConfirm { npc } => {
death::spirit_healer_confirm(npc, &mut death_net)
}
SessionEvent::SpiritHealerConfirm { npc } => death_net.spirit_healer = Some(npc),
SessionEvent::DurabilityDamageDeath => {
// The red line, verbatim GlobalStrings DURABILITYDAMAGE_DEATH (the %% unescaped).
ui_actions
.14
.0
.push("Your equipped items suffer a 10% durability loss.".to_string());
death::durability_damage_death(&mut ui_actions.14)
}
SessionEvent::MoveRoot {
guid,
counter,
rooted,
} => {
// The server only addresses our own mover; the guard keeps a stray relay harmless.
if self_guid.0 == Some(guid) {
move_roots.write(crate::death::MoveRootMessage {
guid,
counter,
rooted,
});
}
}
SessionEvent::WaterWalk { guid, counter, on } => {
if self_guid.0 == Some(guid) {
death_net.water_walk = on;
water_walks.write(crate::death::WaterWalkMessage { guid, counter, on });
}
}
} => death::move_root(guid, counter, rooted, &self_guid, &mut move_roots),
SessionEvent::WaterWalk { guid, counter, on } => death::water_walk(
guid,
counter,
on,
&self_guid,
&mut death_net,
&mut water_walks,
),
SessionEvent::ItemTemplate { entry, info } => {
item_template(entry, info.map(|b| *b), &mut items)
}
@@ -1029,18 +885,9 @@ pub(super) fn apply_net_updates(
&mut audio.15 .1,
),
SessionEvent::XpGain(x) => {
combat_log::xp_gain(x, &index, &self_guid, &mut audio.7);
chat_log.push_xp_gain(&x);
}
SessionEvent::LevelUp(l) => {
// The ding's chat lines (decision 0304). The talent-count arg is not on the
// wire — the client computes `(newLevel >= 10) ? 1 : 0` (byte-verified
// `0x5e407c`, wow-re levelup-ding.md — the 0305 fold-back), exactly this. The
// ding's VISUAL is deliberately absent here: it rides the UNIT_FIELD_LEVEL
// change-watcher (`entities` spell_fx::level_up_flash), never this packet.
let talent_points = u32::from(l.level >= 10);
chat_log.push_level_up(&l, talent_points);
combat_log::xp_gain(x, &index, &self_guid, &mut audio.7, &mut chat_log)
}
SessionEvent::LevelUp(l) => combat_log::level_up(l, &mut chat_log),
SessionEvent::SpellStart {
caster,
spell_id,
@@ -1151,92 +998,44 @@ pub(super) fn apply_net_updates(
SessionEvent::CooldownCheat { caster } => {
cooldown_cheat(caster, &self_guid, &mut ui_actions.10)
}
// The channel pair is self-only on the wire (no guid) — straight to the cast bar;
// the channel *animation* state rides the unit-field pair instead (decision 0137).
SessionEvent::ChannelStart {
spell_id,
duration_ms,
} => {
let now = std::time::Instant::now();
ui_actions.13.start(spell_id, duration_ms, now);
ui_actions
.5
.0
.push(crate::ui_cast::CastBarEdge::ChannelStart {
spell_id,
duration_ms,
})
}
} => channel_start(spell_id, duration_ms, &mut ui_actions.13, &mut ui_actions.5),
SessionEvent::ChannelUpdate { remaining_ms } => {
ui_actions
.13
.update(remaining_ms, std::time::Instant::now());
ui_actions
.5
.0
.push(crate::ui_cast::CastBarEdge::ChannelUpdate { remaining_ms })
channel_update(remaining_ms, &mut ui_actions.13, &mut ui_actions.5)
}
// One of our own auras' remaining time (decisions 0255/0257) — keyed by raw slot,
// stamped with the receive time. The `ui_aura` feed joins it to the aura in that slot
// by arrival order; it arrives *before* the descriptor delta that names the slot.
SessionEvent::AuraDuration { slot, remaining_ms } => {
aura.0.set(slot, remaining_ms, aura.1.elapsed_secs_f64());
aura_duration(slot, remaining_ms, &mut aura.0, aura.1.elapsed_secs_f64())
}
SessionEvent::PlaySpellVisual { unit, kit_id } => {
// The kit-push opcode (decision 0280): stage-0 play on the unit — the eat/drink
// kit cadence and mid-channel swaps. Consumer: `creature_anim::spell_visual`.
if let Some(&e) = index.0.get(&unit) {
audio.12.write(crate::creature_anim::KitPush {
entity: e,
kit_id,
seq: play_seq.next(),
});
}
}
SessionEvent::EnvironmentalDamageLog(e) => {
// The 0x1FC consequence (wow-re `sound/scratch/uisound-tables.md`: reader
// `0x624fcc` inside `0x624f30`): the EnvironmentalDamage.dbc 6-slot table picks
// the damage type's SpellVisualKit — fall's is the DustCloud_Land puff — played
// on the victim through the ordinary discrete kit play (`0x60edf0`), the same
// leg the kit-push opcode rides. The pain vocal's exact trigger is a dispatched
// wow-re §5 (in flight) — it folds in as its own edge when the verdict lands.
if let Some(&ent) = index.0.get(&e.victim) {
if let Some(kit_id) = aura.5.as_ref().and_then(|t| t.0.kit_id(e.damage_type)) {
debug!(
"net: environmental damage on {:#x} (type {}, {} dmg) → kit {kit_id}",
e.victim, e.damage_type, e.damage
);
audio.12.write(crate::creature_anim::KitPush {
entity: ent,
kit_id,
seq: play_seq.next(),
});
}
}
anim::play_spell_visual(unit, kit_id, &index, play_seq, &mut audio.12)
}
SessionEvent::EnvironmentalDamageLog(e) => anim::environmental_damage_log(
e,
&index,
aura.5.as_deref(),
play_seq,
&mut audio.12,
),
// The gossip/vendor/trainer NPC-interaction family — arm bodies in `npc`.
SessionEvent::GossipMenu {
npc,
text_id,
options,
quests,
} => {
let gender = npc_gender(npc, &index, &stores);
npc::gossip_menu(
npc,
gender,
text_id,
options,
quests,
&mut gossip,
&net_commands,
);
}
} => npc::gossip_menu(
npc,
text_id,
options,
quests,
&mut gossip,
&net_commands,
&index,
&stores,
),
SessionEvent::NpcGreeting { text_id, blocks } => {
// The record answers a query we sent for the OPEN menu, so its NPC is the one whose
// gender picks the column (decision 0081's ask-once flow).
let gender = gossip.npc.map_or(0, |npc| npc_gender(npc, &index, &stores));
npc::npc_greeting(text_id, gender, blocks, &mut gossip)
npc::npc_greeting(text_id, blocks, &mut gossip, &index, &stores)
}
SessionEvent::GossipComplete => npc::gossip_complete(&mut gossip, &mut quest),
// Questgiver panels (decision 0088): fill the `QuestGiver` the quest feed
@@ -1249,12 +1048,7 @@ pub(super) fn apply_net_updates(
SessionEvent::QuestDetail(d) => quest_detail(d, &mut quest),
SessionEvent::QuestProgress(p) => quest_progress(p, &mut quest),
SessionEvent::QuestOffer(o) => quest_offer(o, &mut quest),
SessionEvent::QuestComplete(c) => {
// The turn-in result — the `SMSG_QUESTGIVER_*` demux the reference sweeps from
// (0654): every other giver's `!`/`?` can move the moment a quest is handed in.
quest_complete(c, &mut quest);
quest.bump_reask();
}
SessionEvent::QuestComplete(c) => quest_complete(c, &mut quest),
// Quest log (decision 0088's deferred second slice): the full template feeds the log
// window's ask-once detail cache; the `SMSG_QUESTUPDATE_*` toasts have no dedicated
// window of their own on this server (no ErrorsFrame-style transient panel yet), so they
@@ -1267,30 +1061,21 @@ pub(super) fn apply_net_updates(
entry,
count,
required,
} => {
quest_objective_kill(entry, count, required);
quest.bump_reask();
}
} => quest_objective_kill(entry, count, required, &mut quest),
SessionEvent::QuestObjectiveItem { item_id, count } => {
quest_objective_item(item_id, count);
quest.bump_reask();
quest_objective_item(item_id, count, &mut quest)
}
SessionEvent::QuestObjectivesComplete { quest_id } => {
// The `SMSG_QUESTUPDATE_*` family: the turn-in `?` can go gold with no quest-log
// field change of its own, so the reference sweeps from these handlers (0654).
quest_objectives_complete(quest_id);
quest.bump_reask();
}
SessionEvent::QuestFailed { quest_id, timed } => {
quest_failed(
quest_id,
timed,
&mut quest_log,
&net_commands,
&mut chat_log,
);
quest.bump_reask();
quest_objectives_complete(quest_id, &mut quest)
}
SessionEvent::QuestFailed { quest_id, timed } => quest_failed(
quest_id,
timed,
&mut quest_log,
&net_commands,
&mut chat_log,
&mut quest,
),
SessionEvent::QuestLogFull => quest_log_full(&mut quest),
SessionEvent::QuestGiverInvalid { reason } => quest_giver_invalid(reason, &mut quest),
SessionEvent::QuestGiverFailed { quest_id, reason } => {
@@ -1357,35 +1142,12 @@ pub(super) fn apply_net_updates(
SessionEvent::SpeedChanged { guid, kind, speed } => {
objects::speed_changed(guid, kind, speed, &index, &mut aura.3)
}
// The (dis)mount attempt's result code (decision 0441): OK is silent in the reference
// (10 mounting / 3 dismounting); a failure queues the red error line
// (`ui_action::mount_result_key` — resolved against the VM's GlobalStrings at drain).
// ── The mount arc (decision 0441) — arm bodies in `mount` ─────────────────────────
SessionEvent::MountResult { mount, code } => {
let ok = if mount { code == 10 } else { code == 3 };
if !ok {
info!(
"net: {}mount refused (code {code})",
if mount { "" } else { "dis" }
);
ui_actions.1 .1 .0.push((mount, code));
}
mount::mount_result(mount, code, &mut ui_actions.1 .1)
}
// A nearby rider's flourish: rear their mount (MountSpecial 94 on the mount child —
// the hop happens in `creature_anim::flourish_to_anim`). Our OWN guid is dropped:
// we played it locally at send time, and whether the sender gets the SMSG echoed
// back is a server-config detail (LIVE-VERIFIED 2026-07-17, double-flourish probe:
// vmangos's `SendMovementMessageToSet(.., false)` only cheat-logs on the flag — the
// non-broadcaster delivery hardcodes self=true, so our deployment echoes; the
// optional per-player broadcaster honors it and would not). Self-suppression on
// receive is correct under both configs.
SessionEvent::MountSpecial { guid } => {
if self_guid.0 != Some(guid) {
if let Some(&e) = index.0.get(&guid) {
audio
.14
.write(crate::creature_anim::MountFlourish { unit: e });
}
}
mount::mount_special(guid, &self_guid, &index, &mut audio.14)
}
SessionEvent::Pong { sequence } => session::pong(sequence, &aura.2, &mut status),
SessionEvent::PacketDropped {
@@ -1432,18 +1194,8 @@ pub(super) fn apply_net_updates(
SessionEvent::TradeStatusExtended { state } => {
trade::trade_status_extended(&state, &mut trade_session)
}
// The world-state table (`SMSG_INIT_WORLD_STATES` / `SMSG_UPDATE_WORLD_STATE`) — both
// wires funnel into the one setter, as the reference's own handler does. An init does
// NOT clear first: what its `(map, zone)` dwords drive is unrecorded, so we log the
// scope rather than act on it (rationale on `crate::world_state`).
SessionEvent::WorldStates { scope, states } => {
if let Some((map, zone)) = scope {
debug!(
"world states: map {map} zone {zone}, {} entries",
states.len()
);
}
world_states.write(&states);
world::world_states(scope, states, &mut world_states)
}
}
}
@@ -1456,19 +1208,6 @@ pub(super) fn apply_net_updates(
}
}
/// A streamed unit's gender (`UNIT_FIELD_BYTES_0` byte 2) by guid — the gossip greeting's column
/// selector (wow-re `gossip-npctext-law.md`: tested `== 1` for female, so genderless `2` reads as
/// male). `0` when the guid isn't streamed in or carries no descriptor yet, which is the same
/// column the reference takes for a gossip target that isn't a unit at all.
fn npc_gender(guid: u64, index: &GuidIndex, stores: &Query<&mut ObjectStore>) -> u8 {
index
.0
.get(&guid)
.and_then(|&e| stores.get(e).ok())
.and_then(|s| s.0.unit_gender())
.unwrap_or(0)
}
/// Tag our own player's streamed entity with [`SelfPlayer`] once we know our guid — by matching the
/// [`Guid`] component against [`SelfGuid`]. The renderer skips this entity (the controller owns our
/// avatar); the controller reads its transform to take control. Done as its own pass (rather than at
+89
View File
@@ -0,0 +1,89 @@
//! "Play this on that unit" arm bodies for [`super::apply_net_updates`]'s dispatch match — the
//! server packets whose whole content is *an animation to run on a streamed unit*: the two emote
//! relays, the spell-visual kit push (decision 0280), and the environmental-damage kit (the
//! fall-landing dust puff). Each resolves the guid through the index and writes one message; the
//! animation law itself lives in [`crate::creature_anim`]. Each `pub(super)` fn here is exactly one
//! arm's body; the match at the call site stays the dispatcher, one call per arm.
use benilla_protocol::messages::EnvironmentalDamageLog;
use bevy::prelude::*;
use crate::creature_anim::{EnvDamageTable, KitPush, PlaySeq};
use super::super::{EmoteKind, EmoteMessage, GuidIndex};
/// `SMSG_TEXT_EMOTE` — someone performed a `/`-emote (the TextEmote.dbc id; the anim, if any, is
/// the emote row's).
pub(super) fn text_emote(
guid: u64,
text_emote: u32,
index: &GuidIndex,
out: &mut MessageWriter<EmoteMessage>,
) {
out.write(EmoteMessage {
source: index.0.get(&guid).copied(),
kind: EmoteKind::Text(text_emote),
});
}
/// `SMSG_EMOTE` — a bare Emotes.dbc anim id on a unit (the server-driven one-shot: NPC scripts,
/// the `/`-emote's own anim leg).
pub(super) fn emote(
guid: u64,
emote_id: u32,
index: &GuidIndex,
out: &mut MessageWriter<EmoteMessage>,
) {
out.write(EmoteMessage {
source: index.0.get(&guid).copied(),
kind: EmoteKind::Anim(emote_id),
});
}
/// `SMSG_PLAY_SPELL_VISUAL` — the kit-push opcode (decision 0280): a stage-0 play on the unit, the
/// eat/drink kit cadence and mid-channel swaps. Consumer: `creature_anim::spell_visual`. The
/// [`PlaySeq`] stamp is taken only when the unit is streamed in, so an unstreamed guid never
/// advances the call-order counter.
pub(super) fn play_spell_visual(
unit: u64,
kit_id: u32,
index: &GuidIndex,
play_seq: &mut PlaySeq,
out: &mut MessageWriter<KitPush>,
) {
if let Some(&e) = index.0.get(&unit) {
out.write(KitPush {
entity: e,
kit_id,
seq: play_seq.next(),
});
}
}
/// `SMSG_ENVIRONMENTALDAMAGELOG`'s consequence (wow-re `sound/scratch/uisound-tables.md`: reader
/// `0x624fcc` inside `0x624f30`): the EnvironmentalDamage.dbc 6-slot table picks the damage type's
/// SpellVisualKit — fall's is the DustCloud_Land puff — played on the victim through the ordinary
/// discrete kit play (`0x60edf0`), the same leg the kit-push opcode rides. The pain vocal's exact
/// trigger is a dispatched wow-re §5 (in flight) — it folds in as its own edge when the verdict
/// lands.
pub(super) fn environmental_damage_log(
e: EnvironmentalDamageLog,
index: &GuidIndex,
table: Option<&EnvDamageTable>,
play_seq: &mut PlaySeq,
out: &mut MessageWriter<KitPush>,
) {
if let Some(&ent) = index.0.get(&e.victim) {
if let Some(kit_id) = table.and_then(|t| t.0.kit_id(e.damage_type)) {
debug!(
"net: environmental damage on {:#x} (type {}, {} dmg) → kit {kit_id}",
e.victim, e.damage_type, e.damage
);
out.write(KitPush {
entity: ent,
kit_id,
seq: play_seq.next(),
});
}
}
}
+64 -5
View File
@@ -1,11 +1,70 @@
//! Chat-window system-line arm bodies for [`super::apply_net_updates`]'s dispatch match — the
//! server notices and query answers that render as chat lines (the channel roster, whisper
//! refusals, `SMSG_NOTIFICATION`, `/played`). Each `pub(super)` fn here is exactly one arm's
//! body; the match at the call site stays the dispatcher, one call per arm.
//! Chat-window arm bodies for [`super::apply_net_updates`]'s dispatch match — the spoken line
//! itself, plus the server notices and query answers that render as chat lines (the channel
//! roster, whisper refusals, `SMSG_NOTIFICATION`, `/played`). Each `pub(super)` fn here is exactly
//! one arm's body; the match at the call site stays the dispatcher, one call per arm.
use bevy::prelude::info;
use benilla_protocol::messages::{ChatMessage, CHAT_MSG_WHISPER};
use bevy::prelude::*;
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
use crate::ui_social::SocialState;
use super::super::{ClientCommand, NetCommands, ServerSaidMessage};
/// A spoken line (`SMSG_MESSAGECHAT`) — the chat window's own feed (decision 0084):
/// [`crate::ui_chat`] formats + colors per type, resolves the sender name ask-once, and
/// AddMessages it into ChatFrame1.
///
/// System lines (`CHAT_MSG_SYSTEM` 0x0A, vmangos `SharedDefines.h`) are the SERVER'S ANSWER to a GM
/// dot-command — "Premade gear template N applied", "No matching premade player template found",
/// "There is no such command". For a headless probe that is the only channel the server has to say
/// *why* something did not happen, so it rides at `info!`: at `debug!` it was in the log but
/// invisible at the default level, and a refused command read exactly like an applied one
/// (decision 0651 — the rig's whole batch silently no-op'd on a too-low GM level and nothing said
/// so). Ordinary chat stays at `debug!`: conversation, not diagnosis, and high volume.
pub(super) fn chat(
m: ChatMessage,
chat_log: &mut ChatLog,
social: &SocialState,
net_commands: &NetCommands,
server_said: &mut MessageWriter<ServerSaidMessage>,
) {
if m.chat_type == 0x0A {
info!("net: server says — {}", m.text);
// …and as a message, for the senders that must know whether their command landed. Server
// state with no descriptor field (god mode) has no other tell.
server_said.write(ServerSaidMessage {
text: m.text.clone(),
});
} else {
debug!("net: chat [{:#04x}] {}", m.chat_type, m.text);
}
// …and on the trace clock too (decision 0624). A GM dot-command is the only way to ask the
// SERVER what it believes — `.gps` reads back the server-side position of a mover whose packets
// may or may not be reaching it — and its answer is only usable if it lands on the same
// timeline as the `snd`/`rly`/`run` lines it must be read against. `debug!` timestamps are
// wall-clock in a different format; this is one clock, one file.
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
"sys",
&format!("[{:#04x}] {}", m.chat_type, m.text.replace('\n', " ⏎ ")),
);
}
// The ignore gate (decision 0668): an ignored speaker is dropped SILENTLY — no line at all —
// which is the client's own `FriendList::IsIgnored 0x5ae5a0` check, VERIFIED for the sibling
// text-emote path (wow-re `system/ui/scratch/text-emote-composition.md`). A dropped WHISPER
// additionally tells the server, so the sender gets the "is ignoring you" answer: that is what
// `CMSG_CHAT_IGNORED` is for, and only the client can send it.
if social.is_ignored(m.sender_guid) {
if m.chat_type == CHAT_MSG_WHISPER {
let _ = net_commands.0.send(ClientCommand::ChatIgnored {
guid: m.sender_guid,
});
}
return;
}
chat_log.push_wire(m);
}
/// The `/chatlist` roster (`SMSG_CHANNEL_LIST`) — CHAT_CHANNEL_LIST_GET "[%s] " + the roster.
/// Names arrive as guids; v1 renders the count (the per-member resolve fan-out lands with the
+17 -3
View File
@@ -17,12 +17,13 @@
use bevy::prelude::*;
use benilla_protocol::messages::{
DamageShield, PeriodicAuraLog, PeriodicTick, SpellDamageLog, SpellEnergizeLog, SpellHealLog,
SpellLogMiss, XpGain,
DamageShield, LevelUpInfo, PeriodicAuraLog, PeriodicTick, SpellDamageLog, SpellEnergizeLog,
SpellHealLog, SpellLogMiss, XpGain,
};
use crate::combat_text::{damage_color, miss_word, spell_text, CombatTextSpawn, DamageSource};
use crate::names::NameCache;
use crate::ui_chat::ChatLog;
use crate::ui_unit::{CombatTextEvent, UnitCombatFeedback};
use super::super::{GuidIndex, NetCommands, ObjectStore, SelfGuid};
@@ -567,12 +568,14 @@ pub(super) fn spell_log_miss(
}
/// `SMSG_LOG_XPGAIN` → `"XP: %d"` over **self**, category 4 — the one emitter that is
/// self-anchored by design and skips Gate A (`0x607260`). Fires for kill and quest XP alike.
/// self-anchored by design and skips Gate A (`0x607260`). Fires for kill and quest XP alike. The
/// same packet also writes the combat log's own chat line.
pub(super) fn xp_gain(
x: XpGain,
index: &GuidIndex,
self_guid: &SelfGuid,
text: &mut MessageWriter<CombatTextSpawn>,
chat_log: &mut ChatLog,
) {
if let Some(&me) = self_guid.0.as_ref().and_then(|g| index.0.get(g)) {
text.write(CombatTextSpawn {
@@ -582,4 +585,15 @@ pub(super) fn xp_gain(
color: None, // row 4's own purple — XP skips the damage color law entirely
});
}
chat_log.push_xp_gain(&x);
}
/// `SMSG_LEVELUP_INFO` → the ding's chat lines (decision 0304). The talent-count arg is not on the
/// wire — the client computes `(newLevel >= 10) ? 1 : 0` (byte-verified `0x5e407c`, wow-re
/// levelup-ding.md — the 0305 fold-back), exactly this. The ding's VISUAL is deliberately absent
/// here: it rides the UNIT_FIELD_LEVEL change-watcher (`entities` spell_fx::level_up_flash), never
/// this packet.
pub(super) fn level_up(l: LevelUpInfo, chat_log: &mut ChatLog) {
let talent_points = u32::from(l.level >= 10);
chat_log.push_level_up(&l, talent_points);
}
+125
View File
@@ -0,0 +1,125 @@
//! Death-arc arm bodies for [`super::apply_net_updates`]'s dispatch match (decision 0308) — the
//! wire-fed [`DeathNet`] stores (the corpse marker and its guid latch, the reclaim clock, the
//! resurrect offer, the spirit-healer confirm, the death durability notice) plus the two
//! controller-facing acks the server addresses to our own mover. Each `pub(super)` fn here is
//! exactly one arm's body — except the two corpse-guid latches, which ride the object-lifecycle
//! arms; the match at the call site stays the dispatcher, one call per arm.
use benilla_protocol::{EntityKind, ObjectFields};
use bevy::prelude::*;
use crate::death::{CorpsePoint, DeathNet, MoveRootMessage, ResurrectOffer, WaterWalkMessage};
use crate::ui_items::UiErrorLines;
use super::super::SelfGuid;
/// OUR corpse streaming into range (a `TYPEID_CORPSE` create whose owner is us): remember its guid
/// for the reclaim send (decision 0308 §5). Corpses classify as [`EntityKind::Other`], and the
/// owner field is corpse-only, so the filter is exact. Rides the `ObjectCreate` arm.
pub(super) fn note_corpse(
guid: u64,
kind: EntityKind,
fields: &ObjectFields,
self_guid: &SelfGuid,
death_net: &mut DeathNet,
) {
if kind == EntityKind::Other && fields.corpse_owner() == self_guid.0 {
death_net.corpse_guid = Some(guid);
}
}
/// The corpse-to-bones swap destroys the corpse object under its guid (0308 §1); a stale guid must
/// not ride a later reclaim. Rides the `ObjectDestroyed` arm.
pub(super) fn forget_corpse(guid: u64, death_net: &mut DeathNet) {
if death_net.corpse_guid == Some(guid) {
death_net.corpse_guid = None;
}
}
/// `MSG_CORPSE_QUERY`'s answer — where the corpse marker goes. A not-found (reactive, or the
/// server's unprompted bones-conversion push) drops the marker.
pub(super) fn corpse_query(
found: bool,
display_map: i32,
position: [f32; 3],
corpse_map: u32,
death_net: &mut DeathNet,
) {
death_net.corpse = found.then_some(CorpsePoint {
display_map,
position,
corpse_map,
});
}
/// `SMSG_CORPSE_RECLAIM_DELAY` — the delay, anchored to arrival time (`now_secs` is the same
/// `Time::elapsed_secs_f64` the feed reads back). The client's `0x269` handler re-fires the
/// corpse-range events through its latch (wow-re death-ui.md §4), so the feed re-announces on the
/// generation bump.
pub(super) fn corpse_reclaim_delay(delay_ms: u32, now_secs: f64, death_net: &mut DeathNet) {
death_net.reclaim_at = Some(now_secs + f64::from(delay_ms) / 1000.0);
death_net.reclaim_generation = death_net.reclaim_generation.wrapping_add(1);
}
/// `SMSG_RESURRECT_REQUEST` — the RESURRECT popup's data.
pub(super) fn resurrect_request(
caster: u64,
name: String,
sickness: bool,
has_timer: bool,
death_net: &mut DeathNet,
) {
death_net.resurrect = Some(ResurrectOffer {
caster,
name,
sickness,
has_timer,
});
}
/// `SMSG_SPIRIT_HEALER_CONFIRM` — the healer awaiting the XP-loss two-step's Accept.
pub(super) fn spirit_healer_confirm(npc: u64, death_net: &mut DeathNet) {
death_net.spirit_healer = Some(npc);
}
/// `SMSG_DURABILITY_DAMAGE_DEATH` — the red line, verbatim GlobalStrings `DURABILITYDAMAGE_DEATH`
/// (the `%%` unescaped).
pub(super) fn durability_damage_death(lines: &mut UiErrorLines) {
lines
.0
.push("Your equipped items suffer a 10% durability loss.".to_string());
}
/// `SMSG_FORCE_MOVE_ROOT`/`SMSG_FORCE_MOVE_UNROOT` — the server only addresses our own mover; the
/// guard keeps a stray relay harmless.
pub(super) fn move_root(
guid: u64,
counter: u32,
rooted: bool,
self_guid: &SelfGuid,
out: &mut MessageWriter<MoveRootMessage>,
) {
if self_guid.0 == Some(guid) {
out.write(MoveRootMessage {
guid,
counter,
rooted,
});
}
}
/// `SMSG_MOVE_WATER_WALK`/`SMSG_MOVE_LAND_WALK` — mirrored into [`DeathNet`] for the swim arc's
/// future mover regime; the ack itself is the controller's.
pub(super) fn water_walk(
guid: u64,
counter: u32,
on: bool,
self_guid: &SelfGuid,
death_net: &mut DeathNet,
out: &mut MessageWriter<WaterWalkMessage>,
) {
if self_guid.0 == Some(guid) {
death_net.water_walk = on;
out.write(WaterWalkMessage { guid, counter, on });
}
}
+82 -3
View File
@@ -1,13 +1,92 @@
//! The group/party arm helpers (decision 0434): the composed-system-line push. The composition
//! policy itself lives in [`crate::ui_party::GroupState`] — these are the drain-side shims.
//! The group/party arm bodies (decision 0434 §D2, superseded by 0440) for
//! [`super::apply_net_updates`]'s dispatch match. [`GroupState`] mirrors the wire and *composes*
//! the lines; these are the drain-side shims that push what it composed onto the chat log — the
//! way the reference's engine-side errorId→GlobalStrings display does (mapping byte-verified,
//! decision 0440's §5 fold-back). Each `pub(super)` fn here is exactly one arm's body; the match at
//! the call site stays the dispatcher, one call per arm.
use benilla_protocol::messages::{GroupLootInfo, GroupMemberEntry};
use crate::names::NameCache;
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
use crate::ui_party::GroupState;
use crate::ui_quest::QuestGiver;
use super::super::{NetCommands, SelfGuid};
/// Push the lines a `GroupState::apply_*` composed as CHAT_MSG_SYSTEM — the same seam the other
/// client-composed feeds use (the reference's engine formats these via its errorId→GlobalStrings
/// display and fires them as system chat; benilla's composer hands us the finished strings).
pub(super) fn push_group_lines(chat_log: &mut ChatLog, lines: Vec<String>) {
fn push_group_lines(chat_log: &mut ChatLog, lines: Vec<String>) {
for line in lines {
chat_log.push_event(ChatEvent::text_only(ChatEventKind::System, line));
}
}
/// `SMSG_GROUP_INVITE` — someone asked us into their group.
pub(super) fn invited(group: &mut GroupState, chat_log: &mut ChatLog, inviter: &str) {
push_group_lines(chat_log, group.apply_invited(inviter));
}
/// `SMSG_GROUP_DECLINE` — our invitee said no (sent to the inviter only).
pub(super) fn declined(group: &mut GroupState, chat_log: &mut ChatLog, name: &str) {
push_group_lines(chat_log, group.apply_declined(name));
}
/// `SMSG_GROUP_UNINVITE` — we were kicked.
pub(super) fn uninvited(group: &mut GroupState, chat_log: &mut ChatLog) {
push_group_lines(chat_log, group.apply_uninvited());
}
/// `SMSG_GROUP_DESTROYED` — the group is gone outright.
pub(super) fn destroyed(group: &mut GroupState, chat_log: &mut ChatLog) {
push_group_lines(chat_log, group.apply_destroyed());
}
/// `SMSG_GROUP_SET_LEADER` — the line reads differently when the new leader is us, so the composer
/// needs our own name. It is cache-seeded at login (`session::connected`), so this never asks.
pub(super) fn leader_changed(
group: &mut GroupState,
chat_log: &mut ChatLog,
name: &str,
self_guid: &SelfGuid,
names: &mut NameCache,
net_commands: &NetCommands,
) {
let own = self_guid
.0
.and_then(|g| names.resolve(g, net_commands).map(str::to_string));
push_group_lines(chat_log, group.apply_leader_changed(name, own.as_deref()));
}
/// `SMSG_GROUP_LIST` — the roster echo (and the join/leave diff's line source). Roster changes move
/// shared-quest availability, so the questgiver sweep re-asks from here (0654).
#[allow(clippy::too_many_arguments)]
pub(super) fn list(
group: &mut GroupState,
chat_log: &mut ChatLog,
quest: &mut QuestGiver,
group_type: u8,
own_flags: u8,
members: Vec<GroupMemberEntry>,
leader: u64,
loot: Option<GroupLootInfo>,
) {
let lines = group.apply_list(group_type, own_flags, members, leader, loot);
push_group_lines(chat_log, lines);
quest.bump_reask();
}
/// `SMSG_PARTY_COMMAND_RESULT` — the verdict on an invite/kick/leave we asked for.
pub(super) fn command_result(
group: &mut GroupState,
chat_log: &mut ChatLog,
operation: u32,
member: &str,
result: u32,
) {
push_group_lines(
chat_log,
group.apply_command_result(operation, member, result),
);
}
+46
View File
@@ -0,0 +1,46 @@
//! The mount arc's arm bodies for [`super::apply_net_updates`]'s dispatch match (decision 0441) —
//! the (dis)mount attempt's result code (P1) and a nearby rider's flourish (P2). Each `pub(super)`
//! fn here is exactly one arm's body; the match at the call site stays the dispatcher, one call per
//! arm.
use bevy::prelude::*;
use crate::creature_anim::MountFlourish;
use crate::ui_action::MountErrors;
use super::super::{GuidIndex, SelfGuid};
/// `SMSG_MOUNTRESULT`/`SMSG_DISMOUNTRESULT` — OK is silent in the reference (10 mounting,
/// 3 dismounting); a failure queues the red error line (`ui_action::mount_result_key` — resolved
/// against the VM's GlobalStrings at drain).
pub(super) fn mount_result(mount: bool, code: u32, errors: &mut MountErrors) {
let ok = if mount { code == 10 } else { code == 3 };
if !ok {
info!(
"net: {}mount refused (code {code})",
if mount { "" } else { "dis" }
);
errors.0.push((mount, code));
}
}
/// `SMSG_MOUNTSPECIAL_ANIM` — a nearby rider's flourish: rear their mount (MountSpecial 94 on the
/// mount child; the hop happens in `creature_anim::flourish_to_anim`).
///
/// Our OWN guid is dropped: we played it locally at send time, and whether the sender gets the
/// SMSG echoed back is a server-config detail (LIVE-VERIFIED 2026-07-17, double-flourish probe:
/// vmangos's `SendMovementMessageToSet(.., false)` only cheat-logs on the flag — the
/// non-broadcaster delivery hardcodes self=true, so our deployment echoes; the optional per-player
/// broadcaster honors it and would not). Self-suppression on receive is correct under both configs.
pub(super) fn mount_special(
guid: u64,
self_guid: &SelfGuid,
index: &GuidIndex,
out: &mut MessageWriter<MountFlourish>,
) {
if self_guid.0 != Some(guid) {
if let Some(&e) = index.0.get(&guid) {
out.write(MountFlourish { unit: e });
}
}
}
+46
View File
@@ -0,0 +1,46 @@
//! Name-query answer arm bodies for [`super::apply_net_updates`]'s dispatch match — the three
//! `*_QUERY_RESPONSE` packets that fill the ask-once [`NameCache`] (`NameCache::resolve` is what
//! asked for them). Each `pub(super)` fn here is exactly one arm's body; the match at the call site
//! stays the dispatcher, one call per arm.
use crate::names::{CreatureRecord, NameCache};
/// `SMSG_NAME_QUERY_RESPONSE` — a player's name. Race/gender/class ride the same answer but have no
/// consumer yet, so the cache stays name-only until one does.
pub(super) fn player_name(guid: u64, name: String, names: &mut NameCache) {
names.insert_player(guid, name);
}
/// `SMSG_PET_NAME_QUERY_RESPONSE` — keyed by pet *number*, not by template entry.
pub(super) fn pet_name(pet_number: u32, name: String, names: &mut NameCache) {
names.insert_pet(pet_number, name);
}
/// `SMSG_CREATURE_QUERY_RESPONSE` — the template's name plus the hover line's fields. A `None` name
/// is the server's "no such entry", cached as such so the ask never repeats; the remaining fields
/// then carry their miss defaults.
#[allow(clippy::too_many_arguments)]
pub(super) fn creature_name(
entry: u32,
name: Option<String>,
subname: Option<String>,
creature_type: Option<u32>,
rank: u32,
type_flags: u32,
civilian: bool,
racial_leader: bool,
names: &mut NameCache,
) {
names.insert_creature(
entry,
name.map(|n| CreatureRecord {
name: n,
subname,
creature_type: creature_type.unwrap_or(0),
rank,
type_flags,
civilian,
racial_leader,
}),
);
}
+25 -6
View File
@@ -13,7 +13,20 @@ use crate::ui_quest::QuestGiver;
use crate::ui_taxi::TaxiState;
use crate::ui_trainer::{TrainerErrors, TrainerOpen};
use super::super::{ClientCommand, NetCommands};
use super::super::{ClientCommand, GuidIndex, NetCommands, ObjectStore};
/// A streamed unit's gender (`UNIT_FIELD_BYTES_0` byte 2) by guid — the gossip greeting's column
/// selector (wow-re `gossip-npctext-law.md`: tested `== 1` for female, so genderless `2` reads as
/// male). `0` when the guid isn't streamed in or carries no descriptor yet, which is the same
/// column the reference takes for a gossip target that isn't a unit at all.
fn npc_gender(guid: u64, index: &GuidIndex, stores: &Query<&mut ObjectStore>) -> u8 {
index
.0
.get(&guid)
.and_then(|&e| stores.get(e).ok())
.and_then(|s| s.0.unit_gender())
.unwrap_or(0)
}
/// A gossip menu opened (`SMSG_GOSSIP_MESSAGE`): fill the [`GossipState`] the gossip feed
/// (`crate::ui_gossip`) reads. On a menu opening, auto-send the ask-once `CMSG_NPC_TEXT_QUERY`
@@ -21,18 +34,19 @@ use super::super::{ClientCommand, NetCommands};
/// [`gossip_complete`] closes it.
///
/// The greeting is **drawn here**, not at the packet — this is the reference's own moment for it
/// (`0x4e2010`), and the draw needs both this NPC's gender and a fresh roll. `npc_gender` is
/// `UNIT_FIELD_BYTES_0` byte 2 off the NPC's descriptor, defaulting to male when the unit isn't
/// streamed in (which is also what the reference does for a non-unit gossip target).
/// (`0x4e2010`), and the draw needs both this NPC's gender ([`npc_gender`]) and a fresh roll.
#[allow(clippy::too_many_arguments)]
pub(super) fn gossip_menu(
npc: u64,
npc_gender: u8,
text_id: u32,
options: Vec<GossipOption>,
quests: Vec<(u32, u32, String)>,
gossip: &mut GossipState,
net_commands: &NetCommands,
index: &GuidIndex,
stores: &Query<&mut ObjectStore>,
) {
let npc_gender = npc_gender(npc, index, stores);
debug!(
"net: gossip menu on {npc:#x} — {} options, {} quests",
options.len(),
@@ -56,12 +70,17 @@ pub(super) fn gossip_menu(
/// The NPC-text answer (`SMSG_NPC_TEXT_UPDATE`) — seed the cache with the whole record, and draw
/// the greeting only for the OPEN menu (a late answer for a menu we already closed just seeds the
/// cache; the next open draws its own line).
///
/// The record answers a query we sent for the OPEN menu, so its NPC is the one whose gender picks
/// the column (decision 0081's ask-once flow).
pub(super) fn npc_greeting(
text_id: u32,
npc_gender: u8,
blocks: Vec<NpcTextBlock>,
gossip: &mut GossipState,
index: &GuidIndex,
stores: &Query<&mut ObjectStore>,
) {
let npc_gender = gossip.npc.map_or(0, |npc| npc_gender(npc, index, stores));
gossip.remember_record(text_id, blocks);
if gossip.npc.is_some() && gossip.text_id == text_id {
gossip.greeting = gossip.draw_greeting(text_id, npc_gender);
+15
View File
@@ -455,6 +455,21 @@ pub(super) fn monster_move(
}
}
/// The ask-once GameObject template (`SMSG_GAMEOBJECT_QUERY_RESPONSE`, decision 0239): cache it and
/// resolve the lockId from the type-specific `data[]` slot — the interact routing reads it to choose
/// use-vs-cast; the hover tooltip reads the name (decision 0276's GO law).
pub(super) fn gameobject_info(
entry: u32,
type_id: u32,
display_id: u32,
name: String,
data: &[i32; 24],
go_templates: &mut GameObjectTemplates,
) {
debug!("net: gameobject template {entry} type {type_id} display {display_id} {name:?}");
go_templates.insert(entry, type_id, name, data);
}
/// Merge a descriptor delta into an object's store: into the per-drain `pending` seed when the entity was
/// created earlier this same drain (its spawn `Command` hasn't run, so it isn't queryable yet), else in
/// place on the live component. The final `else` — in the index but neither live nor pending — should not
+15 -3
View File
@@ -72,6 +72,9 @@ pub(super) fn quest_complete(c: QuestComplete, quest: &mut QuestGiver) {
c.items.len()
);
quest.clear();
// The turn-in result is one of the `SMSG_QUESTGIVER_*` the reference sweeps from (0654): every
// other giver's `!`/`?` can move the moment a quest is handed in.
quest.bump_reask();
}
/// The full quest template (`SMSG_QUEST_QUERY_RESPONSE`, answering our `CMSG_QUEST_QUERY`) — the
@@ -90,13 +93,15 @@ pub(super) fn quest_template(t: Box<QuestTemplate>, quest_log: &mut QuestLog) {
/// chat-line stopgap here is retired: the reference shows no chat echo for objective progress
/// (INFERRED from ref screenshots; the dispatched §5 adjudicates, and this fn is the fold-back
/// seat if the real handler does more — a sound, a distinct format).
pub(super) fn quest_objective_kill(entry: u32, count: u32, required: u32) {
pub(super) fn quest_objective_kill(entry: u32, count: u32, required: u32, quest: &mut QuestGiver) {
debug!("net: quest kill/use objective {entry:#x} at {count}/{required}");
quest.bump_reask();
}
/// See [`quest_objective_kill`] — same surface, item flavor.
pub(super) fn quest_objective_item(item_id: u32, count: u32) {
pub(super) fn quest_objective_item(item_id: u32, count: u32, quest: &mut QuestGiver) {
debug!("net: quest item objective {item_id} +{count}");
quest.bump_reask();
}
/// Every objective on the quest is complete (`SMSG_QUESTUPDATE_COMPLETE`, 0x198). The visible
@@ -104,8 +109,11 @@ pub(super) fn quest_objective_item(item_id: u32, count: u32) {
/// kind-1 → UI_INFO_MESSAGE, never a chat line) — rides the quest-log diff's COMPLETE-flip
/// detection (`crate::ui_quest_log::feed_quest_log`), same as the progress toasts; the slot's
/// state byte carries the durable fact.
pub(super) fn quest_objectives_complete(quest_id: u32) {
pub(super) fn quest_objectives_complete(quest_id: u32, quest: &mut QuestGiver) {
debug!("net: quest {quest_id} objectives complete");
// The turn-in `?` can go gold with no quest-log field change of its own, so the reference
// sweeps from these `SMSG_QUESTUPDATE_*` handlers (0654).
quest.bump_reask();
}
/// The quest failed (`SMSG_QUESTUPDATE_FAILED` / `_FAILEDTIMER` — `timed` picks which): the one
@@ -118,6 +126,7 @@ pub(super) fn quest_failed(
quest_log: &mut QuestLog,
net_commands: &NetCommands,
chat_log: &mut ChatLog,
quest: &mut QuestGiver,
) {
debug!("net: quest {quest_id} failed (timed: {timed})");
let line = quest_log
@@ -127,6 +136,9 @@ pub(super) fn quest_failed(
crate::ui_chat::ChatEventKind::System,
line,
));
// A failure moves what the givers offer — the reference sweeps from these `SMSG_QUESTUPDATE_*`
// handlers too (0654).
quest.bump_reask();
}
/// The log refused a new quest — no free slot (`SMSG_QUESTLOG_FULL`). The ref's `0x195` arm is a
+54 -7
View File
@@ -1,8 +1,8 @@
//! Session-lifecycle arm bodies for [`super::apply_net_updates`]'s dispatch match — the connection
//! edges (character select, entering the world, logout, the disconnect teardown), our own
//! teleport/worldport snaps, the server clock, and the login reputation store. Each `pub(super)`
//! fn here is exactly one arm's body; the match at the call site stays the dispatcher, one call
//! per arm.
//! edges (the login stages, character select, entering the world, logout, the disconnect
//! teardown), our own teleport/worldport snaps, the server clock, and the login reputation store.
//! Each `pub(super)` fn here is exactly one arm's body; the match at the call site stays the
//! dispatcher, one call per arm.
use benilla_protocol::messages::Character;
use bevy::prelude::*;
@@ -20,11 +20,45 @@ use crate::ui_taxi::TaxiState;
use crate::ui_trainer::TrainerOpen;
use super::super::{
CharListMessage, ClientCommand, DroppedOpcodes, EnteredWorldMessage, GameTime, GuidIndex,
LoggedOutMessage, NetCommands, NetStatus, PendingTransfer, PingShared, Reputations, SelfGuid,
CharActionResultMessage, CharListMessage, ClientCommand, DisconnectedMessage, DroppedOpcodes,
EnteredWorldMessage, GameTime, GuidIndex, LoggedOutMessage, LoginFailedMessage,
LoginStageMessage, NetCommands, NetStatus, PendingTransfer, PingShared, Reputations, SelfGuid,
ServerTime, TeleportMessage, WorldportMessage,
};
/// The pre-logon handshake reached a new stage (decision 0539) — the login screen's dialog reads it.
pub(super) fn login_stage(
stage: benilla_protocol::LoginStage,
out: &mut MessageWriter<LoginStageMessage>,
) {
out.write(LoginStageMessage { stage });
}
/// A login attempt failed before the roster (decision 0539): the IO thread is back at its pre-logon
/// park, and [`crate::login`]'s policy decides what happens next.
pub(super) fn login_failed(
code: Option<u8>,
reason: String,
terminal: bool,
out: &mut MessageWriter<LoginFailedMessage>,
) {
out.write(LoginFailedMessage {
code,
reason,
terminal,
});
}
/// The verdict on a character create/delete (`SMSG_CHAR_CREATE`/`SMSG_CHAR_DELETE`) — the glue
/// screen turns the code into its own refusal string.
pub(super) fn char_action_result(
action: benilla_protocol::CharAction,
code: u8,
out: &mut MessageWriter<CharActionResultMessage>,
) {
out.write(CharActionResultMessage { action, code });
}
/// The account's character roster (`SMSG_CHAR_ENUM`): the world socket is authenticated and parked
/// at character select — surface the list (+ the connected realm's identity) to the glue screen.
pub(super) fn character_list(
@@ -126,7 +160,13 @@ pub(super) fn disconnected(
duel: &mut crate::ui_duel::DuelState,
social: &mut crate::ui_social::SocialState,
pending_transfer: &mut PendingTransfer,
disconnects: &mut MessageWriter<DisconnectedMessage>,
) {
// The reconnect-policy feed first (decision 0539): [`crate::login`] reads it as "the IO thread
// is back at its pre-logon park".
disconnects.write(DisconnectedMessage {
reason: reason.clone(),
});
warn!("net: {reason} — tearing down the streamed world");
// An announced-but-unfinished far teleport died with the socket.
pending_transfer.0 = None;
@@ -304,7 +344,11 @@ pub(super) fn reputations(standings: Vec<(u8, i32)>, reputations: &mut Reputatio
/// A mid-session standing delta (`SMSG_SET_FACTION_STANDING`): overwrite the changed slots,
/// growing the store for a list id past the login snapshot (flags default 0 — the wire's
/// delta carries none).
pub(super) fn reputation_delta(standings: Vec<(u32, i32)>, reputations: &mut Reputations) {
pub(super) fn reputation_delta(
standings: Vec<(u32, i32)>,
reputations: &mut Reputations,
quest: &mut QuestGiver,
) {
for (list_id, standing) in standings {
let i = list_id as usize;
if reputations.0.len() <= i {
@@ -312,6 +356,9 @@ pub(super) fn reputation_delta(standings: Vec<(u32, i32)>, reputations: &mut Rep
}
reputations.0[i].1 = standing;
}
// A standing change is a questgiver-status input (`SatisfyQuestReputation`, and the reaction
// gate): the reference sweeps from this handler too (0654).
quest.bump_reask();
}
/// The keepalive echo (`SMSG_PONG`): match it against the shared ping clock to measure the
+39 -1
View File
@@ -11,7 +11,8 @@ use bevy::prelude::*;
use crate::cooldowns::Cooldowns;
use crate::creature_anim::{CastEvent, CastEventKind, Casting, SpellGoTargets};
use crate::ui_action::{AutoRepeatActive, CastErrors, PlayerActions, Spells};
use crate::ui_cast::{CastBarEdge, CastBarFeed, PendingCast, QueuedMeleeSpell};
use crate::ui_aura::AuraDurations;
use crate::ui_cast::{ActiveChannel, CastBarEdge, CastBarFeed, PendingCast, QueuedMeleeSpell};
use super::super::{GuidIndex, ObjectStore, SelfGuid};
@@ -629,6 +630,43 @@ pub(super) fn cooldown_cheat(caster: u64, self_guid: &SelfGuid, cooldowns: &mut
}
}
/// `MSG_CHANNEL_START` — self-only on the wire (no guid), so it goes straight to the cast bar; the
/// channel *animation* state rides the unit-field pair instead (decision 0137).
pub(super) fn channel_start(
spell_id: u32,
duration_ms: u32,
channel: &mut ActiveChannel,
feed: &mut CastBarFeed,
) {
channel.start(spell_id, duration_ms, Instant::now());
feed.0.push(CastBarEdge::ChannelStart {
spell_id,
duration_ms,
});
}
/// `MSG_CHANNEL_UPDATE` — the running channel's remaining time (`0` is its stop edge).
pub(super) fn channel_update(
remaining_ms: u32,
channel: &mut ActiveChannel,
feed: &mut CastBarFeed,
) {
channel.update(remaining_ms, Instant::now());
feed.0.push(CastBarEdge::ChannelUpdate { remaining_ms });
}
/// `SMSG_UPDATE_AURA_DURATION` — one of our own auras' remaining time (decisions 0255/0257), keyed
/// by raw slot and stamped with the receive time. The `ui_aura` feed joins it to the aura in that
/// slot by arrival order; it arrives *before* the descriptor delta that names the slot.
pub(super) fn aura_duration(
slot: u8,
remaining_ms: u32,
durations: &mut AuraDurations,
now_secs: f64,
) {
durations.set(slot, remaining_ms, now_secs);
}
#[cfg(test)]
mod tests {
use super::*;
+78
View File
@@ -0,0 +1,78 @@
//! World-feed arm bodies for [`super::apply_net_updates`]'s dispatch match — what the server
//! pushes about the *world* rather than about an entity's own state: the three ambient audio
//! triggers, the weather change, and the world-state table the UI's `$<n>w` tokens read. Each
//! `pub(super)` fn here is exactly one arm's body; the match at the call site stays the dispatcher,
//! one call per arm.
use bevy::prelude::*;
use crate::world_state::WorldStates;
use super::super::{GuidIndex, ServerSoundKind, ServerSoundMessage, WeatherMessage};
/// `SMSG_PLAY_SOUND` — a 2D (non-positional) one-shot.
pub(super) fn play_sound(sound_id: u32, out: &mut MessageWriter<ServerSoundMessage>) {
out.write(ServerSoundMessage {
kind: ServerSoundKind::Sound2d,
sound_id,
source: None,
});
}
/// `SMSG_PLAY_MUSIC` — the zone/event music track.
pub(super) fn play_music(music_id: u32, out: &mut MessageWriter<ServerSoundMessage>) {
out.write(ServerSoundMessage {
kind: ServerSoundKind::Music,
sound_id: music_id,
source: None,
});
}
/// `SMSG_PLAY_OBJECT_SOUND` — a one-shot anchored to a streamed object (silent while its guid
/// isn't streamed in: the mixer has nowhere to place it).
pub(super) fn play_object_sound(
sound_id: u32,
guid: u64,
index: &GuidIndex,
out: &mut MessageWriter<ServerSoundMessage>,
) {
out.write(ServerSoundMessage {
kind: ServerSoundKind::ObjectSound,
sound_id,
source: index.0.get(&guid).copied(),
});
}
/// `SMSG_WEATHER` — the zone's weather change (`instant` on zone entry, a ramp otherwise).
pub(super) fn weather(
weather_type: u32,
grade: f32,
sound_id: u32,
instant: bool,
out: &mut MessageWriter<WeatherMessage>,
) {
out.write(WeatherMessage {
weather_type,
grade,
sound_id,
instant,
});
}
/// `SMSG_INIT_WORLD_STATES` / `SMSG_UPDATE_WORLD_STATE` — both wires funnel into the one setter,
/// as the reference's own handler does. An init does NOT clear first: what its `(map, zone)` dwords
/// drive is unrecorded, so we log the scope rather than act on it (rationale on
/// [`crate::world_state`]).
pub(super) fn world_states(
scope: Option<(u32, u32)>,
states: Vec<(u32, u32)>,
world_states: &mut WorldStates,
) {
if let Some((map, zone)) = scope {
debug!(
"world states: map {map} zone {zone}, {} entries",
states.len()
);
}
world_states.write(&states);
}
+16 -4
View File
@@ -884,9 +884,13 @@ fn control(
let fwd_axis = move_fwd * cp + Vec3::Y * sp;
let v = fwd_axis * swim_fwd + move_right * swim_side;
let dir3 = v.normalize_or_zero();
// Fall back to the current feet Y if the surface query briefly misses (a chunk seam):
// the rest cap is then at our own depth, so the avatar just holds for that frame.
let surface = surface_y.unwrap_or(player.pos.y);
// The rest line this stroke may not rise above. **`None` while levitating** — GM flight
// is the swim regime with no liquid under it (decision 0726), so there is no waterline
// to cap against and the climb must be free; capping there would let you dive but never
// gain height. Otherwise fall back to the current feet Y if the surface query briefly
// misses (a chunk seam): the cap is then at our own depth, so the avatar just holds for
// that frame — a transient miss in real water is not the same thing as flight.
let rest_line = (!player.levitating).then(|| surface_y.unwrap_or(player.pos.y));
// Directional swim speed — **VERIFIED** (`0x7c4c90`'s swim arm, the §5's TU-H):
// forward or strafe-only → swim; the backward bit `0x2` → `min(swimBack, swim)` —
// byte-identical in template to the run arm's `min(runBack, run)`. Vanilla defaults
@@ -912,7 +916,7 @@ fn control(
&move_and_slide,
capsule,
dir3 * dir_speed,
surface,
rest_line,
|feet| swim::surface_over_feet(water, feet, claim),
);
// The surface redirect (decisions 0499+0505 — a NAMED DIVERGENCE, see
@@ -1021,6 +1025,14 @@ fn control(
// never disagree. Direction bits mirror the client's MOVEMENTFLAGS; FALLING marks the airborne
// arc (animation-only — it is masked off before going on the wire, see the send block).
let mut move_flags_now = 0u32;
// The granted mover mode rides every packet, in or out of the water — the reference's
// builder reads the one `[cmov+0x40]` the server's merge wrote it into, so it echoes back
// whatever the server granted (decision 0726). Ours has to put it back explicitly, because
// this word is rebuilt from state each frame; dropping it would make the server forget we
// are flying and the next server-authored move would clear the mode under us.
if player.levitating {
move_flags_now |= move_flags::LEVITATING;
}
// `landed`/`started_falling` gate the wire's jump/fall lifecycle; the swim branch never sets
// them (leaving the water resumes the ground mover from rest, no airborne report).
let landed;
@@ -52,6 +52,13 @@ const OUTBOUND_FLAG_MASK: u32 = move_flags::FORWARD
| move_flags::FALLING
| move_flags::FALLING_FAR
| move_flags::SWIMMING
// …**and `LEVITATING`** (0x400, GM flight — decision 0726). Echoing it is not cosmetic: the
// reference's packet builder reads the same `[cmov+0x40]` the server's flags merged into, so a
// real client sends it straight back, and vmangos refreshes its `m_movementInfo` from whatever
// we report. Drop it from our stream and the server's copy loses the bit — then the next
// server-authored move (a `.go forward`, a forced speed change) echoes a LEVITATING-less word
// back at us, our merge clears the mode, and we fall out of the sky mid-flight.
| move_flags::LEVITATING
| move_flags::ON_TRANSPORT;
/// This frame's **arc edges** — the airborne lifecycle as the send law reads it. One struct rather
+12
View File
@@ -184,6 +184,18 @@ pub(crate) struct Player {
/// death and release"); turning stays live, like a real rooted client. Set/cleared by the
/// root-ack handler in [`super::wire_in`].
pub(super) rooted: bool,
/// **The server put us in free flight** (`MOVEFLAG_LEVITATING`, GM `.cheat fly` — decision
/// 0726). Set/cleared only from a server-authored move ([`super::wire_in`]), like
/// [`Player::rooted`] — a granted mover *mode*, which in this architecture is typed state rather
/// than a raw bit ([`Player::move_flags`] is last-streamed wire bookkeeping, rebuilt from state
/// every frame).
///
/// It does exactly one thing, and it does it by suppression: while set, the water/depth swim
/// decision does not run at all ([`super::swim::update_swimming`] — the reference's
/// `0x6030d2 test ah,4` bail). So a [`Player::swimming`] the server switched on stays on with no
/// water under it — which *is* GM flight — and, symmetrically, real water can no longer put us
/// into swim while it is set.
pub(super) levitating: bool,
/// **Autorun** latched on — the reference's input bit `0x1000` in the local mover's input word
/// `[MOVE+4]`, flipped by `ToggleAutoRun 0x513de0` (a read+invert: the command family's only
/// *toggle*, where every directional command is a set/clear pair). VERIFIED, wow-re
+56 -6
View File
@@ -154,6 +154,15 @@ pub(super) fn surface_over_feet(
/// resident, so the water IS the arrived support; without this, a login into swim-depth water
/// held `settling` — and the loading screen, which waits on it — forever.
pub(super) fn update_swimming(player: &mut Player, surface_y: Option<f32>, now: f32) -> bool {
// **LEVITATING bails the whole decision** — the reference's very first instruction here
// (`0x6030d2 test ah,4` → `0x6031fa`; VERIFIED, wow-re `swim-transition.md`): neither the ENTER
// arm nor the STOP arm runs, so the latch is left exactly as it stands. Not an optimisation —
// it IS the mechanism of GM flight (decision 0726). The server sets SWIMMING and LEVITATING in
// one packet, and the second is what stops the dry ground under us clearing the first on the
// very next frame.
if player.levitating {
return player.swimming;
}
let Some(surface) = surface_y else {
player.swimming = false; // not in liquid
return false;
@@ -278,7 +287,7 @@ pub(super) fn swim_step(
ms: &MoveAndSlide<'_, '_>,
capsule: &Collider,
input_vel: Vec3,
surface_y: f32,
surface_y: Option<f32>,
surface_at: impl Fn(Vec3) -> Option<f32>,
) -> SwimOutcome {
let dt = time.delta_secs();
@@ -296,11 +305,17 @@ pub(super) fn swim_step(
// `depth` drops below the leave threshold, so we walk out. dt-based so it can't overshoot the
// rest line and dip back under the exit depth (a flicker). No other vertical force exists here —
// no gravity, no surface-seek (the verified floating resolver).
let rest_feet_y = surface_y - rest_cap(player.collision_height.0);
let cap = if dt > 0.0 {
((rest_feet_y - player.pos.y) / dt).max(0.0)
} else {
f32::INFINITY
// **No waterline, no rest line.** `None` is GM flight (decision 0726): the swim regime with no
// liquid under it at all, where the one vertical constraint below has nothing to constrain
// against and an ascent must be free. It is deliberately NOT the same as a *momentary* sample
// miss in real water — the caller keeps that case as `Some(own feet Y)`, which caps the rise at
// zero and holds for the frame.
let cap = match surface_y {
Some(surface) if dt > 0.0 => {
let rest_feet_y = surface - rest_cap(player.collision_height.0);
((rest_feet_y - player.pos.y) / dt).max(0.0)
}
_ => f32::INFINITY,
};
let (vel, surface_pitch) = cap_redirect(input_vel, cap);
@@ -443,6 +458,41 @@ mod tests {
);
}
/// **GM flight is the suppression, not a lift** (decision 0726). `LEVITATING` bails the whole
/// water decision (`0x6030d2 test ah,4`), so the two things that would otherwise happen the very
/// next frame both stop happening: dry land cannot clear a server-granted swim (which is what
/// keeps you airborne), and deep water cannot grant one (the same bail, the other arm).
#[test]
fn levitating_bails_the_water_decision_in_both_directions() {
// The server just sent `.cheat fly on`: SWIMMING + LEVITATING, standing on dry ground.
let mut flying = player_at(0.0);
flying.swimming = true;
flying.levitating = true;
assert!(
update_swimming(&mut flying, None, 0.0),
"no liquid at all must NOT stop a levitating swimmer — this is the whole of GM flight"
);
assert!(
update_swimming(&mut flying, Some(-50.0), 0.0),
"nor may a surface far below the feet, which is what flying over a lake looks like"
);
// The other arm, and it is the same instruction: water cannot latch swim while levitating.
let mut dry = player_at(0.0);
dry.levitating = true;
assert!(
!update_swimming(&mut dry, Some(swim_enter_depth(HUMAN_MALE) + 1.0), 0.0),
"the ENTER arm is skipped too — the bail is before the branch, not inside it"
);
// And clearing the mode (`.cheat fly off` sends flags 0) hands the water its job back.
flying.levitating = false;
assert!(
!update_swimming(&mut flying, None, 0.0),
"with the mode gone, no liquid stops swimming again"
);
}
/// A login/teleport into swim-depth water must end the settle hold: the settle release gate
/// runs only in the walk mover, which a swimmer never reaches — before this rule, `settling`
/// stayed latched forever and the loading screen (which clears only on `!settling`) hung at a
+37
View File
@@ -322,6 +322,24 @@ fn apply_self_move(
player.move_flags = merge_server_flags(player.move_flags, m.flags);
let now_falling = player.move_flags & move_flags::FALLING != 0;
// **Lift the granted mover MODES out of the merged word into typed state** (decision 0726).
// `move_flags` is our last-streamed wire bookkeeping and is rebuilt from state every frame, so a
// bit parked there alone would be gone before the mover ever read it; the modes live as fields,
// the way `rooted` does. The reference needs no such step — it has one `[cmov+0x40]` that is both
// the state and the wire word — and it has the matching apply anyway: the same inbound merge also
// runs `0x61a1af → 0x61a230 → SetSwim` (wow-re `swim-transition.md`, "the local unit's server
// echo"). This pair is GM flight: `.cheat fly` sends SWIMMING + LEVITATING together.
player.swimming = player.move_flags & move_flags::SWIMMING != 0;
player.levitating = player.move_flags & move_flags::LEVITATING != 0;
if player.swimming {
// A latched swim ends the settle hold — `update_swimming`'s own rule, and it has to be
// repeated here because that function is exactly what LEVITATING switches off. Without it,
// `.cheat fly` during a post-teleport settle strands `settling` forever: the swim mover
// never reaches the walk mover's release gate, and the loading screen waits on `!settling`
// (the shape of the Booty Bay underwater-login hang).
player.settling = false;
}
player.pos = wow_to_bevy(m.position);
// Facing turns **rigidly** — aim, rendered body and camera all take the same delta (the
// transport carry's idiom). The reference writes only the mover's own facing cells; hard-setting
@@ -417,11 +435,30 @@ mod self_move_tests {
assert_eq!(merge_server_flags(0, f::ON_TRANSPORT), 0);
}
/// **`.cheat fly` survives the merge intact** (decision 0726). vmangos's `Player::SetFly` sends
/// `LEVITATING | SWIMMING | MOVED | FLYING`; the two we model must both be inside the mask, or
/// GM flight arrives half-applied — SWIMMING without LEVITATING is a swimmer on dry land that
/// the water decision clears on the next frame, and LEVITATING without SWIMMING is a walker
/// whose swim latch has simply been frozen.
#[test]
fn the_fly_toggle_arrives_whole() {
const SET_FLY: u32 = f::LEVITATING | f::SWIMMING | 0x0080_0000 | 0x0100_0000;
let merged = merge_server_flags(f::FORWARD, SET_FLY);
assert_eq!(merged & f::LEVITATING, f::LEVITATING);
assert_eq!(merged & f::SWIMMING, f::SWIMMING);
// …and `.cheat fly off` (flags 0) takes both away again, which is what lands us.
assert_eq!(
merge_server_flags(merged, 0) & (f::LEVITATING | f::SWIMMING),
0
);
}
/// Every flag benilla models except `ON_TRANSPORT` is inside the mask — a guard against the
/// mask and our constants drifting apart as new bits get modelled.
#[test]
fn every_modelled_flag_but_the_transport_bit_is_server_authored() {
for (name, bit) in [
("LEVITATING", f::LEVITATING),
("FORWARD", f::FORWARD),
("BACKWARD", f::BACKWARD),
("STRAFE_LEFT", f::STRAFE_LEFT),
+35 -4
View File
@@ -19,6 +19,16 @@
//! alive without poisoning any of that (decision 0677), and this banner reports which it is.
//! 3. **Movement is server-blocked** — rooted, stunned, confused, fleeing, or mid-taxi-flight. The
//! controller ignores input and the honest report is "the mover is broken".
//! 4. **The run has no server at all** ([`offline_notice`], decision 0728). Capture mode passes
//! `NetPlugin { connect: false }`, so no IO thread is spawned and the drain receives nothing —
//! the whole packet path (`net::apply`, the movement wire, every `MSG_MOVE_*`) simply does not
//! execute. This one is the mirror image of the other three: they make a healthy client *look*
//! broken, while this makes an untested change *look proven*. A capture run over a change to the
//! packet path exits clean no matter what that change does, and "clean run" then lands in a
//! commit message as evidence it never was (twice in one day — decisions 0725 and 0726, both
//! pure wire work, both reported with a capture run behind them). The banner above cannot catch
//! it: that one waits on [`EnteredWorldMessage`], which needs a server. So this notice fires at
//! **startup**, before anything can be concluded from the run.
//!
//! The banner is **not env-gated**: a warning nobody knows to switch on is not a warning. It costs
//! one line per world entry when everything is fine, and it re-fires on every re-entry (a relog, a
@@ -83,13 +93,34 @@ pub(crate) struct PreflightPlugin;
impl Plugin for PreflightPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<Preflight>().add_systems(
Update,
report_session.after(crate::schedule::WorldStage::Net),
);
app.init_resource::<Preflight>()
.add_systems(Startup, offline_notice)
.add_systems(
Update,
report_session.after(crate::schedule::WorldStage::Net),
);
}
}
/// Say out loud, once at startup, that this run has **no server** — so nobody reads its clean exit
/// as evidence about code that never ran (module header §4, decision 0728).
///
/// Deliberately a `warn!` rather than an `info!`. A netless run is completely normal and completely
/// fine — it is what every visual capture wants — so this is not a complaint about the run. It is
/// aimed at the *reader*, who is usually a session skimming for the word "error" before pasting
/// "clean run" into a commit message, and `warn` is the lowest level that survives that skim.
fn offline_notice(net: Option<Res<crate::net::NetOffline>>) {
if net.is_none() {
return;
}
warn!(
"preflight: NET OFF — no IO thread this run, so the drain gets no packets and NOTHING on \
the wire path executes (net::apply, the movement stream, every MSG_MOVE_*). Fine for a \
visual capture; NOT evidence for a change to any of it. method.md's gate is a clean run \
of the AFFECTED path — for wire work that means a live server run."
);
}
/// The banner's once-per-entry latch: armed by [`EnteredWorldMessage`], disarmed when the report
/// goes out (or when the wait expires).
#[derive(Resource, Default)]
+57 -5
View File
@@ -400,12 +400,22 @@ fn ray_posed_mesh(
else {
return None;
};
let VertexAttributeValues::Uint16x4(joints) = mesh.attribute(Mesh::ATTRIBUTE_JOINT_INDEX)?
else {
return None;
};
let VertexAttributeValues::Float32x4(weights) = mesh.attribute(Mesh::ATTRIBUTE_JOINT_WEIGHT)?
// The joint data rides the WOW attributes (decision 0720 retired Bevy's skin lane, and
// Bevy's `ATTRIBUTE_JOINT_INDEX` left our meshes with it). Every mesh that reaches this
// function is a skinned twin by construction (`RigPart` parts only), so a missing
// attribute is a broken authoring contract — not an unloaded asset — and it silently
// un-picks the whole unit: say so, once, loudly.
let (
Some(VertexAttributeValues::Uint16x4(joints)),
Some(VertexAttributeValues::Float32x4(weights)),
) = (
mesh.attribute(benilla_assets::ATTRIBUTE_WOW_JOINT_INDEX),
mesh.attribute(benilla_assets::ATTRIBUTE_WOW_JOINT_WEIGHT),
)
else {
warn_once!(
"a skinned part's mesh lacks the WOW joint attributes — the posed pick cannot hit it"
);
return None;
};
let normals = match mesh.attribute(Mesh::ATTRIBUTE_NORMAL) {
@@ -530,6 +540,48 @@ mod tests {
assert!(ray_triangle(Vec3::new(0.0, -5.0, 0.0), Vec3::Y, &TRI).is_some());
}
/// The picker ↔ mesh-builder attribute contract: `ray_posed_mesh` must read the WOW joint
/// attributes the skinned twin is authored with ([`benilla_assets::ATTRIBUTE_WOW_JOINT_INDEX`],
/// decision 0720) — reading Bevy's standard skin attributes made every unit silently
/// unpickable, because a `None` here also blocks the AABB fallback (the `faithful` set).
#[test]
fn posed_pick_reads_the_wow_skin_attributes() {
use bevy::asset::RenderAssetUsages;
use bevy::mesh::PrimitiveTopology;
let mut mesh = Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::all());
mesh.insert_attribute(
Mesh::ATTRIBUTE_POSITION,
TRI.iter().map(|v| v.to_array()).collect::<Vec<_>>(),
);
mesh.insert_attribute(
benilla_assets::ATTRIBUTE_WOW_JOINT_INDEX,
VertexAttributeValues::Uint16x4(vec![[1, 0, 0, 0]; 3]),
);
mesh.insert_attribute(
benilla_assets::ATTRIBUTE_WOW_JOINT_WEIGHT,
VertexAttributeValues::Float32x4(vec![[1.0, 0.0, 0.0, 0.0]; 3]),
);
mesh.insert_indices(Indices::U16(vec![0, 1, 2]));
let mut assets = Assets::<Mesh>::default();
let handle = assets.add(mesh);
// Bone 1 lifts the triangle +2 on Y; bone 0 is a zero row (a hit through it would land
// at the origin) — so a 3.0-distance hit proves the indices routed through the WOW
// attribute into the right palette row.
let palette = [Mat4::ZERO, Mat4::from_translation(Vec3::Y * 2.0)];
let t = ray_posed_mesh(
&assets,
handle.id(),
&palette,
Vec3::new(0.0, 5.0, 0.0),
Vec3::NEG_Y,
false,
)
.expect("the posed pick must hit the WOW-attributed mesh");
assert!((t - 3.0).abs() < 1e-5);
}
#[test]
fn ray_triangle_misses_outside_and_behind() {
// Outside the triangle's extent.