Files
benilla-octo/crates/benilla-app/src/game_plugins.rs
T
gromandClaude Opus 5 f8e93c4484 MasterSoundEffects 0→1 is how a 1.12 addon stops a sound — make it stop sounds
grom: SoftSpoken "played a voice line over and over", and its **Silence NPC Voices**
option did nothing although it was switched on. Both are one deviation, and it is ours.

There is no Dialog channel to mute on 1.12, and SoftSpoken's own shim says so:

    function SoundUtils:MuteChannel(channel, muted, fadeOut)
        -- Intentionally unsupported on this client.
    end
    function SoundUtils:StopSound(clip, fadeMs)
        SetCVar("MasterSoundEffects", 0)
        SetCVar("MasterSoundEffects", 1)
    end

That toggle is its whole repertoire, and it is not a hack the addon invented — the
reference makes it work on the **enabling** edge:

    0x457500   the CVar callback: cl = !!value, tail-calls…
    0x7a6570   …the flag store, edge-triggered (7a657f: je on an unchanged flag)
    0x7a67a0   walks the live instance list; per node:
                 flags & 0x2        → skipped (the cinematic bypass)
                 flags & 0x4000008  → muted, not stopped (0x7a65a0)
                 otherwise, and ONLY when enabling (7a67eb: test cl,cl / je):
                   flags & 0x4      → 0x7a5950   stop
                   else             → 0x7a4200   stop, then unlink (0x7604f0)

`0x7a5950` is unmistakable: it takes the node's stream handle (+0x34) and channel id
(+0x30) and calls the FMOD stop thunks. The *disabling* edge stops nothing at all —
sounds run on, gated silent at the pump, which is exactly what `category_amp` already
did. So we had half the CVar, and it was the half that does not matter. The comment on
`SoundConfig::enabled` claimed the two "sound the same"; they do for a player moving a
slider, and do not for an addon.

Both symptoms follow. `MuteGameDialogue` is gated on `AutoToggleDialog`, whose default is
`(Version.IsLegacyVanilla or …)` — true here, which is why his saved profile has no
`Audio` block and the option was on anyway — and on this client its body is nothing but
the toggle. The gate passed, the toggle ran, the NPC greeting played straight over the
line being read. The repeat is the same toggle spent on SoftSpoken's own clip:
`PlaySound` cuts *before* it plays ("On Vanilla, interrupt NPC audio BEFORE playback"),
and `StopKeeping` is documented as "silence the head but keep its place: it replays from
the start when Advance next reaches it, since the client has no seek". With the cut inert
the head never stopped, so the replay layered a second copy on a first still going.

