Files
benilla-octo/crates/benilla-app/src/game_plugins.rs
T
Sam SungandClaude Opus 5.5 73ec78c480 SendChatMessage sends a numbered channel's name, not its number (#377)
Typing into a numbered channel (`/1 hello`, or a line under a "[2. Trade]" header) sent nothing: the channel number went to the wire as the channel's name, so the server found no channel called "2".

Fixes #222.

- `SendChatMessage` reads a CHANNEL target the way 1.12.1 does (`0x49f4d9`-`0x49f4ea`): `SStrToInt` on the text, then the by-number slot getter (`0x49be50`), and the packet carries that slot's name (`0x49f70b`). A number that names no joined slot sends nothing, before the AFK clear and the tutorial acknowledge. There is no by-name leg, so an addon that passes a channel name sends nothing, as in 1.12.1.
- The binding keeps the target as Lua's `tostring` gives it, so 2.7 reads 2 instead of rounding to 3. `LeaveChannelByName` shares the parse and the getter.
- The send drains run before the zone-channel walk, so a `/N` resolves against the roster the script read the same frame.

Test: `a_channel_send_carries_the_numbered_slots_name` runs the real drain over a slot roster. It failed before (the wire target was "2") and passes now; holes, suspended slots, names and missing targets send nothing.

To see it: two characters of one faction in the same zone, both in General. On the first, `/1 hello`: the second now sees it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 02:46:13 +07:00

1302 lines
60 KiB
Rust

