Files
benilla-octo/crates/benilla-app/src/game_plugins.rs
T
SamandClaude Fable 5.1 bc404ca24b 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 <noreply@anthropic.com>
2026-09-17 16:39:46 +07:00

1233 lines
66 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! **[`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::<Self>()
// 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 `<Minimap>` 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 `<Model>` 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/<realm>-<character>.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::<bevy::winit::WinitPlugin>()
.disable::<bevy::log::LogPlugin>()
.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<String>,
pub conditions: Vec<String>,
}
/// 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<SystemKey, SystemInfo>,
/// 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<String>)>,
/// 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<SystemKey>,
}
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<String>> = 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<String> = 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<SystemKey> {
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 `<Enable the debug feature …>`. 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::<Schedules>());
}
/// 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<String> = 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<SystemKey> = 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<SystemKey, Vec<SystemKey>> = HashMap::new();
for (a, b) in &c.dependencies {
preds.entry(*b).or_default().push(*a);
}
let mut exclusive: HashMap<SystemKey, HashSet<SystemKey>> = 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<SystemKey> = HashSet::new();
let mut waves = 0;
while done.len() < order.len() {
let mut wave: Vec<SystemKey> = 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<SystemKey, usize> = HashMap::new();
fn depth_of(
k: SystemKey,
preds: &HashMap<SystemKey, Vec<SystemKey>>,
depth: &mut HashMap<SystemKey, usize>,
) -> 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<SystemKey> = c
.non_send
.iter()
.copied()
.filter(|k| touches(*k, "UiScript"))
.collect();
let audio: Vec<SystemKey> = c
.non_send
.iter()
.copied()
.filter(|k| touches(*k, "benilla_app::sound::"))
.collect();
let other_non_send: Vec<SystemKey> = 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<SystemKey> = c
.non_send
.iter()
.copied()
.filter(|k| !vm.contains(k) || audio.contains(k))
.collect();
let (w1, _) = waves(&c, |w| !vm_only(w), &not_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<DeathAnnounced>`: 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<CharFeedMemo>`; `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<bool>`), 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<FedDuel>` (another player's act: coincidence-only residual)"),
("ui_follow.rs", "feed_follow", Because::MemoLatched,
"`feed.vm: VmMemo<FollowFeedMemo>`; a fresh VM re-fires BEGIN for a follow in progress"),
("ui_gm_ticket.rs", "feed_gm_ticket", Because::MemoLatched,
"`feed.vm: VmMemo<FedTicket>` 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<HonorFeedMemo>`; `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<FedSocial>`); 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]<UiScript>` 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<UiScript>")
|| f.params.contains("NonSend<UiScript>");
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<String> = 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 ")
);
}
}