`kit::cut_effects_on_reenable` now stops every `SoundCategory::Sfx` channel when the edge
is spent. Music and ambience are spared (the reference's mute-not-stop class) and
cinematic audio never enters `SoundOutput::channels` at all (the `flags & 0x2` bypass).

Two details are load-bearing, both pinned:

- **The edge is armed by the write, never sampled from the value.** The addon writes 0
  and 1 inside one Lua call, so a system reading `SoundConfig::enabled` once a frame sees
  `true` on both sides and no edge at all. `EffectCutArmed` is set in the CVar observer,
  the one place that sees every write. It is its own resource rather than a `SoundConfig`
  field because spending it needs `ResMut`, which as a field measured **43** new
  ambiguous pairs against every `Res<SoundConfig>` reader in the frame.
- **The cut is spent between the Lua pass and the sound drain.** The reference stops
  synchronously inside `SetCVar`, which is before the `PlaySoundFile` the addon calls
  next. Applied after the drain it would stop the new clip instead of the old one — the
  symptom inverted, not fixed.

The ceiling rises by 1, for the pair against the collider finisher: the same exclusive
`&mut World` shape `log_new_corpses` and `textinput::open_early` already record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-30 14:48:13 -06:00

1649 lines
85 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 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::item_persist::ItemPersistPlugin;
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_cast_events::SuperWowEventsPlugin;
use crate::ui_char::UiCharPlugin;
use crate::ui_chat::UiChatPlugin;
use crate::ui_chr_classes::ChrClassInfoPlugin;
use crate::ui_chr_races::ChrRacesPlugin;
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_np_events::NpEventsPlugin;
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_self_cast::UiSelfCastPlugin;
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(benilla_world::liquid::WaterDepthPlugin)
.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.
// MONKEY (shadows): `worldShadows 1` hands unit shadows to the shadow-map path and this
// lane hides (the gate inside `update_shadows`); `0`, the default, draws it as 1.12 does.
.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)
// MONKEY (shadows): the shared rig (loads first) + the independent lane plugins. Here,
// between `VideoPlugin` and `RealmlistPlugin`, because every one of them registers cvars
// that `CvarPlugin` (two lines down) must already see when `load_config` applies the
// saved values at Startup — the same edge that pins the two knob plugins around them.
.add(crate::shadow_core::ShadowCorePlugin)
.add(crate::character_shadow::CharacterShadowPlugin)
.add(crate::world_shadow::WorldShadowPlugin)
.add(crate::torch_shadow::TorchShadowPlugin)
// MONKEY (dynamic interiors): the fixture-lit interior lane's cvar bridge — independent
// of the shadow lanes, a plain drop-in.
.add(crate::dynamic_interior::DynamicInteriorPlugin)
// MONKEY (volumetric fog): bridge the live setting to Bevy.
.add(crate::volumetric_fog::VolumetricFogPlugin)
// GFX (volumetric light): after the fog plugin, whose graph node it follows.
.add(crate::volumetric_light::VolumetricLightPlugin)
// GFX (froxel volumetrics): after the shaft plugin, whose graph node it follows.
.add(crate::froxel::FroxelPlugin)
// MONKEY (p0 graphics programme): the programme's cvar bridges (skyDither, ...).
.add(crate::monkey_gfx::MonkeyGfxPlugin)
// A wgpu validation error logs instead of panicking: one bad pipeline is a missing
// shadow, not a lost session. First, so it covers every later plugin'''s own
// pipelines too.
.add(crate::gpu_errors::GpuErrorPlugin)
// MONKEY (post): world-only HDR effects, before the legacy FFX clamp/UI composite.
.add(crate::post::PostPlugin)
// MONKEY (sky): the sky tier bridge.
.add(crate::sky_quality::SkyQualityPlugin)
// MONKEY (ao): contact shadows after the opaque pass, before the water copy.
.add(crate::ssao::AmbientOcclusionPlugin)
// MONKEY (skybox): bridge the zone-skybox setting to the world lane.
.add(crate::zone_skybox::ZoneSkyboxPlugin)
// 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(ChrClassInfoPlugin)
.add(ChrRacesPlugin)
.add(UiPetBookPlugin)
.add(UiPetStatsPlugin)
.add(UiPetDollPlugin)
.add(UiNetPlugin)
.add(UiCastPlugin)
.add(UiMirrorPlugin)
.add(SuperWowEventsPlugin)
.add(NpEventsPlugin)
.add(UiSelfCastPlugin)
.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)
// Item templates across sessions, the reference's `itemcache.wdb`.
.add(ItemPersistPlugin)
.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.
pub(crate) fn type_names_available(c: &Census) -> bool {
c.systems.values().any(|s| s.name.contains("benilla_app::"))
}
/// A test client opens no audio device: one stream per test process, one process per test,
/// floods the system audio server and crackles every sound on the machine.
#[test]
fn a_test_client_opens_no_audio_device() {
let app = headless_client();
let output = app.world().non_send_resource::<crate::sound::SoundOutput>();
assert!(output.mixer.is_none());
}
/// 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.
///
/// MONKEY (lighting, water, fog): upstream's number is 371. This branch adds the carried-light
/// motion tracker, the light-lane classifier, the shadow rig, the lava light and the swim bob,
/// all reading `GlobalTransform`/`Transform`/`Mesh3d` and re-derived every frame, so a
/// frame-late read trails by one frame and corrects itself (`WOW_AMBIGUITY_DUMP_POST=1` lists
/// them). KNOWN FOLLOW-UP: the swim bob writes a rig's visual offset with no order against
/// `rig_anim::compose`.
///
/// GFX (planar water): +28 for `liquid::reflect::drive_mirror`, which places the mirror
/// camera here — after propagation, with the skybox and the billboards, because it must read
/// the eye's settled pose and be culled in the same frame it is placed. It writes that
/// camera's `GlobalTransform`, `Camera` and `Projection`, and every one of the 28 is against
/// a system reading one of those on OTHER entities: the billboard facings, the sky, sun,
/// moon, star and cloud followers, the particle and ribbon sims, the light-lane classifier,
/// the cluster builder, the 2D text layout. The mirror camera carries `WaterMirrorCamera`
/// and is queried `Without<WorldCamera>`; nothing else in the schedule names it, so these
/// pairs are structurally impossible rather than merely harmless, and no order between them
/// could decide anything. The one that was NOT — `VisibilitySystems::UpdateFrusta`, which
/// reads every active camera, this one included — is declared at the system rather than
/// absorbed here. Its sibling `UpdateDirectionalLightCascades` deliberately is not: declaring
/// THAT order moved 0.75% of a Stormwind canal frame with the lane switched off, which is
/// argued where it is dropped.
///
/// Count this WITH an install (`WOW_DATA`): a data-gated system makes it 495 against 494 on
/// a machine with no client data, and `scripts/gates.sh` runs it both ways.
const POST_UPDATE_CEILING: usize = 495;
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;
/// - the cursor classifier against `world_focus`'s focus publish and settle release over
/// `Player`: it reads `control_lost`, and they write only `settling` and `world_stale`;
/// - `ui_quest::lines::feed_quest_lines` against `ui_items::feed_item_stats` and
/// `ui_tooltip::feed_spell_tooltips` over `Items` and the VM: it only reads templates, which
/// those two do not write, and a chat line commutes with their pushes;
/// - `spell::targeting::drain_spell_target_unit` against the lone `.after(UiInput)` cast
/// drains over the `CastLadder`, and the party, pet-book and chat drains over the unit-token
/// resolver: the class `drop_item_on_unit` and the world click's legs already carry, as none
/// of those drains orders against the target chain. A cast press and a unit-frame bind in one
/// frame take either order until the cast drains share a set that does.
/// - addon compat (`docs/ADDON_COMPAT.md`): `ui_spellbook::drain_spell_casts` now reads
/// `target::Hovered` for SuperWoW's `CastSpellByName(name, "CLICK")`, against
/// `target::hover::update_hover`, its only writer — the same one-frame-stale tolerance the
/// unit-token resolver's `"mouseover"` already carries against the cast drains above.
/// - addon compat (`docs/ADDON_COMPAT.md`): `ui_unit::UnitStores` (`feed_units`) and
/// `ui_tooltip::drive_mouseover_tooltip` each gained a `Query<&Transform, With<Guid>>` for
/// `UnitState::position` (SuperWoW's `UnitPosition`/`CursorPosition`, UnitXP_SP3's
/// `distanceBetween`): a read-only, one-frame-stale-tolerant snapshot, same as the rest of
/// this feed, but `Transform` is written by enough movement/animation/physics systems that
/// `With<Guid>` (network-synced objects only, not UI/particles) still leaves +30 pairs no
/// narrower filter resolves without declaring an order against each one individually.
///
/// 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`].
///
/// MONKEY (graphics programme): measured on the merged tree with the lanes' orders declared
/// (wind, wetness and fog-model writers of `MonkeyFrame`; the viewer publish before its
/// readers; the sky clock and tier before the consume set; the grass benders). What remains:
/// `skybox::animate_skyboxes` against the `MatAnimTable` allocators (it writes only its own
/// rows) and one pair each of the new systems against the two exclusive systems. Upstream
/// 5,481 + ours = 5,509, read off the merged tree (upstream v0.2.0 ba7fe6e2; three more of ours
/// pair with upstream's new v0.2.0 systems); +1 for addon compat's `Hovered` read above; +30
/// for addon compat's `UnitState::position` reads above; +3 for `ui_unit::feed_self_movement`
/// (addon compat, `docs/ADDON_COMPAT.md`): ClassicAPI's `State` namespace needs the self
/// player's swim/fall/mount/indoor/form posture once per tick, which means reading the mover's
/// `Player` resource and the area pass's `WorldPoint` beside their own writers — the same
/// one-frame-stale tolerance every other `feed_*` in that set already carries; +1 for
/// `minimap::blips::drain_blip_overrides` (addon compat): VanillaHelpers' `SetUnitBlip` art
/// map, written here and read by `emit_minimap`, which it already declares `.before`; +37 for
/// `target::scan::target_nearest_enemy` (addon compat): UnitXP_SP3's
/// `UnitXP("target", "nearestEnemy")` runs the existing candidate scan and commits a
/// selection, so it pairs with everything that reads those components or `Selection` outside
/// the `TargetUpdate` chain. Measured as **exactly** the 37 its sibling
/// `scan::acquire_and_attack` already contributes (`WOW_AMBIGUITY_DUMP=1`, both grep to 37) —
/// the same `TargetScan`-plus-commit shape, so this is a second instance of an accepted cost,
/// not a new kind of one. Chaining the pair removed the one pair they had with each other.
///
/// 5,581 → 5,194 (addon compat, `docs/ADDON_COMPAT.md`, 3D unit portraits on an addon's own
/// `PlayerModel` pane): the four body-pane booth syncs — `portrait::sync_paperdoll`,
/// `sync_inspect_booth`, `sync_petdoll_booth`, `sync_stable_booth` — were identical wrappers
/// around one `sync_body_booth` call, each holding the same `Commands`, `Booths`,
/// `PortraitImages`, booth-camera `Transform`/`Projection` and material store, so each paid
/// its own copy of the ~130 `Transform` pairs that access implies (131/130/130/130 measured
/// under `WOW_AMBIGUITY_DUMP=1`). They are now one system, `portrait::sync_body_booths`, which
/// also drives the new pooled dynamic unit-pane booths: 134 pairs where the four were 521.
/// The feature itself cost **zero** new pairs, read off the dump: `UnitPaneBooths` names
/// exactly two pairs, `sync_body_booths` ↔ `paint_script` and ↔ `tick_script`, and both list
/// `PortraitImages` and `BoothPanes` beside it — pairs those two resources already made, so
/// the new resource only lengthened an existing pair's component list. `net::GuidIndex`
/// appears in no pair at all.
///
/// Unmoved at 5,194 (addon compat, `docs/ADDON_COMPAT.md`, animating those portraits): the
/// pool's render cadence runs inside `portrait::gate_booth_cameras`, which already held every
/// piece of state it touches — `Booths`, `BoothPanes`, `PaneRate`, `Time<Real>` — so the
/// feature added no system and read no resource that system did not already read. A pacer of
/// its own would have been a second writer of `Booth::wake` and of the `AnimParked` marker,
/// and would have paired with everything this one already pairs with.
///
/// 5,194 → 5,197 (the create screen's challenge picker): `char_create::refresh::refresh_hover`
/// now writes the picker's description panel, which is the first `Text` it has ever written,
/// and that one new component access names three pairs. Measured under
/// `WOW_AMBIGUITY_DUMP=1`, not guessed: the feature first cost 32, and 29 of those were
/// removed rather than paid for — the taken-entry band moved from `Visibility` to the `Node`
/// `display` `refresh_dynamic` already writes (−19), and the realm's RP flag is now read once
/// in `enter_create` (an `OnEnter` system) instead of `Roster` being read per frame against
/// `char_select`'s writers of it (−10). Nothing else the picker added — its `Challenges`
/// resource, its extra `Node` queries, its `CreateAction` variants — names a pair at all.
/// 5,197 → 5,203 (auto-login, `autologin.rs`): six pairs, every one of them a login-screen
/// system against a character-select one, and all on the one new resource —
/// `apply_roster_policy`/`refresh_list` against `login_input`/`to_select_on_roster`, and
/// `select_input` against `materialize_screen`/`refresh_checkbox`. They share
/// [`crate::autologin::AutoLogin`] because the store spans both screens by its nature: the
/// login half writes the credential, the select half the per-realm memory. Nothing orders
/// them because nothing can usefully — the two sets run in different [`ClientState`]s and
/// never in the same frame, which is a run condition the ambiguity pass does not model, and
/// `.after`-ing a character-select system against a login one would state an order that
/// never arises. Not `Classes` either: the resource does **not** commute (one side's write
/// of `password` and the other's of `last_character` would interleave if they ever did run
/// together), so claiming it does would be the stronger and falser statement.
///
/// Measured, not guessed: the feature first cost **41**, and 35 of those were removed rather
/// than paid for. The password fill lives in `enter_login` and `login_input` — systems that
/// already held every parameter it needs, the store included — instead of a system of its
/// own, and the character rows' reorder arrows fold into `refresh_list`'s existing row loop
/// (they are `Button`s carrying a `SelectAction`, so that query was already walking past
/// them) instead of a second `Visibility` writer pairing with every other one in `Update`.
///
/// `cleanLoginScreen` then cost **zero**, the same way: read once in `enter_login`, an
/// `OnEnter` system, and latched on `LoginForm`. Reading it where it is *used* — per frame
/// in `materialize_screen` — measured at six, that system against every writer of the
/// registry, for a value that cannot change while the screen it configures is up.
/// +1 for `flash_trace::trace_visibility_churn`, the `WOW_FLASH_CHURN` observer: it holds the
/// VM to read every named frame's visibility, so it pairs with the other VM holders it has no
/// declared order against. It is a **read-only observer** — it writes nothing a system could
/// disagree with, and with the variable unset it returns before touching the VM at all — so
/// the order it runs in cannot change what any of them see.
///
/// **Down from 5_204**, and the drop is the interesting part. Adding the per-pane look
/// assembler (`attach::preview::build_pane_previews`) and its consumer in the pooled booths
/// cost 15 pairs of the same shape `build_dressup_preview` already carries — both assemble
/// into the same shared caches, each inserting only its own entries — but declaring the
/// consumer's order against the producer (`sync_body_booths.after(entities::PaneLooksSet)`)
/// resolved 261 pairs that `sync_body_booths` previously had no declared order against at
/// all. One honest edge in a busy part of the graph is worth more than a long allowance,
/// which is the whole reason this number ratchets.
/// +4 for `portrait::resize_pane_targets`, which steps a pooled pane's bake between 256² and
/// 512² as the pane drawing it changes size. It holds `Assets<Image>`, so it pairs with the
/// other holders — two debug observers (`log_booth_pose`, `dump_booth_target`), the UI paint
/// that only *reads* the target to draw it, and a collider finisher that shares the collection
/// and nothing in it. It is the sole writer of a booth target's size, and it reaches for
/// `get_mut` only when the size actually changes, so in every other frame it writes nothing at
/// all for an order to matter to.
/// +2 for `target::scan::remember_last_target`, `TargetLastTarget`'s memory. It is a second
/// per-frame sampler of `Selection` in the same chain as the first (`remember_last_enemy`),
/// so it inherits that one's shape: the two pairs are against a pet-action drain and a
/// collider finisher, both of which can move the selection. The order against those cannot
/// change what it records, only which frame it records it in — it stamps the *outgoing*
/// selection whenever the current one differs from what it last saw, so a change seen a
/// frame later is still seen exactly once, with the same two guids. Its order against the
/// systems that do matter — the enemy stamp and the ring's death clear — is declared by the
/// chain it sits in.
/// +8 for `target::by_name::feed_named_targets`, the `TargetByName` pre-resolve. All eight are
/// its `&Transform` read — the distance tiebreak among same-named units — against the frame's
/// `Transform` writers: the two booth/portrait syncs, the billboard facing, the quest-marker
/// scale and the four transport-rider systems. The same class of pair `UnitStores::positions`
/// already carries for the same reason, and its own note applies unchanged: the order can only
/// decide whether a rank is computed against this frame's position or last frame's, and the
/// unit it picks is then committed **by GUID**, so a stale yard or two cannot select something
/// else. The pairs that would matter — its order against the script tick that reads what it
/// publishes — are declared (`UnitFeed`, `.before(UnitFeedDispatch)`).
/// +1 for `entities::corpse::log_new_corpses`, which reads three components and writes
/// nothing: its one pair is against the collider finisher, an exclusive `&mut World` system
/// that pairs with everything it has no declared order against. The order can only decide
/// which frame the line prints in, and `Added<ObjectStore>` still fires exactly once either
/// way.
/// -21: `liquid::reflect::drive_mirror` left `Update` for `PostUpdate`, where placing a
/// camera belongs. It could never be registered here — `&mut Transform` on a camera is
/// shared with everything that moves anything — which is what kept the planar-water lane
/// behind an env var instead of a setting.
/// +2 for `textinput::open_early`, which resolves the OS pasteboard once the window can give
/// the Wayland display pointer. Both pairs are the shape `log_new_corpses` describes two notes
/// up: they are against the net dispatch and the collider finisher, the schedule's two
/// exclusive `&mut World` systems, which pair with everything they have no declared order
/// against and which bevy cannot classify because it cannot see their access. There is nothing
/// to declare an order *about*: it reads one window's `RawHandleWrapper` and writes one
/// `NonSend` handle neither of them can name, and its `run_if` is the handle's own
/// "unresolved" bit, so it runs at most once in a process and then never again.
/// +1 for `sound::kit::cut_effects_on_reenable` (addon compat, `docs/ADDON_COMPAT.md`): the
/// `MasterSoundEffects` re-enable stop, which 1.12 addons use as their only way to cut a
/// playing sound. Its one pair is against the collider finisher, the same exclusive
/// `&mut World` shape `log_new_corpses` and `textinput::open_early` describe above. Its own
/// two accesses name nothing actionable: `SoundOutput` is a `NonSend` the [`Classes`] pass
/// already explains for every system that holds it, and `EffectCutArmed` exists precisely so
/// the flag is not a `SoundConfig` field — as a field it needed `ResMut` against every
/// `Res<SoundConfig>` reader in the frame, which measured 43 pairs under
/// `WOW_AMBIGUITY_DUMP=1`. The orders that *could* decide something are declared: after the
/// Lua pass that arms the edge, before the sound drain that plays what that pass queued.
const UPDATE_ACTIONABLE_CEILING: usize = 4_955;
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}");
}
}
// MONKEY (lighting): the `PostUpdate` half of the dump, which upstream's `Update`-only
// lever had no equivalent of — and without which `POST_UPDATE_CEILING` could only ever be
// raised by reasoning about what the new pairs must be, the exact mistake the ceiling's
// own comments record. Its own env var, not `WOW_AMBIGUITY_DUMP`, because `PostUpdate`
// prints every pair (there is no `Classes` pass over it) and 466 rows would bury the
// actionable `Update` list this test is usually run for.
if std::env::var_os("WOW_AMBIGUITY_DUMP_POST").is_some() {
let mut rows: Vec<String> = post
.conflicts
.iter()
.map(|(a, b, what)| {
format!(
"POST {} <-> {}\n on {}",
post.name(*a),
post.name(*b),
what.iter()
.map(|id| post.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 "));
}
/// `SetMapToCurrentZone()` answers out of a per-frame feed, where the reference's resolver
/// `0x4a6650` reads the player's area live. That one deviation makes the schedule part of the
/// verb's contract, in a chain of three: `update_current_area` writes the area under the
/// player, `feed_world_map` publishes the `(continent, zone)` the verb returns, and
/// `crate::area::feed_zone_events` fires the `ZONE_CHANGED*` an addon answers by calling it.
/// Any two of those in the wrong order and the verb answers for the frame before.
///
/// Asserted rather than commented because the hole cost three play sessions and two wrong
/// diagnoses: only the downstream edge was declared, so the feed published the *previous*
/// frame's leaf, the event fired on the new one, and both addons that answer it reselected
/// the zone the player had just left. Nothing raised, nothing skipped, the verb provably
/// called — and every map-derived overlay a zone behind, `GetPlayerMapPosition` and so
/// pfQuest's minimap pins included, until the player opened the world map by hand.
#[test]
fn the_world_map_feed_sits_between_the_area_authority_and_the_zone_events() {
let mut app = headless_client();
let c = census(&mut app, Update, SyncPoints::Declared);
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 area = one("::queries::update_current_area");
let feed = one("::ui_world_map::feed_world_map");
let events = one("::area::feed_zone_events");
let mut pred: HashMap<SystemKey, Vec<SystemKey>> = HashMap::new();
for (a, b) in &c.dependencies {
pred.entry(*b).or_default().push(*a);
}
let runs_before = |a: SystemKey, b: SystemKey| {
let mut seen = HashSet::new();
let mut stack = vec![b];
while let Some(n) = stack.pop() {
for m in pred.get(&n).into_iter().flatten() {
if *m == a {
return true;
}
if seen.insert(*m) {
stack.push(*m);
}
}
}
false
};
for (a, b, why) in [
(
area,
feed,
"the feed would publish the previous frame's zone, so the verb answers for the \
zone the player just left",
),
(
feed,
events,
"the event would fire before the zone it reports reaches the verb an addon \
answers it with",
),
] {
assert!(
runs_before(a, b),
"`{}` must be declared before `{}` — otherwise {why}",
c.name(a),
c.name(b)
);
}
}
/// 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"),
("chat_bubble.rs", "drive_bubbles", Because::SelfHealing,
"the one-shot is the bubble spawn QUEUE, and its product is app-side state (`Bubbles`), not a VM publish: a line that arrives before the interface exists still becomes a live bubble with its own lifetime, and the widget sync is idempotent per frame — it returns having done nothing while there is no `WorldFrame` and binds the same bubble on the first frame there is one, firing `CREATE_CHATBUBBLE` then. Gating the system instead would be worse: the queue would pile up and every held line would spawn at once, each with a fresh lifetime, when the UI came up"),
("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_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 ")
);
}
}