//! [`GamePlugins`]: the game as one plugin group, on top of the engine's
//! `benilla_world::world_plugins::WorldPlugins`. `schedule_tests` builds it headless and checks
//! the schedule for undeclared orders.
//!
//! The order is load-bearing. Some plugins read a resource an earlier one inserts at build time
//! (`VideoPlugin` and `RealmlistPlugin` before `CvarPlugin`, `WorldBackdropPlugin` after
//! `PlayerUiPlugin`, the `UiActionPlugin` family), and registration order is the executor's
//! tie-break between systems with no declared order, so reordering members changes behaviour.
//!
//! Not here, and added by `run()` around this group: the engine, the three process plugins
//! (`ThreadQos`, `BgWin`, `MacQuit`) and the dev instruments above the stack.
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, where no IO thread runs, so captures are
/// deterministic whether or not 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 in, before anything that could ask for one.
.add(crate::shaders::plugin)
.add(BowstringPlugin)
.add(crate::weapon_trail::WeaponTrailPlugin)
.add(FishingLinePlugin)
.add(QuestMarkersPlugin)
.add(crate::pipe_warm::plugin)
.add(EntitiesPlugin)
// The combat log, ahead of the animation layer, whose two shared kinds run second.
.add(crate::combat_log::CombatLogPlugin)
.add(CreatureAnimPlugin)
// The blob shadow (`0x6d7920`), sized from the Stand box (`playableAnimationLookup[0]`,
// fixed per model), never the playing sequence.
.add(BlobShadowPlugin)
.add(CameraShakePlugin)
.add(FootprintsPlugin)
.add(crate::go_anim::plugin)
.add(crate::doodad_events::plugin)
.add(PlayerPlugin)
.add(crate::screen_fade::ScreenFadePlugin)
// After PlayerPlugin, whose `control` it overrides in the same stage.
.add(CinematicPlugin)
.add(CursorPlugin)
.add(NetPlugin {
connect: self.connect,
})
.add(crate::death::DeathPlugin)
.add(crate::glue::GluePlugin)
.add(crate::char_select::CharSelectPlugin { start: self.start })
.add(crate::login::LoginPlugin)
.add(crate::realm_select::RealmSelectPlugin)
.add(crate::char_create::CharCreatePlugin)
.add(SoundPlugin)
.add(TargetPlugin)
.add(TransportPlugin)
.add(LoadingScreenPlugin)
// The player-UI quad pass; `$WOW_UI_DEMO=1` seeds a proof scene.
.add(PlayerUiPlugin)
// After the UI pass: it points that plugin's camera at the world camera.
.add(WorldBackdropPlugin)
.add(crate::minimap::MinimapPlugin)
.add(crate::ui_models::UiModelsPlugin)
.add(crate::area::AreaPlugin)
.add(crate::area_poi::AreaPoiPlugin)
.add(crate::world_state_ui::WorldStateUiPlugin)
.add(crate::area_trigger::AreaTriggerPlugin)
.add(crate::ui_world_map::WorldMapUiPlugin)
.add(crate::poi_marker::PoiMarkerPlugin)
.add(UiTextPlugin)
// Ahead of the portrait booth and the interaction face-me, which both read it.
.add(crate::ui_session::UiSessionPlugin)
.add(PortraitPlugin)
.add(TextInputPlugin)
.add(UiScriptPlugin)
// Before CvarPlugin, so the resource exists when `load_config` applies the saved value.
.add(crate::video::VideoPlugin)
// A CVar knob too, so before CvarPlugin for the same reason.
.add(crate::realmlist::RealmlistPlugin)
.add(crate::cvars::CvarPlugin)
.add(crate::console::ConsolePlugin)
.add(crate::bindings::BindingsPlugin)
.add(UiUnitPlugin)
.add(UiPartyPlugin)
.add(UiDuelPlugin)
.add(UiBinderPlugin)
.add(UiDialogVerbsPlugin)
.add(BattlefieldScorePlugin)
.add(BattlefieldPlugin)
.add(BattlefieldPositionsPlugin)
.add(crate::game_tip::GameTipPlugin)
.add(crate::text_filter::TextFilterPlugin)
// Re-shapes a `bevy_ui` text root whose last span was despawned, an upstream hole
// that otherwise panics in `bevy_text` on the next resize.
.add(crate::text_reshape::TextReshapePlugin)
.add(TutorialPlugin)
.add(crate::swing_refusal::SwingRefusalPlugin)
.add(UiSummonPlugin)
.add(UiGmTicketPlugin)
.add(UiFollowPlugin)
.add(UiInstancePlugin)
.add(UiLogoutPlugin)
.add(UiSocialPlugin)
// After the social session, whose FriendsFrame and ignore list it uses.
.add(UiGuildPlugin)
// After the guild session, whose error channel its refusals ride.
.add(UiPetitionPlugin)
.add(UiTooltipPlugin)
.add(UiCharPlugin)
.add(crate::ui_reputation::UiReputationPlugin)
.add(crate::ui_inspect::InspectUiPlugin)
// After the inspect feed, whose target it asks about.
.add(crate::ui_honor::UiHonorPlugin)
.add(crate::ui_dressup::DressUpUiPlugin)
.add(UiActionPlugin)
// The cast lifecycle; after UiActionPlugin, whose feeds and cast ladder read it.
.add(crate::spell::SpellPlugin)
// After UiActionPlugin, whose `Spells` catalog it shares.
.add(UiAuraPlugin)
// After UiActionPlugin, whose `Spells` and `send_spell_cast` it shares.
.add(UiSpellbookPlugin)
// After UiSpellbookPlugin: a macro's spell resolves against the book it pushes.
.add(crate::ui_macro::UiMacroPlugin)
// After UiActionPlugin, whose `Spells` catalog it shares.
.add(UiTalentPlugin)
.add(UiTalentWipePlugin)
// The form list by the reference's admission and order (`0x4b25b0`, `0x4b2bb0`); after
// UiActionPlugin (`Spells`) and SpellPlugin (the cast tail).
.add(UiShapeshiftPlugin)
// After UiActionPlugin, whose `Spells` and cooldown clock it shares.
.add(UiPetPlugin)
.add(ChrClassesPlugin)
.add(UiPetBookPlugin)
.add(UiPetStatsPlugin)
.add(UiPetDollPlugin)
.add(UiNetPlugin)
.add(UiCastPlugin)
.add(UiMirrorPlugin)
.add(crate::combat_text::CombatTextPlugin)
.add(crate::nameplates::NameplatesPlugin)
.add(crate::raid_marks::RaidMarksPlugin)
.add(crate::vplates::VPlatesPlugin)
.add(crate::chat_bubble::ChatBubblePlugin)
// TOGGLEUI (`ALT-Z`): the whole quad layer goes dark, leaving the world and the cursor.
.add(crate::ui_hide::UiHidePlugin)
.add(UiItemsPlugin)
.add(UiGossipPlugin)
.add(UiMerchantPlugin)
.add(UiAuctionPlugin)
.add(UiBankPlugin)
.add(UiMailPlugin)
.add(UiTradePlugin)
.add(UiItemTextPlugin)
.add(UiSavedPlugin)
.add(NamePersistPlugin)
.add(UiStablePlugin)
.add(TabardUiPlugin)
.add(UiTrainerPlugin)
.add(UiTaxiPlugin)
.add(UiTradeSkillPlugin)
.add(UiCraftPlugin)
.add(UiLootPlugin)
.add(UiLootRollPlugin)
.add(UiQuestPlugin)
.add(UiQuestLogPlugin)
.add(QuestSharePlugin)
.add(UiChatPlugin)
.add(UiLayoutPlugin)
.add(crate::screenshot::ScreenshotPlugin)
}
}
#[cfg(test)]
pub(crate) mod schedule_tests {
use std::any::TypeId;
use std::collections::{BTreeMap, HashMap, HashSet};
use super::*;
use bevy::ecs::component::ComponentId;
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, winit, logger
/// or GPU (`backends: None`, so no render app), then the engine, then the game. Every
/// plugin's `build` and `finish` runs and no schedule does, which fixes the schedule graph.
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 move
/// into the executable when it does), and the conflict list after.
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.
pub conflicts: Vec<(SystemKey, SystemKey, Vec<ComponentId>)>,
/// Everything any pair fights over, by name (a placeholder in a build without type
/// names, [`type_names_available`]).
pub components: HashMap<ComponentId, String>,
/// The explained classes, resolved against this world.
pub classes: Classes,
/// 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>,
/// The systems whose declared access includes the Lua VM, read off each system's own
/// access set: a holder whose every VM pair is declared shows in no conflict.
pub holds_vm: HashSet<SystemKey>,
}
impl Census {
pub fn name(&self, key: SystemKey) -> &str {
self.systems
.get(&key)
.map(|s| s.name.as_str())
.unwrap_or("?")
}
pub fn component(&self, id: ComponentId) -> &str {
self.components.get(&id).map(String::as_str).unwrap_or("?")
}
}
/// Which graph a census reads. Bevy's build shares one `ApplyDeferred` barrier per distance
/// from the schedule's start (`auto_insert_apply_deferred.rs`, `get_sync_point`), so two
/// systems sharing a barrier read as ordered through it, and one upstream edge can re-home
/// whole groups and move unrelated pairs.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum SyncPoints {
/// The schedule as it runs, barriers included: a barrier is a real wave boundary.
Built,
/// The declared graph only, no barriers: "no path" means "nobody declared an order".
/// What a ratchet on undeclared orders must read.
Declared,
}
/// Take the census of one schedule. Initializing it can insert `Schedules` itself, so this
/// goes through bevy's `schedule_scope`, not a `resource_scope` on that resource.
pub(crate) fn census(app: &mut App, label: impl ScheduleLabel, sync: SyncPoints) -> Census {
let label = label.intern();
app.world_mut().schedule_scope(label, |world, schedule| {
schedule.set_build_settings(ScheduleBuildSettings {
ambiguity_detection: LogLevel::Warn,
auto_insert_apply_deferred: sync == SyncPoints::Built,
..default()
});
// `initialize` fills the systems' access (the build would run it anyway); done
// first so pass 1 can read it while the graph still holds the systems.
schedule.graph_mut().systems.initialize(world);
let vm = world
.components()
.get_resource_id(TypeId::of::<benilla_ui::script::UiScript>());
// 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();
let mut holds_vm = HashSet::new();
for (key, system, conds) in graph.systems.iter() {
if let (Some(vm), Some(with_access)) = (vm, graph.systems.get(key)) {
let access = with_access.access.combined_access();
if access.has_resource_read(vm) || access.has_resource_write(vm) {
holds_vm.insert(key);
}
}
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: an 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 the `ApplyDeferred` sync points; 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 mut components = HashMap::new();
let conflicts = schedule
.graph()
.conflicting_systems()
.0
.iter()
.map(|(a, b, ids)| {
for id in ids.iter() {
components.entry(*id).or_insert_with(|| {
world
.components()
.get_name(*id)
.map(|n| n.to_string())
.unwrap_or_else(|| format!("{id:?}"))
});
}
(*a, *b, ids.to_vec())
})
.collect();
let classes = Classes::read(world);
Census {
systems,
conflicts,
components,
classes,
dependencies,
non_send,
holds_vm,
}
})
}
/// Does this build carry type names? Bevy has them only under its `debug` feature, which
/// rides with `benilla-world`'s `dev`. Probed off the census, not a `cfg`, which belongs in
/// `run_mode` alone; name-dependent tests skip without them.
fn type_names_available(c: &Census) -> bool {
c.systems.values().any(|s| s.name.contains("benilla_app::"))
}
/// One row of the class table: the type's name for the message, and its `TypeId`.
type ClassRow = (&'static str, fn() -> TypeId);
/// The explained classes: what an undeclared order may be about with nothing to declare.
///
/// - A non-`Send` owner (the Lua VM, the audio handles): its systems run on the main thread
/// one at a time, so no order among them is a race; which feed fires first is the `UiFeed`
/// phase's job. Derived: every registration that is not `Send + Sync`.
/// - A pure cache, where a miss records itself (`NameCache`, the GameObject templates, the
/// page texts, `WorldAssets`, `Creatures`' display models): two misses for one key commute.
/// A cache that is also a window's state is not pure, and not here.
/// - An append-only sink (`ChatLog`, `MessageSounds`, `UiErrorKeys`, `UiErrorTexts`):
/// writers commute, and a drain's order against a writer is one frame of latency.
/// - A random stream (`SoundKits`, `AnimRng`): any interleaving of draws is a valid draw,
/// the reference's contract for its single stream.
///
/// Not a class: a pair bevy reports with no component list has an exclusive system on one
/// side (`bevy_ecs` `node.rs:625` never reads its access), and is actionable. A pair is
/// explained when everything it fights over is in a class; only actionable pairs are
/// ratcheted. A row is a claim about every writer of that type, made in review with its
/// reason. Resolved by `TypeId`, so it works in the player build, which carries no names.
pub(crate) struct Classes {
/// The VM's own id, for the executor census.
pub vm: Option<ComponentId>,
pub non_send: HashSet<ComponentId>,
pub caches: HashSet<ComponentId>,
pub sinks: HashSet<ComponentId>,
pub streams: HashSet<ComponentId>,
}
impl Classes {
const CACHES: &[ClassRow] = &[
("NameCache", TypeId::of::<crate::names::NameCache>),
(
"GameObjectTemplates",
TypeId::of::<crate::go_templates::GameObjectTemplates>,
),
("PageTexts", TypeId::of::<crate::ui_item_text::PageTexts>),
("WorldAssets", TypeId::of::<benilla_assets::WorldAssets>),
("Creatures", TypeId::of::<crate::entities::Creatures>),
];
const SINKS: &[ClassRow] = &[
("ChatLog", TypeId::of::<crate::ui_chat::ChatLog>),
("MessageSounds", TypeId::of::<crate::sound::MessageSounds>),
("UiErrorKeys", TypeId::of::<crate::ui_action::UiErrorKeys>),
("UiErrorTexts", TypeId::of::<crate::ui_action::UiErrorTexts>),
];
const STREAMS: &[ClassRow] = &[
("SoundKits", TypeId::of::<crate::sound::SoundKits>),
// The client's one `rand()` stream: the doodad host, the creature driver, the
// GameObject arm and the portrait booth draw from it in frame order, as the
// reference's lanes draw from one TLS cell; declaring an order would invent a
// determinism the reference lacks. It removes no pair today: those lanes already
// conflict on `Query<&mut AnimationPlayer>`.
("AnimRng", TypeId::of::<benilla_assets::AnimRng>),
];
/// Resolve the table against a world whose schedules have initialized; a row that
/// resolves to nothing is stale, and the panic names it.
fn read(world: &World) -> Self {
let comps = world.components();
let resolve = |rows: &[ClassRow]| -> HashSet<ComponentId> {
rows.iter()
.map(|(name, type_id)| {
comps.get_resource_id(type_id()).unwrap_or_else(|| {
panic!("`{name}` is in the class table but is not a resource of this world")
})
})
.collect()
};
Self {
vm: comps.get_resource_id(TypeId::of::<benilla_ui::script::UiScript>()),
non_send: comps
.iter_registered()
.filter(|info| !info.is_send_and_sync())
.map(|info| info.id())
.collect(),
caches: resolve(Self::CACHES),
sinks: resolve(Self::SINKS),
streams: resolve(Self::STREAMS),
}
}
fn explains(&self, id: ComponentId) -> bool {
self.non_send.contains(&id)
|| self.caches.contains(&id)
|| self.sinks.contains(&id)
|| self.streams.contains(&id)
}
/// Which class explains this pair; `None` if it is actionable.
pub fn class_of(&self, what: &[ComponentId]) -> Option<&'static str> {
// An exclusive system on one side: bevy cannot see its access, so no class applies.
if what.is_empty() {
return None;
}
if !what.iter().all(|id| self.explains(*id)) {
return None;
}
Some(if self.vm_only(what) {
"VM only"
} else if self.non_send_only(what) {
"non-Send owners"
} else if what.iter().all(|id| self.caches.contains(id)) {
"pure caches"
} else if what.iter().all(|id| self.sinks.contains(id)) {
"append-only sinks"
} else if what.iter().all(|id| self.streams.contains(id)) {
"random streams"
} else {
"mixed"
})
}
pub fn vm_only(&self, what: &[ComponentId]) -> bool {
what.iter().all(|id| Some(*id) == self.vm)
}
pub fn non_send_only(&self, what: &[ComponentId]) -> bool {
what.iter().all(|id| self.non_send.contains(id))
}
}
#[test]
fn the_client_builds_headless() {
let app = headless_client();
assert!(app.world().contains_resource::<Schedules>());
}
/// `PostUpdate`'s undeclared-order pairs on the declared graph; `GlobalTransform` and the
/// particle `EffectQuads` are most of it.
const POST_UPDATE_CEILING: usize = 371;
const POST_UPDATE_SLACK: usize = 20;
/// The actionable pairs in `Update`: conflicting access, no declared order, and nothing
/// [`Classes`] explains, so the executor orders them however the graph falls. The count may
/// not rise past the ceiling, and the ceiling follows the count down; read on the declared
/// graph ([`SyncPoints::Declared`]). `WOW_AMBIGUITY_DUMP=1` prints every pair.
///
/// The largest part is `Transform` on disjoint lanes, populations that never intersect,
/// which no class can express. Known pairs accepted with a reason:
/// - the ranged prop's two `AnimationPlayer` writers against the booth's and the quest
/// markers';
/// - the fx attaches and `entities::update_display_models`/`attach_entity_visuals` against
/// the mat-anim tick over `MatAnimTable`/`UvAnimMaterials`: attach writes the rows a tick
/// would, off the same clock;
/// - the area-spirit-healer poll over `Transform` and `NetEntity`: it is level-triggered, so a
/// unit seen a frame early or late is decided again next frame;
/// - `ui_models::forget_dead_vm_tiles` against `portrait::glue_booth::sync_glue_scene` over
/// `MatAnimTable`, a slot allocator whose owners touch disjoint slots.
///
/// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the
/// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource
/// that commutes by construction belongs in [`Classes`].
const UPDATE_ACTIONABLE_CEILING: usize = 5_551;
const UPDATE_ACTIONABLE_SLACK: usize = 40;
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; a resource that commutes by \
construction belongs in `Classes` instead."
);
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. Ids, not names, so it runs in the player build too; prints the class
/// census beside the number it holds.
#[test]
fn the_schedules_have_no_more_undeclared_orders_than_the_ceilings_say() {
let mut app = headless_client();
// The declared graph: a barrier bevy placed is not a declared order (`SyncPoints`).
let update = census(&mut app, Update, SyncPoints::Declared);
let post = census(&mut app, PostUpdate, SyncPoints::Declared);
let mut explained: BTreeMap<&str, usize> = BTreeMap::new();
let mut actionable = Vec::new();
for (a, b, what) in &update.conflicts {
match update.classes.class_of(what) {
Some(class) => *explained.entry(class).or_default() += 1,
None => actionable.push((*a, *b, what)),
}
}
eprintln!(
"Update: {} systems, {} ambiguous pairs; {} explained ({}); {} actionable",
update
.systems
.values()
.filter(|s| !is_sync_point(&s.name))
.count(),
update.conflicts.len(),
update.conflicts.len() - actionable.len(),
explained
.iter()
.map(|(class, n)| format!("{n} {class}"))
.collect::<Vec<_>>()
.join(", "),
actionable.len(),
);
if std::env::var_os("WOW_AMBIGUITY_DUMP").is_some() {
let mut rows: Vec<String> = actionable
.iter()
.map(|(a, b, what)| {
// Canonical order within the pair, so two dumps diff by text.
let (x, y) = if update.name(*a) <= update.name(*b) {
(*a, *b)
} else {
(*b, *a)
};
format!(
" {} <-> {}\n on {}",
update.name(x),
update.name(y),
what.iter()
.map(|id| update.component(*id))
.collect::<Vec<_>>()
.join(", ")
)
})
.collect();
rows.sort();
for r in rows {
eprintln!("{r}");
}
}
ratchet(
"Update (actionable)",
actionable.len(),
UPDATE_ACTIONABLE_CEILING,
UPDATE_ACTIONABLE_SLACK,
);
ratchet(
"PostUpdate",
post.conflicts.len(),
POST_UPDATE_CEILING,
POST_UPDATE_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 ready system that shares no undeclared conflict with one already in it, and
/// non-`Send` systems are pairwise exclusive. Returns `(waves, critical_path)`: the frame's
/// serial depth, and its floor, the longest declared chain.
fn waves(
c: &Census,
keep_conflict: impl Fn(&[ComponentId]) -> bool,
non_send: &[SystemKey],
) -> (usize, usize) {
use std::collections::{HashMap, HashSet};
// The build-inserted sync points are left out; their count is reported beside the waves.
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)
}
/// An instrument, not a gate: prints `Update`'s serial depth as-is and with the VM, then
/// the audio layer too, made `Send`-owned. Asserts nothing; run it by hand.
#[test]
#[ignore = "instrument: run by hand (the executor census) — cargo test -p benilla-app --lib concurrency_census -- --ignored --nocapture"]
fn concurrency_census() {
let mut app = headless_client();
let c = census(&mut app, Update, SyncPoints::Built);
let holds = |k: SystemKey, pick: &dyn Fn(ComponentId) -> bool| {
c.conflicts
.iter()
.any(|(a, b, w)| (*a == k || *b == k) && w.iter().any(|id| pick(*id)))
};
let vm: Vec<SystemKey> = c
.non_send
.iter()
.copied()
.filter(|k| c.holds_vm.contains(k))
.collect();
let audio: Vec<SystemKey> = c
.non_send
.iter()
.copied()
.filter(|k| {
holds(*k, &|id| {
c.classes.non_send.contains(&id) && Some(id) != c.classes.vm
})
})
.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| !c.classes.vm_only(w), &not_vm);
eprintln!(" VM Send-owned: {w1} waves");
let (w2, _) = waves(&c, |w| !c.classes.non_send_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)");
}
/// Why a VM holder in `Update` ordered before the tick may stay out of
/// [`crate::ui_script::UiFeed`], keyed by a suffix of the system's full name, with the
/// reason.
const OUTSIDE_THE_FEED_PHASE: &[(&str, &str)] = &[];
/// The VM ticks once a frame (`extract::tick_script`, in `UiInput`): a push the tick must
/// see rides `UiFeed`, chained after the net drain and before the tick; a drain of what it
/// produced is `.after(UiInput)`. Read off the built graph. Fails a holder with no path to or
/// from the tick, one before the tick outside `UiFeed` and not argued in
/// [`OUTSIDE_THE_FEED_PHASE`], a `UiFeed` member the graph does not place between the drain
/// and the tick, and a stale row.
#[test]
fn every_vm_holder_in_update_declares_its_side_of_the_tick() {
let mut app = headless_client();
let c = census(&mut app, Update, SyncPoints::Built);
if !type_names_available(&c) {
eprintln!(
"skipped: this build carries no type names (bevy/debug rides with the dev feature)"
);
return;
}
let one = |suffix: &str| -> SystemKey {
let mut hits = c.systems.iter().filter(|(_, s)| s.name.ends_with(suffix));
match (hits.next(), hits.next()) {
(Some((k, _)), None) => *k,
_ => panic!("exactly one system named `…{suffix}` in Update"),
}
};
let tick = one("::extract::tick_script");
let drain = one("::net::apply::apply_net_updates");
let mut succ: HashMap<SystemKey, Vec<SystemKey>> = HashMap::new();
let mut pred: HashMap<SystemKey, Vec<SystemKey>> = HashMap::new();
for (a, b) in &c.dependencies {
succ.entry(*a).or_default().push(*b);
pred.entry(*b).or_default().push(*a);
}
let reach = |start: SystemKey, edges: &HashMap<SystemKey, Vec<SystemKey>>| {
let mut seen = HashSet::new();
let mut stack = vec![start];
while let Some(n) = stack.pop() {
for m in edges.get(&n).into_iter().flatten() {
if seen.insert(*m) {
stack.push(*m);
}
}
}
seen
};
let before_tick = reach(tick, &pred);
let after_tick = reach(tick, &succ);
let after_drain = reach(drain, &succ);
let mut holders: Vec<SystemKey> = c.holds_vm.iter().copied().collect();
holders.sort_by(|a, b| c.name(*a).cmp(c.name(*b)));
let (mut feed, mut post, mut argued) = (0, 0, 0);
let mut offenders = Vec::new();
let mut stale = Vec::new();
for k in &holders {
if *k == tick {
continue;
}
let s = &c.systems[k];
let in_feed = s.sets.iter().any(|set| set == "UiFeed");
let row = OUTSIDE_THE_FEED_PHASE
.iter()
.find(|(suffix, _)| s.name.ends_with(suffix));
if in_feed {
feed += 1;
if !(after_drain.contains(k) && before_tick.contains(k)) {
offenders.push(format!(
"{}: in `UiFeed`, but the graph does not place it after the net drain and before the tick",
s.name
));
}
} else if after_tick.contains(k) {
post += 1;
} else if before_tick.contains(k) {
if row.is_some() {
argued += 1;
continue;
}
offenders.push(format!(
"{}: ordered before the tick but not in `UiFeed` — it may run before this frame's packets land; `.in_set(UiFeed)`",
s.name
));
} else {
offenders.push(format!(
"{}: no declared side of the tick — `.in_set(UiFeed)` for a push the tick must see this frame, `.after(UiInput)` for a drain of what the tick produced",
s.name
));
}
if row.is_some() {
stale.push(format!(
"{}: argued in OUTSIDE_THE_FEED_PHASE but the graph places it {} — drop the row",
s.name,
if in_feed {
"in the feed phase"
} else {
"after the tick"
}
));
}
}
for (suffix, _) in OUTSIDE_THE_FEED_PHASE {
if !holders.iter().any(|k| c.name(*k).ends_with(suffix)) {
stale.push(format!(
"`…{suffix}` is not a VM holder in Update — drop its row"
));
}
}
eprintln!(
"VM holders in Update: {} — the tick, {feed} in the feed phase, {post} after the tick, {argued} argued outside the phase",
holders.len()
);
assert!(
offenders.is_empty(),
"these systems hold the VM in `Update` without declaring their side of the tick \
(`UiFeed` before it, or `.after(UiInput)`):\n {}",
offenders.join("\n ")
);
assert!(stale.is_empty(), "stale rows:\n {}", stale.join("\n "));
}
/// The consumer markers: a VM holder that 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 an ungated one-shot consumer holding the VM loses nothing in the one-frame window
/// where the wire is in world and the boot VM is still live and frameless.
#[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 a click in the interface, or (noted in the reason)
/// another player's act on us, which meets the login burst only by coincidence.
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.
MemoLatched,
/// Documented at the line as intentional.
Deliberate,
}
/// Every one-shot VM consumer not gated on `ingame_ui_up` (read off the schedule) and not a
/// memo-diffed `fire_event`, keyed `(path under src/, fn)`, with the reason it is safe.
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"),
("capture/probe_bg.rs", "bg_probe", Because::SelfHealing,
"the battleground probe: dev-only (`WOW_PROBE_BG`, `cfg(feature = \"dev\")`) so it is not in a player build at all, and its `mem::take` is of its OWN pending-events string, not a queue anything else fills. Its one real VM dependency is the Lua event tap, which self-heals: `EVENT_DRAIN` returns a `<tap-gone>` sentinel when the tap's globals are missing — the case this window causes, since a tap installed in the boot VM is discarded when `mint_entry_vm` builds the interface — and the probe re-installs on the next frame"),
("death/mod.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"),
("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/mod.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/mod.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; 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/mod.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"),
("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/mod.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 (the duel's 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"),
("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('/', "::")
}
}
/// A consumer (a system taking `NonSend[Mut]<UiScript>` whose body has a [`CONSUMES`]
/// marker) passes if the schedule shows `ingame_ui_up` on it or a set above it, if it is a
/// `fire_event` driven by a `VmMemo` parameter, or if [`EXEMPT`] argues it. Read off the
/// schedule, where a gate on a set, a tuple or the member all look alike. A stale `EXEMPT`
/// row fails too.
#[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, SyncPoints::Built);
if !type_names_available(&update) {
eprintln!(
"skipped: this build carries no type names (bevy/debug rides with the dev feature)"
);
return;
}
let post = census(&mut app, PostUpdate, SyncPoints::Built);
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 its sets: 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 the one frame before it is, they publish to a VM with no \
frames and the edge is lost. 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:\n {}",
offenders.join("\n ")
);
assert!(
stale.is_empty(),
"stale EXEMPT rows:\n {}",
stale.join("\n ")
);
}
}