From 06e94575828651a5ecbe6c085cda78864eaa6811 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 28 Jul 2026 21:55:19 +0700 Subject: [PATCH] GM flight works, and creatures are clickable on the body again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .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 --- crates/benilla/src/creature_anim/select.rs | 13 + crates/benilla/src/entities/display.rs | 6 +- crates/benilla/src/net.rs | 13 + crates/benilla/src/net/apply.rs | 519 +++++---------------- crates/benilla/src/net/apply/anim.rs | 89 ++++ crates/benilla/src/net/apply/chat.rs | 69 ++- crates/benilla/src/net/apply/combat_log.rs | 20 +- crates/benilla/src/net/apply/death.rs | 125 +++++ crates/benilla/src/net/apply/group.rs | 85 +++- crates/benilla/src/net/apply/mount.rs | 46 ++ crates/benilla/src/net/apply/names.rs | 46 ++ crates/benilla/src/net/apply/npc.rs | 31 +- crates/benilla/src/net/apply/objects.rs | 15 + crates/benilla/src/net/apply/quests.rs | 18 +- crates/benilla/src/net/apply/session.rs | 61 ++- crates/benilla/src/net/apply/spells.rs | 40 +- crates/benilla/src/net/apply/world.rs | 78 ++++ crates/benilla/src/player.rs | 20 +- crates/benilla/src/player/movement_net.rs | 7 + crates/benilla/src/player/state.rs | 12 + crates/benilla/src/player/swim.rs | 62 ++- crates/benilla/src/player/wire_in.rs | 37 ++ crates/benilla/src/preflight.rs | 39 +- crates/benilla/src/target/hover.rs | 62 ++- 24 files changed, 1073 insertions(+), 440 deletions(-) create mode 100644 crates/benilla/src/net/apply/anim.rs create mode 100644 crates/benilla/src/net/apply/death.rs create mode 100644 crates/benilla/src/net/apply/mount.rs create mode 100644 crates/benilla/src/net/apply/names.rs create mode 100644 crates/benilla/src/net/apply/world.rs diff --git a/crates/benilla/src/creature_anim/select.rs b/crates/benilla/src/creature_anim/select.rs index d7f41995..3cdd4170 100644 --- a/crates/benilla/src/creature_anim/select.rs +++ b/crates/benilla/src/creature_anim/select.rs @@ -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 diff --git a/crates/benilla/src/entities/display.rs b/crates/benilla/src/entities/display.rs index 412f7670..8f436e85 100644 --- a/crates/benilla/src/entities/display.rs +++ b/crates/benilla/src/entities/display.rs @@ -33,8 +33,8 @@ pub(super) struct EntityPart { pub(super) mesh: Handle, /// 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>, pub(super) material: Handle, /// 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, /// 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>, /// 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. diff --git a/crates/benilla/src/net.rs b/crates/benilla/src/net.rs index ecdad4a2..bc6f16e8 100644 --- a/crates/benilla/src/net.rs +++ b/crates/benilla/src/net.rs @@ -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)) diff --git a/crates/benilla/src/net/apply.rs b/crates/benilla/src/net/apply.rs index 99c99b72..5493cb09 100644 --- a/crates/benilla/src/net/apply.rs +++ b/crates/benilla/src/net/apply.rs @@ -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>, ), ) { - 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 = 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 diff --git a/crates/benilla/src/net/apply/anim.rs b/crates/benilla/src/net/apply/anim.rs new file mode 100644 index 00000000..8ba34ffd --- /dev/null +++ b/crates/benilla/src/net/apply/anim.rs @@ -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, +) { + 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, +) { + 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, +) { + 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, +) { + 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(), + }); + } + } +} diff --git a/crates/benilla/src/net/apply/chat.rs b/crates/benilla/src/net/apply/chat.rs index 28a4a4ab..0e879c80 100644 --- a/crates/benilla/src/net/apply/chat.rs +++ b/crates/benilla/src/net/apply/chat.rs @@ -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, +) { + 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 diff --git a/crates/benilla/src/net/apply/combat_log.rs b/crates/benilla/src/net/apply/combat_log.rs index 7cc83ba7..bd737f9e 100644 --- a/crates/benilla/src/net/apply/combat_log.rs +++ b/crates/benilla/src/net/apply/combat_log.rs @@ -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, + 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); } diff --git a/crates/benilla/src/net/apply/death.rs b/crates/benilla/src/net/apply/death.rs new file mode 100644 index 00000000..d39c39d3 --- /dev/null +++ b/crates/benilla/src/net/apply/death.rs @@ -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, +) { + 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, +) { + if self_guid.0 == Some(guid) { + death_net.water_walk = on; + out.write(WaterWalkMessage { guid, counter, on }); + } +} diff --git a/crates/benilla/src/net/apply/group.rs b/crates/benilla/src/net/apply/group.rs index e7ff522a..2d863ccf 100644 --- a/crates/benilla/src/net/apply/group.rs +++ b/crates/benilla/src/net/apply/group.rs @@ -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) { +fn push_group_lines(chat_log: &mut ChatLog, lines: Vec) { 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, + leader: u64, + loot: Option, +) { + 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), + ); +} diff --git a/crates/benilla/src/net/apply/mount.rs b/crates/benilla/src/net/apply/mount.rs new file mode 100644 index 00000000..77936eae --- /dev/null +++ b/crates/benilla/src/net/apply/mount.rs @@ -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, +) { + if self_guid.0 != Some(guid) { + if let Some(&e) = index.0.get(&guid) { + out.write(MountFlourish { unit: e }); + } + } +} diff --git a/crates/benilla/src/net/apply/names.rs b/crates/benilla/src/net/apply/names.rs new file mode 100644 index 00000000..307c48b8 --- /dev/null +++ b/crates/benilla/src/net/apply/names.rs @@ -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, + subname: Option, + creature_type: Option, + 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, + }), + ); +} diff --git a/crates/benilla/src/net/apply/npc.rs b/crates/benilla/src/net/apply/npc.rs index ee9fa252..6b2ade3c 100644 --- a/crates/benilla/src/net/apply/npc.rs +++ b/crates/benilla/src/net/apply/npc.rs @@ -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, 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, 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); diff --git a/crates/benilla/src/net/apply/objects.rs b/crates/benilla/src/net/apply/objects.rs index 62951850..609e50e9 100644 --- a/crates/benilla/src/net/apply/objects.rs +++ b/crates/benilla/src/net/apply/objects.rs @@ -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 diff --git a/crates/benilla/src/net/apply/quests.rs b/crates/benilla/src/net/apply/quests.rs index 8d4e15a1..f317471a 100644 --- a/crates/benilla/src/net/apply/quests.rs +++ b/crates/benilla/src/net/apply/quests.rs @@ -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, 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 diff --git a/crates/benilla/src/net/apply/session.rs b/crates/benilla/src/net/apply/session.rs index 9fbdb47f..a27b1f96 100644 --- a/crates/benilla/src/net/apply/session.rs +++ b/crates/benilla/src/net/apply/session.rs @@ -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, +) { + 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, + reason: String, + terminal: bool, + out: &mut MessageWriter, +) { + 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, +) { + 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, ) { + // 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 diff --git a/crates/benilla/src/net/apply/spells.rs b/crates/benilla/src/net/apply/spells.rs index 75040f99..c76b092d 100644 --- a/crates/benilla/src/net/apply/spells.rs +++ b/crates/benilla/src/net/apply/spells.rs @@ -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::*; diff --git a/crates/benilla/src/net/apply/world.rs b/crates/benilla/src/net/apply/world.rs new file mode 100644 index 00000000..b8b71315 --- /dev/null +++ b/crates/benilla/src/net/apply/world.rs @@ -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 `$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) { + 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) { + 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, +) { + 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, +) { + 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); +} diff --git a/crates/benilla/src/player.rs b/crates/benilla/src/player.rs index 929d441d..db34d2af 100644 --- a/crates/benilla/src/player.rs +++ b/crates/benilla/src/player.rs @@ -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; diff --git a/crates/benilla/src/player/movement_net.rs b/crates/benilla/src/player/movement_net.rs index db426a86..956559f6 100644 --- a/crates/benilla/src/player/movement_net.rs +++ b/crates/benilla/src/player/movement_net.rs @@ -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 diff --git a/crates/benilla/src/player/state.rs b/crates/benilla/src/player/state.rs index fcb34e52..e8b18d12 100644 --- a/crates/benilla/src/player/state.rs +++ b/crates/benilla/src/player/state.rs @@ -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 diff --git a/crates/benilla/src/player/swim.rs b/crates/benilla/src/player/swim.rs index ab51df8f..471b5d43 100644 --- a/crates/benilla/src/player/swim.rs +++ b/crates/benilla/src/player/swim.rs @@ -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, 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, surface_at: impl Fn(Vec3) -> Option, ) -> 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 diff --git a/crates/benilla/src/player/wire_in.rs b/crates/benilla/src/player/wire_in.rs index 4fad6ad9..cf4ff03f 100644 --- a/crates/benilla/src/player/wire_in.rs +++ b/crates/benilla/src/player/wire_in.rs @@ -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), diff --git a/crates/benilla/src/preflight.rs b/crates/benilla/src/preflight.rs index e54d6b1e..9d852d9c 100644 --- a/crates/benilla/src/preflight.rs +++ b/crates/benilla/src/preflight.rs @@ -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::().add_systems( - Update, - report_session.after(crate::schedule::WorldStage::Net), - ); + app.init_resource::() + .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>) { + 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)] diff --git a/crates/benilla/src/target/hover.rs b/crates/benilla/src/target/hover.rs index b04961a2..534dcd6f 100644 --- a/crates/benilla/src/target/hover.rs +++ b/crates/benilla/src/target/hover.rs @@ -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::>(), + ); + 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::::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.