Must be in names its form, and the whole client builds headless

- a cast refused for the wrong shapeshift form names it, "Must be in Cat Form", a two-form spell joined the reference's way, and the combat log reads its own buffer rather than the red line (2280, 2285)
- three refusals that never had a combat-log line, totems, reagents and equipped item class, get one; a pet's form refusal names one form (2285)
- the game's plugins are one group and the whole client builds headless in 0.12 s, so the schedule is a test subject: three one-shot consumers caught firing into the login drain are gated (2279)
- the net drain's parameters are named bundles, not nested tuples (2279)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-17 16:39:46 +07:00
co-authored by Claude Fable 5.1
parent 00150c55b5
commit bc404ca24b
18 changed files with 2743 additions and 1218 deletions
File diff suppressed because it is too large Load Diff
+8 -399
View File
@@ -73,6 +73,7 @@ mod doodad_events;
mod entities;
mod fishing_line;
mod footprints;
mod game_plugins;
mod glue;
mod glue_strings;
mod go_anim;
@@ -119,6 +120,8 @@ mod sound;
/// The melee swing refusal's latch + 4 s repeat (`SMSG_ATTACKSWING_*`).
mod swing_refusal;
mod target;
#[cfg(test)]
pub(crate) mod test_support;
mod text_filter;
mod text_reshape;
mod textinput;
@@ -199,84 +202,6 @@ mod world_state;
mod world_state_ui;
use bevy::prelude::*;
use blob_shadow::BlobShadowPlugin;
use bowstring::BowstringPlugin;
use camera_shake::CameraShakePlugin;
use chr_classes::ChrClassesPlugin;
use cinematic::CinematicPlugin;
use creature_anim::CreatureAnimPlugin;
use cursor::CursorPlugin;
use entities::EntitiesPlugin;
use fishing_line::FishingLinePlugin;
use footprints::FootprintsPlugin;
use loading_screen::LoadingScreenPlugin;
use name_persist::NamePersistPlugin;
use net::NetPlugin;
use player::PlayerPlugin;
use portrait::PortraitPlugin;
use quest_markers::QuestMarkersPlugin;
use sound::SoundPlugin;
use target::TargetPlugin;
use textinput::TextInputPlugin;
use transport::TransportPlugin;
use tutorial::TutorialPlugin;
use ui_action::UiActionPlugin;
use ui_auction::UiAuctionPlugin;
use ui_aura::UiAuraPlugin;
use ui_bank::UiBankPlugin;
use ui_battlefield::BattlefieldPlugin;
use ui_battlefield_positions::BattlefieldPositionsPlugin;
use ui_battlefield_score::BattlefieldScorePlugin;
use ui_binder::UiBinderPlugin;
use ui_cast::UiCastPlugin;
use ui_char::UiCharPlugin;
use ui_chat::UiChatPlugin;
use ui_craft::UiCraftPlugin;
use ui_dialog_verbs::UiDialogVerbsPlugin;
use ui_duel::UiDuelPlugin;
use ui_follow::UiFollowPlugin;
use ui_gm_ticket::UiGmTicketPlugin;
use ui_gossip::UiGossipPlugin;
use ui_guild::UiGuildPlugin;
use ui_instance::UiInstancePlugin;
use ui_item_text::UiItemTextPlugin;
use ui_items::UiItemsPlugin;
use ui_layout::UiLayoutPlugin;
use ui_logout::UiLogoutPlugin;
use ui_loot::UiLootPlugin;
use ui_loot_roll::UiLootRollPlugin;
use ui_mail::UiMailPlugin;
use ui_merchant::UiMerchantPlugin;
use ui_mirror::UiMirrorPlugin;
use ui_net::UiNetPlugin;
use ui_party::UiPartyPlugin;
use ui_pass::PlayerUiPlugin;
use ui_pet::UiPetPlugin;
use ui_pet_book::UiPetBookPlugin;
use ui_pet_doll::UiPetDollPlugin;
use ui_pet_stats::UiPetStatsPlugin;
use ui_petition::UiPetitionPlugin;
use ui_quest::UiQuestPlugin;
use ui_quest_log::UiQuestLogPlugin;
use ui_quest_share::QuestSharePlugin;
use ui_saved::UiSavedPlugin;
use ui_script::UiScriptPlugin;
use ui_shapeshift::UiShapeshiftPlugin;
use ui_social::UiSocialPlugin;
use ui_spellbook::UiSpellbookPlugin;
use ui_stable::UiStablePlugin;
use ui_summon::UiSummonPlugin;
use ui_tabard::TabardUiPlugin;
use ui_talent::UiTalentPlugin;
use ui_talent_wipe::UiTalentWipePlugin;
use ui_taxi::UiTaxiPlugin;
use ui_text::UiTextPlugin;
use ui_tooltip::UiTooltipPlugin;
use ui_trade::UiTradePlugin;
use ui_tradeskill::UiTradeSkillPlugin;
use ui_trainer::UiTrainerPlugin;
use ui_unit::UiUnitPlugin;
use world_backdrop::WorldBackdropPlugin;
// The `benilla` launcher shim (the bin package) is this library's only caller: it stamps the
// build id at compile time and hands it into [`run`]. Re-exported so the shim needs no bevy
@@ -521,9 +446,6 @@ pub fn run(build: BuildId) -> AppExit {
},
..default()
}))
// The game's own WGSL, compiled into the binary (decision 1175) — before anything that could
// ask for one. The engine's seven register themselves inside `WorldPlugins` below.
.add_plugins(shaders::plugin)
.add_plugins(benilla_world::thread_qos::ThreadQosPlugin)
// The app-side half of background instrumented runs: undo winit's forced macOS app
// activation so a probe/capture launch never yanks focus off the director's screen. The
@@ -552,326 +474,13 @@ pub fn run(build: BuildId) -> AppExit {
// build appends a per-second row of position, frame cost and the GPU's per-pass split to
// `benilla-config/Diagnostics/fps-journal.csv`; `WOW_FPS_JOURNAL=<csv>` is the harness lever.
.add_plugins(perf::FpsJournalPlugin)
.add_plugins(BowstringPlugin)
.add_plugins(weapon_trail::WeaponTrailPlugin)
.add_plugins(FishingLinePlugin)
.add_plugins(QuestMarkersPlugin)
// Pipeline-compile counters + the live-compile tripwire (decision 0837: macOS builds every
// pipeline synchronously on the render thread, so a live compile is a felt stall).
.add_plugins(pipe_warm::plugin)
// Streamed world entities: cube assets + display catalogs at startup, sync each frame.
.add_plugins(EntitiesPlugin)
// Creature animation: pick Stand/Walk/Run from each creature's movement state each frame (Milestone C).
.add_plugins(CreatureAnimPlugin)
// The unit blob shadow: the dark ground oval under every unit, sized from the playing
// animation's box (the byte-verified law — wow-re unit-blob-shadow RE), on the same
// surface-decal projector as the selection ring.
.add_plugins(BlobShadowPlugin)
// Footprint decals (B212, decision 1006): the prints a walking unit leaves on snow/sand,
// spawn-once projections on the same decal projector, fading off the effect stream.
.add_plugins(CameraShakePlugin)
.add_plugins(FootprintsPlugin)
// GameObject animation (decision 0242): net-streamed GObjects (doors/chests) play an M2 sequence
// on GAMEOBJECT_STATE change — the state-machine sibling of the doodad idle loop above.
.add_plugins(go_anim::plugin)
.add_plugins(doodad_events::plugin)
// Avatar + camera + input.
.add_plugins(PlayerPlugin)
// Cinematic fly-bys (`SMSG_TRIGGER_CINEMATIC`): the race intro a first login plays, and the
// GameObject cameras. Takes the world camera for the duration — hence after PlayerPlugin,
// whose `control` it overrides within the same stage (decision 0196's deferred arc).
.add_plugins(crate::screen_fade::ScreenFadePlugin)
.add_plugins(CinematicPlugin)
// The real client's hardware mouse cursor (native NSCursor on macOS).
.add_plugins(CursorPlugin)
// Net↔ECS bridge: spawns the world thread, exposes the snapshot + writer resources. In capture
// mode the IO thread is skipped (`connect: false`) so the scene is deterministic.
.add_plugins(NetPlugin {
// **The game, as one name** (decision 2279): everything the client adds on top of the
// engine, in the order it always had. See `game_plugins.rs` for the members, the ordering
// edges inside it that are load-bearing, and the test that builds it headless.
.add_plugins(game_plugins::GamePlugins {
connect: !capturing,
})
// The death arc (decision 0308): the wire-fed death stores + the root/water-walk ack messages.
.add_plugins(death::DeathPlugin)
// The shared glue vocabulary both pre-world screens stand on (decision 0465): the ADD-mode UI
// material, the client-data art set, the GlueStrings table.
.add_plugins(glue::GluePlugin)
// The glue layer (decision 0193): the ClientState machine + the character-select screen
// that answers the parked IO thread's pick. A world capture boots straight InWorld (no net,
// no picker); a glue capture boots onto the screen it photographs.
.add_plugins(char_select::CharSelectPlugin {
start: run_mode::start_state(),
})
// The login screen (decision 0539): the faithful AccountLogin glue + the credential policy
// that answers the IO thread's pre-logon park.
.add_plugins(login::LoginPlugin)
// The realm list: the faithful RealmList glue + the policy that answers the IO thread's
// realm park. The client used to take `realms.first()` and offer no way to say otherwise.
.add_plugins(realm_select::RealmSelectPlugin)
// The character-creation screen + its live preview booth (decision 0423).
.add_plugins(char_create::CharCreatePlugin)
// Audio: the delegated mixer + WoW's owned selection layer (decision 0070).
.add_plugins(SoundPlugin)
// Targeting: left-click a unit to select it (→ CMSG_SET_SELECTION) + draw its ground ring.
.add_plugins(TargetPlugin)
.add_plugins(TransportPlugin)
// Faithful world-load splash + progress bar on startup + cross-map teleport (the load latency
// streaming can't hide); per-map art via the Map.dbc→LoadingScreens.dbc→BLP chain.
.add_plugins(LoadingScreenPlugin)
// The player-UI quad pass (decision 0068 §2): its own composited-above-the-world,
// below-the-egui-dev-overlays camera + sorted-quad renderer. `$WOW_UI_DEMO=1` seeds a proof scene.
.add_plugins(PlayerUiPlugin)
// The world's frame, rendered off-screen and drawn first in the UI camera's main pass —
// the seam that puts the UI-over-world blend back into gamma bytes (0161/0254's last piece,
// a pass rather than a quad since 2234). Registered AFTER the UI pass: it points that
// plugin's camera at the world camera.
.add_plugins(WorldBackdropPlugin)
// The HUD minimap (decision 0203 phase 1): fills the `<Minimap>` widget's extracted hole with
// the streamed tile window + mask + player arrow, and feeds the zone text.
.add_plugins(minimap::MinimapPlugin)
// The pet-bar / spellbook autocast shine, drawn on the append lane from the conversion's
// parked sites — zero per-frame script-layout traffic (decision 1383, B282).
// The `<Model>` widgets' M2s, rendered as tiles of one atlas and composited at the
// callback rank (decision 2008).
.add_plugins(ui_models::UiModelsPlugin)
// The shared AreaTable catalog + the ZONE_CHANGED event family / zone-text host globals
// behind GetZoneText & co. (the zone-entry splash arc, decision 0287).
.add_plugins(area::AreaPlugin)
.add_plugins(area_poi::AreaPoiPlugin)
.add_plugins(world_state_ui::WorldStateUiPlugin)
// The `AreaTrigger.dbc` volumes + the per-frame containment check that reports walking into
// one (`CMSG_AREATRIGGER`) — the client's whole part in portals, instance entrances and
// explore objectives; the server owns what each trigger means.
.add_plugins(area_trigger::AreaTriggerPlugin)
.add_plugins(ui_world_map::WorldMapUiPlugin)
// The guard's directions marker (`SMSG_GOSSIP_POI`) — one landmark record, drawn by the
// minimap's landmark pass and the world map's POI child, cleared by arriving at it.
.add_plugins(poi_marker::PoiMarkerPlugin)
// The glyph atlas (client TTFs -> baked bitmap) `ui_script`'s extraction draws `FontString`
// regions through. Loads at Startup, after the asset chain opens (decision 0068 §2).
.add_plugins(UiTextPlugin)
// The one "which NPC am I interacting with" answer, shared by the portrait booth's `"npc"`
// token and the interaction face-me (decision 1467) — hence its own plugin, ahead of both.
.add_plugins(ui_session::UiSessionPlugin)
// Unit-frame portraits: the token -> off-screen-baked-face bridge the UI extract samples for a
// `SetPortraitTexture`-bound region (the modern high-res 2D model bake).
.add_plugins(PortraitPlugin)
.add_plugins((TextInputPlugin, UiScriptPlugin))
// The video knobs the CVar host writes into (today: `gxVSync`). Before CvarPlugin so the
// resource exists when `load_config` applies the saved value at Startup.
.add_plugins(video::VideoPlugin)
// The realmlist (decision 1667) — the logon address the login screen edits. Same reason as
// VideoPlugin above: it is a CVar knob, so its resource has to exist before `load_config`.
.add_plugins(realmlist::RealmlistPlugin)
// The CVar host (decision 0954): registration, knob sync, config.toml persistence. After
// UiScriptPlugin only for reading order — its systems gate on the VM existing anyway.
.add_plugins(cvars::CvarPlugin)
// The key-binding engine (decision 0997): the chord→command dispatch every rebindable input
// runs through, its persistence, and the Key Bindings window's capture seam.
.add_plugins(bindings::BindingsPlugin)
// The unit snapshot + event feed (decision 0068 §3): pushes ECS game state into the VM as the
// plain data the `Unit*` bindings read, and fires the matching WoW events.
.add_plugins(UiUnitPlugin)
.add_plugins(UiPartyPlugin)
// Duels (decision 0633): the wire session, the client-side countdown tick, the four Era
// events, and the accept/cancel/challenge intents.
.add_plugins(UiDuelPlugin)
// Setting your hearthstone (decision 1331): the innkeeper's SMSG_BINDER_CONFIRM question, the
// CONFIRM_BINDER dialog it raises, and the CMSG_BINDER_ACTIVATE its Accept sends — the only
// packet in the flow that actually binds anything.
.add_plugins(UiBinderPlugin)
// The dialog engine's own verbs (decision 1963): the pet trainer's question, the instance
// boot clock, the area spirit healer, the battleground queue, the meeting stone.
.add_plugins(UiDialogVerbsPlugin)
.add_plugins(BattlefieldScorePlugin)
.add_plugins(BattlefieldPlugin)
.add_plugins(BattlefieldPositionsPlugin)
.add_plugins(crate::game_tip::GameTipPlugin)
.add_plugins(crate::text_filter::TextFilterPlugin)
// The re-shape a `bevy_ui` text root loses when its last span is despawned
// (decision 2212, B383): an upstream change-detection hole whose only symptom is a
// panic inside `bevy_text` on the next window resize.
.add_plugins(crate::text_reshape::TextReshapePlugin)
.add_plugins(TutorialPlugin)
// The melee swing refusals (`SMSG_ATTACKSWING_NOTINRANGE`/`_BADFACING`/`_DEADTARGET`/
// `_CANT_ATTACK`): the latch the packets set, and the 4 s repeat that shows it while an
// attack target stands and no swing lands.
.add_plugins(swing_refusal::SwingRefusalPlugin)
// Being summoned (decision 1747): SMSG_SUMMON_REQUEST's latch, the CONFIRM_SUMMON dialog it
// raises, and the CMSG_SUMMON_RESPONSE its Accept sends. The binder's twin one line up — a
// server-asked question whose only wire answer is yes — and here for that reason.
.add_plugins(UiSummonPlugin)
// The GM trouble-ticket flow (decision 1673): the Help window's five sends, the UPDATE_TICKET
// answer ticket behind its 10-minute poll, and the GMTicketCategory.dbc list its "page a GM"
// rows are built from. Beside the binder because it is the same feed/drain shape, and after it
// because both want UiInput ordering and this reads better grouped.
.add_plugins(UiGmTicketPlugin)
// Auto-follow's UI seam: the popup's Follow row + `FollowUnit`/`FollowByName` inbound, and
// the AUTOFOLLOW_BEGIN/END pair that drives the centre-screen status line outbound.
.add_plugins(UiFollowPlugin)
// Instance/raid lockouts (decision 1748): the four CHAT_MSG_SYSTEM lines the client composes
// itself out of GlobalStrings, the last-dungeon/ownership bookkeeping behind
// `CanShowResetInstances()`, and the SELF menu's one send. Beside the binder family for the
// same feed/drain shape; it needs the map catalog, which is up long before Update runs.
.add_plugins(UiInstancePlugin)
// Leaving (decision 0674): the game menu's Logout/Exit Game — the request, the server's
// 20-second answer narrated as the CAMP/QUIT countdown, and the process exit.
.add_plugins(UiLogoutPlugin)
.add_plugins(UiSocialPlugin)
// Guilds (decision 1257): the identity/roster mirror behind the four guild windows, the
// membership verbs, and the `ERR_GUILD_*` lines. Right after the social session, whose
// FriendsFrame it shares a window with and whose ignore list its sign-on lines consult.
.add_plugins(UiGuildPlugin)
// Founding a guild (decision 1672): the guild registrar and the charter window — the slice
// 1257 §2 left out. Right after the guild session, whose error channel its refusals ride and
// whose roster its success produces.
.add_plugins(UiPetitionPlugin)
.add_plugins(UiTooltipPlugin)
// The character-window feed (decision 0208): the combat-stats/inventory snapshots + events
// the paper doll reads, and the paper-doll booth's yaw mirror.
.add_plugins(UiCharPlugin)
// The reputation-pane feed: the player's wire faction slots resolved against Faction.dbc into
// the pane's snapshot, plus the pane's three outbound verbs. Beside the character feed because
// it is the same window's other tab.
.add_plugins(ui_reputation::UiReputationPlugin)
// The inspect feed (decision 0631): another player's equipment off their PUBLIC visible-item
// entries, plus the "inspect" booth's unit + yaw. Right after the character feed it mirrors.
.add_plugins(ui_inspect::InspectUiPlugin)
// The honor feed (decision 1512): the PRIVATE honor descriptor block as the snapshot both
// Honor tabs read, plus the inspect-honor round trip. After the inspect feed because it
// resolves that feed's target to address its request at.
.add_plugins(ui_honor::UiHonorPlugin)
// The dressing-room feed (decision 1060): the window's try-on intents → the player's own look
// with the tried-on items substituted in, plus the "dressup" booth's yaw. Beside the inspect
// feed, whose shape it shares (intents in, a booth look out).
.add_plugins(ui_dressup::DressUpUiPlugin)
.add_plugins(UiActionPlugin)
// The aura feed (decisions 0255/0257): the player's insertion-ordered buff/debuff cache + the
// self-only durations, pushed as the data the `UnitAura` bindings read; fires UNIT_AURA and
// drains the right-click cancels. After UiActionPlugin (shares its `Spells` catalog).
.add_plugins(UiAuraPlugin)
// The spellbook window feed (decision 0216 §8, slice 5): builds the book from
// PlayerActions.spells through the Spell.dbc/SkillLine.dbc join and drives
// SpellBookFrame.xml's snapshot + cast-drain seam — the spell SOURCE for the cursor payload
// arc (bags/doll/bars/book). After UiActionPlugin (shares its `Spells` resource + the cast
// tail `send_spell_cast`).
.add_plugins(UiSpellbookPlugin)
// The macro system (decision 0983): the icon chooser's catalog, the `benilla-config/macros/`
// files, `UPDATE_MACROS`, and the macro→bound-spell table the action bar's MACRO slots
// resolve their cooldown/usability through. After UiSpellbookPlugin — the bound spell is
// resolved against the book that feed pushes, by the same law `CastSpellByName` uses.
.add_plugins(ui_macro::UiMacroPlugin)
// The talent window feed (decision 0304): builds the class pages from Talent.dbc × the
// known-spell set + PLAYER_CHARACTER_POINTS, drives TalentFrame.xml through the engine's
// talent seam, and drains learn clicks into CMSG_LEARN_TALENT. After UiActionPlugin
// (shares its `Spells` catalog), beside the spellbook it mirrors.
.add_plugins(UiTalentPlugin)
// Unlearning them again (decision 1580): the class trainer's respec question, its
// CONFIRM_TALENT_WIPE dialog, and the answer that is the only packet in the flow which
// unlearns anything. Beside UiTalentPlugin for the subject, but it is UiBinderPlugin's twin
// in shape — a guid-carrying question over an already-closed gossip menu.
.add_plugins(UiTalentWipePlugin)
// The stance/shapeshift bar feed (wow-re shapeshift-bar-api.md): builds the form list from
// PlayerActions.spells per the byte-verified admission/order, drives the stock shapeshift bar through
// the engine's shapeshift seam, and drains its clicks (cancel-if-active else cast). After
// UiActionPlugin (shares `Spells`, the `usable` walk, and the cast tail).
.add_plugins(UiShapeshiftPlugin)
// The pet action bar (decision 0982) — the stance bar's mirror image: server-authoritative,
// so this renders the ten packed words the last `SMSG_PET_SPELLS` delivered and sends
// intents back. After UiActionPlugin (shares `Spells` and the cooldown triple's clock).
.add_plugins(UiPetPlugin)
.add_plugins(ChrClassesPlugin)
.add_plugins(UiPetBookPlugin)
// The pet's paper-doll stat block (happiness/loyalty/XP/training points). Its own plugin
// because it runs off descriptor fields and two DBC tables rather than off `SMSG_PET_SPELLS`.
.add_plugins(UiPetStatsPlugin)
// The pet paper doll's SHARED surface (decision 1057) — the combat-stats snapshot under the
// `"pet"` token and the page's model booth. Apart from the block above because these values
// pass through the character sheet's own bindings and events, with no hunter gate.
.add_plugins(UiPetDollPlugin)
// The connection-telemetry feed: the averaged ping RTT behind `GetNetStats()`, which the main
// bar's performance meter polls (decision 0658).
.add_plugins(UiNetPlugin)
.add_plugins(UiCastPlugin)
// The breath / fatigue bars (decision 0874): server-authoritative mirror timers off the
// wire into the transcribed MirrorTimer1/2/3 frames. Beside the cast bar it shares its
// feed→drain shape (and its art: the same UI-CastingBar-Border chrome).
.add_plugins(UiMirrorPlugin)
// Floating combat text (decision 0137 phase 2): the WORLDTEXTSTRING law — world-anchored
// damage numbers/outcome words projected into the UI quad pass each frame.
.add_plugins(combat_text::CombatTextPlugin)
// Overhead unit names (nameplates): world-billboard name text over players + NPCs.
.add_plugins(nameplates::NameplatesPlugin)
// Raid-target marker billboards (0434 §6): the mark icon over marked units, one line-pitch
// above the overhead name; plated units show the plate's raid child instead.
.add_plugins(raid_marks::RaidMarksPlugin)
// V-key nameplates (0167): the toggled health-bar plates, a 2-D overlay replacing the
// overhead name on plated units.
.add_plugins(vplates::VPlatesPlugin)
// Chat speech bubbles (0598): the over-the-head bubble a say/yell/party line spawns, the
// plates' 2-D overlay sibling — mutually exclusive with both the plate and the name.
.add_plugins(chat_bubble::ChatBubblePlugin)
// TOGGLEUI (`CTRL-Z`/`Cmd-Z`): the whole quad layer goes dark — frames, minimap, plates,
// bubbles, combat text — leaving the world and the cursor.
.add_plugins(ui_hide::UiHidePlugin)
.add_plugins(UiItemsPlugin)
// The gossip window (decision 0081): fills from the net drain's GossipState and drives
// GossipFrame.xml over the Era gossip API.
.add_plugins(UiGossipPlugin)
// The merchant window (decision 0081 phase 4): fills from the net drain's MerchantOpen and
// drives MerchantFrame.xml over the Era vendor API + the money display.
.add_plugins(UiMerchantPlugin)
// The bank window (decision 0604): the SHOW_BANK session (BankOpen) + the purchase row;
// the vault's slots ride the container feed as bags −1/5..=10.
// The auction house (decision 1511) — an NPC-session window like the bank beside it, but the
// only `doublewide` panel in the UI, so it displaces both the left and center seats.
.add_plugins(UiAuctionPlugin)
.add_plugins(UiBankPlugin)
// The mail window (decision 0544 P1/P2): the client-side mailbox session (MailOpen), the
// NPC-session range guard, and MailFrame.xml over the Era mail API (inbox, open-letter,
// send tab).
.add_plugins(UiMailPlugin)
// Player-to-player trade (TradeFrame.xml): the two-sided trade window, driven server-side over
// the P0 wire; the partner's portrait rides the shared "npc" booth (decision 0592 P1).
.add_plugins(UiTradePlugin)
// The item-text reader (ItemTextFrame.xml): right-clicked bag letters (mail-made permanent
// copies) read in the reference reader window over the shared ask-once item-text cache.
.add_plugins(UiItemTextPlugin)
.add_plugins(UiSavedPlugin)
.add_plugins(NamePersistPlugin)
.add_plugins(UiStablePlugin)
.add_plugins(TabardUiPlugin)
.add_plugins(UiTrainerPlugin)
// The taxi map (decision 0484 phases 1-2): the SMSG_SHOWTAXINODES-fed TaxiState resource, the
// NPC-session range guard, and the TaxiFrame.xml window feed/drain (catalogs, node
// projection/route computation, the activate send, the UnitOnTaxi ride flag).
.add_plugins(UiTaxiPlugin)
.add_plugins(UiTradeSkillPlugin)
.add_plugins(UiCraftPlugin)
// The loot window (decision 0084): fills from the net drain's LootState and drives
// LootFrame.xml over the Era loot API (coin + rows, paging).
.add_plugins(UiLootPlugin)
.add_plugins(UiLootRollPlugin)
// The questgiver window (decision 0088): fills from the net drain's QuestGiver and drives
// the stock questgiver window's four sub-panels over the Era quest API (1944).
.add_plugins(UiQuestPlugin)
// The quest-log window (decision 0088's deferred second slice): fills from the self player's
// PLAYER_QUEST_LOG descriptor slots + the SMSG_QUEST_QUERY_RESPONSE template cache, and drives
// the stock quest log over the Era quest-log API (1944).
.add_plugins(UiQuestLogPlugin)
// The party quest-share (decision 1733): the verdict lines on a quest we pushed, and the
// escort-quest confirm. Neither is bound to a window, so it is its own plugin rather than a
// lodger in either quest plugin above.
.add_plugins(QuestSharePlugin)
.add_plugins(UiChatPlugin)
// The layout cache: the geometry of every window the player has dragged or resized, restored
// at world entry and written back a quiet second after the last drag
// (`benilla-config/layout/<realm>-<character>.txt`). The consumer of the engine's userPlaced
// bit, which nothing read before it.
.add_plugins(UiLayoutPlugin)
// Print screen (decision 1487): the SCREENSHOT binding's engine half — one PNG per
// `Screenshot()` call into `benilla-config/Screenshots/` (never the install — decision 1486),
// answered to the UI as SCREENSHOT_SUCCEEDED/FAILED so the status text can never be in the
// frame it announces.
.add_plugins(screenshot::ScreenshotPlugin);
});
// Register benilla-assets' loaders AFTER `AssetPlugin` (they go into the live `AssetServer`).
benilla_assets::register_asset_loaders(&mut app);
+7 -1
View File
@@ -1096,9 +1096,15 @@ impl Plugin for MinimapPlugin {
// Before the script tick, and after the containment verdict it reads: the
// `MINIMAP_PING` event and `Minimap:GetPingPosition()`'s value land in the
// same tick, on a ping the renderer already drew at the end of last frame.
// Gated on the interface being up (decision 2279): a group member's
// `MSG_MINIMAP_PING` can land in the same drain as the login burst, and the
// `fresh` latch it sets is spent by this system's take — on the boot VM, with
// no `MiniMapPing` frame to show it, if this ran in 2214's one-frame window.
// Gated, the latch simply waits for the first frame with an interface.
ping::drive_minimap_ping
.after(feed_minimap_inside)
.before(crate::ui_script::UiInput),
.before(crate::ui_script::UiInput)
.run_if(crate::ui_script::ingame_ui_up),
// Before the script tick, so GameTimeFrame's OnUpdate reads this frame's
// minute, not last frame's.
feed_game_time.before(crate::ui_script::UiInput),
File diff suppressed because it is too large Load Diff
+372
View File
@@ -0,0 +1,372 @@
//! **The drain's parameters, by name** (decision 2279, 2265 §A1a).
//!
//! [`super::apply_net_updates`] takes every resource the 273 arms write, and Bevy caps a system
//! at sixteen parameters (`bevy_ecs` `system_param.rs`, `all_tuples!(…, 0, 16)`). From day one
//! (0006) the escape was tuples nested up to six deep, addressed by coordinate — `audio.15 .6`,
//! `ui_actions.1 .5` — with fifty comments across the tree explaining that a thing rides where it
//! rides "because this is where the ceiling left room". The `SystemParam` derive emits a named
//! struct with no field cap, so each family below is a struct: the body destructures it by field
//! name, every use site reads as what it is, and a new resource joins the family it belongs to
//! rather than the tuple with a slot free.
//!
//! **Grouping is by what the arms do with a thing**, not by what it is: a session-lifecycle edge,
//! a window's parked state, the action bar's queues, an animation-bearing message, a read-only
//! catalog. Two families the tuples had mis-homed for want of room moved to where they belong
//! (the catalogs, and the queries); one parameter the tuples carried nobody read
//! (`PingShared` — the drain stopped clearing it with B346) is gone. Nothing else changed: the
//! same set of resources, the same access, one system.
//!
//! Splitting the drain itself — each family owning a reader system of its own — is 2265 §A1(b),
//! and starts from these names.
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use super::super::{
AiReactionMessage, CharActionResultMessage, CharListMessage, EmoteMessage, EnteredWorldMessage,
LoggedOutMessage, ObjectStore, PendingTransfer, PetDismissSoundMessage, PetTalkMessage,
RemoteMotion, SelfPlayer, ServerSoundMessage, ServerTime, ServerWallClock, TeleportMessage,
UnitMoveModes, WorldportMessage,
};
use benilla_world::weather::WeatherMessage;
/// The clocks: the server's two, and our real one.
#[derive(SystemParam)]
pub(crate) struct Clocks<'w> {
/// The in-game day/night clock the lighting reads (`SMSG_LOGIN_SETTIMESPEED`).
pub server_time: ResMut<'w, ServerTime>,
/// The wall clock the absolute descriptor stamps are dated in (decision 1150). A different
/// quantity from the one above, from the same handler file.
pub wall_clock: ResMut<'w, ServerWallClock>,
/// `Time<Real>`, NOT the default virtual one, and every reader here is the reason: a span
/// the SERVER sends us (an aura's remaining duration, the corpse-reclaim delay, a relayed
/// mover's fire-time) counts down in real seconds, so it must be stamped and read on a real
/// clock. `Time<Virtual>` clamps at 250 ms per frame (`max_delta`), which is right for
/// simulation — a 2 s hitch must not teleport animations — and wrong here: every long frame
/// would quietly ADD that much to every buff timer, so an aura would vanish with seconds
/// still showing on its clock. Measured on a hitchy run: the virtual clock lost 20 s against
/// real in 33 s (decision 0846). The relayed-move replay (0601/0615) reads the same clock in
/// `drain_pending_moves`/`extrapolate_remote_units`.
pub real: Res<'w, Time<Real>>,
}
/// Every entity query the drain holds — what it reads or writes on streamed objects.
#[derive(SystemParam)]
pub(crate) struct ObjectQueries<'w, 's> {
pub transforms: Query<'w, 's, &'static mut Transform>,
pub stores: Query<'w, 's, &'static mut ObjectStore>,
/// A relayed player's dead-reckon.
pub remote: Query<'w, 's, &'static mut RemoteMotion>,
/// Any unit's server-granted movement modes (decision 1780).
pub modes: Query<'w, 's, &'static mut UnitMoveModes>,
/// The deck a unit is standing on and its pose there (decision 1936).
pub riders: Query<'w, 's, &'static mut crate::transport::TransportRider>,
/// READ-only: every write to a mover's speed set goes through the drain's
/// `objects::SpeedStage` and lands in one flush at the end (decision 1478).
pub speeds: Query<'w, 's, &'static super::super::UnitSpeeds>,
/// The armed-transport lens for the worldport's spare predicate (decision 0455: a boat whose
/// path touches the destination map survives the purge).
pub transports: Query<'w, 's, &'static crate::transport::Transport>,
/// The cast-state read the spell-id-keyed `Casting` reap needs (decision 0107).
pub casting: Query<'w, 's, &'static crate::creature_anim::Casting>,
/// Are *we* already swinging? The ref's `[player+0xc48]`, mirrored by the server-echoed
/// [`crate::creature_anim::Engaged`] — read by the GO handler's deferred auto-attack start
/// (`0x6e83e7`, decision 1593). Filter-only, so it conflicts with nothing else in the drain.
pub engaged_self: Query<'w, 's, (), (With<crate::creature_anim::Engaged>, With<SelfPlayer>)>,
}
/// The session lifecycle: the glue-screen edges, the player's own teleports and mover
/// handshakes, and the entry/transfer state those edges write (decision 0193).
#[derive(SystemParam)]
pub(crate) struct Session<'w> {
pub teleports: MessageWriter<'w, TeleportMessage>,
pub worldports: MessageWriter<'w, WorldportMessage>,
/// The two park publications: the realm list and the character roster are the same thing
/// one park apart, each published so the app's policy can answer it.
pub char_lists: MessageWriter<'w, CharListMessage>,
pub realm_lists: MessageWriter<'w, crate::net::RealmListMessage>,
/// The pick park's two verdicts: what a create/delete came back with, and the refusal of the
/// pick itself.
pub char_actions: MessageWriter<'w, CharActionResultMessage>,
pub char_login_failures: MessageWriter<'w, super::super::CharacterLoginFailedMessage>,
/// The world-entry pair, one edge: the entry message, and the `SMSG_ADDON_INFO` verdict that
/// the entry's own UI load reads before the first addon's file-scope code asks
/// `GetNumAddOns()` (2175).
pub entered_world: MessageWriter<'w, EnteredWorldMessage>,
pub addon_reply: ResMut<'w, crate::net::AddonInfoReply>,
pub logged_out: MessageWriter<'w, LoggedOutMessage>,
pub speed_changes: MessageWriter<'w, super::super::SpeedChangeMessage>,
/// The two server-authored mover edges the controller both *applies* and *answers*: a
/// granted mode (decisions 0308, 0866) and a knockback launch (decision 1702) are the same
/// handshake, an edge the server may not act on until our own live pose comes back.
pub move_modes: MessageWriter<'w, super::super::MoveModeMessage>,
pub knockbacks: MessageWriter<'w, super::super::KnockBackMessage>,
/// The login screen's dialog + reconnect-policy feed (decision 0539).
pub login_stages: MessageWriter<'w, super::super::LoginStageMessage>,
/// The login queue's position feed (decision 1681).
pub login_queued: MessageWriter<'w, super::super::LoginQueuedMessage>,
pub login_failures: MessageWriter<'w, super::super::LoginFailedMessage>,
pub disconnects: MessageWriter<'w, super::super::DisconnectedMessage>,
/// The server's own answer to a GM dot-command, readable rather than only logged — the probe
/// shield's confirmation channel (decision 0677).
pub server_said: MessageWriter<'w, super::super::ServerSaidMessage>,
/// A `MSG_MOVE_*` the server addressed to OUR mover (decision 0725): a pose it wrote, with no
/// handshake — the controller applies it in `player::wire_in`.
pub self_moves: MessageWriter<'w, super::super::SelfMoveMessage>,
/// The possession handoff (`SMSG_CLIENT_CONTROL_UPDATE`): control of a unit granted or
/// revoked. Forwarded verbatim — only the controller knows the pose it would have to park.
pub client_control: MessageWriter<'w, super::super::ClientControlMessage>,
/// A cinematic to play (`SMSG_TRIGGER_CINEMATIC`) — `crate::cinematic` owns both the playback
/// and the ack that has to answer it (decision 0196).
pub cinematics: MessageWriter<'w, super::super::CinematicTriggeredMessage>,
/// The far-teleport latch (decision 0455): `SMSG_TRANSFER_PENDING`'s transport block routes
/// `NEW_WORLD`'s coordinates; cleared on disconnect.
pub transfer: ResMut<'w, PendingTransfer>,
}
/// The windows' parked state: the ask-once caches and every `*Open`/`*State`/`*Session` a
/// `feed_*` pushes into the VM later (decision 0081). Where 195 of the 273 arms land.
#[derive(SystemParam)]
pub(crate) struct WindowStores<'w> {
pub names: ResMut<'w, crate::names::NameCache>,
pub items: ResMut<'w, crate::items::Items>,
pub gossip: ResMut<'w, crate::ui_gossip::GossipState>,
pub merchant: ResMut<'w, crate::ui_merchant::MerchantOpen>,
/// The two gossip-reached NPC service sessions whose state is a whole open window.
pub trainer_open: ResMut<'w, crate::ui_trainer::TrainerOpen>,
pub stable_open: ResMut<'w, crate::ui_stable::StableOpen>,
/// The loot window state, the client-local loot-target latch (the kneel's self trigger,
/// decision 0515), and the open group-loot rolls (decision 0591).
pub loot: ResMut<'w, crate::ui_loot::LootState>,
pub loot_latch: ResMut<'w, crate::ui_loot::LootLatch>,
pub loot_rolls: ResMut<'w, crate::ui_loot_roll::LootRolls>,
pub chat_log: ResMut<'w, crate::ui_chat::ChatLog>,
pub quest: ResMut<'w, crate::ui_quest::QuestGiver>,
/// The quest-log template cache, and the party quest-share state (decision 1733) — the
/// verdicts on a quest we pushed and the escort confirm: a share is a quest-log verb.
pub quest_log: ResMut<'w, crate::ui_quest_log::QuestLog>,
pub quest_share: ResMut<'w, crate::ui_quest_share::QuestShare>,
pub go_templates: ResMut<'w, crate::go_templates::GameObjectTemplates>,
pub home_bind: ResMut<'w, crate::net::HomeBind>,
pub proficiencies: ResMut<'w, crate::net::Proficiencies>,
pub dropped: ResMut<'w, crate::net::DroppedOpcodes>,
/// The death arc's wire-fed store (decision 0308): reclaim expiry, corpse location, resurrect
/// offer, the spirit-healer confirm.
pub death_net: ResMut<'w, crate::death::DeathNet>,
/// The party/raid roster mirror + its composed system lines (decision 0434).
pub group: ResMut<'w, crate::ui_party::GroupState>,
/// The taxi-map session (decision 0484 phase 1).
pub taxi: ResMut<'w, crate::ui_taxi::TaxiState>,
/// The mailbox session + its login-scoped arrival countdown (decision 0544).
pub mail_open: ResMut<'w, crate::ui_mail::MailOpen>,
pub mail_pending: ResMut<'w, crate::ui_mail::MailPending>,
/// The player-trade session (decision 0592).
pub trade_session: ResMut<'w, crate::ui_trade::TradeSession>,
/// The bank session and its purchase-refusal queue (decision 0604).
pub bank_open: ResMut<'w, crate::ui_bank::BankOpen>,
pub bank_errors: ResMut<'w, crate::ui_bank::BankErrors>,
/// The world-state table the NPC-text `$<n>w` tokens read.
pub world_states: ResMut<'w, crate::world_state::WorldStates>,
/// The duel session (decision 0633).
pub duel: ResMut<'w, crate::ui_duel::DuelState>,
pub social: ResMut<'w, crate::ui_social::SocialState>,
/// The pending logout/quit (decision 0674): the server's response and cancel-ack land here,
/// and `crate::ui_logout` turns them into the countdown dialog.
pub logout: ResMut<'w, crate::ui_logout::LogoutState>,
/// The mirror-timer queue (decision 0874): the breath/fatigue START/PAUSE/STOP edges, drained
/// into the FrameXML bars by `crate::ui_mirror`.
pub mirror_timers: ResMut<'w, crate::ui_mirror::MirrorTimerFeed>,
/// The pet action bar's server-authoritative state + its own cooldown store (decision 0982),
/// replaced wholesale on every `SMSG_PET_SPELLS`.
pub pet_bar: ResMut<'w, crate::ui_pet::PetBar>,
/// The by-key red error queue the pet bar's refused-order feedback rides (the `DisplayError`
/// route, resolved through the VM's own GlobalStrings by `ui_action::feed_actions`).
pub ui_error_keys: ResMut<'w, crate::ui_action::UiErrorKeys>,
/// The ask-once book-page cache (decision 1105) — every readable's text, keyed by `PageText`
/// id; the reader session repaints off it.
pub page_texts: ResMut<'w, crate::ui_item_text::PageTexts>,
pub played_time_answer: ResMut<'w, crate::net::PlayedTimeAnswer>,
/// The guild session (decision 1257) — the identity/roster mirror the seven
/// `SessionEvent::Guild*` arms drive.
pub guild: ResMut<'w, crate::ui_guild::GuildState>,
/// The innkeeper's pending bind question (decision 1331) — `SMSG_BINDER_CONFIRM` parks the
/// innkeeper's guid here and `crate::ui_binder` turns it into the CONFIRM_BINDER dialog,
/// whose Accept is the only thing that binds anything.
pub binder: ResMut<'w, crate::ui_binder::BinderState>,
/// The class trainer's pending respec question (decision 1580) — the inbound
/// `MSG_TALENT_WIPE_CONFIRM` parks the trainer's guid + cost here and
/// `crate::ui_talent_wipe` turns it into the CONFIRM_TALENT_WIPE dialog, whose Accept is the
/// only thing that unlearns anything. The binder's twin.
pub talent_wipe: ResMut<'w, crate::ui_talent_wipe::TalentWipeState>,
/// The dialog engine's verbs (decision 1963): the pet trainer's latch, the instance-boot
/// clock, the area spirit healer, the battleground queue and the meeting-stone queue — each
/// a feed for a stock dialog.
pub pet_unlearn: ResMut<'w, crate::ui_dialog_verbs::PetUnlearnState>,
pub instance_boot: ResMut<'w, crate::ui_dialog_verbs::InstanceBoot>,
pub area_spirit: ResMut<'w, crate::ui_dialog_verbs::AreaSpiritHealer>,
pub battlefield_queue: ResMut<'w, crate::ui_dialog_verbs::BattlefieldQueue>,
pub meeting_stone: ResMut<'w, crate::ui_dialog_verbs::MeetingStone>,
pub battlefield_scoreboard: ResMut<'w, crate::ui_battlefield_score::BattlefieldScoreboard>,
pub battlefield: ResMut<'w, crate::ui_battlefield::Battlefield>,
pub tutorials: ResMut<'w, crate::tutorial::Tutorials>,
pub battlefield_positions: ResMut<'w, crate::ui_battlefield_positions::BattlefieldPositions>,
pub tabard: ResMut<'w, crate::ui_tabard::TabardOpen>,
/// The guard's directions marker (`SMSG_GOSSIP_POI`) — the wire carries no map field, so
/// "where you were standing when the guard told you" is the client's to remember
/// (`crate::poi_marker`).
pub poi_marker: ResMut<'w, crate::poi_marker::PoiMarker>,
/// The inspect-honor reply (decision 1512) — `MSG_INSPECT_HONOR_STATS` is the only source of
/// another player's honor numbers, so the reply parks here and `crate::ui_honor` pushes it
/// into the pane and fires `INSPECT_HONOR_UPDATE`.
pub inspect_honor: ResMut<'w, crate::ui_honor::InspectHonor>,
/// The auctioneer session (decision 1511) — the hello reply opens it, the three list results
/// fill it, and `crate::ui_auction` feeds it to the window.
pub auction_open: ResMut<'w, crate::ui_auction::AuctionOpen>,
/// The minimap ping (decision 1596) — a group member's `MSG_MINIMAP_PING` seats the world
/// point here and the minimap renderer derives everything else from it.
pub ping: ResMut<'w, crate::minimap::MinimapPing>,
/// The GM ticket (decision 1673) — `SMSG_GMTICKET_GETTICKET` replaces the held ticket here
/// and BUMPS AN ANSWER COUNTER, which is what `crate::ui_gm_ticket` diffs on: the Help window
/// re-polls every 10 minutes and an unchanged answer still has to re-fire `UPDATE_TICKET`.
pub gm_ticket: ResMut<'w, crate::ui_gm_ticket::GmTicketState>,
/// The guild-charter session (decision 1672) — two resources because only the registrar
/// half is NPC-bound: `SMSG_PETITION_SHOWLIST` opens the registrar, and
/// `SMSG_PETITION_SHOW_SIGNATURES` opens the item-bound charter window, which must survive
/// walking away from the registrar.
pub registrar: ResMut<'w, crate::ui_petition::GuildRegistrarState>,
pub petition: ResMut<'w, crate::ui_petition::PetitionState>,
/// The pending summon question (decision 1747) — `SMSG_SUMMON_REQUEST` parks the summoner's
/// guid, zone and expiry here and `crate::ui_summon` turns it into the CONFIRM_SUMMON dialog,
/// whose Accept is the only packet in the flow.
pub summon: ResMut<'w, crate::ui_summon::SummonState>,
/// The instance-lockout bookkeeping (decision 1748) — four of its six packets queue a
/// GlobalStrings-templated chat line here for `crate::ui_instance` to resolve against the
/// VM, and two write the latch behind `CanShowResetInstances()`.
pub instances: ResMut<'w, crate::ui_instance::InstanceState>,
}
/// The action bar's family: the cast/cooldown state and every error queue the red line drains
/// (decision 0137), plus the item-lock bookkeeping the inventory-failure arm also drives
/// (decision 0216 §4 / 0218 §3 — the drain has no `UiScript` to fire `ITEM_LOCK_CHANGED`
/// through, so the transitioned slots queue in `LockTransitions` for the container feed).
#[derive(SystemParam)]
pub(crate) struct ActionStores<'w> {
pub player_actions: ResMut<'w, crate::ui_action::PlayerActions>,
/// The cast + mount error queues, both drained into the red error line by
/// `ui_action::feed_actions`.
pub cast_errors: ResMut<'w, crate::ui_action::CastErrors>,
pub mount_errors: ResMut<'w, crate::ui_action::MountErrors>,
/// The `modalNextSpell` chain's outbox (1597) — `cast_result` fills it, the ui_action drain
/// sends it through the one cast path.
pub chain_casts: ResMut<'w, crate::ui_action::ChainCasts>,
/// The taming-refusal queue (decision 2039) — `SMSG_PET_TAME_FAILURE`'s reason byte. Its own
/// queue rather than `UiErrorKeys` because its message needs TWO GlobalStrings lookups, and
/// the inner one is only reachable at the drain (see the resource's doc).
pub pet_tame_failures: ResMut<'w, crate::ui_action::PetTameFailures>,
/// Spells learned mid-session, awaiting `LEARNED_SPELL_IN_TAB` (2252). A queue for the same
/// reason the reference sorts before it fires: the tab index is only correct against the
/// rebuilt tab list, which is the spellbook feed's, not this apply's.
pub learned_in_tab: ResMut<'w, crate::ui_spellbook::LearnedInTab>,
pub equip_errors: ResMut<'w, crate::ui_items::EquipErrors>,
pub merchant_errors: ResMut<'w, crate::ui_merchant::MerchantErrors>,
pub cast_bar: ResMut<'w, crate::ui_cast::CastBarFeed>,
pub pending_item_ops: ResMut<'w, crate::pending_item_ops::PendingItemOps>,
pub lock_transitions: ResMut<'w, crate::pending_item_ops::LockTransitions>,
/// The two NPC-service windows' error queues, each drained onto its window's red line by its
/// own feed.
pub trainer_errors: ResMut<'w, crate::ui_trainer::TrainerErrors>,
pub stable_errors: ResMut<'w, crate::ui_stable::StableErrors>,
pub pending_cast: ResMut<'w, crate::ui_cast::PendingCast>,
/// The player's cooldown store (decision 0137 phase 4); the pet's is in its bar, and
/// `addressed_store` picks between them.
pub cooldowns: ResMut<'w, crate::cooldowns::Cooldowns>,
/// The live auto-repeat state the bar's flash rides (decision 0137 phase 4).
pub auto_repeat: ResMut<'w, crate::ui_action::AutoRepeatActive>,
/// Our own running channel (the IsCurrentAction channel leg, decision 0137 phase 4).
pub active_channel: ResMut<'w, crate::ui_cast::ActiveChannel>,
/// Pre-formatted UIErrorsFrame lines — text the wire already resolved, so there is no
/// GlobalStrings key to look up: the death durability notice, `SMSG_NOTIFICATION`, and
/// `SMSG_AREA_TRIGGER_MESSAGE`. Drained by `ui_action::feed_actions` beside `UiErrorKeys`.
pub ui_error_texts: ResMut<'w, crate::ui_action::UiErrorTexts>,
/// The queued on-next-swing strike (the melee-slot half of the cast tracking) — the wire
/// resolves it here: GO fires it, a failing result/interrupt kills it.
pub queued_melee: ResMut<'w, crate::ui_cast::QueuedMeleeSpell>,
/// The aura feed's duration side-table (decisions 0255/0257): the self-only
/// `SMSG_UPDATE_AURA_DURATION` lands here keyed by raw slot, timestamped on
/// [`Clocks::real`] for the `ui_aura` slot-join.
pub aura_durations: ResMut<'w, crate::ui_aura::AuraDurations>,
}
/// Every animation-bearing or audible message the drain emits, in packet order, plus the
/// PlayAnimation call-order counter they stamp (decisions 0107, 0137 phase 2, 0280).
#[derive(SystemParam)]
pub(crate) struct AnimWriters<'w> {
pub server_sounds: MessageWriter<'w, ServerSoundMessage>,
pub weather: MessageWriter<'w, WeatherMessage>,
pub emotes: MessageWriter<'w, EmoteMessage>,
pub swings: MessageWriter<'w, crate::creature_anim::SwingMessage>,
pub cast_events: MessageWriter<'w, crate::creature_anim::CastEvent>,
pub spell_go_targets: MessageWriter<'w, crate::creature_anim::SpellGoTargets>,
/// The floating combat-text feed (decision 0137 phase 2).
pub combat_text: MessageWriter<'w, crate::combat_text::CombatTextSpawn>,
pub swing_impacts: MessageWriter<'w, crate::creature_anim::SwingImpact>,
pub swing_flushes: MessageWriter<'w, crate::creature_anim::SwingFlush>,
pub go_lid_open: MessageWriter<'w, crate::go_anim::GoLidOpen>,
/// The aggro/alert vocal flare + the pushed-kit play (decision 0280).
pub ai_reactions: MessageWriter<'w, AiReactionMessage>,
pub kit_pushes: MessageWriter<'w, crate::creature_anim::KitPush>,
/// The remote landing predictor's report (decision 0415): a relayed FALL_LAND fires the grunt
/// + dust puff for an observed mover, the way the self controller does for us.
pub hard_landings: MessageWriter<'w, crate::creature_anim::HardLanding>,
/// An observed rider's flourish (`SMSG_MOUNTSPECIAL_ANIM`, decision 0441 P2) — the unit →
/// mount-child hop happens in `creature_anim::flourish_to_anim`.
pub mount_flourishes: MessageWriter<'w, crate::creature_anim::MountFlourish>,
/// The UNIT_COMBAT event feed (the portrait hit indicator, decision 0576) + the
/// COMBAT_TEXT_UPDATE feed (the center combat text, decision 0578) — the spell arms'
/// self-facing twins of the floating-text spawn.
pub unit_combat: MessageWriter<'w, crate::ui_unit::UnitCombatFeedback>,
pub combat_text_events: MessageWriter<'w, crate::ui_unit::CombatTextEvent>,
/// The sheath setter's queue, which the ATTACKERSTATEUPDATE arm's melee auto-draw writes.
pub sheath_requests: MessageWriter<'w, crate::creature_anim::SheathRequest>,
/// The GO one-shot Custom play (`SMSG_GAMEOBJECT_CUSTOM_ANIM` — the bobber's bite splash,
/// decision 1086), the step-8 sibling of `go_lid_open`.
pub go_custom_anim: MessageWriter<'w, crate::go_anim::GoCustomAnim>,
/// The pet's bark (`SMSG_PET_ACTION_SOUND`, decision 2039) — `ai_reactions`' sibling: both
/// are pure audio that resolves a guid and hands the sound layer a state for the SAME
/// `0x623a40` dispatcher.
pub pet_talk: MessageWriter<'w, PetTalkMessage>,
pub pet_dismiss: MessageWriter<'w, PetDismissSoundMessage>,
/// The swing-refusal seam's two edges (`crate::swing_refusal`, decision 2037): the four
/// `SMSG_ATTACKSWING_*` refusals, and our own landed swing, which clears their latch. ONE
/// writer for both so the drain hands them on in packet order.
pub swing_refusals: MessageWriter<'w, crate::swing_refusal::SwingRefusalEdge>,
/// The PlayAnimation call-order counter (`creature_anim::PlaySeq`): every animation-bearing
/// message this drain emits stamps `next()`, in packet order.
pub play_seq: ResMut<'w, crate::creature_anim::PlaySeq>,
}
/// The read-only reference tables the arms resolve against. Each DBC-backed one is `Option`:
/// absent if its table failed to load, and every reader degrades rather than drops.
#[derive(SystemParam)]
pub(crate) struct Catalogs<'w> {
/// The FactionTemplate catalog the combat log's friend/foe split reads (B297). Absent, an
/// unresolved unit lands on the friendly side of the classifier rather than losing its lines.
pub factions: Option<Res<'w, crate::target::ring::Factions>>,
/// The Spell.dbc catalog the cooldown/cast wire laws read.
pub spells: Option<Res<'w, crate::ui_action::Spells>>,
/// The combat-feedback CVars — the eight display ranges, `CombatLogPeriodicSpells`, and the
/// three floating-text gates.
pub combat_cvars: crate::ui_chat::combat::CombatFeedbackCvars<'w>,
/// The EnvironmentalDamage.dbc 6-slot table (damage type → SpellVisualKit) the
/// `SMSG_ENVIRONMENTALDAMAGELOG` arm reads — the fall-landing dust puff.
pub env_damage: Option<Res<'w, crate::creature_anim::EnvDamageTable>>,
/// The shared AreaTable catalog — the exploration arm's area-id → name resolve (decision
/// 0828) — and the race-keyed discovery-jingle catalog (decision 0829).
pub area_table: Option<Res<'w, crate::area::AreaTableRes>>,
pub exploration_sounds: Option<Res<'w, crate::sound::ExplorationSounds>>,
/// The map the guard's directions marker is stamped with.
pub current_map: Option<Res<'w, benilla_world::world_map::CurrentMap>>,
/// Guild Member Alert (decision 1589) — the CVar knob the sign-on/sign-off pair's display
/// condition reads; see `ui_guild::apply::event` for the four conjuncts.
pub guild_notify: Res<'w, crate::ui_guild::GuildMemberNotify>,
}
@@ -275,6 +275,7 @@ pub(super) fn cast_result(
arg,
// `SMSG_CAST_FAILED` is addressed to the caster, and this handler is ours.
caster: crate::ui_action::Caster::Player,
redisplay: false,
});
}
}
+2 -2
View File
@@ -166,8 +166,8 @@ impl SwingRefusal {
/// shared `0x148`/`0x149` arm's one and only act.
///
/// The StopAttack lives here rather than in the net drain because [`AttackSeam`] is the whole
/// write set `0x5ecac0` needs and the drain is already at Bevy's SystemParam ceiling — and
/// because putting it beside the latch keeps arm 4's "stop, and say nothing" legible as one fact.
/// write set `0x5ecac0` needs, and because putting it beside the latch keeps arm 4's "stop, and
/// say nothing" legible as one fact.
fn apply_swing_refusals(
mut edges: MessageReader<SwingRefusalEdge>,
time: Res<Time>,
+131
View File
@@ -0,0 +1,131 @@
//! Helpers for the crate's **structural tests** — the ones that read this crate's own source
//! and insist on a shape (decisions 1290, 2220, 2279). A structural test exists where the
//! failure is silent at runtime, so the check has to happen at the line; these are the readers
//! they share.
/// Every `.rs` file under `root`, recursively.
pub(crate) fn rust_files(root: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
out
}
/// This crate's `src/` directory.
pub(crate) fn src_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")
}
/// A file's path under `src/`, with forward slashes — the key every `EXEMPT` table uses.
pub(crate) fn rel_path(file: &std::path::Path) -> String {
let src = src_dir();
file.strip_prefix(&src)
.unwrap_or(file)
.to_string_lossy()
.replace('\\', "/")
}
/// One `fn` item as the scanners see it: its name, its parenthesised parameter list, and its
/// body (the text between the outermost braces), each found by bracket matching so a scan
/// sees a signature or a body rather than whatever text happens to follow.
pub(crate) struct FnItem<'a> {
pub name: &'a str,
pub params: &'a str,
pub body: &'a str,
}
/// Every `fn` in `text`. A `fn` with no body (a trait method signature) comes back with an
/// empty body; a generic parameter list before the `(` is tolerated in the shapes this
/// codebase writes.
pub(crate) fn fn_items(text: &str) -> Vec<FnItem<'_>> {
let bytes = text.as_bytes();
let mut out = Vec::new();
for (i, _) in text.match_indices("fn ") {
// `fn` must start a word: `…_fn (` and `Fn(` are not declarations.
if i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_') {
continue;
}
let Some(open) = text[i..].find('(').map(|o| i + o) else {
continue;
};
let name = text[i + 3..open].trim();
let name = name.split('<').next().unwrap_or(name).trim();
let Some(close) = matching(text, open, b'(', b')') else {
continue;
};
let params = &text[open + 1..close];
// The body: the first `{` after the parameter list, unless a `;` (a bodiless
// signature) or another `fn` comes first.
let tail = &text[close + 1..];
let body = match (tail.find('{'), tail.find(';')) {
(Some(b), Some(s)) if s < b => "",
(Some(b), _) => {
let bopen = close + 1 + b;
match matching(text, bopen, b'{', b'}') {
Some(bclose) => &text[bopen + 1..bclose],
None => "",
}
}
(None, _) => "",
};
out.push(FnItem { name, params, body });
}
out
}
/// The parenthesised parameter list of every `fn` in `text` — see [`fn_items`].
pub(crate) fn fn_parameter_lists(text: &str) -> Vec<&str> {
fn_items(text).into_iter().map(|f| f.params).collect()
}
/// The index of the bracket closing the one at `open`, or `None` if it never closes.
fn matching(text: &str, open: usize, lhs: u8, rhs: u8) -> Option<usize> {
let mut depth = 0usize;
for (j, &c) in text.as_bytes()[open..].iter().enumerate() {
if c == lhs {
depth += 1;
} else if c == rhs {
depth -= 1;
if depth == 0 {
return Some(open + j);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fn_comes_back_with_its_name_its_params_and_its_body() {
let text = "fn a(x: u32) -> u32 { x + 1 }\nfn b();\nfn c<T>(t: T) { if t { { } } }";
let items = fn_items(text);
assert_eq!(items.len(), 3);
assert_eq!(
(items[0].name, items[0].params, items[0].body),
("a", "x: u32", " x + 1 ")
);
assert_eq!(
(items[1].name, items[1].params, items[1].body),
("b", "", "")
);
assert_eq!(
(items[2].name, items[2].params, items[2].body),
("c", "t: T", " if t { { } } ")
);
}
}
+484 -45
View File
@@ -30,23 +30,38 @@
//! NO_POWER).
//!
//! **The argument arms, and what is still approximate.** Filled: `0x5e` REQUIRES_SPELL_FOCUS and
//! `0x5d` REQUIRES_AREA here (decision 1313 — `0x6e1f62`/`0x6e1fad`), plus `0x8d`
//! `0x5d` REQUIRES_AREA here (decision 1313 — `0x6e1f62`/`0x6e1fad`), `0x8d`
//! PREVENTED_BY_MECHANIC (decision 1948 — `0x6e2190`, and the one arm whose word is produced
//! locally rather than read off the wire), and `0x78` TOTEMS / `0x5c`
//! locally rather than read off the wire), `0x56` ONLY_SHAPESHIFT (decision 2280 — `0x6e1ff8`,
//! the one arm that reads a **mask** and joins several names), and `0x78` TOTEMS / `0x5c`
//! REAGENTS / `0x19`–`0x1b` EQUIPPED_ITEM_CLASS\* in the drain, which owns them because their
//! fills need the item caches and the query-then-redisplay cache-miss behavior ("Requires Mining
//! Pick", decisions 0545 + 0552). Still stripped rather than filled — each needs a DBC we do not
//! load yet: `0x56` ONLY_SHAPESHIFT (form-name list), `0x90` MIN_SKILL (`SkillLine.dbc`),
//! `0x31` NEED_EXOTIC_AMMO and `0x84`
//! load: `0x90` MIN_SKILL (`SkillLine.dbc`), `0x31` NEED_EXOTIC_AMMO and `0x84`
//! PROSPECT_NEED_MORE. Stripping is a **deliberate divergence**, now byte-confirmed as one: on a
//! bad id or an absent word the reference jumps to the default arm with the pointer still on the
//! *unfilled* template, so it displays a literal `Requires %s` (wow-re §WIRE-ARGS C3 — the fill
//! path's own buffer swap sits after the printf and is skipped). We show the bare stem instead:
//! "Requires" reads as terse, "Requires %s" reads as broken software (§7 — judge by the result).
//! **`0x56` is the exception, and it is the reference's own** ([`Fill::Nothing`]): its three
//! failure exits land on the function epilogue, not on that default arm, so an unfillable
//! ONLY_SHAPESHIFT displays nothing at all.
//! `0x0a`'s item-spell leg (`ERR_INVALID_ITEM_TARGET`) is unmodeled — the drain does not know
//! item-ness.
//!
//! 4. **A fourth thing leaves this function, and it is not the displayed text**
//! ([`CastFailLine::arg_text`], decision 2285). `0x6e1a00` also calls the combat-log formatter
//! `0x62c360`, and hands it `edi` — the **argText** buffer, which layer 2 never touches. So
//! the eighteen re-worded reasons say one thing on screen and another in the log: "Spell is
//! not ready yet." against "Not yet recovered". Modelling the log as "whatever the red line
//! said" is wrong on seven rows; re-deriving it from the key table is wrong on the filled
//! arms. Both buffers come out of the one resolution here.
use benilla_formats::{AreaTableCatalog, SpellDisplay, SpellFocusCatalog, SpellMechanicCatalog};
use std::collections::HashMap;
use benilla_formats::{
AreaTableCatalog, ShapeshiftForm, SpellDisplay, SpellFocusCatalog, SpellMechanicCatalog,
};
use super::Caster;
@@ -234,6 +249,30 @@ pub(super) struct FailArgs<'a> {
pub(super) areas: Option<&'a AreaTableCatalog>,
/// `SpellMechanic.dbc` (`0xc0d7c4`) — the `0x8d` arm's mechanic name (decision 1948).
pub(super) mechanics: Option<&'a SpellMechanicCatalog>,
/// `SpellShapeshiftForm.dbc` (`0xc0d76c`) — the `0x56` arm's form names, keyed by form id
/// exactly as [`crate::ui_action::Spells::forms`] holds them (decision 2280).
pub(super) forms: Option<&'a HashMap<u32, ShapeshiftForm>>,
}
/// What an argument arm produced — **and, when it produced nothing, which exit the reference's
/// own arm takes**, because the two unfilled outcomes are not the same outcome.
///
/// Every other arm in the `0x6e1d8e` table declines through a bounds test that jumps to the
/// shared default `0x6e21d8`, where `edi` still points at the *unfilled* `SPELL_FAILED_*`
/// template and the line is displayed anyway ([`Self::Template`] — benilla strips its tokens
/// rather than showing a raw `%s`). `0x56` alone jumps, from all three of its failure exits, to
/// the function **epilogue** `0x6e224f`
/// ([`Self::Nothing`]), past `DisplayError`, past the error sound and past the cast abort
/// `0x6e4940` alike. That is not a special case anyone chose: the `0x56` arm spends `edi` as its
/// own loop counter, so it has no text pointer left to hand the default arm.
pub(super) enum Fill {
/// The arm resolved its word — the template's `%s` takes it.
Filled(String),
/// The arm declined (or benilla does not model it) and the reference still displays the
/// template: the shared default `0x6e21d8`.
Template,
/// The arm declined and the reference displays **nothing**: the epilogue `0x6e224f`.
Nothing,
}
impl FailArgs<'_> {
@@ -259,7 +298,11 @@ impl FailArgs<'_> {
/// `row + 0x2c + locale*4`, so `"You need to be in %s"` names the zone. This one has **no**
/// client-side stand-in: the server derives the id from its own `spell_area` rows and nothing
/// in `Spell.dbc` holds it, so an absent word means an unfilled message.
fn fill(&self, reason: u8, spell: Option<&SpellDisplay>) -> Option<String> {
fn fill(&self, caster: Caster, reason: u8, spell: Option<&SpellDisplay>) -> Fill {
let named = |name: Option<&str>| match name {
Some(name) => Fill::Filled(name.to_string()),
None => Fill::Template,
};
match reason {
// **`0x8d` PREVENTED_BY_MECHANIC** (`0x6e2190`) → `SpellMechanic.dbc` (`0xc0d7c4`),
// so "Can't do that while %s" reads "Can't do that while stunned". Unlike its two
@@ -267,18 +310,74 @@ impl FailArgs<'_> {
// comes from the crowd-control ladder's own exemption scan (decisions 1941/1948).
// A `0` or unknown id leaves the template to the strip fallback, which is also what
// the arm does when the scan named no mechanic at all.
0x8D => self.mechanics?.name(self.arg?).map(str::to_string),
0x5D => self.areas?.name(self.arg?).map(str::to_string),
0x8D => named(self.arg.and_then(|id| self.mechanics?.name(id))),
0x5D => named(self.arg.and_then(|id| self.areas?.name(id))),
0x5E => {
let id = self
.arg
.filter(|&id| id != 0)
.or_else(|| Some(spell?.requires_spell_focus).filter(|&id| id != 0))?;
self.focus?.name(id).map(str::to_string)
.or_else(|| spell.map(|d| d.requires_spell_focus).filter(|&id| id != 0));
named(id.and_then(|id| self.focus?.name(id)))
}
_ => None,
0x56 => self.shapeshift_forms(caster, spell),
_ => Fill::Template,
}
}
/// **`0x56` ONLY_SHAPESHIFT** (`0x6e1ff8`) → `SpellShapeshiftForm.dbc` (`0xc0d76c`), so
/// `"Must be in %s"` reads "Must be in Cat Form" — the line a bear-form druid gets for
/// pressing Prowl. The one arm that reads a **mask** and can name several forms.
///
/// Byte-exact, and unlike its neighbours it reads **no wire word at all**: `6e2021` re-reads
/// the failing spell's own record, `6e202d: test [eax+0x2c],edx` walks `SpellRec+0x2c`
/// (`Stances`, field 11) bit by bit, and `6e2050: mov eax,[esi+edx*4+0x8]` takes each row's
/// `Name` at `row + 0x8 + locale*4`. The walk is over the **record array** (`6e20b3: add
/// esi,0x38`, stride 56 = one row) with the bit index as the row index, never through the
/// id-lookup table — and 5875's file is 32 rows carrying ids 1..32 in file order (verified on
/// the extracted DBC), so row index `b` IS form id `b+1`. That is the same "bit b = form id
/// b+1" law the spell tooltip's own required-form line already runs on, which is why the
/// tooltip could read "Requires Cat Form" while the refusal read "Must be in".
///
/// Joined with `", "` (`0x84480c`, `SStrCat 0x64a760`) — the reference's own separator, and
/// again the tooltip line's. A row whose `Name` is NULL or **empty** is skipped
/// (`6e2058: cmp BYTE PTR [eax],0x0`); 5875 ships fourteen such rows, so this is live data,
/// not a guard against nothing.
///
/// Two earned negatives. The arm never tests `AttributesEx2` bit `0x80000` — the *permissive*
/// mask attribute the tooltip's required-form line does honour (`0x52f115`, decision 1483) —
/// so a permissive spell that somehow reached this reason would still name its forms; the
/// form gate, not this arm, is what keeps that unreachable. And `StancesNot` (`+0x30`) is
/// never read here either.
///
/// **Nothing to say is said as nothing** ([`Fill::Nothing`]): an empty mask, an unnamed set
/// of forms, no spell record (`6e1ffd`), or an unloaded DBC (`6e2016`, the `[0xc0d770] == 0`
/// test) all leave the "nothing appended" flag set at `6e20c1` and jump to the epilogue.
fn shapeshift_forms(&self, caster: Caster, spell: Option<&SpellDisplay>) -> Fill {
let (Some(forms), Some(stances)) = (self.forms, spell.map(|d| d.stances)) else {
return Fill::Nothing;
};
let mut named = (0..u32::BITS)
.filter(|b| stances & (1 << b) != 0)
.filter_map(|b| forms.get(&(b + 1)))
.map(|f| f.name.as_str())
.filter(|n| !n.is_empty());
match caster {
// **The pet's arm does not join** (decision 2285). `0x6e921a` reads the same store
// the same way — `[0xc0d76c]` at `0x6e924f`, stride `0x38` at `0x6e9285`,
// `row+0x8+locale*4` at `0x6e9277`, the mask at `0x6e924c` — and then
// `0x6e9282: jne 0x6e929a` leaves the loop at the FIRST non-empty name. It is a
// break, not a continue: over `[0x6e8eb0, 0x6e93d0)` there is not one `SStrCat`
// (`0x64a760`) call and not one reference to the separator `0x84480c`. So a
// two-form spell the player reads as "Bear Form, Dire Bear Form" the pet reads as
// "Bear Form".
Caster::Pet => named.next().map(str::to_string),
Caster::Player => {
let all: Vec<&str> = named.collect();
(!all.is_empty()).then(|| all.join(", "))
}
}
.map_or(Fill::Nothing, Fill::Filled)
}
}
/// The errorId every reason that overrides nothing falls through to — `0x2c`, whose text is the
@@ -300,19 +399,57 @@ pub(super) const PASSTHROUGH: &str = "ERR_SPELL_FAILED_S";
pub(super) struct CastFailLine {
pub key: &'static str,
pub text: String,
/// **The reference's OTHER buffer** — `edi` at `0x6e21e2`, which is what the combat-log
/// formatter `0x62c360` receives, not the displayed text (decision 2285).
///
/// `0x6e1d9a: lea edi,[ebp-0x488]` runs **once, before the argument dispatch**, on the buffer
/// `0x6e1d77` has just filled with `GetText("SPELL_FAILED_<name>")` — and the errorId-override
/// chain (`0x6e1aab`–`0x6e1c5f`) writes only `[ebp-8]`, never `edi`. So an override changes
/// what the SCREEN says and leaves the log on the first-layer string: a cooldown refusal
/// toasts "Spell is not ready yet." and logs "Not yet recovered". Only `0x6e21d2` re-points
/// `edi`, at `[ebp-0x888]`, which just three arms write — a filled argument arm (the printf
/// output, so log == toast), `0x02` (the bare power noun), and `0x4d` (blanked).
///
/// Empty means "read the last-error buffer `0xb4da40` instead" — see [`Self::logged`].
pub arg_text: String,
}
impl CastFailLine {
/// A line the second layer left on its default errorId — the drain builds these for the three
/// argument arms it owns (`0x78` TOTEMS, `0x5c` REAGENTS, `0x19`-`0x1b` EQUIPPED_ITEM_CLASS*),
/// whose fills need the item caches.
/// whose fills need the item caches. A filled arm's two buffers hold the same printf output.
pub(super) fn passthrough(text: String) -> Self {
Self {
key: PASSTHROUGH,
arg_text: text.clone(),
text,
}
}
/// What `0x62c360` prints as the reason — `0x6e21e2`'s one-byte test: the argText buffer when
/// it is non-empty, else `0x4968a0`'s global last-error cell `0xb4da40`, which `DisplayError`
/// has just filled with the text it displayed (`0x496802`).
pub(super) fn logged(&self) -> &str {
if self.arg_text.is_empty() {
&self.text
} else {
&self.arg_text
}
}
/// The `0x4d` arm's `mov BYTE PTR [ebp-0x888],0` (`0x6e20fd` / `0x6e2113`): the argText
/// buffer is emptied and `edi` re-pointed at it, so the combat log falls through to the
/// last-error cell and reads what the screen reads. The one override whose two surfaces
/// agree, and it agrees by being blanked rather than by being equal. (Moot on the pet's
/// path, which never reaches a log formatter at all — it is applied there only so the two
/// casters' lines carry the same shape.)
fn arm_blanked(self) -> Self {
Self {
arg_text: String::new(),
..self
}
}
fn fill(self, name: &str) -> Self {
Self {
text: self.text.replace("%s", name),
@@ -332,7 +469,23 @@ pub(super) fn cast_fail_text(
get: &dyn Fn(&str) -> Option<String>,
) -> Option<CastFailLine> {
let get_text = |key: &str| get(key).filter(|s| !s.is_empty());
let get_display = |key: &'static str| get_text(key).map(|text| CastFailLine { key, text });
// `[ebp-0x488]` — the FIRST-layer string, `SStrCopy`d at `0x6e1d77` and pointed at by `edi`
// at `0x6e1d9a`, both of which run before the argument dispatch and neither of which any
// errorId override touches. It is therefore what the combat log reads for every reason no
// argument arm claims, *including* the eighteen the second layer re-words on screen
// ([`CastFailLine::arg_text`], decision 2285). Empty when 5875 ships no such key.
let first_layer = CAST_FAIL_KEYS
.get(usize::from(reason))
.and_then(|k| get_text(k))
.map(|t| strip_tokens(&t))
.unwrap_or_default();
let get_display = |key: &'static str| {
get_text(key).map(|text| CastFailLine {
key,
text,
arg_text: first_layer.clone(),
})
};
// The errorId overrides — the replaced-message reasons. **Which table is asked is the
// caster's** (decision 2033): the reference does not flag one handler, it ships two, and they
// disagree on ten reasons. Everything past this match — the `SPELL_FAILED_*` vocabulary, the
@@ -368,7 +521,7 @@ pub(super) fn cast_fail_text(
// ability reads "Not enough focus" either way.
0x4D => {
let power = spell.map_or(0, |d| d.power_type);
return get_display(power_keys(power).0);
return get_display(power_keys(power).0).map(CastFailLine::arm_blanked);
}
// What is deliberately absent is as load-bearing as what is here: the pet's index
// table sends `0x01`/`0x02` (already at full health/power), `0x09` (no target),
@@ -380,11 +533,13 @@ pub(super) fn cast_fail_text(
// `SPELL_FAILED_DONT_REPORT` has no string in 5875's `GlobalStrings.lua`, so the
// passthrough below draws nothing and the two agree on screen.
//
// `0x56` ONLY_SHAPESHIFT has a real arm here (`0x6e921a`, errorId `0xd6`) that fills
// the template with a `SpellShapeshiftForm.dbc` name and displays it through the bare
// `"%s"` of `ERR_SPELL_FAILED_SHAPESHIFT_FORM_S` — the same net line the player's own
// unmodeled arm would produce, and stripped the same way here for the same reason
// (that DBC is not loaded). Same surface, same silence, no pet-specific work.
// `0x56` ONLY_SHAPESHIFT has its own arm here (`0x6e921a`, errorId `0xd6`), and it
// is the one place the two casters' form lines actually differ: it names the FIRST
// matching form and stops, where the player's joins them all. [`FailArgs::fill`] owns
// that fork, below — the line still resolves through the shared layer, because
// `ERR_SPELL_FAILED_SHAPESHIFT_FORM_S` is the bare `"%s"` exactly as the passthrough
// `0x2c` is, and the message catalog gives ids `0x2c` and `0xd6` the same surface.
// What is NOT shared is the log: `0x6e8eb0` never calls `0x62c360` at all.
_ => {}
},
// `Spell_C::HandleCastFailed 0x6e1a00` (`0x6e1aab`–`0x6e1c5f`).
@@ -394,7 +549,13 @@ pub(super) fn cast_fail_text(
let t = get_display("ERR_SPELL_FAILED_ALREADY_AT_FULL_POWER_S")?;
let power = spell.map_or(0, |d| d.power_type);
let name = get_text(power_keys(power).1).unwrap_or_default();
return Some(t.fill(&name));
// `0x6e211f` copies the localized power NOUN — and nothing else — into
// `[ebp-0x888]`, then points `edi` there. So the screen reads the sentence and
// the combat log reads the bare word: "You fail to cast Rejuvenation: Mana."
return Some(CastFailLine {
arg_text: name.clone(),
..t.fill(&name)
});
}
0x09 => return get_display("ERR_GENERIC_NO_TARGET"),
0x17 => return None, // DONT_REPORT: control-flow hidden (jumps past DisplayError)
@@ -421,7 +582,7 @@ pub(super) fn cast_fail_text(
0x41 => return get_display("ERR_SPELL_FAILED_NOTUNSHEATHED"),
0x4D => {
let power = spell.map_or(0, |d| d.power_type);
return get_display(power_keys(power).0);
return get_display(power_keys(power).0).map(CastFailLine::arm_blanked);
}
0x59 => return get_display("ERR_SPELL_OUT_OF_RANGE"),
0x8E => return get_display("ERR_PLAY_TIME_EXCEEDED"),
@@ -430,36 +591,42 @@ pub(super) fn cast_fail_text(
}
// The passthrough layer: errorId 0x2c ("%s") displays GetText(SPELL_FAILED_<name>) as-is.
let Some(key) = CAST_FAIL_KEYS.get(usize::from(reason)) else {
return Some(CastFailLine {
key: PASSTHROUGH,
text: format!("Spell failed ({reason:#04x})"),
});
return Some(CastFailLine::passthrough(format!(
"Spell failed ({reason:#04x})"
)));
};
let text = get_text(key)?;
let line = |text: String| CastFailLine {
key: PASSTHROUGH,
text,
};
// On this layer the two buffers are one string: errorId `0x2c` is the bare `"%s"`, so what
// `DisplayError` formats IS `[ebp-0x488]`, the same buffer `edi` still points at.
let line = CastFailLine::passthrough;
// The argument arms (`0x6e1d8e`): fill the template's `%s` from the reason's own DBC.
if let Some(name) = args.fill(reason, spell) {
return Some(line(text.replace("%s", &name)));
match args.fill(caster, reason, spell) {
Fill::Filled(name) => return Some(line(text.replace("%s", &name))),
// The arm's own exit is the epilogue, not the shared default — nothing is displayed, and
// nothing is logged either (`0x6e224f` is past `0x62c360` too). See [`Fill`].
Fill::Nothing => return None,
Fill::Template => {}
}
// An arm we don't model (or one whose lookup missed) — strip the tokens so the stem reads
// clean ("Missing reagent: %s" → "Missing reagent"), never a raw % on screen.
if text.contains('%') {
let stripped = text
.replace("%s", "")
.replace("%d", "")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
return Some(line(
stripped
.trim_end_matches([' ', ':', '.', '(', ')'])
.to_string(),
));
Some(line(strip_tokens(&text)))
}
/// The unmodeled-arm fallback's token strip — "Missing reagent: %s" → "Missing reagent", a
/// deliberate divergence from the reference's literal `%s` (module docs). Applied to **both**
/// buffers, so a declined arm never puts a raw token in the combat log either. A string with no
/// token is returned unchanged.
fn strip_tokens(text: &str) -> String {
if !text.contains('%') {
return text.to_string();
}
Some(line(text))
text.replace("%s", "")
.replace("%d", "")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim_end_matches([' ', ':', '.', '(', ')'])
.to_string()
}
#[cfg(test)]
@@ -475,6 +642,7 @@ mod tests {
("SPELL_FAILED_TOO_CLOSE", "Target too close"),
("SPELL_FAILED_NOT_READY", "Not yet recovered"),
("SPELL_FAILED_REAGENTS", "Missing reagent: %s"),
("SPELL_FAILED_ONLY_SHAPESHIFT", "Must be in %s"),
("ERR_SPELL_OUT_OF_RANGE", "Out of range."),
("ERR_GENERIC_NO_TARGET", "You have no target."),
("ERR_SPELL_COOLDOWN", "Spell is not ready yet."),
@@ -483,8 +651,10 @@ mod tests {
("ERR_OUT_OF_MANA", "Not enough mana"),
("ERR_OUT_OF_RAGE", "Not enough rage"),
(
// Verbatim from 5875 — it is NOT "You have nothing to attack with.", which is
// what this fixture claimed until the log leg made the difference observable.
"ERR_SPELL_FAILED_NOTUNSHEATHED",
"You have nothing to attack with.",
"You have to be unsheathed to do that!",
),
// The pet's own six, verbatim from the shipped file — and NO `ERR_PET_SPELL_NOPATH`,
// because 5875 ships none, which is a fact these tests rest on.
@@ -689,6 +859,227 @@ mod tests {
assert_eq!(kind_of(through.key), MsgKind::Error);
}
/// **`0x56` ONLY_SHAPESHIFT, the arm's three shapes** (decision 2280): one form, several
/// joined with the reference's `", "`, and a row whose `Name` the DBC leaves empty.
///
/// The bit→id law is the reference's record walk read as a lookup — row index `b` is form id
/// `b+1` — so Prowl's `Stances = 0x1` names form 1 and Maul's `0x90` names forms 5 and 8, in
/// **mask order**, which is the order the loop visits them in and not the order anything
/// sorts them into.
#[test]
fn only_shapeshift_names_the_forms_the_stance_mask_asks_for() {
let m = gs();
let g = getter(&m);
let forms = forms([
(1, "Cat Form"),
(5, "Bear Form"),
(8, "Dire Bear Form"),
(9, ""),
]);
let text = |stances: u32| {
let d = SpellDisplay {
stances,
..Default::default()
};
cast_fail_text(
Caster::Player,
0x56,
Some(&d),
FailArgs {
forms: Some(&forms),
..FailArgs::default()
},
&g,
)
.map(|l| l.text)
};
assert_eq!(text(0x1).as_deref(), Some("Must be in Cat Form"));
assert_eq!(
text(0x90).as_deref(),
Some("Must be in Bear Form, Dire Bear Form"),
);
// `6e2058: cmp BYTE PTR [eax],0x0` — an empty name is skipped, not joined as a gap.
assert_eq!(
text(0x90 | 0x100).as_deref(),
Some("Must be in Bear Form, Dire Bear Form"),
);
assert_eq!(text(0x101).as_deref(), Some("Must be in Cat Form"));
}
/// **The `0x56` arm's failure exit is the function epilogue, not the strip fallback** — an
/// unfillable ONLY_SHAPESHIFT displays NOTHING (`6e20c6 jne 0x6e224f`, past `DisplayError`),
/// where every other declining arm still shows its stripped stem. Four ways in: no spell
/// record (`6e1ffd`), an unloaded DBC (`6e2016`), an empty mask, and a mask naming only
/// unnamed rows.
///
/// The control is the neighbour: `0x5c` REAGENTS declines through the SHARED default and so
/// still reads "Missing reagent". If this ever regresses, the tell is the words "Must be in"
/// alone on screen — which is exactly how the defect was reported.
#[test]
fn an_unfillable_only_shapeshift_says_nothing_at_all() {
let m = gs();
let g = getter(&m);
let forms = forms([(1, "Cat Form"), (9, "")]);
let line = |spell: Option<&SpellDisplay>, args| {
cast_fail_text(Caster::Player, 0x56, spell, args, &g)
};
let with = FailArgs {
forms: Some(&forms),
..FailArgs::default()
};
let empty_mask = SpellDisplay::default();
let unnamed = SpellDisplay {
stances: 0x100,
..Default::default()
};
let cat = SpellDisplay {
stances: 0x1,
..Default::default()
};
assert_eq!(line(None, with), None, "no spell record");
assert_eq!(line(Some(&cat), FailArgs::default()), None, "no DBC loaded");
assert_eq!(line(Some(&empty_mask), with), None, "an empty stance mask");
assert_eq!(
line(Some(&unnamed), with),
None,
"a mask naming only unnamed rows"
);
// The control: the arm next door still strips rather than falling silent.
assert_eq!(
cast_fail_text(Caster::Player, 0x5C, None, FailArgs::default(), &g)
.unwrap()
.text,
"Missing reagent"
);
}
/// **The pet's `0x56` names ONE form** (`0x6e921a`; decision 2285 corrected 2280, which had
/// assumed the two casters produced the same line). `0x6e9282: jne 0x6e929a` leaves the loop
/// at the first non-empty name — a break, not a continue, and the earned negative is that the
/// whole pet handler contains no `SStrCat` and no reference to the separator.
///
/// The single-form case is asserted beside it deliberately: it is where the two agree, and a
/// test written only on that case is exactly how the wrong claim passed its own test.
#[test]
fn the_pets_only_shapeshift_names_the_first_form_and_never_joins() {
let m = gs();
let g = getter(&m);
let forms = forms([(1, "Cat Form"), (5, "Bear Form"), (8, "Dire Bear Form")]);
let text = |caster, stances: u32| {
let d = SpellDisplay {
stances,
..Default::default()
};
cast_fail_text(
caster,
0x56,
Some(&d),
FailArgs {
forms: Some(&forms),
..FailArgs::default()
},
&g,
)
.map(|l| l.text)
};
// Maul's mask: the player joins, the pet stops at the first.
assert_eq!(
text(Caster::Player, 0x90).as_deref(),
Some("Must be in Bear Form, Dire Bear Form")
);
assert_eq!(
text(Caster::Pet, 0x90).as_deref(),
Some("Must be in Bear Form")
);
// One form: the two agree, and the surface is the same despite the different errorId.
assert_eq!(text(Caster::Player, 0x1), text(Caster::Pet, 0x1));
assert_eq!(
text(Caster::Pet, 0x1).as_deref(),
Some("Must be in Cat Form")
);
// Nothing to name is silence on both handlers, by two different routes.
assert_eq!(text(Caster::Pet, 0x0), None);
}
/// **The combat log reads the reference's OTHER buffer** ([`CastFailLine::logged`] —
/// `edi` at `0x6e21e2`), which decision 2285 exists to correct. Four classes, and the whole
/// point is that "log what the red line said" and "re-derive from the key table" each get
/// some of them wrong.
#[test]
fn the_combat_log_reads_the_argtext_buffer_not_the_displayed_line() {
let m = gs();
let g = getter(&m);
let line = |reason, spell| {
cast_fail_text(Caster::Player, reason, spell, FailArgs::default(), &g).expect("a line")
};
// 1b · an errorId override with NO argument arm: `edi` never moved off the first-layer
// buffer, so the screen and the log disagree. This is the row 2280 got wrong.
let plain = spell(0, 0, 0);
let cooldown = line(0x3C, Some(&plain));
assert_eq!(cooldown.text, "Spell is not ready yet.");
assert_eq!(cooldown.logged(), "Not yet recovered");
let no_target = line(0x09, None);
assert_eq!(no_target.text, "You have no target.");
assert_eq!(no_target.logged(), "No target");
let unsheathed = line(0x41, None);
assert_eq!(unsheathed.text, "You have to be unsheathed to do that!");
assert_eq!(unsheathed.logged(), "You must be unsheathed");
// 1a · no override, no arm: one string in both buffers.
let ammo = line(0x43, None);
assert_eq!(ammo.logged(), ammo.text);
// 4 · `0x4d` BLANKS the buffer (`0x6e20fd`), so the log falls through to the last-error
// cell and reads the screen's wording — the override whose two surfaces agree.
let rage = spell(1, 0, 0);
let power = line(0x4D, Some(&rage));
assert_eq!(power.text, "Not enough rage");
assert_eq!(power.logged(), "Not enough rage");
// A declined arm strips in BOTH buffers — never a raw token in the log either.
let reagents = line(0x5C, None);
assert_eq!(reagents.logged(), "Missing reagent");
assert!(!reagents.logged().contains('%'));
}
/// The `0x02` arm (`0x6e211f`) copies only the localized power NOUN into the argText buffer,
/// so the screen reads a sentence and the log reads one word. The reference's own quirk, and
/// the one row where a "the log says what the screen says" model is visibly wrong.
#[test]
fn already_at_full_power_logs_the_bare_power_noun() {
let mut m = gs();
m.insert(
"ERR_SPELL_FAILED_ALREADY_AT_FULL_POWER_S",
"You are already at full %s.",
);
m.insert("RAGE", "Rage");
let g = getter(&m);
let rage = spell(1, 0, 0);
let line =
cast_fail_text(Caster::Player, 0x02, Some(&rage), FailArgs::default(), &g).unwrap();
assert_eq!(line.text, "You are already at full Rage.");
assert_eq!(line.logged(), "Rage");
}
fn forms<const N: usize>(
rows: [(u32, &str); N],
) -> HashMap<u32, benilla_formats::ShapeshiftForm> {
rows.into_iter()
.map(|(id, name)| {
(
id,
benilla_formats::ShapeshiftForm {
name: name.to_string(),
..Default::default()
},
)
})
.collect()
}
fn getter<'a>(
map: &'a HashMap<&'static str, &'static str>,
) -> impl Fn(&str) -> Option<String> + 'a {
@@ -945,11 +1336,14 @@ mod tests {
let areas = benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable");
let mechanics =
benilla_formats::load_spell_mechanic_catalog(&mut chain).expect("SpellMechanic");
let forms =
benilla_formats::load_shapeshift_forms(&mut chain).expect("SpellShapeshiftForm.dbc");
let args = |arg: u32| FailArgs {
arg: Some(arg),
focus: Some(&focus),
areas: Some(&areas),
mechanics: Some(&mechanics),
forms: Some(&forms),
};
// **0x8d PREVENTED_BY_MECHANIC** (decision 1948) — the crowd-control ladder's renamed
// refusal, and the one argument arm whose word is produced locally rather than read off
@@ -1008,6 +1402,51 @@ mod tests {
.text,
"You need to be in"
);
// **0x56 ONLY_SHAPESHIFT on the real chain — the reported defect** (decision 2280): the
// real `Spell.dbc` `Stances` column, the real `SpellShapeshiftForm.dbc` names and the
// real `"Must be in %s"`. Prowl in bear form is the report; Maul is the multi-form join,
// which a single-bit test could not tell apart from "name the lowest bit".
let catalog = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc");
let shifted = |spell_id: u32| {
let d = catalog.get(spell_id).expect("a shipped spell");
cast_fail_text(
Caster::Player,
0x56,
Some(d),
FailArgs {
forms: Some(&forms),
..FailArgs::default()
},
&g,
)
.map(|l| l.text)
};
// Prowl, all three ranks — `Stances = 0x1`, the one the spellbook tooltip already read as
// "Requires Cat Form" while this line read the bare stem.
for prowl in [5215, 6783, 9913] {
assert_eq!(
shifted(prowl).as_deref(),
Some("Must be in Cat Form"),
"Prowl {prowl}"
);
}
assert_eq!(
shifted(6807).as_deref(),
Some("Must be in Bear Form, Dire Bear Form"),
"Maul's 0x90 names both bear rows, in mask order"
);
assert_eq!(
shifted(1715).as_deref(),
Some("Must be in Battle Stance, Berserker Stance"),
"Hamstring's 0x50000 skips the Defensive row between them"
);
// A spell with no stance requirement reaches the epilogue, not the stem.
assert_eq!(
shifted(22812),
None,
"Barkskin carries an empty Stances mask"
);
// 0x5e alone has a client-side stand-in: the failing spell's own `RequiresSpellFocus`
// column is the very number the server copied onto the wire, so an absent word still
// fills. (Spell 4976 "Filling" — the Crystal Phial's — carries focus 11.)
@@ -60,6 +60,17 @@ pub(crate) struct CastFail {
/// **Whose** refusal this is — which decides which of the reference's two message tables
/// resolves it. See [`Caster`].
pub caster: Caster,
/// **This entry is a REDISPLAY** — it was queued once already, declined for want of an item
/// template, and is being resolved now that the template landed (the `0x78`/`0x5c`/`0x84`
/// cache-miss path, decisions 0545 + 0552).
///
/// It exists because the reference's two attempts do not raise the same things (decision
/// 2285). The first pass bails at `0x6e1eab`/`0x6e1efc` to the epilogue `0x6e224f`, which is
/// past the red line AND past the combat-log formatter; the retry is the DBCACHECALLBACK
/// `0x6e29b0`, which calls `CGGameUI::DisplayError 0x496720` **directly** and never reaches
/// `0x62c360`. So a reagent refusal that had to wait for its item name shows the toast and is
/// never written to the log — where one that resolved first try is written to both.
pub redisplay: bool,
}
/// Who failed to cast — the one input that picks between the reference's **two** cast-failure
@@ -94,6 +105,16 @@ impl CastFail {
reason,
arg: None,
caster: Caster::Player,
redisplay: false,
}
}
/// The same entry, re-queued for the frame its item template lands on — see
/// [`Self::redisplay`].
pub(crate) const fn requeued(self) -> Self {
Self {
redisplay: true,
..self
}
}
}
@@ -112,6 +133,7 @@ impl CastErrors {
reason,
arg: Some(arg),
caster: Caster::Player,
redisplay: false,
});
}
@@ -126,6 +148,7 @@ impl CastErrors {
reason,
arg: None,
caster: Caster::Pet,
redisplay: false,
});
}
}
+127 -97
View File
@@ -79,6 +79,9 @@ pub(super) struct FeedMemory {
/// `Option<Res<…>>` that are one concept and were pushing this system past Bevy's parameter arity
/// (decision 1948). Each is `Option` for the same reason `FailArgs`' fields are: a client with no
/// game data has none, and the arm then declines and the template strips.
///
/// The fourth table, `SpellShapeshiftForm.dbc` (`0x56`), is not here: it already rides
/// [`Spells`], which this system takes anyway, so [`Self::args`] takes that and joins them.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct FailNameTables<'w> {
/// `SpellFocusObject.dbc` — the crafting book's catalog, for `0x5e`.
@@ -90,12 +93,13 @@ pub(super) struct FailNameTables<'w> {
}
impl FailNameTables<'_> {
fn args(&self) -> cast_fail::FailArgs<'_> {
fn args<'a>(&'a self, spells: Option<&'a Spells>) -> cast_fail::FailArgs<'a> {
cast_fail::FailArgs {
arg: None,
focus: self.focus.as_deref().map(|f| &f.catalog),
areas: self.areas.as_deref().map(|a| &a.0),
mechanics: self.mechanics.as_deref().map(|m| &m.catalog),
forms: spells.map(|s| &s.forms),
}
}
}
@@ -140,11 +144,11 @@ pub(super) fn feed_actions(
let self_store = self_q.iter().next();
let mut await_template: Vec<crate::ui_action::CastFail> = Vec::new();
// The same failures, worded for the combat log. Collected beside the red line rather than
// instead of it: `0x6e1a00` calls `0x62c360` AND `DisplayError`, and they say different
// things — "Not enough mana." on the screen, "You fail to cast Frostbolt: Not enough mana."
// instead of it: `0x6e1a00` calls `0x62c360` AND `DisplayError`, which frame the one string
// two ways — "Not enough mana." on the screen, "You fail to cast Frostbolt: Not enough mana."
// in the log.
let mut fail_lines: Vec<crate::ui_chat::combat::PendingCombat> = Vec::new();
let fail_args = name_tables.args();
let fail_args = name_tables.args(spells.as_deref());
let texts: Vec<cast_fail::CastFailLine> = cast_errors
.0
.drain(..)
@@ -158,82 +162,132 @@ pub(super) fn feed_actions(
let pet = caster == crate::ui_action::Caster::Pet;
let d = spells.as_ref().and_then(|s| s.catalog.get(spell_id));
let get = |key: &str| script.lua().globals().get::<String>(key).ok();
// 0x19/0x1a/0x1b EQUIPPED_ITEM_CLASS* — the other argument-formatted family whose
// `%s` benilla models (`0x6e1db7`, the arm that resolves an item class/subclass name
// through `0x6e2380`): "Must have a **Wand** equipped", the SINGULAR DisplayName,
// where the spell tooltip's own requirement line takes the verbose plural. Purely a
// DBC read, so no query/redisplay round trip. A multi-bit mask resolves too — through
// ItemSubClassMask.dbc's group name, else the FIRST matching subclass (law
// §3-EQUIPITEM; the tooltip's twin joins instead).
if let (0x19..=0x1b, Some(d), Some(subs)) = (reason, d, sub_classes.as_deref()) {
if let Some(name) = (d.equipped_item_class >= 0)
.then(|| {
subs.0.requirement_display_name(
d.equipped_item_class as u32,
d.equipped_item_subclass_mask,
)
})
.flatten()
{
let key = cast_fail::CAST_FAIL_KEYS[reason as usize];
// The displayed line first, the log line off it — the reference's own order, and the
// reason the two can no longer drift apart (see the combat-log twin below).
let line = (|| -> Option<cast_fail::CastFailLine> {
// 0x19/0x1a/0x1b EQUIPPED_ITEM_CLASS* — the other argument-formatted family whose
// `%s` benilla models (`0x6e1db7`, the arm that resolves an item class/subclass name
// through `0x6e2380`): "Must have a **Wand** equipped", the SINGULAR DisplayName,
// where the spell tooltip's own requirement line takes the verbose plural. Purely a
// DBC read, so no query/redisplay round trip. A multi-bit mask resolves too — through
// ItemSubClassMask.dbc's group name, else the FIRST matching subclass (law
// §3-EQUIPITEM; the tooltip's twin joins instead).
if let (0x19..=0x1b, Some(d), Some(subs)) = (reason, d, sub_classes.as_deref()) {
if let Some(name) = (d.equipped_item_class >= 0)
.then(|| {
subs.0.requirement_display_name(
d.equipped_item_class as u32,
d.equipped_item_subclass_mask,
)
})
.flatten()
{
let key = cast_fail::CAST_FAIL_KEYS[reason as usize];
return get(key)
.filter(|s| !s.is_empty())
.map(|t| cast_fail::CastFailLine::passthrough(t.replace("%s", &name)));
}
}
// `0x78` TOTEMS / `0x5c` REAGENTS — **the player's arms only**. The pet handler's
// index table (`0x6e93d0`) sends both to its generic arm, so a pet's refusal takes the
// shared passthrough below and never runs the bag walk or the item query (decision
// 2033).
if !pet && (reason == 0x78 || reason == 0x5c) {
let d = d?;
let failing = if reason == 0x78 {
self_store
.and_then(|s| first_missing_totem(d, s, &items))
// No store to test against (a race): name the first tool at all.
.or_else(|| d.totems.iter().copied().find(|&t| t != 0))
} else {
self_store
.and_then(|s| first_short_reagent(d, s, &items))
.or_else(|| d.reagents.iter().map(|&(id, _)| id).find(|&id| id != 0))
}?;
let cached = items
.template(failing, 0, &commands)
.map(|i| i.name.clone());
let name = match cached {
Some(name) => name,
// Answered-unknown → the ref's callback fallback literal (`0x838044`);
// still pending → keep the entry queued for the redisplay.
None if items.template_answered_unknown(failing) => "UNKNOWN".to_string(),
None => {
// The cache-miss re-queue — and it comes back MARKED, because the
// reference's retry is a different raise (see `CastFail::redisplay`).
await_template.push(fail.requeued());
return None;
}
};
let key = if reason == 0x78 {
"SPELL_FAILED_TOTEMS"
} else {
"SPELL_FAILED_REAGENTS"
};
return get(key)
.filter(|s| !s.is_empty())
.map(|t| cast_fail::CastFailLine::passthrough(t.replace("%s", &name)));
}
}
// `0x78` TOTEMS / `0x5c` REAGENTS — **the player's arms only**. The pet handler's
// index table (`0x6e93d0`) sends both to its generic arm, so a pet's refusal takes the
// shared passthrough below and never runs the bag walk or the item query (decision
// 2033).
if !pet && (reason == 0x78 || reason == 0x5c) {
let d = d?;
let failing = if reason == 0x78 {
self_store
.and_then(|s| first_missing_totem(d, s, &items))
// No store to test against (a race): name the first tool at all.
.or_else(|| d.totems.iter().copied().find(|&t| t != 0))
} else {
self_store
.and_then(|s| first_short_reagent(d, s, &items))
.or_else(|| d.reagents.iter().map(|&(id, _)| id).find(|&id| id != 0))
}?;
let cached = items
.template(failing, 0, &commands)
.map(|i| i.name.clone());
let name = match cached {
Some(name) => name,
// Answered-unknown → the ref's callback fallback literal (`0x838044`);
// still pending → keep the entry queued for the redisplay.
None if items.template_answered_unknown(failing) => "UNKNOWN".to_string(),
None => {
await_template.push(fail);
return None;
}
};
let key = if reason == 0x78 {
"SPELL_FAILED_TOTEMS"
} else {
"SPELL_FAILED_REAGENTS"
};
return get(key)
.filter(|s| !s.is_empty())
.map(|t| cast_fail::CastFailLine::passthrough(t.replace("%s", &name)));
}
// The combat-log twin. **Only the `…SELF` half is reachable here**: `SMSG_CAST_FAILED`
// is addressed to the caster alone, so benilla never learns that somebody *else's*
// cast failed — the `…OTHER` keys exist and stay unproduced, exactly as the reference
// leaves its own two unreachable `…SELFSTART` keys.
cast_fail::cast_fail_text(
caster,
reason,
d,
cast_fail::FailArgs {
arg: fail.arg,
..fail_args
},
&get,
)
})();
// The retest instrument for this whole bug class (decision 1313). A red-line defect is
// reported as *seen* — B255 arrived as a screenshot of the word "Requires" — and until
// this line the only way to read what the client resolved was to look at the screen.
// Logging the reason, its wire argument and the resolved line makes an argument arm
// that silently declined (a missing word, an unnamed id) legible from a probe run.
// The line's `Debug` carries BOTH buffers, so a probe also reads the toast string
// beside the log string — the one reading that makes 2285's class of drift visible
// without opening the chat window.
debug!(
"ui_action: cast fail — {caster:?} spell {spell_id} reason {reason:#04x} \
arg {:?} → {:?}",
fail.arg, line
);
// **The combat-log twin — the reference's OTHER buffer, not the displayed text**
// (decision 2285, correcting 2280). It runs after the resolution because the
// reference's does: `0x6e1a00` calls `DisplayError 0x496720` at `0x6e21dd` and the
// log formatter `0x62c360` at `0x6e21fc`. But what it hands the formatter is `edi`
// (`0x6e21e2`), the **argText** buffer — which is the first-layer
// `SPELL_FAILED_<name>` string for every reason no argument arm claims, *including*
// the eighteen the second layer re-words on screen. A cooldown refusal toasts "Spell
// is not ready yet." and logs "Not yet recovered"; `0x09` toasts "You have no target."
// and logs "No target". [`CastFailLine::logged`] is that one-byte test.
//
// The trap this corrects is that deriving the log's `%s` a *second* time, from the
// key table, gets six of the seven divergent rows right by accident and the filled
// arms wrong — which is how a raw "Must be in %s" sat in the log while the red line
// read "Must be in Cat Form". One resolution, two buffers, is the shape that holds.
//
// A refusal that resolved to nothing logs nothing, which is the same control flow:
// `0x17` DONT_REPORT, the keys 5875 leaves out of `GlobalStrings.lua`, and `0x56`'s
// no-forms exit all jump past `0x62c360` as well as past `DisplayError`.
//
// **A redisplay is not a raise.** The item-cache retry (`0x6e29b0`) calls
// `DisplayError` directly and never reaches the log formatter, so an entry that had
// to wait for its item name shows the toast alone — `CastFail::redisplay`.
//
// **Only the `…SELF` half is reachable here**: `SMSG_CAST_FAILED` is addressed to the
// caster alone, so benilla never learns that somebody *else's* cast failed — the
// `…OTHER` keys exist and stay unproduced, exactly as the reference leaves its own
// two unreachable `…SELFSTART` keys.
//
// The reason `%s` is the FIRST-layer string — `GetText("SPELL_FAILED_<name>")`, which
// is what `0x6e1a00` holds when it calls the formatter — not the errorId-substituted
// message the red line shows. An empty one drops the line rather than printing a
// sentence with a hole, the same rule every other family here follows.
// **The pet's refusal is not logged.** `0x6e1a00` calls the log formatter `0x62c360`
// beside its `DisplayError`; `Spell_C::HandlePetCastFailed 0x6e8eb0` calls neither it
// nor the error sound — its entire call set is the two packet readers, `0x496720` and
// the string plumbing. Before decision 2033 a pet's refused Growl printed "You fail to
// cast Growl: ..." in the combat log, attributed to the player.
if let (Some(display), false) = (d, pet) {
if let (Some(shown), Some(display), false, false) =
(line.as_ref(), d, pet, fail.redisplay)
{
// `0x62aff0`: `Attributes` bit 4 marks an ABILITY, which "performs" rather than
// "casts".
const ATTR_IS_ABILITY: u32 = 0x10;
@@ -242,11 +296,7 @@ pub(super) fn feed_actions(
} else {
crate::ui_chat::combat::SPELLFAILCAST
};
let why = cast_fail::CAST_FAIL_KEYS
.get(usize::from(reason))
.and_then(|k| get(k))
.filter(|t| !t.is_empty());
if let (Some(why), false) = (why, display.name.is_empty()) {
if !display.name.is_empty() {
fail_lines.push(crate::ui_chat::combat::PendingCombat {
kind: crate::ui_chat::ChatEventKind::SpellFailedLocalPlayer,
family,
@@ -255,7 +305,7 @@ pub(super) fn feed_actions(
object: 0,
fills: crate::ui_chat::combat::Fills {
spell: display.name.clone(),
named: why,
named: shown.logged().to_string(),
..Default::default()
},
named: crate::ui_chat::combat::Named::Ready,
@@ -263,27 +313,7 @@ pub(super) fn feed_actions(
});
}
}
let text = cast_fail::cast_fail_text(
caster,
reason,
d,
cast_fail::FailArgs {
arg: fail.arg,
..fail_args
},
&get,
);
// The retest instrument for this whole bug class (decision 1313). A red-line defect is
// reported as *seen* — B255 arrived as a screenshot of the word "Requires" — and until
// this line the only way to read what the client resolved was to look at the screen.
// Logging the reason, its wire argument and the resolved text makes an argument arm
// that silently declined (a missing word, an unnamed id) legible from a probe run.
debug!(
"ui_action: cast fail — {caster:?} spell {spell_id} reason {reason:#04x} \
arg {:?} → {:?}",
fail.arg, text
);
text
line
})
.collect();
cast_errors.0.extend(await_template);
@@ -506,6 +506,7 @@ fn a_pets_refused_cast_writes_no_combat_log_line() {
reason: 0x5F,
arg: None,
caster: Caster::Player,
redisplay: false,
});
errors.push_pet(GROWL, 0x5F);
+10 -1
View File
@@ -380,7 +380,16 @@ impl Plugin for UiAuctionPlugin {
// out the same frame. After the UnitFeed set so a row's tooltip reads a landed
// item-template store.
close_npc_session_out_of_range::<AuctionOpen>.before(feed_auction),
feed_auction.after(crate::ui_unit::UnitFeed).before(UiInput),
// Gated on the interface being up (decision 2279): `AuctionOpen::messages` is
// filled by the server's unprompted sold/outbid/expired notices, and one landing
// in the same drain as the login burst would be resolved and shown on the boot
// VM — lost — if this ran in 2214's one-frame window. The queue is bounded by
// what the server sends while no interface is up, and the refresh flags the same
// packets set survive as wire re-asks either way.
feed_auction
.after(crate::ui_unit::UnitFeed)
.before(UiInput)
.run_if(crate::ui_script::ingame_ui_up),
drain_auction.after(UiInput),
),
);
+3 -4
View File
@@ -229,10 +229,9 @@ pub(crate) const LOG_PERIODIC_CVAR: &str = "CombatLogPeriodicSpells";
/// **The combat-feedback CVars, as one system parameter** — what a packet handler needs to know
/// about the player's settings before it emits a line or a floating number.
///
/// Bundled for the reason [`crate::cvars::KnobParams`] is: `net::apply::apply_net_updates` lives
/// against Bevy's 16-parameter ceiling, and three more `Res` would have gone into a nested tuple
/// as positional fields nobody can read at the use site. They are one concern anyway — the
/// reference reads all three inside the same combat-log/world-text translation unit.
/// Bundled because they are one concern — the reference reads all three inside the same
/// combat-log/world-text translation unit — and read by the net drain as the combat-feedback
/// member of its catalogs (`net::apply::params::Catalogs`).
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct CombatFeedbackCvars<'w> {
/// The eight display ranges.
+10 -1
View File
@@ -700,7 +700,16 @@ impl Plugin for UiDialogVerbsPlugin {
Update,
(
close_npc_session_out_of_range::<PetUnlearnState>.before(feed_dialog_verbs),
feed_dialog_verbs.before(UiInput),
// Gated on the interface being up (decision 2279): `InstanceBoot::events`
// is server-driven — vmangos sends `SMSG_RAID_GROUP_ONLY` from
// `Player::UpdateHomebindTime` on the first map tick after a login inside a
// raid instance with no raid group, i.e. inside the login burst's own
// drain — and `INSTANCE_BOOT_START`, the event that raises the stock
// countdown popup, would be fired at the boot VM in 2214's one-frame window
// and lost. Gated, the queue waits; nothing here clears without a VM.
feed_dialog_verbs
.before(UiInput)
.run_if(crate::ui_script::ingame_ui_up),
// In-world only: the claim is about the VM, but the query is a world
// packet, and the boot VM exists at the glue screen too.
meeting_stone_enter_world
+1 -57
View File
@@ -245,63 +245,7 @@ mod tests {
("cursor.rs", "last_ptr"),
];
/// Every `.rs` file under `root`, recursively.
fn rust_files(root: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
out
}
/// The parenthesised parameter list of every `fn` in `text`, by paren matching — so the scan
/// sees a signature rather than a whole body, and a `Local` in some unrelated expression
/// cannot be mistaken for a system parameter.
fn fn_parameter_lists(text: &str) -> Vec<&str> {
let bytes = text.as_bytes();
let mut out = Vec::new();
for (i, _) in text.match_indices("fn ") {
// `fn` must start a word: `…_fn (` and `Fn(` are not declarations.
if i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_') {
continue;
}
let Some(open) = text[i..].find('(').map(|o| i + o) else {
continue;
};
// A generic parameter list can carry parens, but never before the argument list's `(`
// in the shapes this codebase writes; a `<` with an unbalanced `(` would just scan on.
let mut depth = 0usize;
let mut close = None;
for (j, c) in text[open..].char_indices() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
close = Some(open + j);
break;
}
}
_ => {}
}
}
if let Some(close) = close {
out.push(&text[open + 1..close]);
}
}
out
}
use crate::test_support::{fn_parameter_lists, rust_files};
/// Every `name: Local<Ty>` in a parameter list, as `(name, Ty)` — angle-bracket matched, so a
/// nested generic comes back whole.
+4 -2
View File
@@ -22,8 +22,10 @@
use std::path::{Path, PathBuf};
/// The standing count. **2026-09-16, 164** — measured the day the instrument was built, against
/// 1177's 141 five weeks earlier.
const CEILING: usize = 164;
/// 1177's 141 five weeks earlier. **165 the same day (decision 2279):** `game_plugins.rs` carries
/// the structural test for the VM's one-shot consumers, which has to name the identifier it
/// scans for. A test about the wall, not a file that learned the VM exists.
const CEILING: usize = 165;
/// How far under [`CEILING`] the count may sit before the test asks for the ceiling to follow it.
const SLACK: usize = 8;
@@ -241,11 +241,15 @@ pub(super) fn register(app: &mut App) {
)
// After the spawners (PostUpdate): publish the probe table for extraction on change.
.add_systems(PostUpdate, super::prop_probes::publish_prop_probes);
app.sub_app_mut(RenderApp).add_systems(
Render,
(upload_light, super::prop_probes::upload_prop_probes)
.in_set(RenderSystems::PrepareResources),
);
// Guarded like every other render-side registration in the tree: a headless build (no GPU,
// `backends: None`) has no render app, and the schedule tests build the engine that way.
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
render_app.add_systems(
Render,
(upload_light, super::prop_probes::upload_prop_probes)
.in_set(RenderSystems::PrepareResources),
);
}
}
/// Create the single persistent storage buffer. `RenderDevice` is a main-world resource (inserted in