From bc404ca24bd9b6b6ac76a628c17ee634527cf12f Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 17 Sep 2026 16:39:46 +0700 Subject: [PATCH] Must be in names its form, and the whole client builds headless - a cast refused for the wrong shapeshift form names it, "Must be in Cat Form", a two-form spell joined the reference's way, and the combat log reads its own buffer rather than the red line (2280, 2285) - three refusals that never had a combat-log line, totems, reagents and equipped item class, get one; a pet's form refusal names one form (2285) - the game's plugins are one group and the whole client builds headless in 0.12 s, so the schedule is a test subject: three one-shot consumers caught firing into the login drain are gated (2279) - the net drain's parameters are named bundles, not nested tuples (2279) Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/src/game_plugins.rs | 1232 +++++++++++++++++ crates/benilla-app/src/lib.rs | 407 +----- crates/benilla-app/src/minimap/mod.rs | 8 +- crates/benilla-app/src/net/apply.rs | 922 +++++------- crates/benilla-app/src/net/apply/params.rs | 372 +++++ crates/benilla-app/src/net/apply/spells.rs | 1 + crates/benilla-app/src/swing_refusal.rs | 4 +- crates/benilla-app/src/test_support.rs | 131 ++ crates/benilla-app/src/ui_action/cast_fail.rs | 529 ++++++- crates/benilla-app/src/ui_action/errors.rs | 23 + crates/benilla-app/src/ui_action/feed.rs | 224 +-- .../benilla-app/src/ui_action/feed_tests.rs | 1 + crates/benilla-app/src/ui_auction/mod.rs | 11 +- crates/benilla-app/src/ui_chat/combat.rs | 7 +- crates/benilla-app/src/ui_dialog_verbs.rs | 11 +- crates/benilla-app/src/ui_script/session.rs | 58 +- crates/benilla-app/tests/ui_script_wall.rs | 6 +- .../src/lighting/global_light.rs | 14 +- 18 files changed, 2743 insertions(+), 1218 deletions(-) create mode 100644 crates/benilla-app/src/game_plugins.rs create mode 100644 crates/benilla-app/src/net/apply/params.rs create mode 100644 crates/benilla-app/src/test_support.rs diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs new file mode 100644 index 00000000..876bf10c --- /dev/null +++ b/crates/benilla-app/src/game_plugins.rs @@ -0,0 +1,1232 @@ +//! **[`GamePlugins`] — the game, as one name** (decision 2279, 2265 §C2). +//! +//! `benilla_world::world_plugins::WorldPlugins` is the engine as one name (1164); this is the +//! client on top of it: every plugin the game adds, in the order `run()` always added them. +//! Until 2279 that chain lived inline in `run()` — 616 lines, the crate's only public function, +//! ~150 `add_plugins` — so nothing could build the game's schedule except the binary, and no +//! test could ask the one question Bevy can answer about a schedule: which conflicting pairs +//! have no declared order (the instrument that would have caught 2220 and B354 at the line). +//! `schedule_tests` below builds it headless and asks. +//! +//! **The order is load-bearing and is preserved from `run()` as-is.** Two kinds of edge live in +//! it: documented dependencies (a plugin that reads a resource an earlier one inserts at build +//! time — `VideoPlugin` and `RealmlistPlugin` before `CvarPlugin`, `WorldBackdropPlugin` after +//! `PlayerUiPlugin`, the `UiActionPlugin` family), and registration order, which is the +//! executor's tie-break between systems with no declared order. The second kind is exactly +//! what the ambiguity count measures; until it is zero, reordering members is a behaviour +//! change. +//! +//! **What is NOT here:** the engine (`WorldPlugins`), the three process plugins (`ThreadQos`, +//! `BgWin`, `MacQuit`), the instruments that sit above the stack (`dev::DevToolsPlugin`, +//! `perf::FpsJournalPlugin`, `dev::DevProbesPlugin`) — all still `run()`'s, in their places +//! around this group. `pipe_warm` is inside, at its old position: a client instrument, but one +//! that warms the *game's* pipeline set and has always been added among them. + +use bevy::app::PluginGroupBuilder; +use bevy::prelude::*; + +use crate::blob_shadow::BlobShadowPlugin; +use crate::bowstring::BowstringPlugin; +use crate::camera_shake::CameraShakePlugin; +use crate::chr_classes::ChrClassesPlugin; +use crate::cinematic::CinematicPlugin; +use crate::creature_anim::CreatureAnimPlugin; +use crate::cursor::CursorPlugin; +use crate::entities::EntitiesPlugin; +use crate::fishing_line::FishingLinePlugin; +use crate::footprints::FootprintsPlugin; +use crate::loading_screen::LoadingScreenPlugin; +use crate::name_persist::NamePersistPlugin; +use crate::net::NetPlugin; +use crate::player::PlayerPlugin; +use crate::portrait::PortraitPlugin; +use crate::quest_markers::QuestMarkersPlugin; +use crate::sound::SoundPlugin; +use crate::target::TargetPlugin; +use crate::textinput::TextInputPlugin; +use crate::transport::TransportPlugin; +use crate::tutorial::TutorialPlugin; +use crate::ui_action::UiActionPlugin; +use crate::ui_auction::UiAuctionPlugin; +use crate::ui_aura::UiAuraPlugin; +use crate::ui_bank::UiBankPlugin; +use crate::ui_battlefield::BattlefieldPlugin; +use crate::ui_battlefield_positions::BattlefieldPositionsPlugin; +use crate::ui_battlefield_score::BattlefieldScorePlugin; +use crate::ui_binder::UiBinderPlugin; +use crate::ui_cast::UiCastPlugin; +use crate::ui_char::UiCharPlugin; +use crate::ui_chat::UiChatPlugin; +use crate::ui_craft::UiCraftPlugin; +use crate::ui_dialog_verbs::UiDialogVerbsPlugin; +use crate::ui_duel::UiDuelPlugin; +use crate::ui_follow::UiFollowPlugin; +use crate::ui_gm_ticket::UiGmTicketPlugin; +use crate::ui_gossip::UiGossipPlugin; +use crate::ui_guild::UiGuildPlugin; +use crate::ui_instance::UiInstancePlugin; +use crate::ui_item_text::UiItemTextPlugin; +use crate::ui_items::UiItemsPlugin; +use crate::ui_layout::UiLayoutPlugin; +use crate::ui_logout::UiLogoutPlugin; +use crate::ui_loot::UiLootPlugin; +use crate::ui_loot_roll::UiLootRollPlugin; +use crate::ui_mail::UiMailPlugin; +use crate::ui_merchant::UiMerchantPlugin; +use crate::ui_mirror::UiMirrorPlugin; +use crate::ui_net::UiNetPlugin; +use crate::ui_party::UiPartyPlugin; +use crate::ui_pass::PlayerUiPlugin; +use crate::ui_pet::UiPetPlugin; +use crate::ui_pet_book::UiPetBookPlugin; +use crate::ui_pet_doll::UiPetDollPlugin; +use crate::ui_pet_stats::UiPetStatsPlugin; +use crate::ui_petition::UiPetitionPlugin; +use crate::ui_quest::UiQuestPlugin; +use crate::ui_quest_log::UiQuestLogPlugin; +use crate::ui_quest_share::QuestSharePlugin; +use crate::ui_saved::UiSavedPlugin; +use crate::ui_script::UiScriptPlugin; +use crate::ui_shapeshift::UiShapeshiftPlugin; +use crate::ui_social::UiSocialPlugin; +use crate::ui_spellbook::UiSpellbookPlugin; +use crate::ui_stable::UiStablePlugin; +use crate::ui_summon::UiSummonPlugin; +use crate::ui_tabard::TabardUiPlugin; +use crate::ui_talent::UiTalentPlugin; +use crate::ui_talent_wipe::UiTalentWipePlugin; +use crate::ui_taxi::UiTaxiPlugin; +use crate::ui_text::UiTextPlugin; +use crate::ui_tooltip::UiTooltipPlugin; +use crate::ui_trade::UiTradePlugin; +use crate::ui_tradeskill::UiTradeSkillPlugin; +use crate::ui_trainer::UiTrainerPlugin; +use crate::ui_unit::UiUnitPlugin; +use crate::world_backdrop::WorldBackdropPlugin; + +/// The game, as one plugin group. The two fields are the two plugins `run()` parameterises. +pub(crate) struct GamePlugins { + /// [`NetPlugin::connect`]: `false` in capture mode — the channel resources exist, no IO + /// thread runs, so captures are deterministic regardless of whether a server is up. + pub(crate) connect: bool, + /// [`crate::char_select::CharSelectPlugin::start`]: the screen this session opens on. + pub(crate) start: crate::char_select::ClientState, +} + +impl PluginGroup for GamePlugins { + fn build(self) -> PluginGroupBuilder { + PluginGroupBuilder::start::() + // The game's own WGSL, compiled into the binary (decision 1175) — before anything that could + // ask for one. The engine's seven register themselves inside `WorldPlugins`, which + // `run()` adds ahead of this group. + .add(crate::shaders::plugin) + .add(BowstringPlugin) + .add(crate::weapon_trail::WeaponTrailPlugin) + .add(FishingLinePlugin) + .add(QuestMarkersPlugin) + // Pipeline-compile counters + the live-compile tripwire (decision 0837: macOS builds every + // pipeline synchronously on the render thread, so a live compile is a felt stall). + .add(crate::pipe_warm::plugin) + // Streamed world entities: cube assets + display catalogs at startup, sync each frame. + .add(EntitiesPlugin) + // Creature animation: pick Stand/Walk/Run from each creature's movement state each frame (Milestone C). + .add(CreatureAnimPlugin) + // The unit blob shadow: the dark ground oval under every unit, sized from the playing + // animation's box (the byte-verified law — wow-re unit-blob-shadow RE), on the same + // surface-decal projector as the selection ring. + .add(BlobShadowPlugin) + // Footprint decals (B212, decision 1006): the prints a walking unit leaves on snow/sand, + // spawn-once projections on the same decal projector, fading off the effect stream. + .add(CameraShakePlugin) + .add(FootprintsPlugin) + // GameObject animation (decision 0242): net-streamed GObjects (doors/chests) play an M2 sequence + // on GAMEOBJECT_STATE change — the state-machine sibling of the doodad idle loop above. + .add(crate::go_anim::plugin) + .add(crate::doodad_events::plugin) + // Avatar + camera + input. + .add(PlayerPlugin) + // Cinematic fly-bys (`SMSG_TRIGGER_CINEMATIC`): the race intro a first login plays, and the + // GameObject cameras. Takes the world camera for the duration — hence after PlayerPlugin, + // whose `control` it overrides within the same stage (decision 0196's deferred arc). + .add(crate::screen_fade::ScreenFadePlugin) + .add(CinematicPlugin) + // The real client's hardware mouse cursor (native NSCursor on macOS). + .add(CursorPlugin) + // Net↔ECS bridge: spawns the world thread, exposes the snapshot + writer resources. In capture + // mode the IO thread is skipped (`connect: false`) so the scene is deterministic. + .add(NetPlugin { + connect: self.connect, + }) + // The death arc (decision 0308): the wire-fed death stores + the root/water-walk ack messages. + .add(crate::death::DeathPlugin) + // The shared glue vocabulary both pre-world screens stand on (decision 0465): the ADD-mode UI + // material, the client-data art set, the GlueStrings table. + .add(crate::glue::GluePlugin) + // The glue layer (decision 0193): the ClientState machine + the character-select screen + // that answers the parked IO thread's pick. A world capture boots straight InWorld (no net, + // no picker); a glue capture boots onto the screen it photographs. + .add(crate::char_select::CharSelectPlugin { start: self.start }) + // The login screen (decision 0539): the faithful AccountLogin glue + the credential policy + // that answers the IO thread's pre-logon park. + .add(crate::login::LoginPlugin) + // The realm list: the faithful RealmList glue + the policy that answers the IO thread's + // realm park. The client used to take `realms.first()` and offer no way to say otherwise. + .add(crate::realm_select::RealmSelectPlugin) + // The character-creation screen + its live preview booth (decision 0423). + .add(crate::char_create::CharCreatePlugin) + // Audio: the delegated mixer + WoW's owned selection layer (decision 0070). + .add(SoundPlugin) + // Targeting: left-click a unit to select it (→ CMSG_SET_SELECTION) + draw its ground ring. + .add(TargetPlugin) + .add(TransportPlugin) + // Faithful world-load splash + progress bar on startup + cross-map teleport (the load latency + // streaming can't hide); per-map art via the Map.dbc→LoadingScreens.dbc→BLP chain. + .add(LoadingScreenPlugin) + // The player-UI quad pass (decision 0068 §2): its own composited-above-the-world, + // below-the-egui-dev-overlays camera + sorted-quad renderer. `$WOW_UI_DEMO=1` seeds a proof scene. + .add(PlayerUiPlugin) + // The world's frame, rendered off-screen and drawn first in the UI camera's main pass — + // the seam that puts the UI-over-world blend back into gamma bytes (0161/0254's last piece, + // a pass rather than a quad since 2234). Registered AFTER the UI pass: it points that + // plugin's camera at the world camera. + .add(WorldBackdropPlugin) + // The HUD minimap (decision 0203 phase 1): fills the `` widget's extracted hole with + // the streamed tile window + mask + player arrow, and feeds the zone text. + .add(crate::minimap::MinimapPlugin) + // The pet-bar / spellbook autocast shine, drawn on the append lane from the conversion's + // parked sites — zero per-frame script-layout traffic (decision 1383, B282). + // The `` widgets' M2s, rendered as tiles of one atlas and composited at the + // callback rank (decision 2008). + .add(crate::ui_models::UiModelsPlugin) + // The shared AreaTable catalog + the ZONE_CHANGED event family / zone-text host globals + // behind GetZoneText & co. (the zone-entry splash arc, decision 0287). + .add(crate::area::AreaPlugin) + .add(crate::area_poi::AreaPoiPlugin) + .add(crate::world_state_ui::WorldStateUiPlugin) + // The `AreaTrigger.dbc` volumes + the per-frame containment check that reports walking into + // one (`CMSG_AREATRIGGER`) — the client's whole part in portals, instance entrances and + // explore objectives; the server owns what each trigger means. + .add(crate::area_trigger::AreaTriggerPlugin) + .add(crate::ui_world_map::WorldMapUiPlugin) + // The guard's directions marker (`SMSG_GOSSIP_POI`) — one landmark record, drawn by the + // minimap's landmark pass and the world map's POI child, cleared by arriving at it. + .add(crate::poi_marker::PoiMarkerPlugin) + // The glyph atlas (client TTFs -> baked bitmap) `ui_script`'s extraction draws `FontString` + // regions through. Loads at Startup, after the asset chain opens (decision 0068 §2). + .add(UiTextPlugin) + // The one "which NPC am I interacting with" answer, shared by the portrait booth's `"npc"` + // token and the interaction face-me (decision 1467) — hence its own plugin, ahead of both. + .add(crate::ui_session::UiSessionPlugin) + // Unit-frame portraits: the token -> off-screen-baked-face bridge the UI extract samples for a + // `SetPortraitTexture`-bound region (the modern high-res 2D model bake). + .add(PortraitPlugin) + .add(TextInputPlugin) + .add(UiScriptPlugin) + // The video knobs the CVar host writes into (today: `gxVSync`). Before CvarPlugin so the + // resource exists when `load_config` applies the saved value at Startup. + .add(crate::video::VideoPlugin) + // The realmlist (decision 1667) — the logon address the login screen edits. Same reason as + // VideoPlugin above: it is a CVar knob, so its resource has to exist before `load_config`. + .add(crate::realmlist::RealmlistPlugin) + // The CVar host (decision 0954): registration, knob sync, config.toml persistence. After + // UiScriptPlugin only for reading order — its systems gate on the VM existing anyway. + .add(crate::cvars::CvarPlugin) + // The key-binding engine (decision 0997): the chord→command dispatch every rebindable input + // runs through, its persistence, and the Key Bindings window's capture seam. + .add(crate::bindings::BindingsPlugin) + // The unit snapshot + event feed (decision 0068 §3): pushes ECS game state into the VM as the + // plain data the `Unit*` bindings read, and fires the matching WoW events. + .add(UiUnitPlugin) + .add(UiPartyPlugin) + // Duels (decision 0633): the wire session, the client-side countdown tick, the four Era + // events, and the accept/cancel/challenge intents. + .add(UiDuelPlugin) + // Setting your hearthstone (decision 1331): the innkeeper's SMSG_BINDER_CONFIRM question, the + // CONFIRM_BINDER dialog it raises, and the CMSG_BINDER_ACTIVATE its Accept sends — the only + // packet in the flow that actually binds anything. + .add(UiBinderPlugin) + // The dialog engine's own verbs (decision 1963): the pet trainer's question, the instance + // boot clock, the area spirit healer, the battleground queue, the meeting stone. + .add(UiDialogVerbsPlugin) + .add(BattlefieldScorePlugin) + .add(BattlefieldPlugin) + .add(BattlefieldPositionsPlugin) + .add(crate::game_tip::GameTipPlugin) + .add(crate::text_filter::TextFilterPlugin) + // The re-shape a `bevy_ui` text root loses when its last span is despawned + // (decision 2212, B383): an upstream change-detection hole whose only symptom is a + // panic inside `bevy_text` on the next window resize. + .add(crate::text_reshape::TextReshapePlugin) + .add(TutorialPlugin) + // The melee swing refusals (`SMSG_ATTACKSWING_NOTINRANGE`/`_BADFACING`/`_DEADTARGET`/ + // `_CANT_ATTACK`): the latch the packets set, and the 4 s repeat that shows it while an + // attack target stands and no swing lands. + .add(crate::swing_refusal::SwingRefusalPlugin) + // Being summoned (decision 1747): SMSG_SUMMON_REQUEST's latch, the CONFIRM_SUMMON dialog it + // raises, and the CMSG_SUMMON_RESPONSE its Accept sends. The binder's twin one line up — a + // server-asked question whose only wire answer is yes — and here for that reason. + .add(UiSummonPlugin) + // The GM trouble-ticket flow (decision 1673): the Help window's five sends, the UPDATE_TICKET + // answer ticket behind its 10-minute poll, and the GMTicketCategory.dbc list its "page a GM" + // rows are built from. Beside the binder because it is the same feed/drain shape, and after it + // because both want UiInput ordering and this reads better grouped. + .add(UiGmTicketPlugin) + // Auto-follow's UI seam: the popup's Follow row + `FollowUnit`/`FollowByName` inbound, and + // the AUTOFOLLOW_BEGIN/END pair that drives the centre-screen status line outbound. + .add(UiFollowPlugin) + // Instance/raid lockouts (decision 1748): the four CHAT_MSG_SYSTEM lines the client composes + // itself out of GlobalStrings, the last-dungeon/ownership bookkeeping behind + // `CanShowResetInstances()`, and the SELF menu's one send. Beside the binder family for the + // same feed/drain shape; it needs the map catalog, which is up long before Update runs. + .add(UiInstancePlugin) + // Leaving (decision 0674): the game menu's Logout/Exit Game — the request, the server's + // 20-second answer narrated as the CAMP/QUIT countdown, and the process exit. + .add(UiLogoutPlugin) + .add(UiSocialPlugin) + // Guilds (decision 1257): the identity/roster mirror behind the four guild windows, the + // membership verbs, and the `ERR_GUILD_*` lines. Right after the social session, whose + // FriendsFrame it shares a window with and whose ignore list its sign-on lines consult. + .add(UiGuildPlugin) + // Founding a guild (decision 1672): the guild registrar and the charter window — the slice + // 1257 §2 left out. Right after the guild session, whose error channel its refusals ride and + // whose roster its success produces. + .add(UiPetitionPlugin) + .add(UiTooltipPlugin) + // The character-window feed (decision 0208): the combat-stats/inventory snapshots + events + // the paper doll reads, and the paper-doll booth's yaw mirror. + .add(UiCharPlugin) + // The reputation-pane feed: the player's wire faction slots resolved against Faction.dbc into + // the pane's snapshot, plus the pane's three outbound verbs. Beside the character feed because + // it is the same window's other tab. + .add(crate::ui_reputation::UiReputationPlugin) + // The inspect feed (decision 0631): another player's equipment off their PUBLIC visible-item + // entries, plus the "inspect" booth's unit + yaw. Right after the character feed it mirrors. + .add(crate::ui_inspect::InspectUiPlugin) + // The honor feed (decision 1512): the PRIVATE honor descriptor block as the snapshot both + // Honor tabs read, plus the inspect-honor round trip. After the inspect feed because it + // resolves that feed's target to address its request at. + .add(crate::ui_honor::UiHonorPlugin) + // The dressing-room feed (decision 1060): the window's try-on intents → the player's own look + // with the tried-on items substituted in, plus the "dressup" booth's yaw. Beside the inspect + // feed, whose shape it shares (intents in, a booth look out). + .add(crate::ui_dressup::DressUpUiPlugin) + .add(UiActionPlugin) + // The aura feed (decisions 0255/0257): the player's insertion-ordered buff/debuff cache + the + // self-only durations, pushed as the data the `UnitAura` bindings read; fires UNIT_AURA and + // drains the right-click cancels. After UiActionPlugin (shares its `Spells` catalog). + .add(UiAuraPlugin) + // The spellbook window feed (decision 0216 §8, slice 5): builds the book from + // PlayerActions.spells through the Spell.dbc/SkillLine.dbc join and drives + // SpellBookFrame.xml's snapshot + cast-drain seam — the spell SOURCE for the cursor payload + // arc (bags/doll/bars/book). After UiActionPlugin (shares its `Spells` resource + the cast + // tail `send_spell_cast`). + .add(UiSpellbookPlugin) + // The macro system (decision 0983): the icon chooser's catalog, the `benilla-config/macros/` + // files, `UPDATE_MACROS`, and the macro→bound-spell table the action bar's MACRO slots + // resolve their cooldown/usability through. After UiSpellbookPlugin — the bound spell is + // resolved against the book that feed pushes, by the same law `CastSpellByName` uses. + .add(crate::ui_macro::UiMacroPlugin) + // The talent window feed (decision 0304): builds the class pages from Talent.dbc × the + // known-spell set + PLAYER_CHARACTER_POINTS, drives TalentFrame.xml through the engine's + // talent seam, and drains learn clicks into CMSG_LEARN_TALENT. After UiActionPlugin + // (shares its `Spells` catalog), beside the spellbook it mirrors. + .add(UiTalentPlugin) + // Unlearning them again (decision 1580): the class trainer's respec question, its + // CONFIRM_TALENT_WIPE dialog, and the answer that is the only packet in the flow which + // unlearns anything. Beside UiTalentPlugin for the subject, but it is UiBinderPlugin's twin + // in shape — a guid-carrying question over an already-closed gossip menu. + .add(UiTalentWipePlugin) + // The stance/shapeshift bar feed (wow-re shapeshift-bar-api.md): builds the form list from + // PlayerActions.spells per the byte-verified admission/order, drives the stock shapeshift bar through + // the engine's shapeshift seam, and drains its clicks (cancel-if-active else cast). After + // UiActionPlugin (shares `Spells`, the `usable` walk, and the cast tail). + .add(UiShapeshiftPlugin) + // The pet action bar (decision 0982) — the stance bar's mirror image: server-authoritative, + // so this renders the ten packed words the last `SMSG_PET_SPELLS` delivered and sends + // intents back. After UiActionPlugin (shares `Spells` and the cooldown triple's clock). + .add(UiPetPlugin) + .add(ChrClassesPlugin) + .add(UiPetBookPlugin) + // The pet's paper-doll stat block (happiness/loyalty/XP/training points). Its own plugin + // because it runs off descriptor fields and two DBC tables rather than off `SMSG_PET_SPELLS`. + .add(UiPetStatsPlugin) + // The pet paper doll's SHARED surface (decision 1057) — the combat-stats snapshot under the + // `"pet"` token and the page's model booth. Apart from the block above because these values + // pass through the character sheet's own bindings and events, with no hunter gate. + .add(UiPetDollPlugin) + // The connection-telemetry feed: the averaged ping RTT behind `GetNetStats()`, which the main + // bar's performance meter polls (decision 0658). + .add(UiNetPlugin) + .add(UiCastPlugin) + // The breath / fatigue bars (decision 0874): server-authoritative mirror timers off the + // wire into the transcribed MirrorTimer1/2/3 frames. Beside the cast bar it shares its + // feed→drain shape (and its art: the same UI-CastingBar-Border chrome). + .add(UiMirrorPlugin) + // Floating combat text (decision 0137 phase 2): the WORLDTEXTSTRING law — world-anchored + // damage numbers/outcome words projected into the UI quad pass each frame. + .add(crate::combat_text::CombatTextPlugin) + // Overhead unit names (nameplates): world-billboard name text over players + NPCs. + .add(crate::nameplates::NameplatesPlugin) + // Raid-target marker billboards (0434 §6): the mark icon over marked units, one line-pitch + // above the overhead name; plated units show the plate's raid child instead. + .add(crate::raid_marks::RaidMarksPlugin) + // V-key nameplates (0167): the toggled health-bar plates, a 2-D overlay replacing the + // overhead name on plated units. + .add(crate::vplates::VPlatesPlugin) + // Chat speech bubbles (0598): the over-the-head bubble a say/yell/party line spawns, the + // plates' 2-D overlay sibling — mutually exclusive with both the plate and the name. + .add(crate::chat_bubble::ChatBubblePlugin) + // TOGGLEUI (`CTRL-Z`/`Cmd-Z`): the whole quad layer goes dark — frames, minimap, plates, + // bubbles, combat text — leaving the world and the cursor. + .add(crate::ui_hide::UiHidePlugin) + .add(UiItemsPlugin) + // The gossip window (decision 0081): fills from the net drain's GossipState and drives + // GossipFrame.xml over the Era gossip API. + .add(UiGossipPlugin) + // The merchant window (decision 0081 phase 4): fills from the net drain's MerchantOpen and + // drives MerchantFrame.xml over the Era vendor API + the money display. + .add(UiMerchantPlugin) + // The bank window (decision 0604): the SHOW_BANK session (BankOpen) + the purchase row; + // the vault's slots ride the container feed as bags −1/5..=10. + // The auction house (decision 1511) — an NPC-session window like the bank beside it, but the + // only `doublewide` panel in the UI, so it displaces both the left and center seats. + .add(UiAuctionPlugin) + .add(UiBankPlugin) + // The mail window (decision 0544 P1/P2): the client-side mailbox session (MailOpen), the + // NPC-session range guard, and MailFrame.xml over the Era mail API (inbox, open-letter, + // send tab). + .add(UiMailPlugin) + // Player-to-player trade (TradeFrame.xml): the two-sided trade window, driven server-side over + // the P0 wire; the partner's portrait rides the shared "npc" booth (decision 0592 P1). + .add(UiTradePlugin) + // The item-text reader (ItemTextFrame.xml): right-clicked bag letters (mail-made permanent + // copies) read in the reference reader window over the shared ask-once item-text cache. + .add(UiItemTextPlugin) + .add(UiSavedPlugin) + .add(NamePersistPlugin) + .add(UiStablePlugin) + .add(TabardUiPlugin) + .add(UiTrainerPlugin) + // The taxi map (decision 0484 phases 1-2): the SMSG_SHOWTAXINODES-fed TaxiState resource, the + // NPC-session range guard, and the TaxiFrame.xml window feed/drain (catalogs, node + // projection/route computation, the activate send, the UnitOnTaxi ride flag). + .add(UiTaxiPlugin) + .add(UiTradeSkillPlugin) + .add(UiCraftPlugin) + // The loot window (decision 0084): fills from the net drain's LootState and drives + // LootFrame.xml over the Era loot API (coin + rows, paging). + .add(UiLootPlugin) + .add(UiLootRollPlugin) + // The questgiver window (decision 0088): fills from the net drain's QuestGiver and drives + // the stock questgiver window's four sub-panels over the Era quest API (1944). + .add(UiQuestPlugin) + // The quest-log window (decision 0088's deferred second slice): fills from the self player's + // PLAYER_QUEST_LOG descriptor slots + the SMSG_QUEST_QUERY_RESPONSE template cache, and drives + // the stock quest log over the Era quest-log API (1944). + .add(UiQuestLogPlugin) + // The party quest-share (decision 1733): the verdict lines on a quest we pushed, and the + // escort-quest confirm. Neither is bound to a window, so it is its own plugin rather than a + // lodger in either quest plugin above. + .add(QuestSharePlugin) + .add(UiChatPlugin) + // The layout cache: the geometry of every window the player has dragged or resized, restored + // at world entry and written back a quiet second after the last drag + // (`benilla-config/layout/-.txt`). The consumer of the engine's userPlaced + // bit, which nothing read before it. + .add(UiLayoutPlugin) + // Print screen (decision 1487): the SCREENSHOT binding's engine half — one PNG per + // `Screenshot()` call into `benilla-config/Screenshots/` (never the install — decision 1486), + // answered to the UI as SCREENSHOT_SUCCEEDED/FAILED so the status text can never be in the + // frame it announces. + .add(crate::screenshot::ScreenshotPlugin) + } +} + +#[cfg(test)] +pub(crate) mod schedule_tests { + use std::collections::HashMap; + + use super::*; + use bevy::ecs::schedule::graph::Direction; + use bevy::ecs::schedule::{LogLevel, NodeId, ScheduleBuildSettings, ScheduleLabel, SystemKey}; + + /// **The whole client, built headless.** The tuned `DefaultPlugins` with no window, no + /// winit, no logger and no GPU (`backends: None` — bevy then creates no render app), then + /// the engine, then the game. Every plugin's `build` and `finish` runs; no schedule does. + /// What this yields is the schedule GRAPH — which is fixed once the plugins have built + /// (the census probe's own argument, `capture::probes::sched_census`) — so a question + /// about ordering can be asked here, in a test, instead of on a login. + pub(crate) fn headless_client() -> App { + let mut app = App::new(); + app.add_plugins( + benilla_world::boot::tuned_default_plugins(Window::default()) + .disable::() + .disable::() + .set(bevy::window::WindowPlugin { + primary_window: None, + exit_condition: bevy::window::ExitCondition::DontExit, + ..default() + }) + .set(bevy::render::RenderPlugin { + render_creation: bevy::render::settings::WgpuSettings { + backends: None, + ..default() + } + .into(), + ..default() + }), + ); + app.add_plugins(benilla_world::world_plugins::WorldPlugins); + app.add_plugins(GamePlugins { + connect: false, + start: crate::char_select::ClientState::Login, + }); + app.finish(); + app.cleanup(); + app + } + + /// One system as the schedule graph knows it: its name, every set it is under + /// (transitively), and every run condition on it or on one of those sets, by name. + #[derive(Debug, Clone)] + pub(crate) struct SystemInfo { + pub name: String, + pub sets: Vec, + pub conditions: Vec, + } + + /// One schedule, read two ways: the graph before it builds (names, sets, conditions — all + /// of which move into the executable when it does), and the conflict list after (which + /// only exists once it has). + pub(crate) struct Census { + pub systems: HashMap, + /// Pairs of systems with conflicting access and no path between them, each with what + /// they fight over, by name. + pub conflicts: Vec<(SystemKey, SystemKey, Vec)>, + /// Every declared `a` runs before `b`, at the system level. + pub dependencies: Vec<(SystemKey, SystemKey)>, + /// The systems that must run on the main thread (the VM's, the audio layer's). + pub non_send: Vec, + } + + impl Census { + pub fn name(&self, key: SystemKey) -> &str { + self.systems + .get(&key) + .map(|s| s.name.as_str()) + .unwrap_or("?") + } + } + + /// Take the census of one schedule. Initializing it runs every system's param setup, and + /// at least one of those inserts `Schedules` itself, so this goes through bevy's own + /// take-out-initialize-put-back rather than a `resource_scope` on that resource. + pub(crate) fn census(app: &mut App, label: impl ScheduleLabel) -> Census { + let label = label.intern(); + app.world_mut().schedule_scope(label, |world, schedule| { + schedule.set_build_settings(ScheduleBuildSettings { + ambiguity_detection: LogLevel::Warn, + ..default() + }); + // Pass 1, before the build: the graph still holds the systems. + let graph = schedule.graph(); + let set_conditions: HashMap<_, Vec> = graph + .system_sets + .iter() + .map(|(key, _, conds)| { + ( + key, + conds + .iter() + .map(|c| c.condition.name().to_string()) + .collect(), + ) + }) + .collect(); + let mut systems = HashMap::new(); + for (key, system, conds) in graph.systems.iter() { + let mut sets = Vec::new(); + let mut conditions: Vec = conds + .iter() + .map(|c| c.condition.name().to_string()) + .collect(); + let mut stack = vec![NodeId::System(key)]; + while let Some(node) = stack.pop() { + for parent in graph + .hierarchy() + .graph() + .neighbors_directed(node, Direction::Incoming) + { + if let NodeId::Set(set) = parent { + if let Some(s) = graph.system_sets.get(set) { + sets.push(format!("{s:?}")); + } + if let Some(c) = set_conditions.get(&set) { + conditions.extend(c.iter().cloned()); + } + stack.push(parent); + } + } + } + systems.insert( + key, + SystemInfo { + name: system.name().to_string(), + sets, + conditions, + }, + ); + } + // Every declared order, at the system level: a `.before`/`.after`/`chain` edge + // between sets is an edge between every member of one and every member of the + // other. + let members = |node: NodeId| -> Vec { + let mut out = Vec::new(); + let mut stack = vec![node]; + while let Some(n) = stack.pop() { + match n { + NodeId::System(k) => out.push(k), + NodeId::Set(_) => stack.extend( + graph + .hierarchy() + .graph() + .neighbors_directed(n, Direction::Outgoing), + ), + } + } + out + }; + let mut dependencies = Vec::new(); + for (a, b) in graph.dependency().graph().all_edges() { + for x in members(a) { + for y in members(b) { + if x != y { + dependencies.push((x, y)); + } + } + } + } + // Pass 2: build, and read what the build found. `is_send` is only known once a + // system's params have registered their access, i.e. after this. + schedule.initialize(world).expect("the schedule builds"); + let mut non_send = Vec::new(); + for (key, system) in schedule.systems().expect("initialized") { + if !system.is_send() { + non_send.push(key); + } + // The build inserts what nobody added: the `ApplyDeferred` sync points between a + // system with commands and its dependents. Name them so a census can count them. + systems.entry(key).or_insert_with(|| SystemInfo { + name: system.name().to_string(), + sets: Vec::new(), + conditions: Vec::new(), + }); + } + let conflicts = schedule + .graph() + .conflicting_systems() + .0 + .iter() + .map(|(a, b, ids)| { + let what = ids + .iter() + .map(|id| { + world + .components() + .get_name(*id) + .map(|n| n.to_string()) + .unwrap_or_else(|| format!("{id:?}")) + }) + .collect(); + (*a, *b, what) + }) + .collect(); + Census { + systems, + conflicts, + dependencies, + non_send, + } + }) + } + + /// **Does this build carry type names?** bevy only does under its `debug` feature, which + /// rides with ours (`benilla-world`'s `dev`, decision 1451); a player build names every + /// system and resource ``. Probed off the census itself rather + /// than off a feature flag — a `cfg(feature = "dev")` outside `run_mode` is a seam leak (1179), + /// and what these tests need is the capability, not the plane. The name-dependent tests + /// announce the skip and return; the name-free ratchets run either way. + fn type_names_available(c: &Census) -> bool { + c.systems.values().any(|s| s.name.contains("benilla_app::")) + } + + /// Is this conflict set nothing but the Lua VM? + fn vm_only(what: &[String]) -> bool { + what.iter() + .all(|w| w.contains("benilla_ui::script::UiScript")) + } + + /// …or nothing but the VM and the audio layer (kira's handles, the kit catalog, the + /// config) — the two non-`Send` owners 2265 §A3 names as the noise floor. + fn vm_or_audio_only(what: &[String]) -> bool { + what.iter().all(|w| { + w.contains("benilla_ui::script::UiScript") || w.contains("benilla_app::sound::") + }) + } + + #[test] + fn the_client_builds_headless() { + let app = headless_client(); + assert!(app.world().contains_resource::()); + } + + /// The standing count of ambiguous pairs in `Update` — two systems with conflicting access + /// and no declared order between them, which the executor runs in whatever order the graph + /// around them happens to produce (B354, 2220). Same shape as `world_api_wall.rs`: the + /// count may not rise past the ceiling, and the ceiling follows the count down. + /// + /// **Measured 2026-09-16 (decision 2279), 659 systems:** 16,437 pairs, of which 10,613 are + /// on the Lua VM alone and 1,107 on the VM or the audio layer — the two non-`Send` owners + /// that every feed and every sound call takes, so nearly every pair of them conflicts. The + /// 4,717 that remain fight over ordinary resources and components (`Transform` 716, + /// `WorldAssets` 362, `Items`, `NameCache`, `ChatLog`, `Player`…) and are the actionable + /// number; `UPDATE_OTHER_CEILING` ratchets it on its own. `WOW_AMBIGUITY_DUMP=1` prints + /// every non-VM, non-audio pair with what it fights over. + /// + /// Raising a ceiling is a claim that a new undeclared order is acceptable; make it with the + /// reason, or declare the order instead (`.after`, a set, a `chain`). + const UPDATE_CEILING: usize = 16_437; + const UPDATE_SLACK: usize = 100; + /// `PostUpdate`, 181 systems: `GlobalTransform` and the particle `EffectQuads` are most of it. + const POST_UPDATE_CEILING: usize = 351; + const POST_UPDATE_SLACK: usize = 20; + /// The pairs in `Update` that are NOT explained by the VM or the audio layer. + const UPDATE_OTHER_CEILING: usize = 4_717; + const UPDATE_OTHER_SLACK: usize = 50; + + fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { + eprintln!("{what}: {n} ambiguous pairs (ceiling {ceiling}, slack {slack})"); + assert!( + n <= ceiling, + "{n} ambiguous pairs in {what}; the ceiling is {ceiling}. A new system runs in an \ + undeclared order against something it shares state with — declare the order \ + (`.after`, a set, a `chain`), or raise the ceiling here with the reason. \ + `WOW_AMBIGUITY_DUMP=1` on this test lists the pairs (decision 2279)." + ); + assert!( + n + slack >= ceiling, + "only {n} ambiguous pairs in {what} and the ceiling still says {ceiling}. Lower the \ + ceiling to {n} so it keeps ratcheting." + ); + } + + /// The ratchet on the raw pair counts — name-free, so it runs in the player build too. + #[test] + fn the_schedules_have_no_more_undeclared_orders_than_the_ceilings_say() { + let mut app = headless_client(); + let update = census(&mut app, Update); + let post = census(&mut app, PostUpdate); + ratchet( + "Update", + update.conflicts.len(), + UPDATE_CEILING, + UPDATE_SLACK, + ); + ratchet( + "PostUpdate", + post.conflicts.len(), + POST_UPDATE_CEILING, + POST_UPDATE_SLACK, + ); + } + + /// The ratchet on the actionable class, and the dump. Needs type names + /// ([`type_names_available`]). + #[test] + fn the_non_vm_ambiguities_do_not_grow() { + let mut app = headless_client(); + let c = census(&mut app, Update); + if !type_names_available(&c) { + eprintln!( + "skipped: this build carries no type names (bevy/debug rides with dev, 1451)" + ); + return; + } + let vm = c.conflicts.iter().filter(|(_, _, w)| vm_only(w)).count(); + let audio = c + .conflicts + .iter() + .filter(|(_, _, w)| !vm_only(w) && vm_or_audio_only(w)) + .count(); + let other = c.conflicts.len() - vm - audio; + eprintln!( + "Update: {} systems, {} ambiguous pairs; {vm} VM-only; {audio} VM-or-audio-only; {other} other", + c.systems.values().filter(|s| !is_sync_point(&s.name)).count(), + c.conflicts.len(), + ); + if std::env::var_os("WOW_AMBIGUITY_DUMP").is_some() { + let mut rows: Vec = c + .conflicts + .iter() + .filter(|(_, _, w)| !vm_or_audio_only(w)) + .map(|(a, b, w)| { + format!( + " {} <-> {}\n on {}", + c.name(*a), + c.name(*b), + w.join(", ") + ) + }) + .collect(); + rows.sort(); + for r in rows { + eprintln!("{r}"); + } + } + ratchet( + "Update (neither VM nor audio)", + other, + UPDATE_OTHER_CEILING, + UPDATE_OTHER_SLACK, + ); + } + + /// The `ApplyDeferred` the build inserts between a system with commands and its dependents. + fn is_sync_point(name: &str) -> bool { + name == "bevy_ecs::apply_deferred" + } + + /// **How parallel could `Update` be?** A greedy list schedule with unlimited threads: each + /// wave takes every system whose predecessors have run and that shares no undeclared + /// conflict with a system already in the wave; non-`Send` systems are pairwise exclusive + /// besides (the multi-threaded executor runs them one at a time on the main thread). The + /// wave count is the frame's serial depth under that executor; the longest declared chain + /// is its floor. Returned as `(waves, critical_path)`. + fn waves( + c: &Census, + keep_conflict: impl Fn(&[String]) -> bool, + non_send: &[SystemKey], + ) -> (usize, usize) { + use std::collections::{HashMap, HashSet}; + // The build-inserted sync points are left out: they are exclusive barriers the build + // places from the flattened graph, which this model does not see; their count is + // reported beside the waves instead. + let mut order: Vec = c + .systems + .iter() + .filter(|(_, s)| !is_sync_point(&s.name)) + .map(|(k, _)| *k) + .collect(); + order.sort_by(|a, b| c.name(*a).cmp(c.name(*b))); + let mut preds: HashMap> = HashMap::new(); + for (a, b) in &c.dependencies { + preds.entry(*b).or_default().push(*a); + } + let mut exclusive: HashMap> = HashMap::new(); + for (a, b, what) in &c.conflicts { + if keep_conflict(what) { + exclusive.entry(*a).or_default().insert(*b); + exclusive.entry(*b).or_default().insert(*a); + } + } + for (i, a) in non_send.iter().enumerate() { + for b in &non_send[i + 1..] { + exclusive.entry(*a).or_default().insert(*b); + exclusive.entry(*b).or_default().insert(*a); + } + } + let mut done: HashSet = HashSet::new(); + let mut waves = 0; + while done.len() < order.len() { + let mut wave: Vec = Vec::new(); + for k in &order { + if done.contains(k) { + continue; + } + let ready = preds + .get(k) + .is_none_or(|p| p.iter().all(|x| done.contains(x))); + if !ready { + continue; + } + let clash = exclusive + .get(k) + .is_some_and(|ex| wave.iter().any(|w| ex.contains(w))); + if !clash { + wave.push(*k); + } + } + assert!( + !wave.is_empty(), + "no system is ready — the dependency graph has a cycle" + ); + done.extend(wave.iter().copied()); + waves += 1; + } + // The longest declared chain, by depth over the dependency DAG. + let mut depth: HashMap = HashMap::new(); + fn depth_of( + k: SystemKey, + preds: &HashMap>, + depth: &mut HashMap, + ) -> usize { + if let Some(d) = depth.get(&k) { + return *d; + } + let d = 1 + preds + .get(&k) + .map(|p| { + p.iter() + .map(|x| depth_of(*x, preds, depth)) + .max() + .unwrap_or(0) + }) + .unwrap_or(0); + depth.insert(k, d); + d + } + let critical = order + .iter() + .map(|k| depth_of(*k, &preds, &mut depth)) + .max() + .unwrap_or(0); + (waves, critical) + } + + /// The structural half of 2265 §A3's executor question — is the prize of collapsing the VM + /// (and the audio layer) to one `Send` owner correctness only, or correctness plus + /// parallelism? Prints the serial depth of `Update` today and under each collapse. + #[test] + fn concurrency_census() { + let mut app = headless_client(); + let c = census(&mut app, Update); + if !type_names_available(&c) { + eprintln!( + "skipped: this build carries no type names (bevy/debug rides with dev, 1451)" + ); + return; + } + let touches = |k: SystemKey, needle: &str| { + c.conflicts + .iter() + .any(|(a, b, w)| (*a == k || *b == k) && w.iter().any(|x| x.contains(needle))) + }; + let vm: Vec = c + .non_send + .iter() + .copied() + .filter(|k| touches(*k, "UiScript")) + .collect(); + let audio: Vec = c + .non_send + .iter() + .copied() + .filter(|k| touches(*k, "benilla_app::sound::")) + .collect(); + let other_non_send: Vec = c + .non_send + .iter() + .copied() + .filter(|k| !vm.contains(k) && !audio.contains(k)) + .collect(); + let real = c + .systems + .values() + .filter(|s| !is_sync_point(&s.name)) + .count(); + eprintln!( + "Update: {real} systems; {} non-Send ({} touch the VM, {} the audio layer, {} neither); {} declared edges; {} ambiguous pairs", + c.non_send.len() - c.non_send.iter().filter(|k| is_sync_point(c.name(**k))).count(), vm.len(), audio.len(), other_non_send.len() - c.non_send.iter().filter(|k| is_sync_point(c.name(**k))).count(), c.dependencies.len(), c.conflicts.len() + ); + let sync_points = c + .systems + .values() + .filter(|s| is_sync_point(&s.name)) + .count(); + let mut neither: Vec<&str> = other_non_send + .iter() + .map(|k| c.name(*k)) + .filter(|n| !is_sync_point(n)) + .collect(); + neither.sort_unstable(); + eprintln!( + " {sync_points} auto-inserted ApplyDeferred sync points (exclusive barriers); other non-Send: {}", + neither.join(", ") + ); + let (w0, cp) = waves(&c, |_| true, &c.non_send); + eprintln!(" as-is: {w0} waves (critical path {cp})"); + let not_vm: Vec = c + .non_send + .iter() + .copied() + .filter(|k| !vm.contains(k) || audio.contains(k)) + .collect(); + let (w1, _) = waves(&c, |w| !vm_only(w), ¬_vm); + eprintln!(" VM Send-owned: {w1} waves"); + let (w2, _) = waves(&c, |w| !vm_or_audio_only(w), &other_non_send); + eprintln!(" VM + audio Send-owned: {w2} waves"); + let (w3, _) = waves(&c, |_| false, &[]); + eprintln!(" declared edges only: {w3} waves (every conflict ordered, everything Send)"); + } + + /// The consumer markers 2220's census used: a system that holds the VM and does one of + /// these to host state has spent something this frame that nothing can re-spend. + const CONSUMES: &[&str] = &["fire_event(", "mem::take(", ".drain("]; + + /// Why a one-shot consumer that holds the VM and is NOT gated on `ingame_ui_up` cannot lose + /// anything in 2214's one-frame window — 2232's discriminator, as a type. (In that window + /// the wire is in-world and the boot VM is still live and frameless, so a one-shot spent + /// then is published to a VM with nothing to show it.) + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Because { + /// The consumed queue is filled only by Lua asking; a frameless VM asks for nothing. + FilledByVm, + /// Filled only by a server reply to something the player had to click in the interface + /// — or, noted in the reason, by another player's act on us, which can share a drain + /// with the login burst only by coincidence (the residual 2279 names). + PlayerRoundTrip, + /// Consumed in the window or not, the state is re-asked or re-derived once the UI is up. + SelfHealing, + /// The publication is driven by a `VmMemo` diff, so a new VM re-derives and re-fires it + /// (decision 2226). + MemoLatched, + /// Documented at the line as intentional. + Deliberate, + } + + /// Every one-shot consumer that holds the VM, is not gated on `ingame_ui_up` (by its own + /// run condition or a set's — read off the real schedule, not off the registration text), + /// and is not a memo-diffed `fire_event` — each with the reason it is safe. Keyed + /// `(path under src/, fn)`. Audited 2026-09-16 (decision 2279) against the fill sites; + /// the reason names them. Adding a consumer the ordinary way (gated) passes; adding one + /// ungated fails here, at the line, until it is either gated or argued. + const EXEMPT: &[(&str, &str, Because, &str)] = &[ + ("bindings.rs", "sync_dispatch", Because::MemoLatched, + "`seen_generation` is a `VmMemo`: a new VM reads `None`, rebuilds and re-fires UPDATE_BINDINGS"), + ("death.rs", "feed_death", Because::MemoLatched, + "`feed.vm: VmMemo`: a fresh memo makes the first snapshot an edge and re-announces a held offer, confirm and corpse range"), + ("screenshot.rs", "ask_for_captures", Because::FilledByVm, + "`pending` holds only the VM's own `Screenshot()` asks, and the take spawns a capture, publishing nothing"), + ("screenshot.rs", "report_captures", Because::FilledByVm, + "the outcome chain starts with the VM's own `Screenshot()` call"), + ("tutorial.rs", "drain_tutorials", Because::FilledByVm, + "`sends` exist only after Lua acknowledged, cleared or reset a flag, and go to the wire"), + ("ui_action/drain.rs", "drain_go_openers", Because::FilledByVm, + "filled only by a world right-click on a GameObject; the drain sends casts to the wire (2232's own bucket)"), + ("ui_auction/mod.rs", "drain_auction", Because::FilledByVm, + "VM verbs; the refresh flags act only under an open window and become wire re-asks"), + ("ui_bank.rs", "feed_bank", Because::PlayerRoundTrip, + "`BankErrors` answers a `BuyBankSlot` click; the OPENED edge rides `VmMemo`s"), + ("ui_battlefield.rs", "feed_battlefield", Because::SelfHealing, + "`reset_on_world_enter` runs before it, clears the session and re-sends `BattlefieldStatusRequest`"), + ("ui_binder.rs", "feed_binder", Because::PlayerRoundTrip, + "`SMSG_BINDER_CONFIRM` only answers the innkeeper's gossip line"), + ("ui_char.rs", "feed_char", Because::MemoLatched, + "`feed.vm: VmMemo`; `vm_reset` fires every transition on a fresh memo"), + ("ui_chat/recruitment.rs", "guild_recruitment_cascade", Because::SelfHealing, + "`pending` is held until the zone mask and zone id are settled, which happens on the entry VM"), + ("ui_dialog_verbs.rs", "feed_meeting_stone", Because::SelfHealing, + "the query is sent once per VM (`asked: VmMemo`), so the entry VM re-asks and the reply lands after the UI is up"), + ("ui_duel.rs", "feed_duel", Because::PlayerRoundTrip, + "a duel exists only after someone's Duel cast, and the server ends any duel at logout; FINISHED/bounds ride `VmMemo` (another player's act: coincidence-only residual)"), + ("ui_follow.rs", "feed_follow", Because::MemoLatched, + "`feed.vm: VmMemo`; a fresh VM re-fires BEGIN for a follow in progress"), + ("ui_gm_ticket.rs", "feed_gm_ticket", Because::MemoLatched, + "`feed.vm: VmMemo` counters restart per VM while `state.answers` keeps counting, so the latest answer is re-fired"), + ("ui_honor.rs", "feed_honor", Because::MemoLatched, + "`state.vm: VmMemo`; `events_for(None, ..)` re-fires both events on a fresh VM"), + ("ui_inspect.rs", "feed_inspect", Because::MemoLatched, + "the token is latched only by Lua's `NotifyInspect`, and the diffs ride a `VmMemo`"), + ("ui_item_text.rs", "drain_item_text", Because::FilledByVm, + "page turns and close are Lua intents"), + ("ui_item_text.rs", "feed_item_text", Because::MemoLatched, + "`told` is a `VmMemo` inside the session (a fresh VM re-begins), and the open is a player click"), + ("ui_items/drain.rs", "drain_container_destroys", Because::FilledByVm, + "`take_container_destroys` is a VM-owned queue filled by Lua's `DeleteCursorItem`"), + ("ui_items/drain.rs", "drain_container_uses", Because::FilledByVm, + "`take_container_uses` and `take_container_repairs` are Lua intents held by the VM"), + ("ui_logout.rs", "feed_logout", Because::PlayerRoundTrip, + "both packets answer the `CMSG_LOGOUT_REQUEST`/cancel the game menu sent"), + ("ui_loot.rs", "drain_loot", Because::Deliberate, + "the pre-VM take of `LootMoveStart` is documented at the line and publishes nothing to the VM; the event-firing takes are Lua's queues"), + ("ui_loot_roll.rs", "drain_loot_rolls", Because::FilledByVm, + "only a Need/Greed/Pass click queues a confirm or vote, held in the VM"), + ("ui_loot_roll.rs", "feed_loot_rolls", Because::PlayerRoundTrip, + "a roll exists only after a group member loots under group loot (2232's bucket); leftovers are cleared at socket teardown (another player's act: coincidence-only residual)"), + ("ui_macro/mod.rs", "load_macros", Because::MemoLatched, + "`MacroFiles.identity` is a `VmMemo`, so a new session re-reads the files and re-fires UPDATE_MACROS; also `InWorldGated`"), + ("ui_macro/mod.rs", "save_dirty_macros", Because::FilledByVm, + "`take_macros_dirty` is raised only by the macro window's Lua"), + ("ui_mail/mod.rs", "feed_mail", Because::SelfHealing, + "the window queues are mailbox-click replies; the one server push (`SMSG_RECEIVED_MAIL`) is re-asked by `send_query_next_mail_time_on_enter`, whose reply sets `notify` unconditionally"), + ("ui_merchant.rs", "feed_merchant", Because::PlayerRoundTrip, + "a buy/sell refusal answers a click on an open merchant; show/update ride `VmMemo`s"), + ("ui_petition/feed.rs", "feed_petition", Because::PlayerRoundTrip, + "every line answers a charter action of ours; the window edges ride the `fed` memo"), + ("ui_quest_share.rs", "feed_quest_share", Because::PlayerRoundTrip, + "a verdict answers our own push and a confirm follows a party member's escort accept, both held until the name resolves (the confirm is another player's act: coincidence-only residual)"), + ("ui_script/extract/mod.rs", "paint_script", Because::Deliberate, + "per-frame paint and cost state, not a queue (2232)"), + ("ui_social/feed.rs", "feed_social", Because::MemoLatched, + "the login-burst lists are re-announced to a new VM by `fed.seeded` (`VmMemo`); the show flag is Lua's; a status line waits on the name resolve"), + ("ui_stable.rs", "feed_stable", Because::PlayerRoundTrip, + "both packets follow a stable master's gossip and a click in its window"), + ("ui_summon.rs", "feed_summon", Because::PlayerRoundTrip, + "only another player's Ritual of Summoning latches the ask (2232's duel shape; coincidence-only residual)"), + ("ui_tabard.rs", "drain_tabard", Because::FilledByVm, + "the intents are Lua's; the event answers a Save intent"), + ("ui_tabard.rs", "feed_tabard", Because::PlayerRoundTrip, + "every latch follows a tabard-vendor click, and `reset_on_world_enter` zeroes the resource on entry"), + ("ui_talent_wipe.rs", "feed_talent_wipe", Because::PlayerRoundTrip, + "`MSG_TALENT_WIPE_CONFIRM` answers the trainer's gossip option"), + ("ui_taxi/mod.rs", "feed_taxi", Because::PlayerRoundTrip, + "both packets follow talking to a flight master (2232's own example)"), + ("ui_tradeskill.rs", "drain_trade_skill", Because::FilledByVm, + "every consumed queue is Lua's; the cast-event continuation is inert until a Lua `DoTradeSkill` latched a count"), + ("ui_trainer/mod.rs", "feed_trainer", Because::PlayerRoundTrip, + "a refusal answers a Train click and a list answers a trainer gossip click; the edges ride `VmMemo`s"), + ]; + + /// `ui_chat/feed.rs` → `ui_chat::feed`; `target/mod.rs` → `target`; `lib.rs` → ``. + fn module_of(rel: &str) -> String { + let no_ext = rel.trim_end_matches(".rs"); + let no_mod = no_ext.trim_end_matches("/mod"); + if no_mod == "lib" { + String::new() + } else { + no_mod.replace('/', "::") + } + } + + /// **Every one-shot consumer that holds the VM is gated, memo-latched, or argued** (2220's + /// proposed test, built on the real schedule rather than on the registration text — which + /// is why 2220 refused a mechanical pass: `.run_if(ingame_ui_up)` on a set, on a tuple, or + /// on the member all read differently in source and identically here). A consumer is one + /// of 2220's census: a system taking `NonSend[Mut]` whose body has a + /// [`CONSUMES`] marker. It passes if the schedule shows `ingame_ui_up` on it or on a set + /// above it, or if it is a `fire_event` driven by a `VmMemo` parameter, or if it is in + /// [`EXEMPT`] with its reason. An `EXEMPT` row for a system that is gated after all, or + /// that no longer consumes, fails too — the table describes the tree, not its history. + #[test] + fn every_one_shot_consumer_that_holds_the_vm_is_gated_or_argued() { + use crate::test_support::{fn_items, rel_path, rust_files, src_dir}; + let mut consumers = Vec::new(); + for file in rust_files(&src_dir()) { + let text = std::fs::read_to_string(&file).expect("readable source"); + if !text.contains("UiScript") { + continue; + } + let rel = rel_path(&file); + for f in fn_items(&text) { + let holds_vm = f.params.contains("NonSendMut") + || f.params.contains("NonSend"); + if !holds_vm || !CONSUMES.iter().any(|m| f.body.contains(m)) { + continue; + } + let takes = f.body.contains("mem::take(") || f.body.contains(".drain("); + let memo = f.params.contains("VmMemo<"); + consumers.push((rel.clone(), f.name.to_string(), takes, memo)); + } + } + let mut app = headless_client(); + let update = census(&mut app, Update); + if !type_names_available(&update) { + eprintln!( + "skipped: this build carries no type names (bevy/debug rides with dev, 1451)" + ); + return; + } + let post = census(&mut app, PostUpdate); + let by_name: HashMap<&str, &SystemInfo> = update + .systems + .values() + .chain(post.systems.values()) + .map(|s| (s.name.as_str(), s)) + .collect(); + let mut gated = 0; + let mut latched = 0; + let mut argued = 0; + let mut offenders = Vec::new(); + let mut stale: Vec = Vec::new(); + consumers.sort(); + for (rel, name, takes, memo) in &consumers { + let module = module_of(rel); + let full = if module.is_empty() { + format!("benilla_app::{name}") + } else { + format!("benilla_app::{module}::{name}") + }; + let info = by_name.get(full.as_str()).copied().or_else(|| { + let suffix = format!("::{name}"); + let mut hits = by_name.iter().filter(|(k, _)| k.ends_with(&suffix)); + match (hits.next(), hits.next()) { + (Some((_, v)), None) => Some(*v), + _ => None, + } + }); + let exempt = EXEMPT.iter().find(|(p, f, _, _)| p == rel && f == name); + let is_gated = + info.is_some_and(|i| i.conditions.iter().any(|c| c.ends_with("::ingame_ui_up"))); + if is_gated { + gated += 1; + if exempt.is_some() { + stale.push(format!( + "{rel}: `{name}` is gated on `ingame_ui_up` now — drop its EXEMPT row" + )); + } + } else if !*takes && *memo { + latched += 1; + if exempt.is_some() { + stale.push(format!( + "{rel}: `{name}` is a memo-diffed fire_event — drop its EXEMPT row" + )); + } + } else if exempt.is_some() { + argued += 1; + } else { + // Name the sets it is under: the fix is usually a gate on one of them. + let where_ = match info { + None => " (not found in Update/PostUpdate)".to_string(), + Some(i) => { + let named: Vec<&str> = i + .sets + .iter() + .filter(|s| !s.starts_with("SystemTypeSet")) + .map(String::as_str) + .collect(); + format!(" (sets: {})", named.join(", ")) + } + }; + offenders.push(format!("{rel}: `{name}`{where_}")); + } + } + for (p, f, _, _) in EXEMPT { + if !consumers + .iter() + .any(|(rel, name, _, _)| rel == p && name == f) + { + stale.push(format!( + "{p}: `{f}` is not a one-shot consumer holding the VM — drop its EXEMPT row" + )); + } + } + eprintln!( + "one-shot consumers holding the VM: {} — {gated} gated on ingame_ui_up, {latched} memo-latched, {argued} argued in EXEMPT", + consumers.len() + ); + assert!( + offenders.is_empty(), + "these systems hold the VM and consume a one-shot with nothing gating them on the \ + interface being up — in 2214's one-frame window they publish to a VM with no frames \ + and the edge is lost (2220, 2232). Gate them (`.run_if(ingame_ui_up)`, on the system \ + or its set), or add them to `EXEMPT` with the reason they cannot be filled before \ + the interface exists (decision 2279):\n {}", + offenders.join("\n ") + ); + assert!( + stale.is_empty(), + "stale EXEMPT rows:\n {}", + stale.join("\n ") + ); + } +} diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 7d392357..eabfde65 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -73,6 +73,7 @@ mod doodad_events; mod entities; mod fishing_line; mod footprints; +mod game_plugins; mod glue; mod glue_strings; mod go_anim; @@ -119,6 +120,8 @@ mod sound; /// The melee swing refusal's latch + 4 s repeat (`SMSG_ATTACKSWING_*`). mod swing_refusal; mod target; +#[cfg(test)] +pub(crate) mod test_support; mod text_filter; mod text_reshape; mod textinput; @@ -199,84 +202,6 @@ mod world_state; mod world_state_ui; use bevy::prelude::*; -use blob_shadow::BlobShadowPlugin; -use bowstring::BowstringPlugin; -use camera_shake::CameraShakePlugin; -use chr_classes::ChrClassesPlugin; -use cinematic::CinematicPlugin; -use creature_anim::CreatureAnimPlugin; -use cursor::CursorPlugin; -use entities::EntitiesPlugin; -use fishing_line::FishingLinePlugin; -use footprints::FootprintsPlugin; -use loading_screen::LoadingScreenPlugin; -use name_persist::NamePersistPlugin; -use net::NetPlugin; -use player::PlayerPlugin; -use portrait::PortraitPlugin; -use quest_markers::QuestMarkersPlugin; -use sound::SoundPlugin; -use target::TargetPlugin; -use textinput::TextInputPlugin; -use transport::TransportPlugin; -use tutorial::TutorialPlugin; -use ui_action::UiActionPlugin; -use ui_auction::UiAuctionPlugin; -use ui_aura::UiAuraPlugin; -use ui_bank::UiBankPlugin; -use ui_battlefield::BattlefieldPlugin; -use ui_battlefield_positions::BattlefieldPositionsPlugin; -use ui_battlefield_score::BattlefieldScorePlugin; -use ui_binder::UiBinderPlugin; -use ui_cast::UiCastPlugin; -use ui_char::UiCharPlugin; -use ui_chat::UiChatPlugin; -use ui_craft::UiCraftPlugin; -use ui_dialog_verbs::UiDialogVerbsPlugin; -use ui_duel::UiDuelPlugin; -use ui_follow::UiFollowPlugin; -use ui_gm_ticket::UiGmTicketPlugin; -use ui_gossip::UiGossipPlugin; -use ui_guild::UiGuildPlugin; -use ui_instance::UiInstancePlugin; -use ui_item_text::UiItemTextPlugin; -use ui_items::UiItemsPlugin; -use ui_layout::UiLayoutPlugin; -use ui_logout::UiLogoutPlugin; -use ui_loot::UiLootPlugin; -use ui_loot_roll::UiLootRollPlugin; -use ui_mail::UiMailPlugin; -use ui_merchant::UiMerchantPlugin; -use ui_mirror::UiMirrorPlugin; -use ui_net::UiNetPlugin; -use ui_party::UiPartyPlugin; -use ui_pass::PlayerUiPlugin; -use ui_pet::UiPetPlugin; -use ui_pet_book::UiPetBookPlugin; -use ui_pet_doll::UiPetDollPlugin; -use ui_pet_stats::UiPetStatsPlugin; -use ui_petition::UiPetitionPlugin; -use ui_quest::UiQuestPlugin; -use ui_quest_log::UiQuestLogPlugin; -use ui_quest_share::QuestSharePlugin; -use ui_saved::UiSavedPlugin; -use ui_script::UiScriptPlugin; -use ui_shapeshift::UiShapeshiftPlugin; -use ui_social::UiSocialPlugin; -use ui_spellbook::UiSpellbookPlugin; -use ui_stable::UiStablePlugin; -use ui_summon::UiSummonPlugin; -use ui_tabard::TabardUiPlugin; -use ui_talent::UiTalentPlugin; -use ui_talent_wipe::UiTalentWipePlugin; -use ui_taxi::UiTaxiPlugin; -use ui_text::UiTextPlugin; -use ui_tooltip::UiTooltipPlugin; -use ui_trade::UiTradePlugin; -use ui_tradeskill::UiTradeSkillPlugin; -use ui_trainer::UiTrainerPlugin; -use ui_unit::UiUnitPlugin; -use world_backdrop::WorldBackdropPlugin; // The `benilla` launcher shim (the bin package) is this library's only caller: it stamps the // build id at compile time and hands it into [`run`]. Re-exported so the shim needs no bevy @@ -521,9 +446,6 @@ pub fn run(build: BuildId) -> AppExit { }, ..default() })) - // The game's own WGSL, compiled into the binary (decision 1175) — before anything that could - // ask for one. The engine's seven register themselves inside `WorldPlugins` below. - .add_plugins(shaders::plugin) .add_plugins(benilla_world::thread_qos::ThreadQosPlugin) // The app-side half of background instrumented runs: undo winit's forced macOS app // activation so a probe/capture launch never yanks focus off the director's screen. The @@ -552,326 +474,13 @@ pub fn run(build: BuildId) -> AppExit { // build appends a per-second row of position, frame cost and the GPU's per-pass split to // `benilla-config/Diagnostics/fps-journal.csv`; `WOW_FPS_JOURNAL=` is the harness lever. .add_plugins(perf::FpsJournalPlugin) - .add_plugins(BowstringPlugin) - .add_plugins(weapon_trail::WeaponTrailPlugin) - .add_plugins(FishingLinePlugin) - .add_plugins(QuestMarkersPlugin) - // Pipeline-compile counters + the live-compile tripwire (decision 0837: macOS builds every - // pipeline synchronously on the render thread, so a live compile is a felt stall). - .add_plugins(pipe_warm::plugin) - // Streamed world entities: cube assets + display catalogs at startup, sync each frame. - .add_plugins(EntitiesPlugin) - // Creature animation: pick Stand/Walk/Run from each creature's movement state each frame (Milestone C). - .add_plugins(CreatureAnimPlugin) - // The unit blob shadow: the dark ground oval under every unit, sized from the playing - // animation's box (the byte-verified law — wow-re unit-blob-shadow RE), on the same - // surface-decal projector as the selection ring. - .add_plugins(BlobShadowPlugin) - // Footprint decals (B212, decision 1006): the prints a walking unit leaves on snow/sand, - // spawn-once projections on the same decal projector, fading off the effect stream. - .add_plugins(CameraShakePlugin) - .add_plugins(FootprintsPlugin) - // GameObject animation (decision 0242): net-streamed GObjects (doors/chests) play an M2 sequence - // on GAMEOBJECT_STATE change — the state-machine sibling of the doodad idle loop above. - .add_plugins(go_anim::plugin) - .add_plugins(doodad_events::plugin) - // Avatar + camera + input. - .add_plugins(PlayerPlugin) - // Cinematic fly-bys (`SMSG_TRIGGER_CINEMATIC`): the race intro a first login plays, and the - // GameObject cameras. Takes the world camera for the duration — hence after PlayerPlugin, - // whose `control` it overrides within the same stage (decision 0196's deferred arc). - .add_plugins(crate::screen_fade::ScreenFadePlugin) - .add_plugins(CinematicPlugin) - // The real client's hardware mouse cursor (native NSCursor on macOS). - .add_plugins(CursorPlugin) - // Net↔ECS bridge: spawns the world thread, exposes the snapshot + writer resources. In capture - // mode the IO thread is skipped (`connect: false`) so the scene is deterministic. - .add_plugins(NetPlugin { + // **The game, as one name** (decision 2279): everything the client adds on top of the + // engine, in the order it always had. See `game_plugins.rs` for the members, the ordering + // edges inside it that are load-bearing, and the test that builds it headless. + .add_plugins(game_plugins::GamePlugins { connect: !capturing, - }) - // The death arc (decision 0308): the wire-fed death stores + the root/water-walk ack messages. - .add_plugins(death::DeathPlugin) - // The shared glue vocabulary both pre-world screens stand on (decision 0465): the ADD-mode UI - // material, the client-data art set, the GlueStrings table. - .add_plugins(glue::GluePlugin) - // The glue layer (decision 0193): the ClientState machine + the character-select screen - // that answers the parked IO thread's pick. A world capture boots straight InWorld (no net, - // no picker); a glue capture boots onto the screen it photographs. - .add_plugins(char_select::CharSelectPlugin { start: run_mode::start_state(), - }) - // The login screen (decision 0539): the faithful AccountLogin glue + the credential policy - // that answers the IO thread's pre-logon park. - .add_plugins(login::LoginPlugin) - // The realm list: the faithful RealmList glue + the policy that answers the IO thread's - // realm park. The client used to take `realms.first()` and offer no way to say otherwise. - .add_plugins(realm_select::RealmSelectPlugin) - // The character-creation screen + its live preview booth (decision 0423). - .add_plugins(char_create::CharCreatePlugin) - // Audio: the delegated mixer + WoW's owned selection layer (decision 0070). - .add_plugins(SoundPlugin) - // Targeting: left-click a unit to select it (→ CMSG_SET_SELECTION) + draw its ground ring. - .add_plugins(TargetPlugin) - .add_plugins(TransportPlugin) - // Faithful world-load splash + progress bar on startup + cross-map teleport (the load latency - // streaming can't hide); per-map art via the Map.dbc→LoadingScreens.dbc→BLP chain. - .add_plugins(LoadingScreenPlugin) - // The player-UI quad pass (decision 0068 §2): its own composited-above-the-world, - // below-the-egui-dev-overlays camera + sorted-quad renderer. `$WOW_UI_DEMO=1` seeds a proof scene. - .add_plugins(PlayerUiPlugin) - // The world's frame, rendered off-screen and drawn first in the UI camera's main pass — - // the seam that puts the UI-over-world blend back into gamma bytes (0161/0254's last piece, - // a pass rather than a quad since 2234). Registered AFTER the UI pass: it points that - // plugin's camera at the world camera. - .add_plugins(WorldBackdropPlugin) - // The HUD minimap (decision 0203 phase 1): fills the `` widget's extracted hole with - // the streamed tile window + mask + player arrow, and feeds the zone text. - .add_plugins(minimap::MinimapPlugin) - // The pet-bar / spellbook autocast shine, drawn on the append lane from the conversion's - // parked sites — zero per-frame script-layout traffic (decision 1383, B282). - // The `` widgets' M2s, rendered as tiles of one atlas and composited at the - // callback rank (decision 2008). - .add_plugins(ui_models::UiModelsPlugin) - // The shared AreaTable catalog + the ZONE_CHANGED event family / zone-text host globals - // behind GetZoneText & co. (the zone-entry splash arc, decision 0287). - .add_plugins(area::AreaPlugin) - .add_plugins(area_poi::AreaPoiPlugin) - .add_plugins(world_state_ui::WorldStateUiPlugin) - // The `AreaTrigger.dbc` volumes + the per-frame containment check that reports walking into - // one (`CMSG_AREATRIGGER`) — the client's whole part in portals, instance entrances and - // explore objectives; the server owns what each trigger means. - .add_plugins(area_trigger::AreaTriggerPlugin) - .add_plugins(ui_world_map::WorldMapUiPlugin) - // The guard's directions marker (`SMSG_GOSSIP_POI`) — one landmark record, drawn by the - // minimap's landmark pass and the world map's POI child, cleared by arriving at it. - .add_plugins(poi_marker::PoiMarkerPlugin) - // The glyph atlas (client TTFs -> baked bitmap) `ui_script`'s extraction draws `FontString` - // regions through. Loads at Startup, after the asset chain opens (decision 0068 §2). - .add_plugins(UiTextPlugin) - // The one "which NPC am I interacting with" answer, shared by the portrait booth's `"npc"` - // token and the interaction face-me (decision 1467) — hence its own plugin, ahead of both. - .add_plugins(ui_session::UiSessionPlugin) - // Unit-frame portraits: the token -> off-screen-baked-face bridge the UI extract samples for a - // `SetPortraitTexture`-bound region (the modern high-res 2D model bake). - .add_plugins(PortraitPlugin) - .add_plugins((TextInputPlugin, UiScriptPlugin)) - // The video knobs the CVar host writes into (today: `gxVSync`). Before CvarPlugin so the - // resource exists when `load_config` applies the saved value at Startup. - .add_plugins(video::VideoPlugin) - // The realmlist (decision 1667) — the logon address the login screen edits. Same reason as - // VideoPlugin above: it is a CVar knob, so its resource has to exist before `load_config`. - .add_plugins(realmlist::RealmlistPlugin) - // The CVar host (decision 0954): registration, knob sync, config.toml persistence. After - // UiScriptPlugin only for reading order — its systems gate on the VM existing anyway. - .add_plugins(cvars::CvarPlugin) - // The key-binding engine (decision 0997): the chord→command dispatch every rebindable input - // runs through, its persistence, and the Key Bindings window's capture seam. - .add_plugins(bindings::BindingsPlugin) - // The unit snapshot + event feed (decision 0068 §3): pushes ECS game state into the VM as the - // plain data the `Unit*` bindings read, and fires the matching WoW events. - .add_plugins(UiUnitPlugin) - .add_plugins(UiPartyPlugin) - // Duels (decision 0633): the wire session, the client-side countdown tick, the four Era - // events, and the accept/cancel/challenge intents. - .add_plugins(UiDuelPlugin) - // Setting your hearthstone (decision 1331): the innkeeper's SMSG_BINDER_CONFIRM question, the - // CONFIRM_BINDER dialog it raises, and the CMSG_BINDER_ACTIVATE its Accept sends — the only - // packet in the flow that actually binds anything. - .add_plugins(UiBinderPlugin) - // The dialog engine's own verbs (decision 1963): the pet trainer's question, the instance - // boot clock, the area spirit healer, the battleground queue, the meeting stone. - .add_plugins(UiDialogVerbsPlugin) - .add_plugins(BattlefieldScorePlugin) - .add_plugins(BattlefieldPlugin) - .add_plugins(BattlefieldPositionsPlugin) - .add_plugins(crate::game_tip::GameTipPlugin) - .add_plugins(crate::text_filter::TextFilterPlugin) - // The re-shape a `bevy_ui` text root loses when its last span is despawned - // (decision 2212, B383): an upstream change-detection hole whose only symptom is a - // panic inside `bevy_text` on the next window resize. - .add_plugins(crate::text_reshape::TextReshapePlugin) - .add_plugins(TutorialPlugin) - // The melee swing refusals (`SMSG_ATTACKSWING_NOTINRANGE`/`_BADFACING`/`_DEADTARGET`/ - // `_CANT_ATTACK`): the latch the packets set, and the 4 s repeat that shows it while an - // attack target stands and no swing lands. - .add_plugins(swing_refusal::SwingRefusalPlugin) - // Being summoned (decision 1747): SMSG_SUMMON_REQUEST's latch, the CONFIRM_SUMMON dialog it - // raises, and the CMSG_SUMMON_RESPONSE its Accept sends. The binder's twin one line up — a - // server-asked question whose only wire answer is yes — and here for that reason. - .add_plugins(UiSummonPlugin) - // The GM trouble-ticket flow (decision 1673): the Help window's five sends, the UPDATE_TICKET - // answer ticket behind its 10-minute poll, and the GMTicketCategory.dbc list its "page a GM" - // rows are built from. Beside the binder because it is the same feed/drain shape, and after it - // because both want UiInput ordering and this reads better grouped. - .add_plugins(UiGmTicketPlugin) - // Auto-follow's UI seam: the popup's Follow row + `FollowUnit`/`FollowByName` inbound, and - // the AUTOFOLLOW_BEGIN/END pair that drives the centre-screen status line outbound. - .add_plugins(UiFollowPlugin) - // Instance/raid lockouts (decision 1748): the four CHAT_MSG_SYSTEM lines the client composes - // itself out of GlobalStrings, the last-dungeon/ownership bookkeeping behind - // `CanShowResetInstances()`, and the SELF menu's one send. Beside the binder family for the - // same feed/drain shape; it needs the map catalog, which is up long before Update runs. - .add_plugins(UiInstancePlugin) - // Leaving (decision 0674): the game menu's Logout/Exit Game — the request, the server's - // 20-second answer narrated as the CAMP/QUIT countdown, and the process exit. - .add_plugins(UiLogoutPlugin) - .add_plugins(UiSocialPlugin) - // Guilds (decision 1257): the identity/roster mirror behind the four guild windows, the - // membership verbs, and the `ERR_GUILD_*` lines. Right after the social session, whose - // FriendsFrame it shares a window with and whose ignore list its sign-on lines consult. - .add_plugins(UiGuildPlugin) - // Founding a guild (decision 1672): the guild registrar and the charter window — the slice - // 1257 §2 left out. Right after the guild session, whose error channel its refusals ride and - // whose roster its success produces. - .add_plugins(UiPetitionPlugin) - .add_plugins(UiTooltipPlugin) - // The character-window feed (decision 0208): the combat-stats/inventory snapshots + events - // the paper doll reads, and the paper-doll booth's yaw mirror. - .add_plugins(UiCharPlugin) - // The reputation-pane feed: the player's wire faction slots resolved against Faction.dbc into - // the pane's snapshot, plus the pane's three outbound verbs. Beside the character feed because - // it is the same window's other tab. - .add_plugins(ui_reputation::UiReputationPlugin) - // The inspect feed (decision 0631): another player's equipment off their PUBLIC visible-item - // entries, plus the "inspect" booth's unit + yaw. Right after the character feed it mirrors. - .add_plugins(ui_inspect::InspectUiPlugin) - // The honor feed (decision 1512): the PRIVATE honor descriptor block as the snapshot both - // Honor tabs read, plus the inspect-honor round trip. After the inspect feed because it - // resolves that feed's target to address its request at. - .add_plugins(ui_honor::UiHonorPlugin) - // The dressing-room feed (decision 1060): the window's try-on intents → the player's own look - // with the tried-on items substituted in, plus the "dressup" booth's yaw. Beside the inspect - // feed, whose shape it shares (intents in, a booth look out). - .add_plugins(ui_dressup::DressUpUiPlugin) - .add_plugins(UiActionPlugin) - // The aura feed (decisions 0255/0257): the player's insertion-ordered buff/debuff cache + the - // self-only durations, pushed as the data the `UnitAura` bindings read; fires UNIT_AURA and - // drains the right-click cancels. After UiActionPlugin (shares its `Spells` catalog). - .add_plugins(UiAuraPlugin) - // The spellbook window feed (decision 0216 §8, slice 5): builds the book from - // PlayerActions.spells through the Spell.dbc/SkillLine.dbc join and drives - // SpellBookFrame.xml's snapshot + cast-drain seam — the spell SOURCE for the cursor payload - // arc (bags/doll/bars/book). After UiActionPlugin (shares its `Spells` resource + the cast - // tail `send_spell_cast`). - .add_plugins(UiSpellbookPlugin) - // The macro system (decision 0983): the icon chooser's catalog, the `benilla-config/macros/` - // files, `UPDATE_MACROS`, and the macro→bound-spell table the action bar's MACRO slots - // resolve their cooldown/usability through. After UiSpellbookPlugin — the bound spell is - // resolved against the book that feed pushes, by the same law `CastSpellByName` uses. - .add_plugins(ui_macro::UiMacroPlugin) - // The talent window feed (decision 0304): builds the class pages from Talent.dbc × the - // known-spell set + PLAYER_CHARACTER_POINTS, drives TalentFrame.xml through the engine's - // talent seam, and drains learn clicks into CMSG_LEARN_TALENT. After UiActionPlugin - // (shares its `Spells` catalog), beside the spellbook it mirrors. - .add_plugins(UiTalentPlugin) - // Unlearning them again (decision 1580): the class trainer's respec question, its - // CONFIRM_TALENT_WIPE dialog, and the answer that is the only packet in the flow which - // unlearns anything. Beside UiTalentPlugin for the subject, but it is UiBinderPlugin's twin - // in shape — a guid-carrying question over an already-closed gossip menu. - .add_plugins(UiTalentWipePlugin) - // The stance/shapeshift bar feed (wow-re shapeshift-bar-api.md): builds the form list from - // PlayerActions.spells per the byte-verified admission/order, drives the stock shapeshift bar through - // the engine's shapeshift seam, and drains its clicks (cancel-if-active else cast). After - // UiActionPlugin (shares `Spells`, the `usable` walk, and the cast tail). - .add_plugins(UiShapeshiftPlugin) - // The pet action bar (decision 0982) — the stance bar's mirror image: server-authoritative, - // so this renders the ten packed words the last `SMSG_PET_SPELLS` delivered and sends - // intents back. After UiActionPlugin (shares `Spells` and the cooldown triple's clock). - .add_plugins(UiPetPlugin) - .add_plugins(ChrClassesPlugin) - .add_plugins(UiPetBookPlugin) - // The pet's paper-doll stat block (happiness/loyalty/XP/training points). Its own plugin - // because it runs off descriptor fields and two DBC tables rather than off `SMSG_PET_SPELLS`. - .add_plugins(UiPetStatsPlugin) - // The pet paper doll's SHARED surface (decision 1057) — the combat-stats snapshot under the - // `"pet"` token and the page's model booth. Apart from the block above because these values - // pass through the character sheet's own bindings and events, with no hunter gate. - .add_plugins(UiPetDollPlugin) - // The connection-telemetry feed: the averaged ping RTT behind `GetNetStats()`, which the main - // bar's performance meter polls (decision 0658). - .add_plugins(UiNetPlugin) - .add_plugins(UiCastPlugin) - // The breath / fatigue bars (decision 0874): server-authoritative mirror timers off the - // wire into the transcribed MirrorTimer1/2/3 frames. Beside the cast bar it shares its - // feed→drain shape (and its art: the same UI-CastingBar-Border chrome). - .add_plugins(UiMirrorPlugin) - // Floating combat text (decision 0137 phase 2): the WORLDTEXTSTRING law — world-anchored - // damage numbers/outcome words projected into the UI quad pass each frame. - .add_plugins(combat_text::CombatTextPlugin) - // Overhead unit names (nameplates): world-billboard name text over players + NPCs. - .add_plugins(nameplates::NameplatesPlugin) - // Raid-target marker billboards (0434 §6): the mark icon over marked units, one line-pitch - // above the overhead name; plated units show the plate's raid child instead. - .add_plugins(raid_marks::RaidMarksPlugin) - // V-key nameplates (0167): the toggled health-bar plates, a 2-D overlay replacing the - // overhead name on plated units. - .add_plugins(vplates::VPlatesPlugin) - // Chat speech bubbles (0598): the over-the-head bubble a say/yell/party line spawns, the - // plates' 2-D overlay sibling — mutually exclusive with both the plate and the name. - .add_plugins(chat_bubble::ChatBubblePlugin) - // TOGGLEUI (`CTRL-Z`/`Cmd-Z`): the whole quad layer goes dark — frames, minimap, plates, - // bubbles, combat text — leaving the world and the cursor. - .add_plugins(ui_hide::UiHidePlugin) - .add_plugins(UiItemsPlugin) - // The gossip window (decision 0081): fills from the net drain's GossipState and drives - // GossipFrame.xml over the Era gossip API. - .add_plugins(UiGossipPlugin) - // The merchant window (decision 0081 phase 4): fills from the net drain's MerchantOpen and - // drives MerchantFrame.xml over the Era vendor API + the money display. - .add_plugins(UiMerchantPlugin) - // The bank window (decision 0604): the SHOW_BANK session (BankOpen) + the purchase row; - // the vault's slots ride the container feed as bags −1/5..=10. - // The auction house (decision 1511) — an NPC-session window like the bank beside it, but the - // only `doublewide` panel in the UI, so it displaces both the left and center seats. - .add_plugins(UiAuctionPlugin) - .add_plugins(UiBankPlugin) - // The mail window (decision 0544 P1/P2): the client-side mailbox session (MailOpen), the - // NPC-session range guard, and MailFrame.xml over the Era mail API (inbox, open-letter, - // send tab). - .add_plugins(UiMailPlugin) - // Player-to-player trade (TradeFrame.xml): the two-sided trade window, driven server-side over - // the P0 wire; the partner's portrait rides the shared "npc" booth (decision 0592 P1). - .add_plugins(UiTradePlugin) - // The item-text reader (ItemTextFrame.xml): right-clicked bag letters (mail-made permanent - // copies) read in the reference reader window over the shared ask-once item-text cache. - .add_plugins(UiItemTextPlugin) - .add_plugins(UiSavedPlugin) - .add_plugins(NamePersistPlugin) - .add_plugins(UiStablePlugin) - .add_plugins(TabardUiPlugin) - .add_plugins(UiTrainerPlugin) - // The taxi map (decision 0484 phases 1-2): the SMSG_SHOWTAXINODES-fed TaxiState resource, the - // NPC-session range guard, and the TaxiFrame.xml window feed/drain (catalogs, node - // projection/route computation, the activate send, the UnitOnTaxi ride flag). - .add_plugins(UiTaxiPlugin) - .add_plugins(UiTradeSkillPlugin) - .add_plugins(UiCraftPlugin) - // The loot window (decision 0084): fills from the net drain's LootState and drives - // LootFrame.xml over the Era loot API (coin + rows, paging). - .add_plugins(UiLootPlugin) - .add_plugins(UiLootRollPlugin) - // The questgiver window (decision 0088): fills from the net drain's QuestGiver and drives - // the stock questgiver window's four sub-panels over the Era quest API (1944). - .add_plugins(UiQuestPlugin) - // The quest-log window (decision 0088's deferred second slice): fills from the self player's - // PLAYER_QUEST_LOG descriptor slots + the SMSG_QUEST_QUERY_RESPONSE template cache, and drives - // the stock quest log over the Era quest-log API (1944). - .add_plugins(UiQuestLogPlugin) - // The party quest-share (decision 1733): the verdict lines on a quest we pushed, and the - // escort-quest confirm. Neither is bound to a window, so it is its own plugin rather than a - // lodger in either quest plugin above. - .add_plugins(QuestSharePlugin) - .add_plugins(UiChatPlugin) - // The layout cache: the geometry of every window the player has dragged or resized, restored - // at world entry and written back a quiet second after the last drag - // (`benilla-config/layout/-.txt`). The consumer of the engine's userPlaced - // bit, which nothing read before it. - .add_plugins(UiLayoutPlugin) - // Print screen (decision 1487): the SCREENSHOT binding's engine half — one PNG per - // `Screenshot()` call into `benilla-config/Screenshots/` (never the install — decision 1486), - // answered to the UI as SCREENSHOT_SUCCEEDED/FAILED so the status text can never be in the - // frame it announces. - .add_plugins(screenshot::ScreenshotPlugin); + }); // Register benilla-assets' loaders AFTER `AssetPlugin` (they go into the live `AssetServer`). benilla_assets::register_asset_loaders(&mut app); diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs index c263186c..59bce3f9 100644 --- a/crates/benilla-app/src/minimap/mod.rs +++ b/crates/benilla-app/src/minimap/mod.rs @@ -1096,9 +1096,15 @@ impl Plugin for MinimapPlugin { // Before the script tick, and after the containment verdict it reads: the // `MINIMAP_PING` event and `Minimap:GetPingPosition()`'s value land in the // same tick, on a ping the renderer already drew at the end of last frame. + // Gated on the interface being up (decision 2279): a group member's + // `MSG_MINIMAP_PING` can land in the same drain as the login burst, and the + // `fresh` latch it sets is spent by this system's take — on the boot VM, with + // no `MiniMapPing` frame to show it, if this ran in 2214's one-frame window. + // Gated, the latch simply waits for the first frame with an interface. ping::drive_minimap_ping .after(feed_minimap_inside) - .before(crate::ui_script::UiInput), + .before(crate::ui_script::UiInput) + .run_if(crate::ui_script::ingame_ui_up), // Before the script tick, so GameTimeFrame's OnUpdate reads this frame's // minute, not last frame's. feed_game_time.before(crate::ui_script::UiInput), diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 581f77a6..42a367a3 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -9,13 +9,9 @@ use benilla_protocol::{ObjectFields, SessionEvent}; use bevy::prelude::*; use super::{ - AiReactionMessage, CharActionResultMessage, CharListMessage, EmoteMessage, EnteredWorldMessage, - Guid, GuidIndex, LoggedOutMessage, NetCommands, NetEvents, NetStatus, ObjectStore, - PendingTransfer, PetDismissSoundMessage, PetTalkMessage, RemoteMotion, Reputations, SelfGuid, - SelfPlayer, ServerSoundMessage, ServerTime, ServerWallClock, TeleportMessage, UnitMoveModes, - WorldportMessage, + Guid, GuidIndex, NetCommands, NetEvents, NetStatus, ObjectStore, Reputations, SelfGuid, + SelfPlayer, }; -use benilla_world::weather::WeatherMessage; mod anim; mod auction; @@ -31,6 +27,7 @@ mod mount; mod names; mod npc; mod objects; +mod params; mod pet; mod quests; mod session; @@ -38,6 +35,8 @@ mod spells; mod trade; mod world; +use params::{ActionStores, AnimWriters, Catalogs, Clocks, ObjectQueries, Session, WindowStores}; + // 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::{ @@ -89,475 +88,170 @@ fn addressed_store<'a>( /// Drain the inbound event channel and mutate real ECS entities: spawn on create, move existing, /// despawn on remove, attach/clear movement splines, and surface teleport/worldport/clock changes. -// The tuple params below batch resources to stay under Bevy's 16-SystemParam ceiling; clippy reads -// the 5-element ResMut tuples as "very complex types", but a named alias per tuple would be less -// legible than the inline, commented groups. -#[allow(clippy::type_complexity)] pub(crate) fn apply_net_updates( mut commands: Commands, events: Res, + net_commands: Res, mut index: ResMut, mut self_guid: ResMut, mut status: ResMut, - // The two server clocks, paired in one param (the 16-SystemParam ceiling this signature - // already lives against): the in-game day/night clock the lighting reads, and the wall clock - // the absolute descriptor stamps are dated in (decision 1150). Different quantities, same - // handler file. - mut clocks: (ResMut, ResMut), mut reputations: ResMut, - mut transforms: Query<&mut Transform>, - mut stores: Query<&mut ObjectStore>, - // One tuple param (the 16-SystemParam ceiling this signature already lives against): the three - // per-unit motion states the drain writes. [`RemoteMotion`] is a relayed player's dead-reckon; - // [`UnitMoveModes`] is any unit's server-granted movement modes (decision 1780); - // [`crate::transport::TransportRider`] is the deck a unit is standing on and its pose there - // (decision 1936). Different components, one concern — what the wire says about how a body we - // don't control is moving. - mut motion: ( - Query<&mut RemoteMotion>, - Query<&mut UnitMoveModes>, - Query<&mut crate::transport::TransportRider>, - ), - // One tuple param (the 16-SystemParam ceiling): the session-lifecycle one-shot writers — the - // player's teleport/worldport snaps + the glue-screen edges (decision 0193). - session_msgs: ( - MessageWriter, - MessageWriter, - // **The two park publications, paired** — the tuple is at Bevy's 16-param ceiling, and - // these are the pair that belongs together: the realm list and the character roster are - // the same thing one park apart, each published so the app's policy can answer it. - ( - MessageWriter, - MessageWriter, - ), - // **The pick park's two verdicts, paired** (the tuple is at Bevy's 16-element ceiling, and - // these belong together): what a create/delete came back with, and the refusal of the pick - // itself. - ( - MessageWriter, - MessageWriter, - ), - // **The world-entry pair** — the tuple is at Bevy's 16-element ceiling, and these two are - // one edge: the entry message, and the `SMSG_ADDON_INFO` verdict that the entry's own UI - // load reads before the first addon's file-scope code asks `GetNumAddOns()` (2175). - ( - MessageWriter, - ResMut, - ), - MessageWriter, - MessageWriter, - // The two server-authored mover edges the controller both *applies* and *answers*, paired to - // stay inside Bevy's 16-element tuple limit — and they do belong together: a granted mode - // (decisions 0308, 0866) and a knockback launch (decision 1702) are the same handshake, an - // edge the server may not act on until our own live pose comes back. - ( - MessageWriter, - MessageWriter, - ), - // The login screen's dialog + reconnect-policy feed (decision 0539). - MessageWriter, - // The login queue's position feed (decision 1681). - MessageWriter, - MessageWriter, - MessageWriter, - // The server's own answer to a GM dot-command, readable rather than only logged — the - // probe shield's confirmation channel (decision 0677). - MessageWriter, - // A `MSG_MOVE_*` the server addressed to OUR mover (decision 0725): a pose it wrote, with - // no handshake — the controller applies it in `player::wire_in`. - MessageWriter, - // The possession handoff (`SMSG_CLIENT_CONTROL_UPDATE`): control of a unit granted or - // revoked. Forwarded verbatim — only the controller knows the pose it would have to park. - MessageWriter, - // A cinematic to play (`SMSG_TRIGGER_CINEMATIC`) — `crate::cinematic` owns both the - // playback and the ack that has to answer it (decision 0196). - MessageWriter, - ), - // One tuple param (the 16-SystemParam ceiling): the ask-once query caches + the gossip/merchant - // state the net drain fills for the NPC-interaction windows (decision 0081). - caches: ( - ResMut, - ResMut, - ResMut, - ResMut, - // Nested pair: the two gossip-reached NPC service sessions the net drain fills whose - // state is a whole open window (the tuple is at the 16-param ceiling). - ( - ResMut, - ResMut, - ), - // Nested triple (the tuple is at the 16-param ceiling): the loot window state, the - // client-local loot-target latch (the kneel's self trigger, decision 0515), and the open - // group-loot rolls (decision 0591). - ( - ResMut, - ResMut, - ResMut, - ), - ResMut, - ResMut, - // Nested pair (the tuple is at the 16-param ceiling): the quest-log template cache, and - // the party quest-share state (decision 1733) — the verdicts on a quest we pushed and the - // escort confirm. Same family, one slot; unlike most nestings here these two genuinely - // belong together, since a share is a quest-log verb. - ( - ResMut, - ResMut, - ), - ResMut, - ResMut, - ResMut, - ResMut, - // The death arc's wire-fed store (decision 0308): reclaim expiry, corpse location, - // resurrect offer, the spirit-healer confirm. - ResMut, - // The party/raid roster mirror + its composed system lines (decision 0434). - ResMut, - // The taxi-map session (decision 0484 phase 1) + the mailbox session + its login-scoped - // arrival countdown (decision 0544) + the player-trade session (decision 0592) + the bank - // session and its purchase-refusal queue (decision 0604) + the world-state table the - // NPC-text `$w` tokens read + the duel session (decision 0633), grouped to stay under - // Bevy's 16-SystemParam ceiling (this tuple's 16th and last slot). - ( - ResMut, - ResMut, - ResMut, - ResMut, - ResMut, - ResMut, - ResMut, - ResMut, - ResMut, - // The pending logout/quit (decision 0674): the server's response and cancel-ack land - // here, and `crate::ui_logout` turns them into the countdown dialog. - ResMut, - // The shared AreaTable catalog — the exploration arm's area-id → name resolve - // (decision 0828) — and the race-keyed discovery-jingle catalog (decision 0829). - // Either absent if its DBC failed to load. - Option>, - Option>, - // The mirror-timer queue (decision 0874): the breath/fatigue START/PAUSE/STOP edges, - // drained into the FrameXML bars by `crate::ui_mirror`. - ResMut, - // The pet action bar's server-authoritative state + its own cooldown store - // (decision 0982), replaced wholesale on every `SMSG_PET_SPELLS`; and the by-key red - // error queue its refused-order feedback rides (the `DisplayError` route, resolved - // through the VM's own GlobalStrings by `ui_action::feed_actions`). - ResMut, - ResMut, - // The ask-once book-page cache (decision 1105) — every readable's text, keyed by - // `PageText` id; the reader session repaints off it. - // Paired into one slot: the group is itself at Bevy's 16-param ceiling, so a 17th - - // entry stops the whole system implementing `SystemParam` — nesting is the same - - // escape this tuple already is. Third member: the guild session (decision 1257) — - // the identity/roster mirror the seven `SessionEvent::Guild*` arms below drive. - ( - ResMut, - ResMut, - ResMut, - // The innkeeper's pending bind question (decision 1331) — `SMSG_BINDER_CONFIRM` - // parks the innkeeper's guid here and `crate::ui_binder` turns it into the - // CONFIRM_BINDER dialog, whose Accept is the only thing that binds anything. - ResMut, - // The class trainer's pending respec question (decision 1580) — the inbound - // `MSG_TALENT_WIPE_CONFIRM` parks the trainer's guid + cost here and - // `crate::ui_talent_wipe` turns it into the CONFIRM_TALENT_WIPE dialog, whose - // Accept is the only thing that unlearns anything. The binder's twin above. - ( - ResMut, - // The dialog engine's verbs (decision 1963): the pet trainer's latch, the - // instance-boot clock, the area spirit healer, the battleground queue and - // the meeting-stone queue — each a feed for a stock dialog. Nested beside - // the talent wipe because this tuple sits at Bevy's sixteen-entry limit. - ( - ResMut, - ResMut, - ResMut, - ResMut, - ResMut, - ResMut, - ResMut, - ResMut, - ResMut, - ResMut, - ), - ), - // The guard's directions marker (`SMSG_GOSSIP_POI`) and the map id it has to be - // stamped with — the wire carries no map field, so "where you were standing when - // the guard told you" is the client's to remember (`crate::poi_marker`). - ResMut, - Option>, - // The inspect-honor reply (decision 1512) — `MSG_INSPECT_HONOR_STATS` is the only - // source of another player's honor numbers, so the reply parks here and - // `crate::ui_honor` pushes it into the pane and fires `INSPECT_HONOR_UPDATE`. - ResMut, - // The auctioneer session (decision 1511) — the hello reply opens it, the three - // list results fill it, and `crate::ui_auction` feeds it to the window. - ResMut, - // Guild Member Alert (decision 1589) — the CVar knob the sign-on/sign-off pair's - // display condition reads; see `ui_guild::apply::event` for the four conjuncts. - Res, - // The minimap ping (decision 1596) — a group member's `MSG_MINIMAP_PING` seats the - // world point here and the minimap renderer derives everything else from it. - ResMut, - // The GM ticket (decision 1673) — `SMSG_GMTICKET_GETTICKET` replaces the held - // ticket here and BUMPS AN ANSWER COUNTER, which is what `crate::ui_gm_ticket` - // diffs on: the Help window re-polls every 10 minutes and an unchanged answer - // still has to re-fire `UPDATE_TICKET`. - ResMut, - // The guild-charter session (decision 1672) — two resources because only the - // registrar half is NPC-bound: `SMSG_PETITION_SHOWLIST` opens the registrar, and - // `SMSG_PETITION_SHOW_SIGNATURES` opens the item-bound charter window, which must - // survive walking away from the registrar. - ResMut, - ResMut, - // The pending summon question (decision 1747) — `SMSG_SUMMON_REQUEST` parks the - // summoner's guid, zone and expiry here and `crate::ui_summon` turns it into the - // CONFIRM_SUMMON dialog, whose Accept is the only packet in the flow. - ResMut, - // The instance-lockout bookkeeping (decision 1748) — four of its six packets - // queue a GlobalStrings-templated chat line here for `crate::ui_instance` to - // resolve against the VM, and two write the latch behind - // `CanShowResetInstances()`. - ResMut, - ), - ), - ), - // One tuple param (the 16-SystemParam ceiling again): the action-bar- + merchant-facing errors - // and the cast-bar feed (decision 0137), plus the item-lock bookkeeping the inventory-failure - // arm also drains (decision 0216 §4 / 0218 §3 — this apply site has no `UiScript` to fire - // `ITEM_LOCK_CHANGED` through, so the transitioned slots queue in `LockTransitions` for - // the container feed to pick up). - mut ui_actions: ( - ResMut, - // Nested group (the tuple is at the 16-param ceiling): the cast + mount error queues, - // both drained into the red error line by `ui_action::feed_actions`, plus the - // FactionTemplate catalog the combat log's friend/foe split reads (B297). The catalog has - // nothing to do with the other two — it rides here purely because this is where the - // ceiling left room. Absent if the DBC failed to load, which degrades an unresolved unit - // to the friendly side of the classifier rather than dropping its lines. - ( - ResMut, - ResMut, - Option>, - // The `modalNextSpell` chain's outbox (1597) — `cast_result` fills it, the ui_action - // drain sends it through the one cast path. Rides here for the same reason the - // catalog does: this is where the ceiling left room. - ResMut, - // The taming-refusal queue (decision 2039) — `SMSG_PET_TAME_FAILURE`'s reason byte. - // Its own queue rather than `UiErrorKeys` because its message needs TWO GlobalStrings - // lookups, and the inner one is only reachable at the drain (see the resource's doc). - ResMut, - // The combat-feedback CVars — the eight display ranges, `CombatLogPeriodicSpells`, - // and the three floating-text gates. Rides here for exactly the reason the - // FactionTemplate catalog two fields up does: the signature is at the 16-param ceiling - // and this is where the room is. One bundle rather than three `Res`, so the use sites - // read `cvars.ranges` instead of `ui_actions.1 .7`. - crate::ui_chat::combat::CombatFeedbackCvars<'_>, - // Spells learned mid-session, awaiting `LEARNED_SPELL_IN_TAB` (2252). A queue for the - // same reason the reference sorts before it fires: the tab index is only correct - // against the rebuilt tab list, which is the spellbook feed's, not this apply's. - ResMut, - ), - ResMut, - ResMut, - ResMut, - ResMut, - ResMut, - // Nested pair (the tuple is at the ceiling): the two NPC-service windows' error queues, - // each drained onto its window's red line by its own feed. - ( - ResMut, - ResMut, - ), - ResMut, - // The cooldown store + the Spell.dbc catalog its wire laws read, and the live - // auto-repeat state the bar's flash rides (decision 0137 phase 4). - ResMut, - Option>, - ResMut, - // Our own running channel (the IsCurrentAction channel leg, decision 0137 phase 4). - ResMut, - // Pre-formatted UIErrorsFrame lines — text the wire already resolved, so there is no - // GlobalStrings key to look up: the death durability notice, `SMSG_NOTIFICATION`, and - // `SMSG_AREA_TRIGGER_MESSAGE`. Drained by `ui_action::feed_actions` beside UiErrorKeys. - ResMut, - // The queued on-next-swing strike (the melee-slot half of the cast tracking) — the - // wire resolves it here: GO fires it, a failing result/interrupt kills it. - ResMut, - ), - net_commands: Res, - // One tuple param (Bevy's 16-SystemParam ceiling): the audio + combat/cast bridge writers, the - // cast-state read the spell-id-keyed `Casting` reap needs (decision 0107), and the floating - // combat-text feed (decision 0137 phase 2). - mut audio: ( - MessageWriter, - MessageWriter, - MessageWriter, - MessageWriter, - Query<&crate::creature_anim::Casting>, - MessageWriter, - MessageWriter, - MessageWriter, - MessageWriter, - MessageWriter, - MessageWriter, - // The aggro/alert vocal flare + the pushed-kit play (decision 0280). - MessageWriter, - MessageWriter, - // The remote landing predictor's report (decision 0415): a relayed FALL_LAND fires the - // grunt + dust puff for an observed mover, the way the self controller does for us. - MessageWriter, - // An observed rider's flourish (`SMSG_MOUNTSPECIAL_ANIM`, decision 0441 P2) — the - // unit → mount-child hop happens in `creature_anim::flourish_to_anim`. - MessageWriter, - // The UNIT_COMBAT event feed (the portrait hit indicator, decision 0576) + the - // COMBAT_TEXT_UPDATE feed (the center combat text, decision 0578) — the spell arms' - // self-facing twins of the floating-text spawn — and the sheath setter's queue, which the - // ATTACKERSTATEUPDATE arm's melee auto-draw writes into. Nested: the tuple is at the - // 16-param ceiling. - ( - MessageWriter, - MessageWriter, - MessageWriter, - // The GO one-shot Custom play (`SMSG_GAMEOBJECT_CUSTOM_ANIM` — the bobber's bite - // splash, decision 1086), the step-8 sibling of the `GoLidOpen` writer above. - MessageWriter, - // Are *we* already swinging? The ref's `[player+0xc48]`, mirrored by the - // server-echoed [`crate::creature_anim::Engaged`] — read by the GO handler's deferred - // auto-attack start (`0x6e83e7`, decision 1593). Filter-only, so it conflicts with - // nothing else in this drain. - Query<(), (With, With)>, - // The pet's bark (`SMSG_PET_ACTION_SOUND`, decision 2039) — `AiReactionMessage`'s - // sibling: both are pure audio that resolves a guid and hands the sound layer a - // state for the SAME `0x623a40` dispatcher. Nested for the same reason as its - // neighbours: the outer tuple is at the 16-param ceiling. - MessageWriter, - MessageWriter, - // The swing-refusal seam's two edges (`crate::swing_refusal`, decision 2037): the four - // `SMSG_ATTACKSWING_*` refusals, and our own landed swing, which clears their latch. - // ONE writer for both so the drain hands them on in packet order. - MessageWriter, - ), - ), - // The aura feed's duration side-table + the clock to stamp arrivals (decisions 0255/0257): the - // self-only `SMSG_UPDATE_AURA_DURATION` lands here keyed by raw slot, timestamped for the - // `ui_aura` slot-join — plus the ping clock, which this drain now only CLEARS on a - // disconnect: the measuring moved to the read thread with B346's fix. - // Grouped as a tuple to stay under Bevy's 16-SystemParam ceiling. - // - // The stamp clock is `Time`, NOT the default virtual one, and both its readers - // (`aura_duration`, `corpse_reclaim_delay`) are the reason: a span the SERVER sends us counts - // down in real seconds, so it must be stamped and read on a real clock. `Time` clamps - // at 250 ms per frame (`max_delta`), which is right for simulation — a 2 s hitch must not - // teleport animations — and wrong here: every long frame would quietly ADD that much to every - // buff timer, so an aura would vanish with seconds still showing on its clock. Measured on a - // hitchy run: the virtual clock lost 20 s against real in 33 s (decision 0846). - mut aura: ( - ResMut, - Res>, - Res, - // READ-only: every write to a mover's speed set goes through this drain's - // `objects::SpeedStage` and lands in one flush at the end (decision 1478). - Query<&super::UnitSpeeds>, - // The PlayAnimation call-order counter (`creature_anim::PlaySeq`): every - // animation-bearing message this drain emits stamps `next()`, in packet order. - ResMut, - // The EnvironmentalDamage.dbc 6-slot table (damage type → SpellVisualKit) the - // `SMSG_ENVIRONMENTALDAMAGELOG` arm reads — the fall-landing dust puff. - Option>, - // The far-teleport latch + the armed-transport lens for the worldport's spare - // predicate (decision 0455: a boat whose path touches the destination map survives - // the purge; the TRANSFER_PENDING transport block routes NEW_WORLD's coordinates). - ResMut, - Query<&crate::transport::Transport>, - // The REAL-time clock the relayed-move replay runs on (decisions 0601/0615): a remote's - // fire-time is stamped against this, and `drain_pending_moves`/`extrapolate_remote_units` - // read the same clock. Virtual time's `max_delta` clamp falls behind real time under - // occlusion throttling and would displace the whole replay schedule. - Res>, - ), + // The families, by name (`params`): the drain destructures each one below so every arm + // reads a plain local. + clocks: Clocks, + objects: ObjectQueries, + session: Session, + windows: WindowStores, + actions: ActionStores, + anim: AnimWriters, + catalogs: Catalogs, ) { + let Clocks { + mut server_time, + mut wall_clock, + real: real_clock, + } = clocks; + let ObjectQueries { + mut transforms, + mut stores, + remote: mut remote_motion, + modes: mut unit_modes, + mut riders, + speeds: unit_speeds, + transports, + casting: casting_units, + engaged_self, + } = objects; + let ActionStores { + mut player_actions, + mut cast_errors, + mut mount_errors, + mut chain_casts, + mut pet_tame_failures, + mut learned_in_tab, + mut equip_errors, + mut merchant_errors, + mut cast_bar, + mut pending_item_ops, + mut lock_transitions, + mut trainer_errors, + mut stable_errors, + mut pending_cast, + cooldowns: mut cooldown_store, + mut auto_repeat, + mut active_channel, + mut ui_error_texts, + mut queued_melee, + mut aura_durations, + } = actions; + let AnimWriters { + mut server_sounds, + weather: mut weather_msgs, + mut emotes, + mut swings, + mut cast_events, + mut spell_go_targets, + combat_text: mut combat_text_spawns, + mut swing_impacts, + mut swing_flushes, + go_lid_open: mut go_lid_opens, + mut ai_reactions, + mut kit_pushes, + mut hard_landings, + mut mount_flourishes, + unit_combat: mut unit_combat_feedback, + mut combat_text_events, + mut sheath_requests, + go_custom_anim: mut go_custom_anims, + pet_talk: mut pet_talks, + pet_dismiss: mut pet_dismiss_sounds, + swing_refusals: mut swing_refusal_edges, + mut play_seq, + } = anim; + let Catalogs { + factions: faction_catalog, + spells: spell_catalog, + combat_cvars, + env_damage: env_damage_table, + area_table, + exploration_sounds, + current_map, + guild_notify, + } = catalogs; // 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 ( + let play_seq: &mut crate::creature_anim::PlaySeq = &mut play_seq; + let WindowStores { mut names, mut items, mut gossip, mut merchant, - (mut trainer_open, mut stable_open), - (mut loot, mut loot_latch, mut loot_rolls), + mut trainer_open, + mut stable_open, + mut loot, + mut loot_latch, + mut loot_rolls, mut chat_log, mut quest, - (mut quest_log, mut quest_share), + mut quest_log, + mut quest_share, mut go_templates, mut home_bind, mut proficiencies, mut dropped, mut death_net, mut group, - ( - mut taxi, - mut mail_open, - mut mail_pending, - mut trade_session, - mut bank_open, - mut bank_errors, - mut world_states, - mut duel, - mut social, - mut logout, - area_table, - exploration_sounds, - mut mirror_timers, - mut pet_bar, - mut ui_error_keys, - ( - mut page_texts, - mut played_time_answer, - mut guild, - mut binder, - ( - mut talent_wipe, - ( - mut pet_unlearn, - mut instance_boot, - mut area_spirit, - mut battlefield_queue, - mut meeting_stone, - mut battlefield_scoreboard, - mut battlefield, - mut tutorials, - mut battlefield_positions, - mut tabard, - ), - ), - mut poi_marker, - current_map, - mut inspect_honor, - mut auction_open, - guild_notify, - mut ping, - mut gm_ticket, - mut registrar, - mut petition, - mut summon, - mut instances, - ), - ), - ) = caches; - let ( + mut taxi, + mut mail_open, + mut mail_pending, + mut trade_session, + mut bank_open, + mut bank_errors, + mut world_states, + mut duel, + mut social, + mut logout, + mut mirror_timers, + mut pet_bar, + mut ui_error_keys, + mut page_texts, + mut played_time_answer, + mut guild, + mut binder, + mut talent_wipe, + mut pet_unlearn, + mut instance_boot, + mut area_spirit, + mut battlefield_queue, + mut meeting_stone, + mut battlefield_scoreboard, + mut battlefield, + mut tutorials, + mut battlefield_positions, + mut tabard, + mut poi_marker, + mut inspect_honor, + mut auction_open, + mut ping, + mut gm_ticket, + mut registrar, + mut petition, + mut summon, + mut instances, + } = windows; + let Session { mut teleports, mut worldports, - (mut char_lists, mut realm_lists), - (mut char_actions, mut char_login_failures), - (mut entered_world, mut addon_reply), + mut char_lists, + mut realm_lists, + mut char_actions, + mut char_login_failures, + mut entered_world, + mut addon_reply, mut logged_out, mut speed_changes, - (mut move_modes, mut knockbacks), + mut move_modes, + mut knockbacks, mut login_stages, mut login_queued, mut login_failures, @@ -566,7 +260,8 @@ pub(crate) fn apply_net_updates( mut self_moves, mut client_control, mut cinematics, - ) = session_msgs; + transfer: mut pending_transfer, + } = session; // Descriptor seeds/deltas for objects created *earlier in this same drain* can't land on their // entities yet (the spawn `Command` hasn't run), so they accumulate here and flush once at the end. // This also removes a latent clobber: a plain per-delta `insert` on a not-yet-spawned entity would @@ -590,11 +285,11 @@ pub(crate) fn apply_net_updates( self_guid: &self_guid, group: Some(&group), index: &index, - factions: ui_actions.1 .2.as_deref(), + factions: faction_catalog.as_deref(), reputations: &reputations, - spells: ui_actions.10.as_deref(), - ranges: &ui_actions.1 .5.ranges, - periodic: ui_actions.1 .5.periodic.0, + spells: spell_catalog.as_deref(), + ranges: &combat_cvars.ranges, + periodic: combat_cvars.periodic.0, } }; } @@ -684,8 +379,8 @@ pub(crate) fn apply_net_updates( &mut social, &mut guild, &mut gm_ticket, - &mut ui_actions.9, - &mut aura.6, + &mut cooldown_store, + &mut pending_transfer, &mut disconnects, ); } @@ -751,7 +446,11 @@ pub(crate) fn apply_net_updates( // `[CMovement+0xac] += lag`). A guid we do not hold is dropped, faithfully: the // reference resolves under `TYPEMASK_UNIT` and returns on a miss. SessionEvent::MoveTimeSkipped { guid, lag_ms } => { - if let Some(mut m) = index.0.get(&guid).and_then(|&e| motion.0.get_mut(e).ok()) { + if let Some(mut m) = index + .0 + .get(&guid) + .and_then(|&e| remote_motion.get_mut(e).ok()) + { m.relay.skip_time(lag_ms); } } @@ -770,7 +469,7 @@ pub(crate) fn apply_net_updates( // The scheduled-replay law (decisions 0601/0615): `unit_move` runs the mover's own // replay chain over this packet's wire stamp to get its client fire-time, then // applies it now if due, else queues it on the unit for `drain_pending_moves`. - let now_ms = aura.8.elapsed_secs_f64() * 1000.0; + let now_ms = real_clock.elapsed_secs_f64() * 1000.0; objects::unit_move( guid, crate::net::motion::RelayMove { @@ -788,9 +487,9 @@ pub(crate) fn apply_net_updates( &mut commands, &index, &self_guid, - &mut motion.0, + &mut remote_motion, &mut transforms, - &mut audio.13, + &mut hard_landings, &mut self_moves, ); } @@ -841,11 +540,11 @@ pub(crate) fn apply_net_updates( duration_ms, flying, run_mode, - objects::modes_of(guid, &index, &motion.1, &staged_modes).rooted(), + objects::modes_of(guid, &index, &unit_modes, &staged_modes).rooted(), &mut commands, &index, &mut transforms, - &mut motion.2, + &mut riders, ), SessionEvent::Teleport { guid, @@ -876,26 +575,26 @@ pub(crate) fn apply_net_updates( needs_ack, &mut commands, &mut index, - &mut aura.6, - &aura.7, + &mut pending_transfer, + &transports, &mut worldports, ) } SessionEvent::TransferPending { map_id, transport_entry, - } => session::transfer_pending(map_id, transport_entry, &mut aura.6), + } => session::transfer_pending(map_id, transport_entry, &mut pending_transfer), SessionEvent::TransferAborted { reason } => { - session::transfer_aborted(reason, &mut aura.6) + session::transfer_aborted(reason, &mut pending_transfer) } SessionEvent::TimeSpeed { hours, minutes, day_serial, timescale, - } => session::time_speed(hours, minutes, day_serial, timescale, &mut clocks.0), + } => session::time_speed(hours, minutes, day_serial, timescale, &mut server_time), SessionEvent::ServerUnixTime { unix_time } => { - session::server_unix_time(unix_time, &mut clocks.1) + session::server_unix_time(unix_time, &mut wall_clock) } SessionEvent::Reputations { standings } => { session::reputations(standings, &mut reputations) @@ -906,7 +605,7 @@ pub(crate) fn apply_net_updates( combat_chat::faction_standing( &standings, &reputations, - ui_actions.1 .2.as_deref(), + faction_catalog.as_deref(), &mut chat_log, ); session::reputation_delta(standings, &mut reputations, &mut quest) @@ -932,7 +631,7 @@ pub(crate) fn apply_net_updates( credit.victim_guid, u8::try_from(credit.victim_rank).unwrap_or(0), ); - audio.15 .1.write(crate::ui_unit::CombatTextEvent { + combat_text_events.write(crate::ui_unit::CombatTextEvent { message_type: "HONOR_GAINED", data: Some(credit.honor.to_string()), extra: None, @@ -961,7 +660,7 @@ pub(crate) fn apply_net_updates( zone, delay_ms, dead_or_ghost, - aura.1.elapsed_secs_f64(), + real_clock.elapsed_secs_f64(), &mut summon, ); } @@ -1052,7 +751,7 @@ pub(crate) fn apply_net_updates( &mut binder, &mut ui_error_keys, area_table.as_deref(), - &mut audio.0, + &mut server_sounds, ) } SessionEvent::Proficiency { @@ -1105,24 +804,24 @@ pub(crate) fn apply_net_updates( objects::gameobject_info(entry, type_id, display_id, name, &data, &mut go_templates) } SessionEvent::GameObjectCustomAnim { guid, anim_id } => { - objects::gameobject_custom_anim(guid, anim_id, &mut audio.15 .3) + objects::gameobject_custom_anim(guid, anim_id, &mut go_custom_anims) } SessionEvent::GameObjectDespawnAnim { guid } => { objects::gameobject_despawn_anim(guid, &mut commands, &index) } SessionEvent::FishNotHooked => loot::fish_verdict(false, &mut ui_error_keys), SessionEvent::FishEscaped => loot::fish_verdict(true, &mut ui_error_keys), - 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::PlaySound { sound_id } => world::play_sound(sound_id, &mut server_sounds), + SessionEvent::PlayMusic { music_id } => world::play_music(music_id, &mut server_sounds), SessionEvent::PlayObjectSound { sound_id, guid } => { - world::play_object_sound(sound_id, guid, &index, &mut audio.0) + world::play_object_sound(sound_id, guid, &index, &mut server_sounds) } SessionEvent::Weather { weather_type, grade, sound_id, instant, - } => world::weather(weather_type, grade, sound_id, instant, &mut audio.1), + } => world::weather(weather_type, grade, sound_id, instant, &mut weather_msgs), SessionEvent::TextEmote { guid, text_emote, @@ -1132,30 +831,35 @@ pub(crate) fn apply_net_updates( text_emote, target_name, &index, - &mut audio.2, + &mut emotes, &mut chat_log, ), SessionEvent::Emote { guid, emote_id } => { - anim::emote(guid, emote_id, &index, &mut audio.2) + anim::emote(guid, emote_id, &index, &mut emotes) } // 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). SessionEvent::SpellBook { spell_ids, cooldowns, - } => spell_book(spell_ids, cooldowns, &mut ui_actions.0, &mut ui_actions.9), - SessionEvent::ActionButtons { buttons } => action_buttons(buttons, &mut ui_actions.0), + } => spell_book( + spell_ids, + cooldowns, + &mut player_actions, + &mut cooldown_store, + ), + SessionEvent::ActionButtons { buttons } => action_buttons(buttons, &mut player_actions), SessionEvent::SpellLearned { spell_id } => learned_spell( spell_id, - &mut ui_actions.0, - ui_actions.10.as_deref(), + &mut player_actions, + spell_catalog.as_deref(), &mut ui_error_keys, - &mut ui_actions.1 .6, + &mut learned_in_tab, ), SessionEvent::SpellRemoved { spell_id } => removed_spell( spell_id, - &mut ui_actions.0, - ui_actions.10.as_deref(), + &mut player_actions, + spell_catalog.as_deref(), &mut ui_error_keys, ), SessionEvent::SpellSuperceded { @@ -1164,10 +868,10 @@ pub(crate) fn apply_net_updates( } => superceded_spell( old_spell_id, new_spell_id, - &mut ui_actions.0, - ui_actions.10.as_deref(), + &mut player_actions, + spell_catalog.as_deref(), &mut ui_error_keys, - &mut ui_actions.1 .6, + &mut learned_in_tab, ), SessionEvent::CastResult { spell_id, @@ -1182,17 +886,17 @@ pub(crate) fn apply_net_updates( &mut commands, &self_guid, &index, - &mut ui_actions.1 .0, - &audio.4, - &mut audio.5, - &mut ui_actions.4, - &mut ui_actions.8, - &mut ui_actions.14, - &mut ui_actions.9, - &mut ui_actions.11, - ui_actions.10.as_deref(), + &mut cast_errors, + &casting_units, + &mut cast_events, + &mut cast_bar, + &mut pending_cast, + &mut queued_melee, + &mut cooldown_store, + &mut auto_repeat, + spell_catalog.as_deref(), &net_commands, - &mut ui_actions.1 .3, + &mut chain_casts, play_seq.next(), ), SessionEvent::InventoryFailure { @@ -1205,9 +909,9 @@ pub(crate) fn apply_net_updates( required_level, item_guid, bag_slot, - &mut ui_actions.2, - &mut ui_actions.5, - &mut ui_actions.6, + &mut equip_errors, + &mut pending_item_ops, + &mut lock_transitions, &mut loot_latch, ), SessionEvent::Chat(m) => { @@ -1242,9 +946,9 @@ pub(crate) fn apply_net_updates( SessionEvent::ChatRestricted => { chat::broadcast(crate::ui_chat::Broadcast::ChatRestricted, &mut chat_log) } - SessionEvent::Notification { text } => chat::notification(text, &mut ui_actions.13), + SessionEvent::Notification { text } => chat::notification(text, &mut ui_error_texts), SessionEvent::AreaTriggerMessage { text } => { - chat::area_trigger_message(text, &mut ui_actions.13) + chat::area_trigger_message(text, &mut ui_error_texts) } SessionEvent::PlayedTime { total, level } => { // BOTH halves, and they are not redundant. The chat breakdown is our stand-in for @@ -1517,7 +1221,7 @@ pub(crate) fn apply_net_updates( corpse_map, } => death::corpse_query(found, display_map, position, corpse_map, &mut death_net), SessionEvent::CorpseReclaimDelay { delay_ms } => { - death::corpse_reclaim_delay(delay_ms, aura.1.elapsed_secs_f64(), &mut death_net) + death::corpse_reclaim_delay(delay_ms, real_clock.elapsed_secs_f64(), &mut death_net) } SessionEvent::ResurrectRequest { caster, @@ -1551,8 +1255,8 @@ pub(crate) fn apply_net_updates( apply, &mut commands, &index, - &mut motion.1, - &mut motion.0, + &mut unit_modes, + &mut remote_motion, &mut staged_modes, ), SessionEvent::KnockBack { @@ -1567,10 +1271,10 @@ pub(crate) fn apply_net_updates( combat::attack_start(attacker, victim, &mut commands, &index) } SessionEvent::AttackStop { attacker, victim } => { - combat::attack_stop(attacker, victim, &mut commands, &index, &mut audio.9) + combat::attack_stop(attacker, victim, &mut commands, &index, &mut swing_flushes) } SessionEvent::AiReaction { unit, reaction } => { - combat::ai_reaction(unit, reaction, &index, &mut audio.11) + combat::ai_reaction(unit, reaction, &index, &mut ai_reactions) } SessionEvent::AttackerState(s) => { combat_chat::attacker_state(s, &chat_ctx!(), &stores, &transforms, &mut chat_log); @@ -1578,17 +1282,19 @@ pub(crate) fn apply_net_updates( s, &index, &self_guid, - &mut audio.3, - &mut audio.8, - &mut audio.15 .1, - &mut audio.15 .2, - &mut audio.15 .7, + &mut swings, + &mut swing_impacts, + &mut combat_text_events, + &mut sheath_requests, + &mut swing_refusal_edges, &stores, play_seq.next(), ) } - SessionEvent::AttackSwingError(e) => combat::attack_swing_error(e, &mut audio.15 .7), - SessionEvent::CancelCombat => combat::cancel_combat(&mut audio.15 .7), + SessionEvent::AttackSwingError(e) => { + combat::attack_swing_error(e, &mut swing_refusal_edges) + } + SessionEvent::CancelCombat => combat::cancel_combat(&mut swing_refusal_edges), SessionEvent::FeignDeathResisted => combat::feign_death_resisted(&mut ui_error_keys), SessionEvent::SpellDamageLog(s) => { combat_chat::spell_damage_log(s, &chat_ctx!(), &stores, &transforms, &mut chat_log); @@ -1597,11 +1303,11 @@ pub(crate) fn apply_net_updates( &index, &self_guid, &stores, - ui_actions.10.as_deref(), - *ui_actions.1 .5.damage_text, - &mut audio.7, - &mut audio.15 .0, - &mut audio.15 .1, + spell_catalog.as_deref(), + *combat_cvars.damage_text, + &mut combat_text_spawns, + &mut unit_combat_feedback, + &mut combat_text_events, ) } SessionEvent::PeriodicAuraLog(s) => { @@ -1610,7 +1316,7 @@ pub(crate) fn apply_net_updates( // the handler `0x626dd0` does, and a zero jumps to the bare epilogue `0x6271b4`: // no chat line, no floating tick number, no periodic miss word. Gating inside // either half below would model it as two filters; it is one gate over both. - if !ui_actions.1 .5.periodic.0 { + if !combat_cvars.periodic.0 { continue; } combat_chat::periodic_aura_log( @@ -1625,11 +1331,11 @@ pub(crate) fn apply_net_updates( &index, &self_guid, &stores, - ui_actions.10.as_deref(), - *ui_actions.1 .5.damage_text, - &mut audio.7, - &mut audio.15 .0, - &mut audio.15 .1, + spell_catalog.as_deref(), + *combat_cvars.damage_text, + &mut combat_text_spawns, + &mut unit_combat_feedback, + &mut combat_text_events, &mut names, &net_commands, ) @@ -1640,8 +1346,8 @@ pub(crate) fn apply_net_updates( s, &index, &self_guid, - &mut audio.15 .0, - &mut audio.15 .1, + &mut unit_combat_feedback, + &mut combat_text_events, &mut names, &net_commands, ) @@ -1654,7 +1360,7 @@ pub(crate) fn apply_net_updates( &transforms, &mut chat_log, ); - combat_log::spell_energize_log(s, &self_guid, &mut audio.15 .1) + combat_log::spell_energize_log(s, &self_guid, &mut combat_text_events) } SessionEvent::DamageShield(s) => { combat_chat::damage_shield(s, &chat_ctx!(), &stores, &transforms, &mut chat_log); @@ -1663,9 +1369,9 @@ pub(crate) fn apply_net_updates( &index, &self_guid, &stores, - *ui_actions.1 .5.damage_text, - &mut audio.7, - &mut audio.15 .0, + *combat_cvars.damage_text, + &mut combat_text_spawns, + &mut unit_combat_feedback, ) } SessionEvent::SpellLogMiss(s) => { @@ -1675,11 +1381,11 @@ pub(crate) fn apply_net_updates( &index, &self_guid, &stores, - ui_actions.10.as_deref(), - *ui_actions.1 .5.damage_text, - &mut audio.7, - &mut audio.15 .0, - &mut audio.15 .1, + spell_catalog.as_deref(), + *combat_cvars.damage_text, + &mut combat_text_spawns, + &mut unit_combat_feedback, + &mut combat_text_events, ) } // ── the combat log's completeness block (1703) ──────────────────────────────── @@ -1727,9 +1433,13 @@ pub(crate) fn apply_net_updates( &transforms, &mut chat_log, ), - SessionEvent::XpGain(x) => { - combat_log::xp_gain(x, &index, &self_guid, &mut audio.7, &mut chat_log) - } + SessionEvent::XpGain(x) => combat_log::xp_gain( + x, + &index, + &self_guid, + &mut combat_text_spawns, + &mut chat_log, + ), SessionEvent::ExplorationXp(x) => combat_log::exploration_xp( x, area_table.as_deref(), @@ -1737,7 +1447,7 @@ pub(crate) fn apply_net_updates( &index, &self_guid, &stores, - &mut audio.0, + &mut server_sounds, &mut chat_log, ), SessionEvent::LevelUp(l) => combat_log::level_up(l, &mut chat_log), @@ -1757,11 +1467,11 @@ pub(crate) fn apply_net_updates( ammo_display_id, &mut commands, &index, - &mut audio.5, + &mut cast_events, &self_guid, - &mut ui_actions.4, - &mut ui_actions.8, - ui_actions.10.as_deref(), + &mut cast_bar, + &mut pending_cast, + spell_catalog.as_deref(), play_seq.next(), ), SessionEvent::SpellGo { @@ -1788,29 +1498,29 @@ pub(crate) fn apply_net_updates( item_caster, &mut commands, &index, - &audio.4, - &mut audio.5, - &mut audio.6, + &casting_units, + &mut cast_events, + &mut spell_go_targets, &self_guid, &stores, - &mut ui_actions.4, - &mut ui_actions.8, - &mut ui_actions.14, - &mut audio.7, - *ui_actions.1 .5.damage_text, - &mut audio.10, + &mut cast_bar, + &mut pending_cast, + &mut queued_melee, + &mut combat_text_spawns, + *combat_cvars.damage_text, + &mut go_lid_opens, &mut loot_latch, ( - &mut ui_actions.9, - ui_actions.10.as_deref(), + &mut cooldown_store, + spell_catalog.as_deref(), &mut items, &net_commands, &mut pet_bar, ), ( - &mut ui_actions.11, - &mut audio.15 .2, - !audio.15 .4.is_empty(), + &mut auto_repeat, + &mut sheath_requests, + !engaged_self.is_empty(), ), play_seq.next(), ), @@ -1824,23 +1534,23 @@ pub(crate) fn apply_net_updates( spell_id, &mut commands, &index, - &audio.4, - &mut audio.5, + &casting_units, + &mut cast_events, &self_guid, - &mut ui_actions.4, - &mut ui_actions.8, - &mut ui_actions.14, + &mut cast_bar, + &mut pending_cast, + &mut queued_melee, play_seq.next(), ), SessionEvent::SpellDelayed { caster, delay_ms } => spell_delayed( caster, delay_ms, &self_guid, - &mut ui_actions.4, - &mut ui_actions.8, + &mut cast_bar, + &mut pending_cast, ), SessionEvent::CancelAutoRepeat => cancel_auto_repeat( - &mut ui_actions.11, + &mut auto_repeat, &self_guid, &index, &mut commands, @@ -1848,15 +1558,15 @@ pub(crate) fn apply_net_updates( ), SessionEvent::SpellCooldowns { caster, cooldowns } => { if let Some(store) = - addressed_store(caster, &self_guid, &mut ui_actions.9, &mut pet_bar) + addressed_store(caster, &self_guid, &mut cooldown_store, &mut pet_bar) { - spell_cooldowns(caster, cooldowns, ui_actions.10.as_deref(), store); + spell_cooldowns(caster, cooldowns, spell_catalog.as_deref(), store); } } SessionEvent::ItemCooldown { item_guid, spell_id, - } => item_cooldown(item_guid, spell_id, &items, &mut ui_actions.9), + } => item_cooldown(item_guid, spell_id, &items, &mut cooldown_store), // The item-lifetime countdown's ONLY feed (decision 1933): park the deadline on the // item store, exactly as the enchant timer below does — vmangos's own writer says the // `ITEM_FIELD_DURATION` field the client also holds is not what it displays from. @@ -1872,21 +1582,21 @@ pub(crate) fn apply_net_updates( } => items.set_enchant_deadline(item_guid, slot, seconds), SessionEvent::CooldownEvent { spell_id, caster } => { if let Some(store) = - addressed_store(caster, &self_guid, &mut ui_actions.9, &mut pet_bar) + addressed_store(caster, &self_guid, &mut cooldown_store, &mut pet_bar) { cooldown_event(spell_id, caster, store); } } SessionEvent::ClearCooldown { spell_id, caster } => { if let Some(store) = - addressed_store(caster, &self_guid, &mut ui_actions.9, &mut pet_bar) + addressed_store(caster, &self_guid, &mut cooldown_store, &mut pet_bar) { clear_cooldown(spell_id, caster, store); } } SessionEvent::CooldownCheat { caster } => { if let Some(store) = - addressed_store(caster, &self_guid, &mut ui_actions.9, &mut pet_bar) + addressed_store(caster, &self_guid, &mut cooldown_store, &mut pet_bar) { cooldown_cheat(caster, store); } @@ -1894,40 +1604,43 @@ pub(crate) fn apply_net_updates( // The pet action bar (decision 0982) — server-authoritative, so PET_SPELLS is a // wholesale replace and its zero-guid form is the teardown. SessionEvent::PetSpells(spells) => { - pet::pet_spells(*spells, ui_actions.10.as_deref(), &mut pet_bar) + pet::pet_spells(*spells, spell_catalog.as_deref(), &mut pet_bar) } SessionEvent::PetMode(mode) => pet::pet_mode(mode, &mut pet_bar), SessionEvent::PetActionFeedback { reason } => { pet::pet_action_feedback(reason, &mut ui_error_keys) } SessionEvent::PetCastFailed { spell_id, reason } => { - pet::pet_cast_failed(spell_id, reason, &mut ui_actions.1 .0) + pet::pet_cast_failed(spell_id, reason, &mut cast_errors) } // The three pet-feedback arms and the pet's voice (decision 2039). Each was // name-table-only until then; each is something the reference visibly does. SessionEvent::PetTameFailure { reason } => { - pet::pet_tame_failure(reason, &mut ui_actions.1 .4) + pet::pet_tame_failure(reason, &mut pet_tame_failures) } SessionEvent::PetNameInvalid => pet::pet_name_invalid(&mut ui_error_keys), SessionEvent::PetBroken => pet::pet_broken(&mut ui_error_keys), SessionEvent::PetActionSound { pet_guid, talk } => { - pet::pet_action_sound(pet_guid, talk, &index, &mut audio.15 .5) + pet::pet_action_sound(pet_guid, talk, &index, &mut pet_talks) } SessionEvent::PetDismissSound { model_id, position } => { - pet::pet_dismiss_sound(model_id, position, &mut audio.15 .6) + pet::pet_dismiss_sound(model_id, position, &mut pet_dismiss_sounds) } SessionEvent::ChannelStart { spell_id, duration_ms, - } => channel_start(spell_id, duration_ms, &mut ui_actions.12, &mut ui_actions.4), + } => channel_start(spell_id, duration_ms, &mut active_channel, &mut cast_bar), SessionEvent::ChannelUpdate { remaining_ms } => { - channel_update(remaining_ms, &mut ui_actions.12, &mut ui_actions.4) - } - SessionEvent::AuraDuration { slot, remaining_ms } => { - aura_duration(slot, remaining_ms, &mut aura.0, aura.1.elapsed_secs_f64()) + channel_update(remaining_ms, &mut active_channel, &mut cast_bar) } + SessionEvent::AuraDuration { slot, remaining_ms } => aura_duration( + slot, + remaining_ms, + &mut aura_durations, + real_clock.elapsed_secs_f64(), + ), SessionEvent::PlaySpellVisual { unit, kit_id } => { - anim::play_spell_visual(unit, kit_id, &index, play_seq, &mut audio.12) + anim::play_spell_visual(unit, kit_id, &index, play_seq, &mut kit_pushes) } SessionEvent::EnvironmentalDamageLog(e) => { combat_chat::environmental_damage_log( @@ -1940,9 +1653,9 @@ pub(crate) fn apply_net_updates( anim::environmental_damage_log( e, &index, - aura.5.as_deref(), + env_damage_table.as_deref(), play_seq, - &mut audio.12, + &mut kit_pushes, ) } // The gossip/vendor/trainer NPC-interaction family — arm bodies in `npc`. @@ -1969,7 +1682,7 @@ pub(crate) fn apply_net_updates( &poi, &mut poi_marker, current_map.as_ref().map_or(0, |m| m.0), - aura.1.elapsed_secs_f64(), + real_clock.elapsed_secs_f64(), ), // Questgiver panels (decision 0088): fill the `QuestGiver` the quest feed // (`crate::ui_quest`) reads. Each panel packet replaces the open view; the greeting/gossip @@ -2035,7 +1748,7 @@ pub(crate) fn apply_net_updates( npc::trainer_buy_succeeded(trainer, spell_id, &mut trainer_open, &net_commands) } SessionEvent::TrainerBuyFailed { error, .. } => { - npc::trainer_buy_failed(error, &mut ui_actions.7 .0) + npc::trainer_buy_failed(error, &mut trainer_errors) } SessionEvent::InvalidatePlayer { guid } => names::invalidate_player(guid, &mut names), SessionEvent::ListStabledPets { @@ -2043,12 +1756,9 @@ pub(crate) fn apply_net_updates( num_stable_slots, pets, } => npc::list_stabled_pets(npc, num_stable_slots, pets, &mut stable_open, &mut names), - SessionEvent::StableResult { result } => npc::stable_result( - result, - &mut stable_open, - &mut ui_actions.7 .1, - &net_commands, - ), + SessionEvent::StableResult { result } => { + npc::stable_result(result, &mut stable_open, &mut stable_errors, &net_commands) + } SessionEvent::TaxiNodesShown { flightmaster, nearest_node, @@ -2069,11 +1779,15 @@ pub(crate) fn apply_net_updates( vendor, item_entry, reason, - } => { - npc::vendor_buy_failed(vendor, item_entry, reason, &mut merchant, &mut ui_actions.3) - } + } => npc::vendor_buy_failed( + vendor, + item_entry, + reason, + &mut merchant, + &mut merchant_errors, + ), SessionEvent::VendorSellFailed { reason, .. } => { - npc::vendor_sell_failed(reason, &mut ui_actions.3) + npc::vendor_sell_failed(reason, &mut merchant_errors) } SessionEvent::ForceSpeedChange { guid, @@ -2086,20 +1800,20 @@ pub(crate) fn apply_net_updates( counter, speed, &index, - &aura.3, + &unit_speeds, &mut speed_stage, &self_guid, &mut speed_changes, ), SessionEvent::SpeedChanged { guid, kind, speed } => { - objects::speed_changed(guid, kind, speed, &index, &aura.3, &mut speed_stage) + objects::speed_changed(guid, kind, speed, &index, &unit_speeds, &mut speed_stage) } // ── The mount arc (decision 0441) — arm bodies in `mount` ───────────────────────── SessionEvent::MountResult { mount, code } => { - mount::mount_result(mount, code, &mut ui_actions.1 .1) + mount::mount_result(mount, code, &mut mount_errors) } SessionEvent::MountSpecial { guid } => { - mount::mount_special(guid, &self_guid, &index, &mut audio.14) + mount::mount_special(guid, &self_guid, &index, &mut mount_flourishes) } // Possession's control half (B211). Forwarded whole and unjudged: the guid may name us // (a revoke) or somebody else (a grant), and only the controller can act on either — @@ -2140,7 +1854,7 @@ pub(crate) fn apply_net_updates( item, &mut mail_open, &net_commands, - &mut ui_actions.2, + &mut equip_errors, ), SessionEvent::MailItemText { text_id, text } => { mail::mail_item_text(text_id, text, &mut mail_open) diff --git a/crates/benilla-app/src/net/apply/params.rs b/crates/benilla-app/src/net/apply/params.rs new file mode 100644 index 00000000..d3de45d3 --- /dev/null +++ b/crates/benilla-app/src/net/apply/params.rs @@ -0,0 +1,372 @@ +//! **The drain's parameters, by name** (decision 2279, 2265 §A1a). +//! +//! [`super::apply_net_updates`] takes every resource the 273 arms write, and Bevy caps a system +//! at sixteen parameters (`bevy_ecs` `system_param.rs`, `all_tuples!(…, 0, 16)`). From day one +//! (0006) the escape was tuples nested up to six deep, addressed by coordinate — `audio.15 .6`, +//! `ui_actions.1 .5` — with fifty comments across the tree explaining that a thing rides where it +//! rides "because this is where the ceiling left room". The `SystemParam` derive emits a named +//! struct with no field cap, so each family below is a struct: the body destructures it by field +//! name, every use site reads as what it is, and a new resource joins the family it belongs to +//! rather than the tuple with a slot free. +//! +//! **Grouping is by what the arms do with a thing**, not by what it is: a session-lifecycle edge, +//! a window's parked state, the action bar's queues, an animation-bearing message, a read-only +//! catalog. Two families the tuples had mis-homed for want of room moved to where they belong +//! (the catalogs, and the queries); one parameter the tuples carried nobody read +//! (`PingShared` — the drain stopped clearing it with B346) is gone. Nothing else changed: the +//! same set of resources, the same access, one system. +//! +//! Splitting the drain itself — each family owning a reader system of its own — is 2265 §A1(b), +//! and starts from these names. + +use bevy::ecs::system::SystemParam; +use bevy::prelude::*; + +use super::super::{ + AiReactionMessage, CharActionResultMessage, CharListMessage, EmoteMessage, EnteredWorldMessage, + LoggedOutMessage, ObjectStore, PendingTransfer, PetDismissSoundMessage, PetTalkMessage, + RemoteMotion, SelfPlayer, ServerSoundMessage, ServerTime, ServerWallClock, TeleportMessage, + UnitMoveModes, WorldportMessage, +}; +use benilla_world::weather::WeatherMessage; + +/// The clocks: the server's two, and our real one. +#[derive(SystemParam)] +pub(crate) struct Clocks<'w> { + /// The in-game day/night clock the lighting reads (`SMSG_LOGIN_SETTIMESPEED`). + pub server_time: ResMut<'w, ServerTime>, + /// The wall clock the absolute descriptor stamps are dated in (decision 1150). A different + /// quantity from the one above, from the same handler file. + pub wall_clock: ResMut<'w, ServerWallClock>, + /// `Time`, NOT the default virtual one, and every reader here is the reason: a span + /// the SERVER sends us (an aura's remaining duration, the corpse-reclaim delay, a relayed + /// mover's fire-time) counts down in real seconds, so it must be stamped and read on a real + /// clock. `Time` clamps at 250 ms per frame (`max_delta`), which is right for + /// simulation — a 2 s hitch must not teleport animations — and wrong here: every long frame + /// would quietly ADD that much to every buff timer, so an aura would vanish with seconds + /// still showing on its clock. Measured on a hitchy run: the virtual clock lost 20 s against + /// real in 33 s (decision 0846). The relayed-move replay (0601/0615) reads the same clock in + /// `drain_pending_moves`/`extrapolate_remote_units`. + pub real: Res<'w, Time>, +} + +/// Every entity query the drain holds — what it reads or writes on streamed objects. +#[derive(SystemParam)] +pub(crate) struct ObjectQueries<'w, 's> { + pub transforms: Query<'w, 's, &'static mut Transform>, + pub stores: Query<'w, 's, &'static mut ObjectStore>, + /// A relayed player's dead-reckon. + pub remote: Query<'w, 's, &'static mut RemoteMotion>, + /// Any unit's server-granted movement modes (decision 1780). + pub modes: Query<'w, 's, &'static mut UnitMoveModes>, + /// The deck a unit is standing on and its pose there (decision 1936). + pub riders: Query<'w, 's, &'static mut crate::transport::TransportRider>, + /// READ-only: every write to a mover's speed set goes through the drain's + /// `objects::SpeedStage` and lands in one flush at the end (decision 1478). + pub speeds: Query<'w, 's, &'static super::super::UnitSpeeds>, + /// The armed-transport lens for the worldport's spare predicate (decision 0455: a boat whose + /// path touches the destination map survives the purge). + pub transports: Query<'w, 's, &'static crate::transport::Transport>, + /// The cast-state read the spell-id-keyed `Casting` reap needs (decision 0107). + pub casting: Query<'w, 's, &'static crate::creature_anim::Casting>, + /// Are *we* already swinging? The ref's `[player+0xc48]`, mirrored by the server-echoed + /// [`crate::creature_anim::Engaged`] — read by the GO handler's deferred auto-attack start + /// (`0x6e83e7`, decision 1593). Filter-only, so it conflicts with nothing else in the drain. + pub engaged_self: Query<'w, 's, (), (With, With)>, +} + +/// The session lifecycle: the glue-screen edges, the player's own teleports and mover +/// handshakes, and the entry/transfer state those edges write (decision 0193). +#[derive(SystemParam)] +pub(crate) struct Session<'w> { + pub teleports: MessageWriter<'w, TeleportMessage>, + pub worldports: MessageWriter<'w, WorldportMessage>, + /// The two park publications: the realm list and the character roster are the same thing + /// one park apart, each published so the app's policy can answer it. + pub char_lists: MessageWriter<'w, CharListMessage>, + pub realm_lists: MessageWriter<'w, crate::net::RealmListMessage>, + /// The pick park's two verdicts: what a create/delete came back with, and the refusal of the + /// pick itself. + pub char_actions: MessageWriter<'w, CharActionResultMessage>, + pub char_login_failures: MessageWriter<'w, super::super::CharacterLoginFailedMessage>, + /// The world-entry pair, one edge: the entry message, and the `SMSG_ADDON_INFO` verdict that + /// the entry's own UI load reads before the first addon's file-scope code asks + /// `GetNumAddOns()` (2175). + pub entered_world: MessageWriter<'w, EnteredWorldMessage>, + pub addon_reply: ResMut<'w, crate::net::AddonInfoReply>, + pub logged_out: MessageWriter<'w, LoggedOutMessage>, + pub speed_changes: MessageWriter<'w, super::super::SpeedChangeMessage>, + /// The two server-authored mover edges the controller both *applies* and *answers*: a + /// granted mode (decisions 0308, 0866) and a knockback launch (decision 1702) are the same + /// handshake, an edge the server may not act on until our own live pose comes back. + pub move_modes: MessageWriter<'w, super::super::MoveModeMessage>, + pub knockbacks: MessageWriter<'w, super::super::KnockBackMessage>, + /// The login screen's dialog + reconnect-policy feed (decision 0539). + pub login_stages: MessageWriter<'w, super::super::LoginStageMessage>, + /// The login queue's position feed (decision 1681). + pub login_queued: MessageWriter<'w, super::super::LoginQueuedMessage>, + pub login_failures: MessageWriter<'w, super::super::LoginFailedMessage>, + pub disconnects: MessageWriter<'w, super::super::DisconnectedMessage>, + /// The server's own answer to a GM dot-command, readable rather than only logged — the probe + /// shield's confirmation channel (decision 0677). + pub server_said: MessageWriter<'w, super::super::ServerSaidMessage>, + /// A `MSG_MOVE_*` the server addressed to OUR mover (decision 0725): a pose it wrote, with no + /// handshake — the controller applies it in `player::wire_in`. + pub self_moves: MessageWriter<'w, super::super::SelfMoveMessage>, + /// The possession handoff (`SMSG_CLIENT_CONTROL_UPDATE`): control of a unit granted or + /// revoked. Forwarded verbatim — only the controller knows the pose it would have to park. + pub client_control: MessageWriter<'w, super::super::ClientControlMessage>, + /// A cinematic to play (`SMSG_TRIGGER_CINEMATIC`) — `crate::cinematic` owns both the playback + /// and the ack that has to answer it (decision 0196). + pub cinematics: MessageWriter<'w, super::super::CinematicTriggeredMessage>, + /// The far-teleport latch (decision 0455): `SMSG_TRANSFER_PENDING`'s transport block routes + /// `NEW_WORLD`'s coordinates; cleared on disconnect. + pub transfer: ResMut<'w, PendingTransfer>, +} + +/// The windows' parked state: the ask-once caches and every `*Open`/`*State`/`*Session` a +/// `feed_*` pushes into the VM later (decision 0081). Where 195 of the 273 arms land. +#[derive(SystemParam)] +pub(crate) struct WindowStores<'w> { + pub names: ResMut<'w, crate::names::NameCache>, + pub items: ResMut<'w, crate::items::Items>, + pub gossip: ResMut<'w, crate::ui_gossip::GossipState>, + pub merchant: ResMut<'w, crate::ui_merchant::MerchantOpen>, + /// The two gossip-reached NPC service sessions whose state is a whole open window. + pub trainer_open: ResMut<'w, crate::ui_trainer::TrainerOpen>, + pub stable_open: ResMut<'w, crate::ui_stable::StableOpen>, + /// The loot window state, the client-local loot-target latch (the kneel's self trigger, + /// decision 0515), and the open group-loot rolls (decision 0591). + pub loot: ResMut<'w, crate::ui_loot::LootState>, + pub loot_latch: ResMut<'w, crate::ui_loot::LootLatch>, + pub loot_rolls: ResMut<'w, crate::ui_loot_roll::LootRolls>, + pub chat_log: ResMut<'w, crate::ui_chat::ChatLog>, + pub quest: ResMut<'w, crate::ui_quest::QuestGiver>, + /// The quest-log template cache, and the party quest-share state (decision 1733) — the + /// verdicts on a quest we pushed and the escort confirm: a share is a quest-log verb. + pub quest_log: ResMut<'w, crate::ui_quest_log::QuestLog>, + pub quest_share: ResMut<'w, crate::ui_quest_share::QuestShare>, + pub go_templates: ResMut<'w, crate::go_templates::GameObjectTemplates>, + pub home_bind: ResMut<'w, crate::net::HomeBind>, + pub proficiencies: ResMut<'w, crate::net::Proficiencies>, + pub dropped: ResMut<'w, crate::net::DroppedOpcodes>, + /// The death arc's wire-fed store (decision 0308): reclaim expiry, corpse location, resurrect + /// offer, the spirit-healer confirm. + pub death_net: ResMut<'w, crate::death::DeathNet>, + /// The party/raid roster mirror + its composed system lines (decision 0434). + pub group: ResMut<'w, crate::ui_party::GroupState>, + /// The taxi-map session (decision 0484 phase 1). + pub taxi: ResMut<'w, crate::ui_taxi::TaxiState>, + /// The mailbox session + its login-scoped arrival countdown (decision 0544). + pub mail_open: ResMut<'w, crate::ui_mail::MailOpen>, + pub mail_pending: ResMut<'w, crate::ui_mail::MailPending>, + /// The player-trade session (decision 0592). + pub trade_session: ResMut<'w, crate::ui_trade::TradeSession>, + /// The bank session and its purchase-refusal queue (decision 0604). + pub bank_open: ResMut<'w, crate::ui_bank::BankOpen>, + pub bank_errors: ResMut<'w, crate::ui_bank::BankErrors>, + /// The world-state table the NPC-text `$w` tokens read. + pub world_states: ResMut<'w, crate::world_state::WorldStates>, + /// The duel session (decision 0633). + pub duel: ResMut<'w, crate::ui_duel::DuelState>, + pub social: ResMut<'w, crate::ui_social::SocialState>, + /// The pending logout/quit (decision 0674): the server's response and cancel-ack land here, + /// and `crate::ui_logout` turns them into the countdown dialog. + pub logout: ResMut<'w, crate::ui_logout::LogoutState>, + /// The mirror-timer queue (decision 0874): the breath/fatigue START/PAUSE/STOP edges, drained + /// into the FrameXML bars by `crate::ui_mirror`. + pub mirror_timers: ResMut<'w, crate::ui_mirror::MirrorTimerFeed>, + /// The pet action bar's server-authoritative state + its own cooldown store (decision 0982), + /// replaced wholesale on every `SMSG_PET_SPELLS`. + pub pet_bar: ResMut<'w, crate::ui_pet::PetBar>, + /// The by-key red error queue the pet bar's refused-order feedback rides (the `DisplayError` + /// route, resolved through the VM's own GlobalStrings by `ui_action::feed_actions`). + pub ui_error_keys: ResMut<'w, crate::ui_action::UiErrorKeys>, + /// The ask-once book-page cache (decision 1105) — every readable's text, keyed by `PageText` + /// id; the reader session repaints off it. + pub page_texts: ResMut<'w, crate::ui_item_text::PageTexts>, + pub played_time_answer: ResMut<'w, crate::net::PlayedTimeAnswer>, + /// The guild session (decision 1257) — the identity/roster mirror the seven + /// `SessionEvent::Guild*` arms drive. + pub guild: ResMut<'w, crate::ui_guild::GuildState>, + /// The innkeeper's pending bind question (decision 1331) — `SMSG_BINDER_CONFIRM` parks the + /// innkeeper's guid here and `crate::ui_binder` turns it into the CONFIRM_BINDER dialog, + /// whose Accept is the only thing that binds anything. + pub binder: ResMut<'w, crate::ui_binder::BinderState>, + /// The class trainer's pending respec question (decision 1580) — the inbound + /// `MSG_TALENT_WIPE_CONFIRM` parks the trainer's guid + cost here and + /// `crate::ui_talent_wipe` turns it into the CONFIRM_TALENT_WIPE dialog, whose Accept is the + /// only thing that unlearns anything. The binder's twin. + pub talent_wipe: ResMut<'w, crate::ui_talent_wipe::TalentWipeState>, + /// The dialog engine's verbs (decision 1963): the pet trainer's latch, the instance-boot + /// clock, the area spirit healer, the battleground queue and the meeting-stone queue — each + /// a feed for a stock dialog. + pub pet_unlearn: ResMut<'w, crate::ui_dialog_verbs::PetUnlearnState>, + pub instance_boot: ResMut<'w, crate::ui_dialog_verbs::InstanceBoot>, + pub area_spirit: ResMut<'w, crate::ui_dialog_verbs::AreaSpiritHealer>, + pub battlefield_queue: ResMut<'w, crate::ui_dialog_verbs::BattlefieldQueue>, + pub meeting_stone: ResMut<'w, crate::ui_dialog_verbs::MeetingStone>, + pub battlefield_scoreboard: ResMut<'w, crate::ui_battlefield_score::BattlefieldScoreboard>, + pub battlefield: ResMut<'w, crate::ui_battlefield::Battlefield>, + pub tutorials: ResMut<'w, crate::tutorial::Tutorials>, + pub battlefield_positions: ResMut<'w, crate::ui_battlefield_positions::BattlefieldPositions>, + pub tabard: ResMut<'w, crate::ui_tabard::TabardOpen>, + /// The guard's directions marker (`SMSG_GOSSIP_POI`) — the wire carries no map field, so + /// "where you were standing when the guard told you" is the client's to remember + /// (`crate::poi_marker`). + pub poi_marker: ResMut<'w, crate::poi_marker::PoiMarker>, + /// The inspect-honor reply (decision 1512) — `MSG_INSPECT_HONOR_STATS` is the only source of + /// another player's honor numbers, so the reply parks here and `crate::ui_honor` pushes it + /// into the pane and fires `INSPECT_HONOR_UPDATE`. + pub inspect_honor: ResMut<'w, crate::ui_honor::InspectHonor>, + /// The auctioneer session (decision 1511) — the hello reply opens it, the three list results + /// fill it, and `crate::ui_auction` feeds it to the window. + pub auction_open: ResMut<'w, crate::ui_auction::AuctionOpen>, + /// The minimap ping (decision 1596) — a group member's `MSG_MINIMAP_PING` seats the world + /// point here and the minimap renderer derives everything else from it. + pub ping: ResMut<'w, crate::minimap::MinimapPing>, + /// The GM ticket (decision 1673) — `SMSG_GMTICKET_GETTICKET` replaces the held ticket here + /// and BUMPS AN ANSWER COUNTER, which is what `crate::ui_gm_ticket` diffs on: the Help window + /// re-polls every 10 minutes and an unchanged answer still has to re-fire `UPDATE_TICKET`. + pub gm_ticket: ResMut<'w, crate::ui_gm_ticket::GmTicketState>, + /// The guild-charter session (decision 1672) — two resources because only the registrar + /// half is NPC-bound: `SMSG_PETITION_SHOWLIST` opens the registrar, and + /// `SMSG_PETITION_SHOW_SIGNATURES` opens the item-bound charter window, which must survive + /// walking away from the registrar. + pub registrar: ResMut<'w, crate::ui_petition::GuildRegistrarState>, + pub petition: ResMut<'w, crate::ui_petition::PetitionState>, + /// The pending summon question (decision 1747) — `SMSG_SUMMON_REQUEST` parks the summoner's + /// guid, zone and expiry here and `crate::ui_summon` turns it into the CONFIRM_SUMMON dialog, + /// whose Accept is the only packet in the flow. + pub summon: ResMut<'w, crate::ui_summon::SummonState>, + /// The instance-lockout bookkeeping (decision 1748) — four of its six packets queue a + /// GlobalStrings-templated chat line here for `crate::ui_instance` to resolve against the + /// VM, and two write the latch behind `CanShowResetInstances()`. + pub instances: ResMut<'w, crate::ui_instance::InstanceState>, +} + +/// The action bar's family: the cast/cooldown state and every error queue the red line drains +/// (decision 0137), plus the item-lock bookkeeping the inventory-failure arm also drives +/// (decision 0216 §4 / 0218 §3 — the drain has no `UiScript` to fire `ITEM_LOCK_CHANGED` +/// through, so the transitioned slots queue in `LockTransitions` for the container feed). +#[derive(SystemParam)] +pub(crate) struct ActionStores<'w> { + pub player_actions: ResMut<'w, crate::ui_action::PlayerActions>, + /// The cast + mount error queues, both drained into the red error line by + /// `ui_action::feed_actions`. + pub cast_errors: ResMut<'w, crate::ui_action::CastErrors>, + pub mount_errors: ResMut<'w, crate::ui_action::MountErrors>, + /// The `modalNextSpell` chain's outbox (1597) — `cast_result` fills it, the ui_action drain + /// sends it through the one cast path. + pub chain_casts: ResMut<'w, crate::ui_action::ChainCasts>, + /// The taming-refusal queue (decision 2039) — `SMSG_PET_TAME_FAILURE`'s reason byte. Its own + /// queue rather than `UiErrorKeys` because its message needs TWO GlobalStrings lookups, and + /// the inner one is only reachable at the drain (see the resource's doc). + pub pet_tame_failures: ResMut<'w, crate::ui_action::PetTameFailures>, + /// Spells learned mid-session, awaiting `LEARNED_SPELL_IN_TAB` (2252). A queue for the same + /// reason the reference sorts before it fires: the tab index is only correct against the + /// rebuilt tab list, which is the spellbook feed's, not this apply's. + pub learned_in_tab: ResMut<'w, crate::ui_spellbook::LearnedInTab>, + pub equip_errors: ResMut<'w, crate::ui_items::EquipErrors>, + pub merchant_errors: ResMut<'w, crate::ui_merchant::MerchantErrors>, + pub cast_bar: ResMut<'w, crate::ui_cast::CastBarFeed>, + pub pending_item_ops: ResMut<'w, crate::pending_item_ops::PendingItemOps>, + pub lock_transitions: ResMut<'w, crate::pending_item_ops::LockTransitions>, + /// The two NPC-service windows' error queues, each drained onto its window's red line by its + /// own feed. + pub trainer_errors: ResMut<'w, crate::ui_trainer::TrainerErrors>, + pub stable_errors: ResMut<'w, crate::ui_stable::StableErrors>, + pub pending_cast: ResMut<'w, crate::ui_cast::PendingCast>, + /// The player's cooldown store (decision 0137 phase 4); the pet's is in its bar, and + /// `addressed_store` picks between them. + pub cooldowns: ResMut<'w, crate::cooldowns::Cooldowns>, + /// The live auto-repeat state the bar's flash rides (decision 0137 phase 4). + pub auto_repeat: ResMut<'w, crate::ui_action::AutoRepeatActive>, + /// Our own running channel (the IsCurrentAction channel leg, decision 0137 phase 4). + pub active_channel: ResMut<'w, crate::ui_cast::ActiveChannel>, + /// Pre-formatted UIErrorsFrame lines — text the wire already resolved, so there is no + /// GlobalStrings key to look up: the death durability notice, `SMSG_NOTIFICATION`, and + /// `SMSG_AREA_TRIGGER_MESSAGE`. Drained by `ui_action::feed_actions` beside `UiErrorKeys`. + pub ui_error_texts: ResMut<'w, crate::ui_action::UiErrorTexts>, + /// The queued on-next-swing strike (the melee-slot half of the cast tracking) — the wire + /// resolves it here: GO fires it, a failing result/interrupt kills it. + pub queued_melee: ResMut<'w, crate::ui_cast::QueuedMeleeSpell>, + /// The aura feed's duration side-table (decisions 0255/0257): the self-only + /// `SMSG_UPDATE_AURA_DURATION` lands here keyed by raw slot, timestamped on + /// [`Clocks::real`] for the `ui_aura` slot-join. + pub aura_durations: ResMut<'w, crate::ui_aura::AuraDurations>, +} + +/// Every animation-bearing or audible message the drain emits, in packet order, plus the +/// PlayAnimation call-order counter they stamp (decisions 0107, 0137 phase 2, 0280). +#[derive(SystemParam)] +pub(crate) struct AnimWriters<'w> { + pub server_sounds: MessageWriter<'w, ServerSoundMessage>, + pub weather: MessageWriter<'w, WeatherMessage>, + pub emotes: MessageWriter<'w, EmoteMessage>, + pub swings: MessageWriter<'w, crate::creature_anim::SwingMessage>, + pub cast_events: MessageWriter<'w, crate::creature_anim::CastEvent>, + pub spell_go_targets: MessageWriter<'w, crate::creature_anim::SpellGoTargets>, + /// The floating combat-text feed (decision 0137 phase 2). + pub combat_text: MessageWriter<'w, crate::combat_text::CombatTextSpawn>, + pub swing_impacts: MessageWriter<'w, crate::creature_anim::SwingImpact>, + pub swing_flushes: MessageWriter<'w, crate::creature_anim::SwingFlush>, + pub go_lid_open: MessageWriter<'w, crate::go_anim::GoLidOpen>, + /// The aggro/alert vocal flare + the pushed-kit play (decision 0280). + pub ai_reactions: MessageWriter<'w, AiReactionMessage>, + pub kit_pushes: MessageWriter<'w, crate::creature_anim::KitPush>, + /// The remote landing predictor's report (decision 0415): a relayed FALL_LAND fires the grunt + /// + dust puff for an observed mover, the way the self controller does for us. + pub hard_landings: MessageWriter<'w, crate::creature_anim::HardLanding>, + /// An observed rider's flourish (`SMSG_MOUNTSPECIAL_ANIM`, decision 0441 P2) — the unit → + /// mount-child hop happens in `creature_anim::flourish_to_anim`. + pub mount_flourishes: MessageWriter<'w, crate::creature_anim::MountFlourish>, + /// The UNIT_COMBAT event feed (the portrait hit indicator, decision 0576) + the + /// COMBAT_TEXT_UPDATE feed (the center combat text, decision 0578) — the spell arms' + /// self-facing twins of the floating-text spawn. + pub unit_combat: MessageWriter<'w, crate::ui_unit::UnitCombatFeedback>, + pub combat_text_events: MessageWriter<'w, crate::ui_unit::CombatTextEvent>, + /// The sheath setter's queue, which the ATTACKERSTATEUPDATE arm's melee auto-draw writes. + pub sheath_requests: MessageWriter<'w, crate::creature_anim::SheathRequest>, + /// The GO one-shot Custom play (`SMSG_GAMEOBJECT_CUSTOM_ANIM` — the bobber's bite splash, + /// decision 1086), the step-8 sibling of `go_lid_open`. + pub go_custom_anim: MessageWriter<'w, crate::go_anim::GoCustomAnim>, + /// The pet's bark (`SMSG_PET_ACTION_SOUND`, decision 2039) — `ai_reactions`' sibling: both + /// are pure audio that resolves a guid and hands the sound layer a state for the SAME + /// `0x623a40` dispatcher. + pub pet_talk: MessageWriter<'w, PetTalkMessage>, + pub pet_dismiss: MessageWriter<'w, PetDismissSoundMessage>, + /// The swing-refusal seam's two edges (`crate::swing_refusal`, decision 2037): the four + /// `SMSG_ATTACKSWING_*` refusals, and our own landed swing, which clears their latch. ONE + /// writer for both so the drain hands them on in packet order. + pub swing_refusals: MessageWriter<'w, crate::swing_refusal::SwingRefusalEdge>, + /// The PlayAnimation call-order counter (`creature_anim::PlaySeq`): every animation-bearing + /// message this drain emits stamps `next()`, in packet order. + pub play_seq: ResMut<'w, crate::creature_anim::PlaySeq>, +} + +/// The read-only reference tables the arms resolve against. Each DBC-backed one is `Option`: +/// absent if its table failed to load, and every reader degrades rather than drops. +#[derive(SystemParam)] +pub(crate) struct Catalogs<'w> { + /// The FactionTemplate catalog the combat log's friend/foe split reads (B297). Absent, an + /// unresolved unit lands on the friendly side of the classifier rather than losing its lines. + pub factions: Option>, + /// The Spell.dbc catalog the cooldown/cast wire laws read. + pub spells: Option>, + /// The combat-feedback CVars — the eight display ranges, `CombatLogPeriodicSpells`, and the + /// three floating-text gates. + pub combat_cvars: crate::ui_chat::combat::CombatFeedbackCvars<'w>, + /// The EnvironmentalDamage.dbc 6-slot table (damage type → SpellVisualKit) the + /// `SMSG_ENVIRONMENTALDAMAGELOG` arm reads — the fall-landing dust puff. + pub env_damage: Option>, + /// The shared AreaTable catalog — the exploration arm's area-id → name resolve (decision + /// 0828) — and the race-keyed discovery-jingle catalog (decision 0829). + pub area_table: Option>, + pub exploration_sounds: Option>, + /// The map the guard's directions marker is stamped with. + pub current_map: Option>, + /// Guild Member Alert (decision 1589) — the CVar knob the sign-on/sign-off pair's display + /// condition reads; see `ui_guild::apply::event` for the four conjuncts. + pub guild_notify: Res<'w, crate::ui_guild::GuildMemberNotify>, +} diff --git a/crates/benilla-app/src/net/apply/spells.rs b/crates/benilla-app/src/net/apply/spells.rs index aac14127..b17012e3 100644 --- a/crates/benilla-app/src/net/apply/spells.rs +++ b/crates/benilla-app/src/net/apply/spells.rs @@ -275,6 +275,7 @@ pub(super) fn cast_result( arg, // `SMSG_CAST_FAILED` is addressed to the caster, and this handler is ours. caster: crate::ui_action::Caster::Player, + redisplay: false, }); } } diff --git a/crates/benilla-app/src/swing_refusal.rs b/crates/benilla-app/src/swing_refusal.rs index 08eb7c9b..4aa0c861 100644 --- a/crates/benilla-app/src/swing_refusal.rs +++ b/crates/benilla-app/src/swing_refusal.rs @@ -166,8 +166,8 @@ impl SwingRefusal { /// shared `0x148`/`0x149` arm's one and only act. /// /// The StopAttack lives here rather than in the net drain because [`AttackSeam`] is the whole -/// write set `0x5ecac0` needs and the drain is already at Bevy's SystemParam ceiling — and -/// because putting it beside the latch keeps arm 4's "stop, and say nothing" legible as one fact. +/// write set `0x5ecac0` needs, and because putting it beside the latch keeps arm 4's "stop, and +/// say nothing" legible as one fact. fn apply_swing_refusals( mut edges: MessageReader, time: Res