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:
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
+318
-604
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 { { } } ")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user