the player build: one binary that carries its content

- a player build ships beside the dev one — dev chords and probe surfaces go dark, and a gate enforces what a player can still reach, by medium not by module
- the binary carries its own shaders and its shipped UI, finds the WoW folder by itself, and keeps its local state in one benilla-config folder
- the glue screens get a capture shutter, and the engine stops depending on the protocol crate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-11 18:18:02 +07:00
co-authored by Claude Fable 5
parent 8eea16157c
commit 6b980b9efb
255 changed files with 3428 additions and 3202 deletions
Generated
+20 -1
View File
@@ -604,6 +604,7 @@ dependencies = [
"fontdb",
"half",
"image",
"include_dir",
"kira",
"libc",
"mint",
@@ -742,7 +743,6 @@ dependencies = [
"avian3d",
"benilla-assets",
"benilla-formats",
"benilla-protocol",
"benilla-wmo",
"bevy",
"bytemuck",
@@ -4058,6 +4058,25 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2"
[[package]]
name = "include_dir"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
dependencies = [
"include_dir_macros",
]
[[package]]
name = "include_dir_macros"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "indexmap"
version = "2.14.0"
+29 -5
View File
@@ -6,6 +6,23 @@ license.workspace = true
description = "The Bevy app as a library: everything but the build-id stamp, which lives in the `benilla` launcher shim so a commit dirties ~30 lines instead of this crate (decision 0993)"
[features]
# **The dev/player seam** (decisions 0026 + 1173). Default-ON, so every ordinary `cargo run`,
# capture, probe and gate builds exactly what it built before. `--no-default-features` compiles
# the instruments — the debug panel, the perf HUD, the inspector, the capture harness and the
# probe fleet — physically OUT: not dormant behind a runtime flag, absent. That is the whole
# requirement a player client has of them, and a runtime toggle cannot meet it.
#
# The rule the seam enforces is one-directional: **dev may see anything; nothing may depend on
# dev.** It is enforced by `cargo build -p benilla --no-default-features` in `scripts/gates.sh`,
# not by anyone remembering it — 0026 adopted the rule in June 2026 with no failing build behind
# it, and twenty-four couplings had accumulated by August (1173).
default = ["dev"]
#
# The three benilla crates below are taken with `default-features = false` and their `dev`
# re-exported here (1175). Their `dev` is default-on too, so without the round trip a
# `--no-default-features` build gets the feature back through unification — and the binary
# carries the build machine's source-tree path after all. Same trap as the shim's, one level down.
dev = ["benilla-assets/dev", "benilla-formats/dev", "benilla-world/dev", "dep:bevy_egui", "dep:libc"]
# Opt-in Tracy profiling: `cargo run --features tracy` streams per-system `info_span!` markers (on the
# hot systems) + render-pass CPU timings to a Tracy capture (decision 0026 — the dev/player seam;
# a `docs/howto/running.md` this once cited never landed).
@@ -25,7 +42,9 @@ anyhow.workspace = true
arboard = "3"
benilla-ui = { path = "../benilla-ui" }
bevy.workspace = true
bevy_egui = "0.39"
# Optional: the dev overlays are its only consumers (the panel, the perf pill, the inspector
# card), so a player build does not link it at all (`dev` feature, decision 1173).
bevy_egui = { version = "0.39", optional = true }
# Zero-copy POD→bytes for the shared light storage buffer (one per-frame write_buffer). Already in
# the tree transitively; pinned to the same version as the dev-dep below.
bytemuck = { version = "1", features = ["derive"] }
@@ -33,12 +52,17 @@ bytemuck = { version = "1", features = ["derive"] }
# `Rgba16Float` (decision 0804) and the PNG wants display bytes. Already in the tree via
# wgpu/bevy_image; declared so the call site is an honest dependency.
half = "2"
# The shipped FrameXML tree, compiled into the binary (decision 1175 §2): 61 files, ~2.8 MB, so the
# client cannot boot without its interface on a machine that has never seen our source. A proc
# macro plus a no-dependency runtime shim — it adds nothing to the graph at runtime, and it is the
# crate everyone uses for this. `assets/ui` is OUR content (MIT/Apache), not Blizzard's.
include_dir = "0.7"
# `getrusage` — whole-process CPU time (all threads), the perf probes' `cpu_ms`/`cpu_pct` metric
# (`perf::process_cpu_secs`). Wall-clock fps is unusable as a regression instrument on a machine
# running parallel sessions' builds; CPU-per-frame is, and it is also the number the Mac report
# is about ("250 % CPU at 59 fps"). Already in the tree transitively; declared so the call site is
# an honest dependency.
libc = "0.2"
libc = { version = "0.2", optional = true }
# Resample varying-size layer BLPs to a uniform size for the terrain texture array; `png` so we can
# encode the cursor image into data an NSImage can load (macOS hardware cursor).
image = { version = "0.25", default-features = false, features = ["png"] }
@@ -47,10 +71,10 @@ image = { version = "0.25", default-features = false, features = ["png"] }
# (std's mpsc Sender is `!Sync`, so it can't be a Bevy resource without a mutex — the very thing this
# bridge deletes). The canonical Bevy background-thread→channel→system idiom. See decision 0006.
crossbeam-channel = "0.5"
benilla-assets = { path = "../benilla-assets" }
benilla-formats = { path = "../benilla-formats" }
benilla-assets = { path = "../benilla-assets", default-features = false }
benilla-formats = { path = "../benilla-formats", default-features = false }
benilla-protocol = { path = "../benilla-protocol" }
benilla-world = { path = "../benilla-world" }
benilla-world = { path = "../benilla-world", default-features = false }
# The CVar config diff (`benilla/config.toml`, decision 0954): serde derive on the persisted
# shape, toml as its format. (serde_json left with the era manifest — 0978.)
serde = { version = "1", features = ["derive"] }
+297
View File
@@ -0,0 +1,297 @@
# benilla.toc — the built-in interface, as an ordinary addon manifest.
#
# The reference's own interface ships as `FrameXML.toc`; ours was a hand-ordered Rust array until
# decision 1178. It is parsed by the same `.toc` parser any third-party addon will be
# (`benilla-ui/src/toc.rs`), and that is the point: a privileged built-in UI is how addon support
# rots. If our own interface loads through the addon path, the addon path works — it is exercised
# by every single run. If it keeps a private door, that path is untested by construction.
#
# The order is LOAD order and it is load-bearing: a template must be defined before the frame that
# inherits it, and a later file paints on top of an earlier one. Every entry's comment is why it
# sits where it sits; those comments are knowledge, not decoration.
#
# The FIRST entry is the font-object registry, and the loader splits there: it alone loads at
# startup, ahead of the glue screens that share our one glyph atlas, and everything after it
# materializes on entering the world (decision 1051).
## Interface: 11200
## Title: benilla
# The named virtual Font objects (decision 0084) — must load first so any FontString's
# `inherits="GameFontNormal"` resolves against a registered object.
Fonts.xml
# The UIPanel slot manager (decision 0084 §2): script-only, no frames of its own — must load
# before any panel frame (Gossip/Merchant today) so ShowUIPanel/HideUIPanel already exist
# when those frames' OnLoad/OnEvent reference them.
UiPanels.xml
# The managed bottom-stack positions (decision 0272): script-only —
# UIPARENT_MANAGED_FRAME_POSITIONS + UIParent_ManageFramePositions, the ref
# UIParent.lua mechanism that re-anchors the cast bar / chat over the bottom bars.
# Loaded early so the stance bar's OnShow/OnHide can call it; applied once by the
# loader's post-load bootstrap (ui_script/manifest.rs).
UIParent.xml
# The tooltip: a hover window the bag/merchant/loot/unit frames drive. Loaded before them so
# `GameTooltip` + its verbs exist when their OnEnter handlers fire (runtime, so order
# is not strictly required — but it keeps the dependency legible).
GameTooltip.xml
# The dropdown kit (0203 phase 2's closing sub-slice, widened for 0434 phase 5):
# UIDropDownMenuTemplate + DropDownList1/2 + the UIDropDownMenu_* API — before its
# first customers (the unit popups below, the world map's pickers, the trainer filter).
UIDropDownMenu.xml
# The unit right-click popups (0434 phase 5): UnitPopupMenus/Buttons + UnitPopup_ShowMenu
# — after the dropdown kit it drives, before the unit/party frames whose DropDown
# children's OnLoad initialize into it.
UnitPopup.xml
# The chat-link click router (SetItemRef): item links → ItemRefTooltip:SetHyperlink; a
# player name → left-click whisper / right-click FRIEND dropdown. After GameTooltip.xml
# (GameTooltip_OnLoad) AND the dropdown kit + UnitPopup — its FriendsDropDown inherits
# UIDropDownMenuTemplate and opens a UnitPopup FRIEND menu.
ItemRef.xml
# The shared status-bar numerals machinery (decision 1082): TextStatusBar_* + the opt-in
# template — before its customers (the XP bar in ActionBar.xml today; the unit-frame
# health/mana numerals are the ref's other users, when that slice lands). The ref's own TOC
# loads TextStatusBar right before its unit frames, same seat as here.
TextStatusBar.xml
UnitFrames.xml
# The combo-point dots (decision 0869) — anchored to BenillaTargetFrame, so after
# UnitFrames.xml; its fade chain needs UiPanels.xml's UIFrameFade above.
ComboFrame.xml
# The center-screen scrolling combat text (decision 0578) — the Blizzard_CombatText
# transcription; consumes COMBAT_TEXT_UPDATE + the regen/health/power events, needs
# only Fonts.xml (CombatTextFont) + UIParent before it.
CombatText.xml
# The party member frames + the PARTY_INVITE popup + its event driver (decision 0434
# phase 2): a StaticPopup registry entry, so after UiPanels.xml.
PartyFrame.xml
# The death arc's dialogs + event driver (decision 0308): registry entries on the shared
# StaticPopup engine, so after UiPanels.xml.
DeathFrame.xml
# The two duel dialogs + their event driver (decision 0633): registry entries on the same
# StaticPopup engine, and DUEL_OUTOFBOUNDS leans on its per-tick countdown branch.
DuelFrame.xml
# The two enchant-apply confirms + their event driver (decision 0928): the same StaticPopup
# registry again. Not CraftFrame's — the gate that raises them is the generic item-target
# bind, reached by a poison or a sharpening stone with no profession window open.
EnchantConfirm.xml
# The shared CooldownFrame_SetTimer (the ref's Cooldown.lua file split) — before its
# consumers (ActionBar's buttons, the multibars, the stance bar, BagFrame's slots).
Cooldown.xml
ActionBar.xml
# The two always-on bottom multibars (actions 61-72 / 49-60): pure XML/Lua over
# ActionBar.xml's shared button template + handler set — loads right after it
# (inherits BenillaActionButtonTemplate, anchors to BenillaActionButton1, and its
# buttons join the BENILLA_ACTION_BUTTONS roster ActionBar.xml declares).
MultiBars.xml
# The stance/shapeshift bar: hidden until `crate::ui_shapeshift`'s feed pushes a
# non-empty form list (a formless class never shows it). After Cooldown.xml
# (CooldownFrame_SetTimer) + ActionBar.xml (BENILLA_FALLBACK_ICON, the BenillaActionBar
# anchor target).
StanceBar.xml
# The pet action bar (decision 0982): hidden until `crate::ui_pet`'s feed reports a bar,
# i.e. until the server sends `SMSG_PET_SPELLS` for a live pet or charm — so a class that
# never has one never sees it. Right after the stance bar, whose row it shares (either
# one showing raises UIParent.xml's managed "pet" delta), and after Cooldown.xml
# (CooldownFrame_SetTimer), ActionBar.xml (the `BenillaActionBar` anchor target) and
# GameTooltip.xml (its hover). It is also the pet ARC's file, so decision 1066's three
# right-click-menu dialogs (ABANDON_PET / RENAME_PET / PETRENAMECONFIRM) register here —
# which adds UiPanels.xml to its dependency set, already far above.
PetActionBar.xml
# The micro-button row in the bar's right-hand recess (the ref's own file split, its TOC
# loads MainMenuBarMicroButtons right after MainMenuBar). After ActionBar.xml: it anchors
# into BenillaActionBarArtFrame by name and seats itself with that file's
# BenillaActionBarArt_SeatAbove. It must come BEFORE the panels it toggles, because each of
# their OnShow/OnHide calls the `UpdateMicroButtons` this file defines (the panel frames
# themselves are read back through getglobal, so the reverse dependency is call-time).
MicroMenu.xml
# The HUD minimap cluster (decision 0203 phase 1): the chrome around the engine-rendered
# <Minimap> widget hole (`crate::minimap` fills it). After GameTooltip (its zone-text
# hover uses the shared tooltip).
MinimapCluster.xml
# The minimap's time-of-day indicator (GameTimeFrame): the reference's own file split — its
# TOC loads GameTime.xml right after Minimap.xml. After MinimapCluster (its parent) and
# GameTooltip (the hover tooltip); driven by the app's game clock (`minimap::feed_game_time`).
GameTime.xml
# The zone-entry splash (decision 0287): ZoneTextFrame/SubZoneTextFrame + the inlined
# FadingFrame kit, driven by the ZONE_CHANGED family `crate::area` fires. Needs only
# Fonts (ZoneTextFont/SubZoneTextFont) + UIParent; kept by the minimap cluster — the
# other consumer of the zone-text host globals.
ZoneText.xml
# The faux-scroll kit (decisions 0247/0250/0251): BenillaScrollBarTemplate (the draggable
# Slider scroll bar) + BenillaFauxScrollFrameTemplate + the BenillaFauxScrollFrame_* API —
# before its first customer (the trainer list) below.
ScrollTemplates.xml
# The fullscreen world map (decision 0203 phase 2), over the worldmap host API
# (`crate::ui_world_map` feeds it); the 'M' binding (ui_script/input.rs) calls its
# ToggleWorldMap().
WorldMapFrame.xml
# The cast bar (decision 0137 phase 1) — the extracted 1.12 CastingBarFrame, driven by
# the SPELLCAST_* events `crate::ui_cast` fires. Only needs Fonts before it.
CastingBar.xml
# The mirror timers (decision 0874): the breath/fatigue bars at top-center, driven by the
# MIRROR_TIMER_* events `crate::ui_mirror` fires. Needs only Fonts (GameFontHighlight)
# before it; kept beside the cast bar, whose border art it shares.
MirrorTimer.xml
# The player buff/debuff bar (decisions 0255/0257 — the player aura arc): the HUD row of
# aura icons under the minimap, driven by the UnitAura feed (`crate::ui_aura`). Needs Fonts
# (its FontStrings) and ActionBar's `BENILLA_FALLBACK_ICON` global (both already loaded).
BuffFrame.xml
# The durability alert (the "armor guy"): under the minimap beside BuffFrame, driven by
# UPDATE_INVENTORY_ALERTS off the same inventory push the character window reads. Needs
# MinimapCluster (its anchor) — loaded above.
DurabilityFrame.xml
ErrorsFrame.xml
BagFrame.xml
# The stack-split spinner (decision 0216 §6/slice 2): loaded beside BagFrame, whose slot
# click handler (`BenillaBagSlot_OnClick`) calls `OpenStackSplitFrame`. Order between the
# two doesn't matter for correctness (Lua globals resolve at call time, and neither file's
# XML `inherits=` reaches into the other's templates) — kept adjacent for legibility.
StackSplit.xml
# The character window (decision 0208 phase 1a): the C-key paper doll. Loaded after BagFrame
# (needs nothing from it, but keeps player-state windows grouped) and before the NPC session
# windows (Gossip/Merchant/Quest*) — it depends only on Fonts/UiPanels/GameTooltip, all
# already loaded above.
CharacterFrame.xml
# The Pet tab (decision 1057): the same separate-top-level-file shape as SkillFrame below,
# and ahead of it because it is the earlier tab. It leans on CharacterFrame.xml for real —
# the `(unit, prefix)` stat setters, the `BenillaStatRow`/`BenillaMagicResistanceFrame`
# templates and the tab row it raises tab 2 of — all of which only exist once that file has
# loaded, so this order is a requirement here, not just tidiness.
PetPaperDollFrame.xml
# The Skills tab (decision 0437 phase 4): a CharacterFrame child page, but a separate
# top-level file (SkillFrame.xml's own header comment — this engine has no cross-file
# `parent=` attachment, so it's positioned by name via `<Anchors relativeTo=
# "BenillaCharacterFrame">` instead). Loaded immediately after CharacterFrame.xml so it
# paints ON TOP of it (later file = higher z) and so `BenillaCharacterFrame` already exists
# for that anchor to resolve against.
SkillFrame.xml
# The inspect window (decision 0631): another player's paper doll. Beside the character
# window it mirrors — it needs the same three (Fonts/UiPanels/GameTooltip) plus
# `CharacterFrameTabButtonTemplate` from UiPanels.xml, and nothing from CharacterFrame.xml
# itself (its slot template, handlers, and booth slot are all its own — the two windows
# share only the reference's art).
InspectFrame.xml
# The dressing room (decision 1060): the ctrl-click item preview. Loaded right after the
# windows whose slots feed it (the character window above, the bags before that) and before
# everything else that ctrl-clicks into it — order is legibility only, since `DressUpItemLink`
# is a global resolved at call time. Needs UiPanels (the panel manager, its left-slot row) and
# Fonts; its booth pane needs nothing loaded at all.
DressUpFrame.xml
# The spellbook window (decision 0216 §8, slice 5): the P-key window, the spell SOURCE
# for the cursor-payload arc. Loaded right after CharacterFrame (the same "player-state
# windows grouped" posture, same Fonts/UiPanels/GameTooltip-only dependency set); the
# 'P' binding (ui_script/input.rs) calls its bare ToggleSpellBook(BOOKTYPE_SPELL).
SpellBookFrame.xml
# The talent window (decision 0304 §4): the N-key window, the class talent grid over the
# `benilla-ui::script::talent` engine seam. Loaded right after SpellBookFrame (the same
# "player-state windows grouped" posture; needs Fonts/UiPanels/GameTooltip plus
# ScrollTemplates.xml — its real ScrollFrame's scrollbar reuses BenillaScrollBarTemplate —
# all already loaded above). The 'N' binding (ui_script/input.rs) calls its bare
# ToggleTalentFrame().
TalentFrame.xml
# The social window (decision 0668): the O-key friends/ignore/who window over the social
# API (`benilla-ui/src/script/social.rs`) + `crate::ui_social`'s feed/drain. Needs
# UiPanels (the panel manager, `CharacterFrameTabButtonTemplate`, and the StaticPopup
# engine its two name-entry dialogs register into), ScrollTemplates (three faux lists),
# GameTooltip (the newbie tips) and UIDropDownMenu (the who list's variable column) —
# all loaded above.
FriendsFrame.xml
GossipFrame.xml
MerchantFrame.xml
# The mail window (decision 0544 P1/P2): the mailbox inbox, open-letter, and send tab over
# the Era mail API (`benilla-ui/src/script/mail.rs`) + `crate::ui_mail`'s feed/drain. Loaded
# right after MerchantFrame because it REUSES that file's global BenillaMoney_Set/_Clear
# coin helpers (postage/enclosed/COD displays) — they must be defined first.
MailFrame.xml
# The player-to-player trade window (decision 0592 P1): the two-sided offer over the trade
# API (`benilla-ui/src/script/trade.rs`) + `crate::ui_trade`'s feed/drain. Loaded after
# MerchantFrame because it REUSES that file's global BenillaMoney_Set coin helpers (the two
# gold displays) — they must be defined first.
TradeFrame.xml
# The item-text reader: letters (mail-made permanent copies) and, later, books/plaques —
# the ItemTextGet* seam (`benilla-ui/src/script/item_text.rs`) over `crate::ui_item_text`'s
# feed/drain. Loaded right after MailFrame (the same arc; needs only Fonts/UiPanels, both
# loaded above).
ItemTextFrame.xml
# The trainer window (decision 0237 phase 3): the read-only class/profession trainer list.
# Loaded right after MerchantFrame per the director's brief (kept adjacent even though this
# v1 uses plain-text costs, not MerchantFrame's coin helpers) and before the other NPC
# session windows below.
TrainerFrame.xml
# The bank window (decision 0604 phase 4): the vault, purchase ladder, and the 6 bank-bag
# popouts, over the C_Container feed already carrying container -1/5..10 (ui_items.rs) +
# the bank Lua surface (benilla-ui/src/script/bank.rs) + crate::ui_bank's feed/drain.
# Loaded AFTER MerchantFrame.xml (reuses its BenillaMoney_* coin helpers for the purchase
# row + purse) and AFTER BagFrame.xml, already above (its BenillaBagSlotTemplate/
# BenillaBagWindowTemplate + the BenillaBagSlot_*/BenillaBagFrame_* function family are
# reused verbatim — the 24 generic slots and the 6 bank-bag popouts are, respectively, a
# container-slot grid and ordinary container windows, exactly like the bag arc's own).
# Kept in the "NPC session windows grouped" cluster beside Trainer/Taxi.
BankFrame.xml
# The taxi-map window (decision 0484 phase 2): the flight-master session, driven by the
# taxi engine seam (`benilla-ui/src/script/taxi.rs`) over `crate::ui_taxi`'s feed/drain.
# Loaded right after TrainerFrame per the same "NPC session windows grouped" posture — it
# needs only Fonts/UiPanels/GameTooltip (SetTooltipMoney), both already loaded above.
TaxiFrame.xml
# The crafting window (decision 0437 phase 2): NOT an NPC session (opens off your own cast of
# an effect-47 opener spell, TradeSkillFrame.xml's own header comment) — loaded right after
# TrainerFrame per the same "player-state/profession window" grouping and because it depends
# on nothing TrainerFrame doesn't already need (Fonts/UiPanels/GameTooltip/ScrollTemplates,
# all loaded above).
TradeSkillFrame.xml
# The Enchanting window (decision 0437 phase 3): the exact twin of TradeSkillFrame above —
# same "opens off your own cast, not an NPC session" shape (CraftFrame.xml's own header
# comment) — loaded right after it so `BuildColoredListString` (a guarded GLOBAL
# TradeSkillFrame.xml defines) already exists when CraftFrame.xml reuses it without
# redefining (CraftFrame.xml's own header comment, deviation 2).
CraftFrame.xml
LootFrame.xml
# The group-loot roll popups (decision 0591): the Need/Greed/Pass dialogs, a sibling of
# LootFrame.xml (loaded right after it) over the `benilla-ui::script::loot_roll` seam
# (`crate::ui_loot_roll`'s feed/drain). NOT a UIPanel — four `frameStrata="DIALOG"` toplevel
# popups with their own hardcoded anchors — so it needs only Fonts (GameFontNormalSmall) +
# GameTooltip (the item/PASS/NEED/GREED hovers), both already loaded above.
GroupLootFrame.xml
# The questgiver window (decision 0088): four sub-panels over the Era quest API. Loaded after
# MerchantFrame (the BenillaMoney coin helpers live there) and GossipFrame (shared parchment
# art conventions); its title/panel-button labels use the auto-centred ButtonText slot.
QuestFrame.xml
# The quest log window (decision 0088 arc, the quest-log slice): durable player state, not an
# NPC session — loaded after MerchantFrame (the BenillaMoney coin helpers it reuses) and
# alongside QuestFrame (same arc); the 'L' binding (ui_script/input.rs) calls its
# ToggleQuestLog().
QuestLogFrame.xml
# The timed-quest countdown window under the minimap (decision 1150): the reference's own
# file split. After MinimapCluster (its anchor), UIParent (the manage pass that re-seats it,
# and SecondsToTime), GameTooltip (its row hover), and QuestLogFrame — whose window a row
# click opens, and whose MAX_QUESTS it shares.
QuestTimerFrame.xml
ChatFrame.xml
# The macro window (decision 0983): the editor + its name/icon popup, driven over the
# engine's OWN macro table (`benilla_ui::script::macros` — 1.12 macros have no server
# side, so this is the one window whose model is not an app feed). Needs UiPanels (the
# panel manager + `TabButtonTemplate`), ScrollTemplates (BOTH kits — the real one scrolls
# the body box, the faux one the icon grid), MicroMenu (`UpdateMicroButtons` on
# show/hide), and it must come AFTER SpellBookFrame, whose shift-click reaches
# `BenillaMacroFrame_AddMacroLine`. Before GameMenuFrame, whose Macros button opens it.
MacroFrame.xml
# The Keybindings page MODULE (decision 1008, superseding 0997's standalone window):
# the templates + script of the Options window's Keybindings category, over the
# engine's binding table (benilla_ui::script::keybind + crate::bindings — the 1.12
# GetBinding/SetBinding Lua API). Needs UiPanels (popup engine) and GameTooltip (the
# character-specific checkbox's hover); MUST load before OptionsFrame.xml, whose
# Keybindings body inherits these templates and calls this script at OnLoad.
KeyBindingsPage.xml
# The options window (the 0985 provenance split: 0950's era structure, 0978's
# 1.12-native art, 0981/0984's 1.14 chrome + working era search, 0989's directed
# cuts — bare fully-live sliders, no corner X; 0992's dropdown rows + the Nameplates
# page): the era-shaped OptionsFrame the game menu's Options button opens. Needs
# Fonts' objects, UiPanels' panel manager, and UIDropDownMenu's capsule template
# (all far above); sits right before GameMenuFrame so the menu — which reaches it by
# name at click time — stays the last, outermost layer.
OptionsFrame.xml
# The game menu (decision 0674): the frame ESC opens, plus the CAMP/QUIT dialogs and their
# event driver. LAST on purpose — it is the outermost layer of the shell, it depends on
# everything it touches being loaded (UiPanels' popup engine + panel manager, MicroMenu's
# UpdateMicroButtons, BagFrame's Disable_BagButtons), and nothing depends on it: the ESC
# chain and the micro button both reach it by name at call time.
GameMenuFrame.xml
+1 -5
View File
@@ -387,11 +387,7 @@ mod tests {
/// run through the same WmoAreaCatalog the live feed reads. Skips without client data.
#[test]
fn indoor_naming_matches_the_byte_law_on_real_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present");
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("chain");
let cat = benilla_formats::load_wmo_area_catalog(&mut chain).expect("WMOAreaTable");
+1 -5
View File
@@ -165,11 +165,7 @@ mod tests {
use super::*;
fn real_catalog() -> Option<AreaTriggerCatalog> {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return None;
}
let data = benilla_formats::wow_data_or_skip!(None);
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
Some(benilla_formats::load_area_trigger_catalog(&mut chain).expect("AreaTrigger.dbc"))
}
+10 -4
View File
@@ -27,7 +27,7 @@
//! — the 1.12 law (lone modifiers ignored, left/right clicks stay UI clicks, ESC binds like
//! any key).
//!
//! Persistence: `benilla/bindings/account.txt` + `<Realm>-<Char>.txt` ([`store`], through
//! Persistence: `benilla-config/bindings/account.txt` + `<Realm>-<Char>.txt` ([`store`], through
//! [`crate::local_state`]); the character file's existence is the character-set state, deleted
//! on the confirmed switch back to general — the reference's own semantics.
@@ -333,10 +333,16 @@ fn latch_and_dispatch(
let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
let alt = keys.pressed(KeyCode::AltLeft) || keys.pressed(KeyCode::AltRight);
let sup = keys.pressed(KeyCode::SuperLeft) || keys.pressed(KeyCode::SuperRight);
// Exactly the dev overlays' plane (`debug_panel::dev_plane`, minus the Super arm the `sup`
// gate below already covers) — it costs the keyboard its fallback probe. See
// Exactly the dev overlays' plane (`modkeys::dev_chord`, minus the Super arm the `sup` gate
// below already covers) — it costs the keyboard its fallback probe. See
// [`BindingDispatch::resolve`].
let dev_plane = ctrl && shift && !alt;
//
// **Only when there is something on the plane** (decision 1179). A player build holds no dev
// chord at all, so suppressing the reference's fallback there buys nothing and costs fidelity:
// `CTRL-SHIFT-P` would resolve to `None` instead of falling through to `SHIFT-P`
// (`TOGGLECHARACTER3`, the pet paper doll) the way the binary does. 1176 gated what the plane
// *offers* and left what it *costs*; this is the other half.
let dev_plane = ctrl && shift && !alt && crate::run_mode::dev_affordances();
let mut script = script;
let armed = script.as_ref().is_some_and(|s| s.bind_capture_armed());
+2 -2
View File
@@ -1,5 +1,5 @@
//! Binding persistence (decision 0997): `benilla/bindings/account.txt` and
//! `benilla/bindings/<Realm>-<Char>.txt`, through [`crate::local_state`] like every resident.
//! Binding persistence (decision 0997): `benilla-config/bindings/account.txt` and
//! `benilla-config/bindings/<Realm>-<Char>.txt`, through [`crate::local_state`] like every resident.
//!
//! Format is **command-centric diff-vs-defaults** — one line per command whose keys moved:
//!
+8 -4
View File
@@ -56,7 +56,11 @@ pub(super) fn seed_ui_fixture(
ResMut<crate::ui_bank::BankOpen>,
),
) {
let Some(fixture) = ctx.scenario.ui else {
// A glue-screen capture has no world scenario, and no glue screen opens a UI fixture.
let Some(scenario) = ctx.scenario else {
return;
};
let Some(fixture) = scenario.ui else {
return;
};
if ctx.ui_seeded || !(progress.total > 0 && progress.ready == progress.total) {
@@ -636,7 +640,7 @@ pub(super) fn seed_ui_fixture(
// Server-less, Player defaults to the origin — off every zone rect, so the blip
// projects to the (0,0) hide sentinel and the arrow never shows. Park the avatar at
// the scenario's Northshire spot so the arrow lands on the Elwynn map for real.
player.pos = benilla_assets::coords::wow_to_bevy(ctx.scenario.eye);
player.pos = benilla_assets::coords::wow_to_bevy(scenario.eye);
// Alternating explore bits: roughly half of every zone's overlays reveal, so the
// capture shows fog doing its job (some sub-areas drawn, some parchment).
script.set_world_map_explored(vec![0x5555_5555; 64]);
@@ -881,7 +885,7 @@ pub(super) fn seed_ui_fixture(
])),
crate::net::SelfPlayer,
crate::net::Guid(PLAYER_GUID),
Transform::from_translation(wow_to_bevy(ctx.scenario.eye)),
Transform::from_translation(wow_to_bevy(scenario.eye)),
));
// The wolf: the live spawn's component set (net/apply.rs) with the descriptor seeded
// directly, standing at the look point facing the camera, at full health.
@@ -1164,7 +1168,7 @@ pub(super) fn seed_ui_fixture(
])),
crate::net::SelfPlayer,
crate::net::Guid(SELF_GUID),
Transform::from_translation(wow_to_bevy(ctx.scenario.eye)),
Transform::from_translation(wow_to_bevy(scenario.eye)),
));
// The named unit out in the river (the `vplates` wolf, re-seated): 25 yd along the
// scenario's own look bearing, so its overhead name lands on the water surface
+231
View File
@@ -0,0 +1,231 @@
//! The `fxview` fixture's **driver** — the half of the effect-viewer instrument that stands the
//! subject up in the world (the request, its knobs and the camera are [`super`]'s). It lives here
//! because a fixture's driver belongs with its fixture: `entities::spell_fx` is gameplay, and
//! gameplay never depends on the capture harness.
use bevy::prelude::*;
use benilla_assets::m2_url;
use benilla_assets::materials::WowModelMaterial;
use super::{FxViewRequest, FxViewState, FXVIEW_POS};
use crate::creature_anim::FxStage;
use crate::entities::display::{empty_shell, ModelHandle};
use crate::entities::spell_fx::{
attach_effect_visuals, EffectHost, FxTintAnims, SpellFx, FALLBACK_SPAN,
};
/// The `fxview` unit lane's synthetic guid (`WOW_FX_DISPLAY`) — a high-word `0xF130` creature guid
/// like the wire's, in a serverless capture where nothing else claims one.
const FXVIEW_UNIT_GUID: u64 = (0xF130u64 << 48) | 0xFC0FEE;
/// The `fxview` GameObject lane's synthetic guid (`WOW_FX_GO`) — the wire's `0xF110` high word,
/// which is what `crate::net` reads a GO's identity out of.
const FXVIEW_GO_GUID: u64 = (0xF110u64 << 48) | 0xFC0FEE;
/// Drive the `fxview` capture fixture (see [`super::FxViewRequest`]) — the effect-
/// viewer instrument: once the capture driver arms it (scene settled), spawn a root at the
/// fixture point, create the model's [`SpellFx`] cache entry, attach the full visual set
/// through the same [`attach_effect_visuals`] body the game uses, and (for missiles) fly the
/// root along its facing so trails extend. Inert outside fxview captures (the request resource
/// only exists then).
#[allow(clippy::too_many_arguments)]
pub(crate) fn drive_fx_view(
req: Option<Res<FxViewRequest>>,
state: Option<ResMut<FxViewState>>,
mut commands: Commands,
fx: Option<ResMut<SpellFx>>,
asset_server: Res<AssetServer>,
time: Res<Time>,
mut wow_materials: ResMut<Assets<WowModelMaterial>>,
mut tint_reg: ResMut<FxTintAnims>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
spatial: avian3d::prelude::SpatialQuery,
mut transforms: Query<&mut Transform>,
) {
let (Some(req), Some(mut state)) = (req, state) else {
return;
};
if !state.armed {
return;
}
let Some(mut fx) = fx else {
// Server-less capture boot: the cache resource normally rides the net session.
commands.init_resource::<SpellFx>();
return;
};
let root = *state.root.get_or_insert_with(|| {
let mut pos = benilla_assets::coords::wow_to_bevy(FXVIEW_POS);
// `WOW_FX_DISPLAY`: the UNIT lane. Spawn the live component set a streamed creature gets
// (`net::apply`'s, the same one the `vplates` fixture's wolf uses) and let the ordinary
// unit pipeline build the model — so what this shoots is a creature, not a model, and
// every unit-only term (tag alpha, the fade gate, anim LOD, the emitters' sequence host)
// is in the picture. Always seated on the terrain: a creature stands on the ground.
if let Some(display) = req.display {
if let Some(hit) = spatial.cast_ray(
pos,
Dir3::NEG_Y,
500.0,
true,
&benilla_world::collision::WorldCollision::body_filter(),
) {
pos.y -= hit.distance;
}
pos.y += req.up;
return commands
.spawn((
crate::net::Guid(FXVIEW_UNIT_GUID),
crate::net::NetEntity {
kind: benilla_protocol::EntityKind::Unit,
display_id: Some(display),
scale: req.scale,
},
crate::net::ObjectStore(benilla_protocol::messages::ObjectFields::from_pairs(
&[
(22, 100), // UNIT_FIELD_HEALTH
(28, 100), // UNIT_FIELD_MAXHEALTH
(34, 60), // UNIT_FIELD_LEVEL
(35, 35), // UNIT_FIELD_FACTIONTEMPLATE — friendly, so no combat pose
],
)),
Transform::from_translation(pos)
.with_rotation(Quat::from_rotation_y(req.yaw_deg.to_radians())),
Visibility::default(),
))
.id();
}
// `WOW_FX_GO`: the GAMEOBJECT lane. A placed trap/door/chest reaches the screen through
// `crate::go_anim`'s §243 state machine, never through the effect pool, so this is the
// only lane that reproduces one. The descriptor carries the three fields the machine
// reads — display, TYPE_ID (the `go_animates` gate) and STATE (the substate) — and
// nothing else, so an unset knob renders exactly what an omitted wire field renders.
// Always seated on the terrain: a GO stands on the ground.
if let Some(display) = req.go {
if let Some(hit) = spatial.cast_ray(
pos,
Dir3::NEG_Y,
500.0,
true,
&benilla_world::collision::WorldCollision::body_filter(),
) {
pos.y -= hit.distance;
}
pos.y += req.up;
return commands
.spawn((
crate::net::Guid(FXVIEW_GO_GUID),
crate::net::NetEntity {
kind: benilla_protocol::EntityKind::GameObject,
display_id: Some(display),
scale: req.scale,
},
crate::net::ObjectStore(benilla_protocol::messages::ObjectFields::from_pairs(
&[
(14, req.go_state), // GAMEOBJECT_STATE
(21, req.go_type), // GAMEOBJECT_TYPE_ID
],
)),
Transform::from_translation(pos)
.with_rotation(Quat::from_rotation_y(req.yaw_deg.to_radians())),
Visibility::default(),
))
.id();
}
// `WOW_FX_GROUND=1`: seat the fixture ON the terrain — a ground-anchored effect's flat
// quads render as projected surface decals and need ground inside their vertical slab.
// The scene settled before arming, so the streamed tile's collider is there to hit.
if req.ground {
if let Some(hit) = spatial.cast_ray(
pos,
Dir3::NEG_Y,
500.0,
true,
&benilla_world::collision::WorldCollision::body_filter(),
) {
pos.y -= hit.distance;
}
}
pos.y += req.up;
commands
.spawn((
Transform::from_translation(pos)
.with_rotation(Quat::from_rotation_y(req.yaw_deg.to_radians())),
Visibility::default(),
))
.id()
});
// A missile only trails in motion: fly the root along its model-forward (local −Z).
// WOW_FX_TURN keeps the fixture rotating — the attached-model heading-since-birth fan.
if req.fly > 0.0 || req.turn != 0.0 {
if let Ok(mut t) = transforms.get_mut(root) {
let fwd = t.rotation * -Vec3::Z;
t.translation += fwd * req.fly * time.delta_secs();
if req.turn != 0.0 {
t.rotation =
Quat::from_rotation_y((req.turn * time.delta_secs()).to_radians()) * t.rotation;
}
}
}
// The one-pass reap (the game's completion callback, `fx_attach`): a discrete kit
// instance dies at exactly one pass of sequence 0 and its emitters DRAIN — the fixture
// mirrors it so a capture past the span shows the truth. `WOW_FX_HOLD=1` previews a
// persistent hold instead (reaped by its spell edge in game, so no clock here).
// The one-pass reap is an effect-instance rule; a unit — or a placed GameObject — stands there.
if !req.hold && !state.expired && req.display.is_none() && req.go.is_none() {
if let Some(at) = state.attached_at {
let span = fx
.models
.get(&req.model_path)
.and_then(|dm| dm.first_seq_span)
.unwrap_or(FALLBACK_SPAN);
if time.elapsed_secs() >= at + span {
commands.entity(root).despawn();
state.expired = true;
}
}
}
if state.attached_at.is_none() {
// The unit and GameObject lanes have no attach step — the entity pipeline builds the model
// on its own. Their "age" is therefore seconds since the entity appeared, which is what the
// animation phase is measured in.
if req.display.is_some() || req.go.is_some() {
state.attached_at = Some(time.elapsed_secs());
return;
}
fx.models.entry(req.model_path.clone()).or_insert_with(|| {
let mut dm = empty_shell();
dm.handle = ModelHandle::M2(asset_server.load(m2_url(&req.model_path)));
dm
});
if attach_effect_visuals(
&mut commands,
root,
&fx.models[&req.model_path],
time.elapsed_secs(),
true, // the fixture plants at a world point — ground quads decal onto the terrain
// The fixture previews kit effects, which are attached models — but it hangs on
// nothing (there is no host model in a preview), so its pool keeps the drain.
EffectHost {
attached: true,
parent: None,
},
// `WOW_FX_HOLD=1` previews a PERSISTENT instance, so it must show the persistent
// lifecycle — birth then `Hold` — or the instrument would report a freeze the game
// does not have. Without it the fixture previews a one-shot, which runs its birth once
// and is reaped by the fixture's own span clock below.
Some(if req.hold {
FxStage::State
} else {
FxStage::OneShot
}),
&mut wow_materials,
&mut tint_reg,
&ibps,
&mut palettes,
None, // the fixture previews a kit effect on its model's own `Stand`
) {
state.attached_at = Some(time.elapsed_secs());
}
}
}
+257 -193
View File
@@ -39,16 +39,22 @@
//! ## Running one capture by hand
//! **Run through Cargo — never the built binary directly:**
//! ```text
//! WOW_DATA=<vanilla Data dir> WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-unitframes \
//! WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-unitframes \
//! WOW_CAPTURE_OUT=/tmp/shot.png cargo run -q -p benilla
//! ```
//! (`WOW_DATA` is only needed for a non-standard install — the client finds one in the project
//! folder or beside the binary on its own, `benilla_formats::wow_data`, decision 1175.)
//!
//! `cargo run` rebuilds first; a bare `target/debug/benilla` can silently be **stale code** — the
//! classic way a capture "disproves" a fix that was never in the binary. (Before 0993 it was
//! worse: `assets/` resolved through Cargo's runtime `CARGO_MANIFEST_DIR`, so a bare run silently
//! loaded **no** WGSL shaders and every custom-shader layer — the *entire* player UI, sky, liquid,
//! models — rendered blank, read as a UI bug for hours once. The app now bakes the absolute
//! `crates/benilla-world/assets/` path in at compile time, so a bare run finds its shaders;
//! staleness is the trap that remains.) `WOW_CAPTURE_UI=1` opts the player UI into the shot (off
//! classic way a capture "disproves" a fix that was never in the binary. Staleness is now the
//! *only* trap here, and this note is worth keeping for why. Before 0993, `assets/` resolved
//! through Cargo's runtime `CARGO_MANIFEST_DIR`, so a bare run loaded **no** WGSL at all and every
//! custom-shader layer — the entire player UI, sky, liquid, models — rendered blank; it was read
//! as a UI bug for hours once. 0993 patched that by baking an absolute source-tree path in at
//! compile time, which fixed the capture and left a binary that worked only on the machine that
//! built it. 1175 deleted the path instead: every shader is compiled into the binary and addressed
//! `embedded://<crate>/shaders/…`, so there is no asset root left to resolve wrongly.
//! `WOW_CAPTURE_UI=1` opts the player UI into the shot (off
//! by default so world baselines stay UI-free; omit it for world-only scenes). `WOW_CAPTURE=list`
//! prints the scenario names. `scripts/visual.sh` wraps all of this.
@@ -86,6 +92,7 @@ mod probe_rig;
mod probe_taxi;
mod probes;
mod scenarios;
use crate::run_mode::CaptureMode;
pub(crate) use depth_probe::DepthProbePlugin;
use fixtures::seed_ui_fixture;
pub(crate) use live_shot::LiveShotPlugin;
@@ -98,27 +105,53 @@ pub(crate) use probe_crossing::ProbeCrossingPlugin;
pub(crate) use probe_mail::ProbeMailPlugin;
pub(crate) use probe_melee::ProbeMeleePlugin;
pub(crate) use probe_partner::ProbePartnerPlugin;
pub(crate) use probe_rig::{rig_char_name_from_env, ProbeRigPlugin};
pub(crate) use probe_rig::ProbeRigPlugin;
pub(crate) use probe_taxi::ProbeTaxiPlugin;
pub(crate) use probes::{
fx_draw_census_plugin, EntityCensusPlugin, LiveFpsPlugin, NodeProbePlugin,
ParticleCensusPlugin, ProbeChatPlugin, ProbeClock, ProbeExitPlugin, ProbeFocusPlugin,
ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin,
};
use scenarios::{Scenario, SubjectKind, UiFixture, GROUND_EYE, SCENARIOS};
use scenarios::GlueScreen;
use scenarios::{Scenario, SubjectKind, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS};
pub(crate) mod fxview;
// The two scripted probe drivers, which lived in `player/` until decision 1174: they turn the
// avatar's aim and park the camera rig, and both order themselves BEFORE `player::control`. An
// instrument may name the gameplay system it runs against; gameplay may not name the instrument.
mod probe_cam;
mod probe_look;
pub(crate) mod waterfx;
/// Is the app running a capture (`$WOW_CAPTURE` set)? Read by `main` to disable net + add the
/// harness.
pub(crate) use probe_cam::ProbeCamPlugin;
pub(crate) use probe_look::ProbeLookPlugin;
/// Which screen a capture starts the client on — the dev arm of [`crate::run_mode::start_state`],
/// which is what `main` actually calls. A glue capture boots onto the screen it photographs; any
/// other capture boots straight in-world (no net, no picker); with no capture at all this is the
/// ordinary login screen.
///
/// The engine asks the same question of the same variable, on its own, as
/// `dev_state::deterministic_run` — because after 1160's split it *is* its own question: "run
/// deterministically" is a property of the world, and the world must be able to ask it with no
/// harness above it at all. One environment variable, two readers, no shared symbol across the
/// boundary. Keep the two names in step.
pub(crate) fn scenario_active() -> bool {
std::env::var("WOW_CAPTURE").is_ok()
/// The **third** independent reader of `$WOW_CAPTURE`, and deliberately so: `run_mode` asks it for
/// the app and `dev_state::deterministic_run` asks it for the engine, because after 1160's split
/// each layer must be able to ask with nothing above it. One environment variable, three readers,
/// no shared symbol across either boundary. Keep them in step.
pub(crate) fn start_state() -> crate::char_select::ClientState {
match glue_screen() {
Some(GlueScreen::CharCreate) => crate::char_select::ClientState::CharCreate,
None if crate::run_mode::scenario_active() => crate::char_select::ClientState::InWorld,
None => crate::char_select::ClientState::Login,
}
}
/// Is `$WOW_CAPTURE` naming a **glue** screen? Consulted before the plugins build, because
/// the answer decides which screen the client starts on — a glue capture is the one kind of
/// capture that must NOT boot straight into the world.
fn glue_screen() -> Option<GlueScreen> {
let name = std::env::var("WOW_CAPTURE").ok()?;
GLUE_SCENARIOS
.iter()
.find(|g| g.name == name)
.map(|g| g.screen)
}
/// The `fxview` fixture request — the **effect-viewer instrument**: spawn one effect/missile
@@ -212,7 +245,7 @@ pub(crate) struct FxViewRequest {
pub(crate) up: f32,
}
/// The fixture's live state, written by `entities::spell_fx::drive_fx_view` and the phase
/// The fixture's live state, written by `fxview::drive_fx_view` and the phase
/// driver below.
#[derive(Resource, Default)]
pub(crate) struct FxViewState {
@@ -248,11 +281,6 @@ pub(crate) fn print_scenario_names() {
}
}
/// Marker that a capture is in progress — its presence gates `player::control` off so the harness is
/// the sole author of the camera (and thus the terrain stream focus).
#[derive(Resource)]
pub(crate) struct CaptureMode;
/// Consecutive byte-identical framebuffer readbacks that mean "the scene is built" (decision 0815).
///
/// This is the harness's ONE residency test, and it is a measurement rather than a proxy: whatever is
@@ -380,7 +408,11 @@ enum Phase {
#[derive(Resource)]
struct CaptureCtx {
scenario: Scenario,
/// The in-world viewpoint — `None` for a glue-screen capture, which has no world, no camera
/// and no map. Every reader of it sits on a world path; the shutter itself needs neither.
scenario: Option<Scenario>,
/// The scenario's name, whichever table it came from — for the output path and the probe line.
name: &'static str,
out: String,
phase: Phase,
/// UI fixture already seeded (once, at residency) — see [`seed_ui_fixture`].
@@ -440,174 +472,200 @@ impl Plugin for CapturePlugin {
.before(benilla_world::water_fx::WaterFoamSet)
.in_set(benilla_world::schedule::WorldStage::Present),
);
// The `fxview` fixture's driver, same principle and the same shape (decision 1174): it
// creates the subject's display-cache entry, so it runs before the frame's build, inside
// the entity-visuals set and after the net stage — exactly the slot it held while it was
// an element of `EntitiesPlugin`'s chain, now stated rather than positional.
app.add_systems(
Update,
fxview::drive_fx_view
.in_set(crate::entities::EntityVisualsSet)
.before(crate::entities::DisplayBuildSet)
.after(benilla_world::schedule::WorldStage::Net),
);
let name = std::env::var("WOW_CAPTURE").unwrap_or_default();
// A **glue** capture short-circuits everything below: no map to seed, no viewpoint to
// pin, no fixture to open. Only the shutter is shared, and the shutter wants no world.
let glue = GLUE_SCENARIOS.iter().find(|g| g.name == name).copied();
if let Some(g) = glue {
// The preview pick goes through the existing `WOW_CHARCREATE_PICK` instrument rather
// than a second path into `CreateSelection` — same reason the map is seeded by env
// (decision 0743): one route into a fact, whoever is asking.
let (race, sex, class) = g.pick;
std::env::set_var("WOW_CHARCREATE_PICK", format!("{race},{sex},{class}"));
}
// The fxview instrument: a synthetic scenario (ground scene, noon) + the fixture
// request from env. Not in SCENARIOS — `scripts/visual.sh`'s golden sweep must never
// run it (its output depends on the model/age/angle knobs, not just the name).
let scenario = if name == "fxview" {
let display: Option<u32> = std::env::var("WOW_FX_DISPLAY")
.ok()
.and_then(|v| v.trim().parse().ok());
let go: Option<u32> = std::env::var("WOW_FX_GO")
.ok()
.and_then(|v| v.trim().parse().ok());
let model_path = match (std::env::var("WOW_FX_MODEL"), display.or(go)) {
(Ok(p), _) => p,
(Err(_), Some(_)) => String::new(), // the id lanes name their model by display id
(Err(_), None) => {
eprintln!(
"WOW_CAPTURE=fxview needs WOW_FX_MODEL=<internal .mdx/.m2 path>, \
let scenario: Option<Scenario> = if glue.is_some() {
None
} else {
Some(if name == "fxview" {
let display: Option<u32> = std::env::var("WOW_FX_DISPLAY")
.ok()
.and_then(|v| v.trim().parse().ok());
let go: Option<u32> = std::env::var("WOW_FX_GO")
.ok()
.and_then(|v| v.trim().parse().ok());
let model_path = match (std::env::var("WOW_FX_MODEL"), display.or(go)) {
(Ok(p), _) => p,
(Err(_), Some(_)) => String::new(), // the id lanes name their model by display id
(Err(_), None) => {
eprintln!(
"WOW_CAPTURE=fxview needs WOW_FX_MODEL=<internal .mdx/.m2 path>, \
WOW_FX_DISPLAY=<CreatureDisplayInfo id> or \
WOW_FX_GO=<GameObjectDisplayInfo id>"
);
std::process::exit(2);
);
std::process::exit(2);
}
};
let knob = |k: &str, d: f32| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
app.insert_resource(FxViewRequest {
model_path,
display,
go,
go_state: knob("WOW_FX_GO_STATE", 1.0) as u32,
go_type: knob("WOW_FX_GO_TYPE", 6.0) as u32,
scale: knob("WOW_FX_SCALE", 1.0),
age: knob("WOW_FX_AGE", 1.0),
az_deg: knob("WOW_FX_AZ", 0.0),
el_deg: knob("WOW_FX_EL", 10.0),
dist: knob("WOW_FX_DIST", 5.0),
fly: knob("WOW_FX_FLY", 0.0),
yaw_deg: knob("WOW_FX_YAW", 0.0),
turn: knob("WOW_FX_TURN", 0.0),
ground: knob("WOW_FX_GROUND", 0.0) > 0.5,
hold: knob("WOW_FX_HOLD", 0.0) > 0.5,
up: knob("WOW_FX_UP", 0.0),
})
.init_resource::<FxViewState>();
Scenario {
name: "fxview",
map: scenarios::MAP_AZEROTH, // the fixture spawns over the Northshire slope
eye: GROUND_EYE, // overridden per frame by the orbit in `pin_scene`
look: FXVIEW_POS,
minute: 720,
ui: None,
}
};
let knob = |k: &str, d: f32| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
app.insert_resource(FxViewRequest {
model_path,
display,
go,
go_state: knob("WOW_FX_GO_STATE", 1.0) as u32,
go_type: knob("WOW_FX_GO_TYPE", 6.0) as u32,
scale: knob("WOW_FX_SCALE", 1.0),
age: knob("WOW_FX_AGE", 1.0),
az_deg: knob("WOW_FX_AZ", 0.0),
el_deg: knob("WOW_FX_EL", 10.0),
dist: knob("WOW_FX_DIST", 5.0),
fly: knob("WOW_FX_FLY", 0.0),
yaw_deg: knob("WOW_FX_YAW", 0.0),
turn: knob("WOW_FX_TURN", 0.0),
ground: knob("WOW_FX_GROUND", 0.0) > 0.5,
hold: knob("WOW_FX_HOLD", 0.0) > 0.5,
up: knob("WOW_FX_UP", 0.0),
})
.init_resource::<FxViewState>();
Scenario {
name: "fxview",
map: scenarios::MAP_AZEROTH, // the fixture spawns over the Northshire slope
eye: GROUND_EYE, // overridden per frame by the orbit in `pin_scene`
look: FXVIEW_POS,
minute: 720,
ui: None,
}
} else if name == "waterfx" {
// The water-foam viewer (see `water_fx::view`): a synthetic wading unit over a
// synthetic wet lattice, framed by a fixed orbit around the rig centre. Knobs:
// WOW_WFX_MODE (ring|wake|turn), WOW_WFX_SPEED (yd/s), WOW_WFX_AGE (s),
// WOW_WFX_DEPTH (yd), camera WOW_WFX_AZ/EL/DIST. Not a golden scenario.
let knob = |k: &str, d: f32| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
let mode = match std::env::var("WOW_WFX_MODE").as_deref() {
Ok("wake") => waterfx::WfxMode::Wake,
Ok("turn") => waterfx::WfxMode::Turn,
_ => waterfx::WfxMode::Ring,
};
// Rig centre in raw WoW coords: over the Northshire ground scene, the synthetic
// surface a few yards above the terrain so the backdrop plane reads clean.
let center = [-8961.0_f32, -145.0, 95.0];
let az = knob("WOW_WFX_AZ", 180.0).to_radians();
let el = knob("WOW_WFX_EL", 35.0).to_radians();
let dist = knob("WOW_WFX_DIST", 14.0);
let eye = [
center[0] + dist * el.cos() * az.cos(),
center[1] + dist * el.cos() * az.sin(),
center[2] + dist * el.sin(),
];
app.insert_resource(waterfx::WaterFxView {
mode,
speed: knob("WOW_WFX_SPEED", 4.0),
age: knob("WOW_WFX_AGE", 1.5),
center,
depth: knob("WOW_WFX_DEPTH", 0.5),
})
.init_resource::<FxViewState>();
Scenario {
name: "waterfx",
map: scenarios::MAP_AZEROTH, // the synthetic lattice sits over the Northshire slope
eye,
look: center,
minute: 720,
ui: None,
}
} else if name == "vista" {
// The **arbitrary-viewpoint** instrument: stand anywhere on the map, face any heading,
// at any clock — the world half of what `fxview` is for effects. A director report that
// arrives as "look at this horizon, here" (position, facing and time are all on the debug
// panel, and `copy .go xyz` puts the position on the clipboard) becomes a reproducible
// headless capture instead of a round-trip. Pair it with `WOW_FARCLIP` to match their
// slider — horizon and fog artifacts live and die by the far-clip wall. Not a golden
// scenario (its output depends on the knobs, not the name).
//
// WOW_CAPTURE=vista WOW_VISTA_AT=-5841.9,-3802.4,-59.7 WOW_VISTA_FACE=24 \
// WOW_VISTA_MIN=1052 WOW_FARCLIP=320 WOW_CAPTURE_OUT=/tmp/v.png cargo run -q -p benilla
//
// Knobs: `WOW_VISTA_AT` (required, raw WoW `x,y,z` — the PLAYER position; the eye seats
// `VISTA_EYE_HEIGHT` above it), `WOW_VISTA_FACE` (heading in degrees — the panel's
// "facing" in its `(24°)` form; 0 = +X, counter-clockwise), `WOW_VISTA_PITCH` (degrees,
// + = up, default 0 = level), `WOW_VISTA_MIN` (game minute of day, default 720 = noon).
let knob = |k: &str, d: f32| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
let Some(at) = std::env::var("WOW_VISTA_AT").ok().and_then(|v| {
let c: Vec<f32> = v.split(',').filter_map(|p| p.trim().parse().ok()).collect();
(c.len() == 3).then(|| [c[0], c[1], c[2]])
}) else {
eprintln!("WOW_CAPTURE=vista needs WOW_VISTA_AT=<x,y,z> (raw WoW coords)");
std::process::exit(2);
};
let face = knob("WOW_VISTA_FACE", 0.0).to_radians();
let pitch = knob("WOW_VISTA_PITCH", 0.0).to_radians();
let eye = [at[0], at[1], at[2] + VISTA_EYE_HEIGHT];
// A look point far enough out that the framing is the heading, not the distance.
let d = 500.0_f32;
Scenario {
name: "vista",
// The arbitrary-viewpoint instrument goes anywhere, so its map is a knob: a
// horizon report from Kalimdor is `WOW_MAP=1` (a `Map.dbc` id).
map: std::env::var("WOW_MAP")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(scenarios::MAP_AZEROTH),
eye,
look: [
eye[0] + d * pitch.cos() * face.cos(),
eye[1] + d * pitch.cos() * face.sin(),
eye[2] + d * pitch.sin(),
],
minute: knob("WOW_VISTA_MIN", 720.0) as u32,
ui: None,
}
// By name, EITHER table: the blessed six or an on-demand fixture. Only the sweep is
// narrowed — every old viewpoint is still capturable by name (decision 0632).
} else if let Some(&s) = SCENARIOS
.iter()
.chain(scenarios::ON_DEMAND.iter())
.find(|s| s.name == name)
{
s
} else {
let known: Vec<_> = SCENARIOS
} else if name == "waterfx" {
// The water-foam viewer (see `water_fx::view`): a synthetic wading unit over a
// synthetic wet lattice, framed by a fixed orbit around the rig centre. Knobs:
// WOW_WFX_MODE (ring|wake|turn), WOW_WFX_SPEED (yd/s), WOW_WFX_AGE (s),
// WOW_WFX_DEPTH (yd), camera WOW_WFX_AZ/EL/DIST. Not a golden scenario.
let knob = |k: &str, d: f32| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
let mode = match std::env::var("WOW_WFX_MODE").as_deref() {
Ok("wake") => waterfx::WfxMode::Wake,
Ok("turn") => waterfx::WfxMode::Turn,
_ => waterfx::WfxMode::Ring,
};
// Rig centre in raw WoW coords: over the Northshire ground scene, the synthetic
// surface a few yards above the terrain so the backdrop plane reads clean.
let center = [-8961.0_f32, -145.0, 95.0];
let az = knob("WOW_WFX_AZ", 180.0).to_radians();
let el = knob("WOW_WFX_EL", 35.0).to_radians();
let dist = knob("WOW_WFX_DIST", 14.0);
let eye = [
center[0] + dist * el.cos() * az.cos(),
center[1] + dist * el.cos() * az.sin(),
center[2] + dist * el.sin(),
];
app.insert_resource(waterfx::WaterFxView {
mode,
speed: knob("WOW_WFX_SPEED", 4.0),
age: knob("WOW_WFX_AGE", 1.5),
center,
depth: knob("WOW_WFX_DEPTH", 0.5),
})
.init_resource::<FxViewState>();
Scenario {
name: "waterfx",
map: scenarios::MAP_AZEROTH, // the synthetic lattice sits over the Northshire slope
eye,
look: center,
minute: 720,
ui: None,
}
} else if name == "vista" {
// The **arbitrary-viewpoint** instrument: stand anywhere on the map, face any heading,
// at any clock — the world half of what `fxview` is for effects. A director report that
// arrives as "look at this horizon, here" (position, facing and time are all on the debug
// panel, and `copy .go xyz` puts the position on the clipboard) becomes a reproducible
// headless capture instead of a round-trip. Pair it with `WOW_FARCLIP` to match their
// slider — horizon and fog artifacts live and die by the far-clip wall. Not a golden
// scenario (its output depends on the knobs, not the name).
//
// WOW_CAPTURE=vista WOW_VISTA_AT=-5841.9,-3802.4,-59.7 WOW_VISTA_FACE=24 \
// WOW_VISTA_MIN=1052 WOW_FARCLIP=320 WOW_CAPTURE_OUT=/tmp/v.png cargo run -q -p benilla
//
// Knobs: `WOW_VISTA_AT` (required, raw WoW `x,y,z` — the PLAYER position; the eye seats
// `VISTA_EYE_HEIGHT` above it), `WOW_VISTA_FACE` (heading in degrees — the panel's
// "facing" in its `(24°)` form; 0 = +X, counter-clockwise), `WOW_VISTA_PITCH` (degrees,
// + = up, default 0 = level), `WOW_VISTA_MIN` (game minute of day, default 720 = noon).
let knob = |k: &str, d: f32| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
let Some(at) = std::env::var("WOW_VISTA_AT").ok().and_then(|v| {
let c: Vec<f32> = v.split(',').filter_map(|p| p.trim().parse().ok()).collect();
(c.len() == 3).then(|| [c[0], c[1], c[2]])
}) else {
eprintln!("WOW_CAPTURE=vista needs WOW_VISTA_AT=<x,y,z> (raw WoW coords)");
std::process::exit(2);
};
let face = knob("WOW_VISTA_FACE", 0.0).to_radians();
let pitch = knob("WOW_VISTA_PITCH", 0.0).to_radians();
let eye = [at[0], at[1], at[2] + VISTA_EYE_HEIGHT];
// A look point far enough out that the framing is the heading, not the distance.
let d = 500.0_f32;
Scenario {
name: "vista",
// The arbitrary-viewpoint instrument goes anywhere, so its map is a knob: a
// horizon report from Kalimdor is `WOW_MAP=1` (a `Map.dbc` id).
map: std::env::var("WOW_MAP")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(scenarios::MAP_AZEROTH),
eye,
look: [
eye[0] + d * pitch.cos() * face.cos(),
eye[1] + d * pitch.cos() * face.sin(),
eye[2] + d * pitch.sin(),
],
minute: knob("WOW_VISTA_MIN", 720.0) as u32,
ui: None,
}
// By name, EITHER table: the blessed six or an on-demand fixture. Only the sweep is
// narrowed — every old viewpoint is still capturable by name (decision 0632).
} else if let Some(&s) = SCENARIOS
.iter()
.chain(scenarios::ON_DEMAND.iter())
.map(|s| s.name)
.collect();
eprintln!(
"WOW_CAPTURE={name:?} is not a known scenario; choose one of: {known:?} (or fxview, waterfx)"
.find(|s| s.name == name)
{
s
} else {
let glue_known: Vec<_> = GLUE_SCENARIOS.iter().map(|g| g.name).collect();
let known: Vec<_> = SCENARIOS
.iter()
.chain(scenarios::ON_DEMAND.iter())
.map(|s| s.name)
.collect();
eprintln!(
"WOW_CAPTURE={name:?} is not a known scenario; choose one of: {known:?}, {glue_known:?} (or fxview, waterfx)"
);
std::process::exit(2);
std::process::exit(2);
})
};
// Seed the continent the scenario names, BEFORE `world_map::load_world_map` reads it at
// `Startup` — that is the single place `CurrentMap` is set for a server-less run, and the
@@ -615,10 +673,13 @@ impl Plugin for CapturePlugin {
// continent, so a scenario that could not say which map it meant would stream the wrong
// world's tiles and photograph a void (Felwood's tile `33_24` exists in Azeroth, empty).
// Written back for `vista` too, which is where the value came from — a harmless no-op that
// keeps one path for "which map is this run on" (decision 0743).
std::env::set_var("WOW_MAP", scenario.map.to_string());
// keeps one path for "which map is this run on" (decision 0743). A glue screen has no map.
if let Some(s) = &scenario {
std::env::set_var("WOW_MAP", s.map.to_string());
}
let shot_name = scenario.map(|s| s.name).or(glue.map(|g| g.name)).unwrap();
let out = std::env::var("WOW_CAPTURE_OUT")
.unwrap_or_else(|_| format!("target/visual/{}.png", scenario.name));
.unwrap_or_else(|_| format!("target/visual/{shot_name}.png"));
if let Some(parent) = Path::new(&out).parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!(
@@ -644,6 +705,7 @@ impl Plugin for CapturePlugin {
.init_resource::<FrameWatch>()
.insert_resource(CaptureCtx {
scenario,
name: shot_name,
out,
phase: Phase::Building(0),
ui_seeded: false,
@@ -678,9 +740,14 @@ fn pin_scene(
roots: Query<&Transform, Without<WorldCamera>>,
mut cam: Query<&mut Transform, With<WorldCamera>>,
) {
debug.lighting.follow_server_time = false;
debug.lighting.manual_minute = ctx.scenario.minute;
perf.visible = false; // the perf HUD is default-on; suppress it for a pristine, UI-free shot
// A glue screen has no world to light, no clock to pin and no camera to place. The shutter
// above needs none of that — it is watching the framebuffer.
let Some(scenario) = ctx.scenario else {
return;
};
debug.lighting.follow_server_time = false;
debug.lighting.manual_minute = scenario.minute;
// `WOW_MM_PROBE=x,y,z` (raw WoW coords) drops the player at that point and marks them active, so
// the interior minimap (which keys off `player.active` + `player.pos`) renders in a headless
@@ -718,10 +785,7 @@ fn pin_scene(
* Quat::from_rotation_x(-req.el_deg.to_radians());
(look + orbit * (Vec3::Z * req.dist), look)
}
_ => (
wow_to_bevy(ctx.scenario.eye),
wow_to_bevy(ctx.scenario.look),
),
_ => (wow_to_bevy(scenario.eye), wow_to_bevy(scenario.look)),
};
for mut t in &mut cam {
*t = Transform::from_translation(eye).looking_at(look, Vec3::Y);
@@ -947,7 +1011,7 @@ fn drive_capture(
// capture it without log-filter noise.
println!(
"FPS_PROBE scenario={} frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} entities={entity_count} px={}x{}{cpu}{present}",
ctx.scenario.name,
ctx.name,
v.len(),
at(0.50),
at(0.95),
@@ -29,7 +29,10 @@
use bevy::prelude::*;
use super::camera::{CameraControl, FlyCam};
use crate::player::camera::{CameraControl, FlyCam};
use benilla_world::schedule::WorldStage;
use super::ProbeClock;
/// One parked pose: absolute yaw/pitch (radians), an optional orbit distance (yards), when it takes
/// over, and an optional constant yaw pan (rad/s) applied from that moment.
@@ -42,13 +45,13 @@ struct Pose {
}
#[derive(Resource)]
pub(super) struct ProbeCam {
pub(crate) struct ProbeCam {
poses: Vec<Pose>,
}
/// Parse the env script. Absent or unparseable entries yield no poses (with a warning), so the probe
/// is inert unless asked for.
pub(super) fn from_env() -> Option<ProbeCam> {
pub(crate) fn from_env() -> Option<ProbeCam> {
let spec = std::env::var("WOW_PROBE_CAM").ok()?;
let poses: Vec<Pose> = spec
.split(';')
@@ -87,9 +90,17 @@ pub(super) fn from_env() -> Option<ProbeCam> {
/// Hold the camera at the latest armed pose. Runs in `WorldStage::Input` **before** `control`, so
/// the frame that sees the pose is the frame that renders it.
pub(super) fn drive_probe_cam(
///
/// On the **wall clock** ([`ProbeClock`], decision 0789), like every other probe schedule: `@25`
/// means twenty-five real seconds in, and a pan of `8°/s` means eight degrees per real second. It
/// read `Res<Time>` from 0653 until decision 1174 — clamped to `max_delta` (250 ms) and frozen
/// outright by the capture harness, so a hitching or occluded run silently under-ran every knob.
/// The drift was never caught because this file lived in `player/`, outside the reach of the very
/// checker that exists to catch it ([`super::probes`]); moving the instrument into the harness is
/// what surfaced it.
pub(crate) fn drive_probe_cam(
probe: Res<ProbeCam>,
time: Res<Time>,
time: ProbeClock,
mut rig: ResMut<CameraControl>,
mut cam: Query<&mut FlyCam, With<benilla_world::view::WorldCamera>>,
self_player: Query<(), With<crate::net::SelfPlayer>>,
@@ -107,12 +118,30 @@ pub(super) fn drive_probe_cam(
};
// Absolute, from the pose's own start — so the sweep is reproducible frame-for-frame across
// runs rather than accumulating whatever frame times this run happened to get.
cam.yaw = pose.yaw + pose.pan * (now - pose.at);
cam.pitch = pose.pitch;
cam.park(pose.yaw + pose.pan * (now - pose.at), pose.pitch);
if let Some(d) = pose.distance {
// Both, or the wheel glide eases `distance` back toward the old target every frame and the
// parked shot drifts through the whole zoom while the burst is running.
rig.distance = d;
rig.target_distance = d;
// `park_distance`, not the wheel target alone: the glide would ease `distance` back toward
// the old target every frame and the parked shot would drift through the whole zoom while
// the burst is running.
rig.park_distance(d);
}
}
/// `WOW_PROBE_CAM`'s registration — [`super::probe_look::ProbeLookPlugin`]'s twin, in the same slot
/// (`control` reads the rig right after) and equally inert without the variable.
pub(crate) struct ProbeCamPlugin;
impl Plugin for ProbeCamPlugin {
fn build(&self, app: &mut App) {
let Some(cam) = from_env() else {
return;
};
app.insert_resource(cam).add_systems(
Update,
drive_probe_cam
.in_set(WorldStage::Input)
.before(crate::player::PlayerControlSet)
.run_if(in_state(crate::char_select::ClientState::InWorld)),
);
}
}
@@ -17,8 +17,10 @@
use bevy::prelude::*;
use super::Player;
use crate::capture::ProbeClock;
use crate::player::Player;
use benilla_world::schedule::WorldStage;
use super::ProbeClock;
/// One scripted turn: `rate` (rad/s), when it starts, and how long it runs — **wall-clock seconds**
/// ([`ProbeClock`], decision 0789). Both halves want real time here, for one reason: `90°/s for 6 s`
@@ -32,13 +34,13 @@ struct Turn {
}
#[derive(Resource)]
pub(super) struct ProbeLook {
pub(crate) struct ProbeLook {
turns: Vec<Turn>,
}
/// Parse the env script. Absent or unparseable entries yield no turns (with a warning), so the probe
/// is inert unless asked for.
pub(super) fn from_env() -> Option<ProbeLook> {
pub(crate) fn from_env() -> Option<ProbeLook> {
let spec = std::env::var("WOW_PROBE_LOOK").ok()?;
let turns: Vec<Turn> = spec
.split(';')
@@ -69,7 +71,7 @@ pub(super) fn from_env() -> Option<ProbeLook> {
/// Rotate the aim for every turn whose window covers this frame. Runs in `WorldStage::Input`
/// **before** the controller, so the frame that sees the new `face_yaw` is the frame that streams it.
pub(super) fn drive_probe_look(
pub(crate) fn drive_probe_look(
probe: Res<ProbeLook>,
time: ProbeClock,
mut player: ResMut<Player>,
@@ -87,6 +89,28 @@ pub(super) fn drive_probe_look(
.map(|t| t.rate)
.sum();
if rate != 0.0 {
player.face_yaw += rate * dt;
player.turn_aim(rate * dt);
}
}
/// `WOW_PROBE_LOOK`'s registration. Added by the probe fleet when the variable is set, and inert
/// (no resource, no systems) if the script parses to nothing — the same shape every other probe in
/// the fleet has. It orders itself **before** [`crate::player::control`], which is what makes the
/// frame that sees the new `face_yaw` the frame that streams it; the controller knows nothing
/// about it (decision 1174).
pub(crate) struct ProbeLookPlugin;
impl Plugin for ProbeLookPlugin {
fn build(&self, app: &mut App) {
let Some(look) = from_env() else {
return;
};
app.insert_resource(look).add_systems(
Update,
drive_probe_look
.in_set(WorldStage::Input)
.before(crate::player::PlayerControlSet)
.run_if(in_state(crate::char_select::ClientState::InWorld)),
);
}
}
+8 -9
View File
@@ -124,6 +124,12 @@ impl Plugin for ProbeRigPlugin {
return; // inert without a parseable spec (parse() has already said why)
};
info!("rig: {}", spec.describe());
// Publish the claim on the character pick as an always-present run fact (decision 1174).
// The roster reads it to know the pick is already spoken for; before 1174 it called
// `rig_char_name_from_env` directly, which is gameplay naming the harness.
if let Some(name) = rig_char_name(&spec) {
app.insert_resource(crate::run_mode::RigCharacter(name));
}
app.insert_resource(Rig {
spec,
phase: RigPhase::AwaitRoster,
@@ -355,7 +361,7 @@ fn drive_rig(
messages::CHAR_CREATE_SUCCESS => rig.phase = RigPhase::AwaitRoster,
messages::CHAR_CREATE_SERVER_LIMIT if !rig.evicted => {
rig.evicted = true;
match crate::preflight::slot_word().and_then(|slot| {
match crate::run_mode::slot_word().and_then(|slot| {
evictable(&rig.roster, rig_char_name(&rig.spec).as_deref(), slot)
}) {
Some((guid, name)) => {
@@ -519,16 +525,9 @@ fn parse_point(at: &str) -> Option<(i32, f32, f32, f32)> {
parts.next().is_none().then_some((map, x, y, z))
}
/// The character a `WOW_RIG` body maps to on this slot: `<Race3><Class3><slot-word>[f]`. `None` when
/// the spec named no body (the rig then configures whatever the normal pick logs in as), or when the
/// build is outside a pool slot (there is no slot identity to key a name to).
pub(crate) fn rig_char_name_from_env() -> Option<String> {
rig_char_name(&RigSpec::parse(&std::env::var("WOW_RIG").ok()?)?)
}
fn rig_char_name(spec: &RigSpec) -> Option<String> {
let (race, class, gender) = spec.body?;
let slot = crate::preflight::slot_word()?;
let slot = crate::run_mode::slot_word()?;
let name = format!(
"{}{}{slot}{}",
race_code(race)?,
+14 -3
View File
@@ -71,6 +71,14 @@ mod tests {
fn probe_schedules_read_the_wall_clock() {
/// `(file, system, why it is genuinely an age or a delta on the animating clock)`.
const ALLOWED: &[(&str, &str, &str)] = &[
(
"capture/fxview.rs",
"drive_fx_view",
"the other half of `drive_capture`'s fixture age below: the driver spawns, flies \
and reaps the subject on the same clock the effect animates on, which the capture \
freezes at save time. A wall clock here would age the fixture apart from the \
visuals it exists to show — an age, not a schedule",
),
(
"capture/mod.rs",
"drive_capture",
@@ -98,10 +106,13 @@ mod tests {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = Vec::new();
let mut allowed_seen = 0usize;
// The probe harness: everything under `capture/`, plus the one probe that lives beside the
// player controller because it writes `face_yaw` directly.
// The probe harness: everything under `capture/`. It used to need a hand-added exception
// for the one probe that lived beside the player controller; decision 1174 moved every
// instrument in here, so the directory walk IS the scope again — and the move immediately
// earned its keep by catching `probe_cam`, which had been scheduling on the virtual clock
// outside this checker's reach ever since 0653.
let mut stack = vec![src.join("capture")];
let mut files = vec![src.join("player/probe_look.rs")];
let mut files: Vec<std::path::PathBuf> = Vec::new();
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).expect("probe harness dir is readable") {
let path = entry.expect("dir entry").path();
+51 -25
View File
@@ -187,11 +187,58 @@ pub(super) const MAP_AZEROTH: u32 = 0;
pub(super) const MAP_KALIMDOR: u32 = 1;
pub(super) const MAP_DEEPRUN_TRAM: u32 = 369;
/// THE golden baseline: **three spots FRAMED BY THE DIRECTOR, each at noon and at night — six
/// captures, and that is the whole sweep.** A Stormwind canal view, Elwynn water, and a Felwood
/// hollow on Kalimdor. The numbers below are verbatim from their `~/.benilla/shots.txt` ([`/shot`]).
/// A **glue-screen** capture — the login-side screens, which have no world, no camera and no map.
///
/// **Held at six on purpose (decision 0817).** 0632 cut a thirty-shot set down to six for exactly
/// A separate table rather than a variant of [`Scenario`], because the two share nothing but a
/// name and an output path: every field of a `Scenario` says where a camera stands in a streamed
/// world. What they do share is the shutter, and that is the part worth sharing — it waits for the
/// *image* to stop changing (decision 0815), which is as true of a glue screen as of a landscape.
///
/// Not in [`SCENARIOS`]: the golden sweep is the director's blessed set (0632's law, 0817's cut,
/// 1183's eviction) and growing it is their call, not the harness's. Capturable by name like any
/// on-demand viewpoint.
#[derive(Clone, Copy)]
pub(super) struct GlueScenario {
pub(super) name: &'static str,
pub(super) screen: GlueScreen,
/// The preview's race, sex and class ids — the reference comparisons are per-race, so a
/// scenario says which one it photographs. Applied through the existing
/// `WOW_CHARCREATE_PICK` instrument rather than a second path into `CreateSelection`.
pub(super) pick: (u8, u8, u8),
}
/// Which glue screen a [`GlueScenario`] photographs. One today; the select screen wants a roster
/// (so it needs a server, or a seeded one) and the login screen is nearly static.
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum GlueScreen {
/// The character-creation screen (decision 0423). Photographs, in one frame: the authored-rig
/// lighting of the `UI_*` backdrop scene and its per-race fog (the lane 1171 left uncovered),
/// the preview body in its starting outfit (0527), and the GlueXML panel around them.
CharCreate,
}
/// The glue sweep. Human, 1 (Human male warrior) — the race whose scene the reference's own
/// screenshots use, and the outfit path most exercised elsewhere.
pub(super) const GLUE_SCENARIOS: &[GlueScenario] = &[GlueScenario {
name: "glue-charcreate",
screen: GlueScreen::CharCreate,
pick: (1, 0, 1),
}];
/// THE golden baseline: **two spots FRAMED BY THE DIRECTOR, each at noon and at night — four
/// captures, and that is the whole sweep.** Elwynn water and a Felwood hollow on Kalimdor. The
/// numbers below are verbatim from their `benilla-config/shots.txt` ([`/shot`] — it was
/// `~/.benilla/shots.txt` when they were taken; decision 1181 moved it).
///
/// **The Stormwind canal was the third spot and is deleted, not evicted** (decision 1183, at the
/// director's instruction — the one place the "nothing is deleted" rule below does not apply). It
/// stopped holding the bar of 0 by one pixel per cell, and the cause is a renderer defect that is
/// still open: two WMO surfaces intersect there, one MSAA sample sits in an exact depth tie, and
/// reverse-Z `GreaterEqual` gives that sample to whichever batch drew last — which varies because
/// draw order follows spawn order (1182 measured it, 16/16). **Deleting the shot is not a fix**; it
/// removes the only cell that was reporting the bug. 1182 holds the repro recipe.
///
/// **Held small on purpose (decision 0817).** 0632 cut a thirty-shot set down to six for exactly
/// this reason and stated the law — *a few spots chosen with the director, times a couple of day
/// times* — and then the set grew back to **twenty-one** one well-argued addition at a time: an
/// interior, a second continent, a shadow-line fence, four creature cells, four chest cells, two
@@ -210,22 +257,6 @@ pub(super) const MAP_DEEPRUN_TRAM: u32 = 369;
/// continent, its own tileset, fog palette, liquid type and WDL horizon, none of which the Elwynn
/// spots can photograph at all.
pub(super) const SCENARIOS: &[Scenario] = &[
Scenario {
name: "canal-noon",
map: MAP_AZEROTH,
eye: CANAL_EYE,
look: CANAL_LOOK,
minute: 720,
ui: None,
},
Scenario {
name: "canal-night",
map: MAP_AZEROTH,
eye: CANAL_EYE,
look: CANAL_LOOK,
minute: 0,
ui: None,
},
Scenario {
name: "water-noon",
map: MAP_AZEROTH,
@@ -265,11 +296,6 @@ pub(super) const SCENARIOS: &[Scenario] = &[
pub(super) const OVERLOOK_EYE: [f32; 3] = [-8955.0, -98.5, 91.1];
pub(super) const OVERLOOK_LOOK: [f32; 3] = [-8912.9, -125.4, 87.7];
/// Director shot 2 — the Stormwind canal (Cathedral Square side): water, bridge, walls, spires,
/// far mountains.
pub(super) const CANAL_EYE: [f32; 3] = [-8871.7, 724.6, 110.7];
pub(super) const CANAL_LOOK: [f32; 3] = [-8836.7, 760.3, 112.3];
/// Director shot 3 — Elwynn river, south-east of Northshire: open water dominant, shoreline blend,
/// murloc camp, fog.
pub(super) const WATER_EYE: [f32; 3] = [-9527.0, -310.6, 70.8];
+1 -1
View File
@@ -130,7 +130,7 @@ pub(crate) fn spawn(
// `WorldStage::Present` — so waiting for it would cost the rig its first frame of foam
// and shift every ripple's age by one step for the whole aged capture.
benilla_world::world_unit::WorldUnit {
kind: EntityKind::Unit,
wades: true,
scale: 1.0,
height: crate::entities::CollisionHeight::default().0,
},
+55 -52
View File
@@ -56,9 +56,14 @@ pub(crate) enum ClientState {
/// The character-select subsystem: the state machine + the select screen.
pub(crate) struct CharSelectPlugin {
/// Capture mode boots straight `InWorld` (no net thread, no picker) so the deterministic
/// scene harness is untouched by the glue layer.
pub(crate) start_in_world: bool,
/// The screen this session opens on.
///
/// A connected boot starts at [`ClientState::Login`] (decision 0539) and the roster's arrival
/// flips it. A world capture starts straight at [`ClientState::InWorld`] — no net thread, no
/// picker — so the deterministic scene harness is untouched by the glue layer. A **glue**
/// capture starts on the glue screen it photographs, which is the reason this is a state and
/// not the bool it used to be: there turned out to be three answers, not two.
pub(crate) start: ClientState,
}
/// Mirror the session's screen onto the engine's one-bit "is there a world" fact.
@@ -77,53 +82,47 @@ fn publish_world_live(
impl Plugin for CharSelectPlugin {
fn build(&self, app: &mut App) {
app.insert_state(if self.start_in_world {
ClientState::InWorld
} else {
// Connected boots start at the login screen (decision 0539); the roster's arrival
// flips to CharSelect (`crate::login::to_select_on_roster`).
ClientState::Login
})
.init_resource::<Roster>()
// **The engine's world-existence bit** (1160's wire (b)): the session owner is this
// module, so this module tells the world whether there is one. Ordered ahead of every
// world stage so the fact and its falling edge are this frame's, not last frame's — which
// is the whole reason `WorldLive` is a resource and not a mirrored state.
.add_systems(
Update,
publish_world_live.before(benilla_world::schedule::WorldStage::Net),
)
.init_resource::<dialog::DeleteDialog>()
.add_systems(OnEnter(ClientState::CharSelect), screen::enter_select)
.add_systems(OnExit(ClientState::CharSelect), screen::exit_select)
.add_systems(Update, (debug_glue_roundtrip, debug_logout_smoke))
.add_systems(
Update,
(
// The policy + transitions run in BOTH states: the roster auto-answer (reconnect
// relogin) happens while `InWorld`, and the logout edge arrives there too.
(apply_roster_policy, enter_on_connected, back_on_logout).chain(),
app.insert_state(self.start)
.init_resource::<Roster>()
// **The engine's world-existence bit** (1160's wire (b)): the session owner is this
// module, so this module tells the world whether there is one. Ordered ahead of every
// world stage so the fact and its falling edge are this frame's, not last frame's — which
// is the whole reason `WorldLive` is a resource and not a mirrored state.
.add_systems(
Update,
publish_world_live.before(benilla_world::schedule::WorldStage::Net),
)
.init_resource::<dialog::DeleteDialog>()
.add_systems(OnEnter(ClientState::CharSelect), screen::enter_select)
.add_systems(OnExit(ClientState::CharSelect), screen::exit_select)
.add_systems(Update, (debug_glue_roundtrip, debug_logout_smoke))
.add_systems(
Update,
(
screen::materialize_screen,
input::select_input,
input::rotate_model,
debug_select_dialog,
dialog::drive_delete_dialog,
refresh::refresh_list,
refresh::refresh_banner_and_buttons,
refresh::feed_glue_preview,
crate::glue::art_swaps,
crate::glue::glue_button_visuals,
delete_result,
debug_select_shot,
crate::glue::sync_outlines,
// The policy + transitions run in BOTH states: the roster auto-answer (reconnect
// relogin) happens while `InWorld`, and the logout edge arrives there too.
(apply_roster_policy, enter_on_connected, back_on_logout).chain(),
(
screen::materialize_screen,
input::select_input,
input::rotate_model,
debug_select_dialog,
dialog::drive_delete_dialog,
refresh::refresh_list,
refresh::refresh_banner_and_buttons,
refresh::feed_glue_preview,
crate::glue::art_swaps,
crate::glue::glue_button_visuals,
delete_result,
debug_select_shot,
crate::glue::sync_outlines,
)
.chain()
.run_if(in_state(ClientState::CharSelect)),
)
.chain()
.run_if(in_state(ClientState::CharSelect)),
)
.chain()
.after(benilla_world::schedule::WorldStage::Net),
);
.after(benilla_world::schedule::WorldStage::Net),
);
}
}
@@ -222,9 +221,9 @@ impl Roster {
}
/// Ask the parked IO thread to log in as `guid` (the pick channel) and remember it as pending.
/// `pub(crate)` for the rig ([`crate::capture::ProbeRigPlugin`]), which picks a character it may
/// have had to *create* first — going through here is what keeps `pending_pick` truthful, so 0065's
/// reconnect re-answers with the rigged body rather than showing the roster.
/// `pub(crate)` for the probe rig (decision 0651), which picks a character it may have had to
/// *create* first — going through here is what keeps `pending_pick` truthful, so 0065's reconnect
/// re-answers with the rigged body rather than showing the roster.
pub(crate) fn send_pick(roster: &mut Roster, pick: &CharPick, guid: u64) {
roster.pending_pick = Some(guid);
let _ = pick.0.send(CharRequest::Enter(guid));
@@ -238,14 +237,18 @@ fn apply_roster_policy(
mut msgs: MessageReader<CharListMessage>,
mut roster: ResMut<Roster>,
pick: Res<CharPick>,
// Is the character pick already spoken for? Present only when a rig is driving this run
// (decision 1174's always-present run fact) — absent in every ordinary run, which is the
// player answer and the one this screen was written for.
rig: Option<Res<crate::run_mode::RigCharacter>>,
) {
if !roster.env_read {
roster.env_read = true;
// `WOW_RIG` with a body outranks `WOW_CHAR`: the rig picks its own derived character, and
// it may have to CREATE it first, which this fast path (a one-shot `take()` that gives up
// on a name it can't find) structurally cannot wait for.
roster.env_char = match crate::capture::rig_char_name_from_env() {
Some(name) => {
roster.env_char = match rig.as_deref() {
Some(crate::run_mode::RigCharacter(name)) => {
if let Ok(ignored) = std::env::var("WOW_CHAR") {
warn!("char select: WOW_RIG names {name} — ignoring WOW_CHAR={ignored}");
}
+2 -2
View File
@@ -8,7 +8,7 @@
//! verified CVar registration defaults (wow-re `benilla-pins.md` B10, quoted in
//! [`crate::sound::SoundConfig`]), `uiScale`/`farclip` are benilla's shipped defaults —
//! and a test welds each string to the constant it mirrors so they cannot drift.
//! - **Boot**: read `benilla/config.toml` ([`crate::local_state`]) and apply it to the knob
//! - **Boot**: read `benilla-config/config.toml` ([`crate::local_state`]) and apply it to the knob
//! resources; when the UI VM exists, register the table and push the resolved session values
//! so `GetCVar` answers what the client is actually doing.
//! - **Sync**: drain Lua `SetCVar` changes into the knob resources each frame and mark the
@@ -223,7 +223,7 @@ fn zoom_index(v: f32) -> u8 {
v.clamp(0.0, f32::from(MINIMAP_ZOOM_LEVELS - 1)) as u8
}
/// Startup: read `benilla/config.toml` (absent file = all defaults, not an error) and apply it
/// Startup: read `benilla-config/config.toml` (absent file = all defaults, not an error) and apply it
/// to the knob resources — except keys the environment overrides this session (their resources
/// already read the env var in their `Default`s). The VM does not exist yet; [`sync_cvars`]
/// seeds the table when it does.
@@ -9,7 +9,7 @@ use bevy_egui::{egui, EguiContexts};
use super::{overlay_text, OVERLAY_FILL, OVERLAY_TEXT_DIM};
use crate::net::ObjectStore;
use crate::ui_script::PointerOverUi;
use crate::ui_script::{InspectMode, PointerOverUi};
use benilla_world::interact::{pick_at_cursor, PickParts, WorldObject};
use benilla_world::model_render::ModelKind;
use benilla_world::modkeys::DEV_CHORD;
@@ -477,13 +477,6 @@ pub(super) fn inspect_ui(
Ok(())
}
/// Whether mouseover picking runs. Today it's armed/disarmed by the **dev chord + `I`** inspector toggle
/// ([`toggle_inspect`]).
#[derive(Resource, Default)]
pub struct InspectMode {
pub enabled: bool,
}
/// The nearest [`WorldObject`] under the cursor this frame, or `None`. Consumers read `entity` and look
/// up its [`WorldObject`]; `point`/`distance` are the world-space hit.
#[derive(Resource, Default)]
@@ -21,7 +21,7 @@ use crate::debug_panel::{overlay_text, OVERLAY_FILL, OVERLAY_TEXT_DIM};
use crate::net::{Guid, NetCommands, SelfGuid};
use crate::ui_action::Spells;
use super::InspectMode;
use crate::ui_script::InspectMode;
/// Records kept in the ring buffer.
const KEPT: usize = 24;
+18 -18
View File
@@ -14,9 +14,11 @@
//! Each only does work when its slice of the state actually changes (tracked with a `Local`
//! snapshot), so leaving the panel open costs nothing. (Lighting is now resolved in `lighting.rs`;
//! this section is time controls + a readout, not knobs.)
//! - [`EguiPointerOver`] publishes "the mouse is talking to the egui overlays" each pass; the
//! combined source of truth gameplay reads is [`crate::ui_script::PointerOverUi`] (owned by its
//! combiner, decision 0026 — dev plugins must be droppable without breaking gameplay reads).
//! - [`EguiPointerOver`] publishes "the mouse is talking to the egui overlays" each pass; both it
//! and the combined source of truth gameplay reads ([`crate::ui_script::PointerOverUi`]) are
//! *defined* by that combiner, and this module only writes them (decisions 0026/1174 — dev
//! plugins must be droppable without breaking gameplay reads, so gameplay may not name a
//! dev-owned type).
//!
//! Adding a section for a new subsystem is therefore: a `FooDebug` field, a few widgets in the
//! panel, and one `apply_foo` system — no plumbing changes. (Weather is the worked example.)
@@ -39,6 +41,12 @@ use benilla_world::model_render::{ModelKind, ModelPart};
use benilla_world::modkeys::{dev_chord, DEV_CHORD};
use benilla_world::view::ViewDistance;
/// The egui half of the pointer arbitration: this panel *writes* it, `ui_script` owns it —
/// so a build without these overlays still compiles gameplay's reads (decision 1174; the
/// module-doc note above). `InspectMode`, the other half of 0026's named pair, lives there too
/// and is imported by the two files that touch it (`inspect`, `journal`).
use crate::ui_script::EguiPointerOver;
mod inspect;
mod journal;
@@ -77,13 +85,6 @@ pub(crate) fn overlay_text(ui: &mut egui::Ui) {
style.wrap_mode = Some(egui::TextWrapMode::Extend);
}
/// The egui dev overlay's half of the pointer arbitration, written each egui pass. The combined
/// truth — [`crate::ui_script::PointerOverUi`] — lives with its combiner in `ui_script` (decision
/// 0026: gameplay must not read dev-owned resources; the arbiter tolerates this half's absence),
/// which OR's this with the player-UI hover. Neither writer depends on the other.
#[derive(Resource, Default)]
pub(crate) struct EguiPointerOver(pub(crate) bool);
/// Strip the **Tab** key from egui's per-frame input so egui never treats it as focus navigation.
///
/// Tab is a bound game key — `TargetNearestEnemy` (decision 0166). Left to itself, egui reads Tab at
@@ -154,7 +155,7 @@ fn source_label(s: ClockSource) -> &'static str {
}
/// Adds the egui plugin, the debug-state resource, the panel UI, and the apply systems.
pub(crate) use inspect::{InspectMode, MouseoverTarget};
pub(crate) use inspect::MouseoverTarget;
pub struct DebugPanelPlugin;
@@ -176,13 +177,12 @@ impl Plugin for DebugPanelPlugin {
// `DebugState` itself is `benilla_world::dev_state`'s and the engine inits it — this panel is only
// its editor (decision 0026), and eight other subsystems read it whether or not the panel
// is installed.
app.init_resource::<EguiPointerOver>()
// The inspector surface and the cast journal — the two instruments that stood on
// `interact` and were registered by it until decision 1160's stage zero. The mouseover
// pick comes with them: it never ran except while the inspector was armed, so it was never
// the engine's picking, only this overlay's.
.init_resource::<InspectMode>()
.init_resource::<MouseoverTarget>()
// The inspector surface and the cast journal — the two instruments that stood on
// `interact` and were registered by it until decision 1160's stage zero. The mouseover
// pick comes with them: it never ran except while the inspector was armed, so it was never
// the engine's picking, only this overlay's. (`InspectMode` and `EguiPointerOver`, which
// this panel writes, are inited by their owner `UiScriptPlugin` — 1174.)
app.init_resource::<MouseoverTarget>()
.init_resource::<journal::CastJournal>()
// After the UI keyboard feed because `update_mouseover` reads `PointerOverUi`, whose
// player-UI half `UiInput` writes — the pick must see this frame's hover, not last
+248
View File
@@ -0,0 +1,248 @@
//! **The dev/player seam** — the two instrument groups, and the one rule that governs them
//! (decision 0026 set the target, 1173 mandated the build, 1174 is what got built).
//!
//! ```text
//! Dev may see anything; nothing may depend on dev.
//! ```
//!
//! That is the whole boundary, and it is one-directional on purpose. An instrument's job *is* to
//! see everything — 1160 measured 266 references from the instruments into 63 of the app's 142
//! modules — so a crate wall here would mean publishing hundreds of internals as permanent public
//! API shaped by whatever a panel wanted to poke at. A `cfg` seam has the opposite property: the
//! instruments keep reaching in exactly as far as they like, and only the *direction* is
//! constrained.
//!
//! **The mechanism is not this comment.** It is `cargo build -p benilla --no-default-features` in
//! `scripts/gates.sh`. 0026 wrote this same rule down in June 2026 with no failing build behind
//! it, and by August there were 24 references from non-dev code into the instruments across 12
//! files (1173) — not through carelessness, but because a rule with no failing test is a wish.
//! When gameplay needs a fact an instrument happens to know, the fact goes to
//! [`crate::run_mode`] (always present, player-faithful default) and the instrument writes it
//! there. It does not get a `use` line from gameplay.
//!
//! **What is deliberately NOT in here:** `benilla_world::dev_state` (the always-present config
//! layer — its defaults *are* the player behaviour, so it ships), `pipe_warm` (a player on macOS
//! eats every synchronous pipeline stall without it — 0837/1116), and `art_scope` (within-map art
//! residency: engine, and it travels with `WorldPlugins` — 0729).
use bevy::prelude::*;
/// `WOW_CAPTURE=list` — the harness scenario names `scripts/visual.sh` reads, printed before any
/// window or asset setup. Answers nothing in a player build, which has no scenarios.
pub(crate) fn print_scenario_names() {
#[cfg(feature = "dev")]
crate::capture::print_scenario_names();
}
/// `WOW_HOVER_LOG_REPORT=<csv>` — re-read a recorded run and print its report, no window, no game.
/// New analysis lands on runs already captured (see `hover_log`).
pub(crate) fn report_recorded_hover_log(_path: &str) {
#[cfg(feature = "dev")]
crate::hover_log::report_recorded_file(_path);
}
/// **The instruments.** One group, added where the debug panel has always sat — first among them,
/// because `PerfPlugin` needs the egui plugin and context it sets up.
pub(crate) struct DevToolsPlugin;
impl Plugin for DevToolsPlugin {
#[cfg_attr(not(feature = "dev"), allow(unused_variables))]
fn build(&self, app: &mut App) {
#[cfg(feature = "dev")]
{
app.add_plugins(crate::debug_panel::DebugPanelPlugin)
.add_plugins(crate::perf::PerfPlugin)
// `WOW_FX_CENSUS=1`: where this frame's particle draws are addressed, and whether
// the view they name is switched on (decision 0775). An instrument, and one that
// reads the portrait booths — so it belongs on this side of the line.
.add_plugins(crate::capture::fx_draw_census_plugin)
// The hover-cost recorder (`WOW_HOVER_LOG`) and the asset-churn meter
// (`WOW_ASSET_CHURN`) — both no-ops without their variable.
.add_plugins(crate::hover_log::HoverLogPlugin)
.add_plugins(crate::asset_churn::AssetChurnPlugin)
// The session preflight (decision 0649): one banner per world entry naming the body
// we logged into, and loud warnings for the states — dead/ghost, GM mode,
// server-blocked movement — that silently invalidate a session's readings. Never
// env-gated; a warning nobody switches on isn't one.
.add_plugins(crate::preflight::PreflightPlugin)
// The probe shield (decision 0677): a body on a probe account is put into vmangos's
// `.cheat god` on every world entry — damage clamps at 1 hp instead of killing —
// and GM mode is turned OFF, because the shield replaces the only reason it was
// ever on. Inert on any other account.
.add_plugins(crate::probe_shield::ProbeShieldPlugin);
}
}
}
/// **The probe fleet** — the capture harness and every scripted live probe, each armed by its own
/// environment variable and inert without it. Added last so they observe the fully-built app.
pub(crate) struct DevProbesPlugin;
impl Plugin for DevProbesPlugin {
#[cfg_attr(not(feature = "dev"), allow(unused_variables))]
fn build(&self, app: &mut App) {
#[cfg(feature = "dev")]
{
// The capture harness drives one deterministic screenshot then exits — added last so it observes
// the fully-built app. Inert unless `$WOW_CAPTURE` is set.
if crate::run_mode::scenario_active() {
app.add_plugins(crate::capture::CapturePlugin);
}
// The LIVE probe shot (orthogonal to the harness): `WOW_LIVE_SHOT=<png>` on a NORMAL connected
// run writes one screenshot `WOW_LIVE_SHOT_AT` seconds (default 12) after startup and keeps
// running — the agent-side instrument for seeing a live server scene (NPCs, GameObjects, event
// spawns) without a scenario. Pair with `WOW_USER`/`WOW_CHAR` + an outer `timeout`.
if std::env::var("WOW_LIVE_SHOT").is_ok() {
app.add_plugins(crate::capture::LiveShotPlugin);
}
// The probe RIG (decision 0651): `WOW_RIG="tauren druid 60 gear:heal-preraid-bis"` finds-or-
// creates that body on this slot's probe account, logs in as it, and applies level/spells/gear/
// spec/place — the one verb that replaces the hand-assembled GM recipe every session used to
// re-derive (see `capture::ProbeRigPlugin`).
if std::env::var("WOW_RIG").is_ok() {
app.add_plugins(crate::capture::ProbeRigPlugin);
}
// Any scripted probe keeps its window un-occludable: a fully covered macOS window drops to
// ~1 fps drawables, and every probe schedule is wall-clock — a throttled run doesn't measure
// slowly, it runs the wrong script (see `capture::ProbeFocusPlugin`, decision 0906).
// (`WOW_LIVE_FPS` is in the list because an occluded SETTLE phase streams the world at ~1 fps
// and under-warms the scene before sampling even starts — the assertion has to be live from
// the first tick, not at the uncap.)
if [
"WOW_PROBE",
"WOW_PROBE_CHAT",
"WOW_PROBE_KEY",
"WOW_PROBE_LUA",
"WOW_RIG",
"WOW_LIVE_FPS",
]
.iter()
.any(|k| std::env::var(k).is_ok())
{
app.add_plugins(crate::capture::ProbeFocusPlugin);
}
// The probe-chat one-shot: `WOW_PROBE_CHAT=".go xyz …"` sends GM/chat lines once in-world —
// the "park the probe character anywhere" instrument (see `capture::ProbeChatPlugin`).
if std::env::var("WOW_PROBE_CHAT").is_ok() {
app.add_plugins(crate::capture::ProbeChatPlugin);
}
// The probe-lua one-shot: `WOW_PROBE_LUA="CastSpell(…)"` runs a chunk in the live UI VM once
// in-world — the "press the button headlessly" instrument (see `capture::ProbeLuaPlugin`).
if std::env::var("WOW_PROBE_LUA").is_ok() {
app.add_plugins(crate::capture::ProbeLuaPlugin);
}
// The probe-key taps: `WOW_PROBE_KEY="Space@14"` presses keys once in-world — the "press
// space headlessly" instrument for input-gated behavior (see `capture::ProbeKeyPlugin`).
if std::env::var("WOW_PROBE_KEY").is_ok() {
app.add_plugins(crate::capture::ProbeKeyPlugin);
}
// The probe self-termination: `WOW_PROBE_EXIT_AT=<secs>` bounds any scripted live probe's
// lifetime — its own knob, not a rider on the Lua probe (see `capture::ProbeExitPlugin`).
if std::env::var("WOW_PROBE_EXIT_AT").is_ok() {
app.add_plugins(crate::capture::ProbeExitPlugin);
}
// The ray pick: `WOW_PICK="<x>,<y>"` names every surface along the ray through a screenshot
// pixel, nearest first — "what is at the spot `benilla-visual hotspot` flagged, and what is
// right behind it" (see `capture::PickProbePlugin`).
if std::env::var("WOW_PICK").is_ok() {
app.add_plugins(crate::capture::PickProbePlugin);
}
// The render-phase census: `WOW_PHASE=<uniqueId>` reports, per frame, which phase each of one
// placement's batches landed in and where in the draw order — the one thing every scene-side
// instrument is blind to, namely whether a surface was submitted at all (see
// `capture::PhaseProbePlugin`).
if std::env::var("WOW_PHASE").is_ok() {
app.add_plugins(crate::capture::PhaseProbePlugin);
}
// The depth readback: `WOW_DEPTH="<x>,<y>"` reports what depth actually won each named pixel,
// per frame, as a distance in yards — the link past submission that decides the pixel. Pair it
// with `WOW_PICK` at the same pixels to turn "what won" into "whose it was" (see
// `capture::DepthProbePlugin`).
// `WOW_DEPTH_QUADS=<bone>…` is the same readback taken at a particle quad's OWN pixels — the
// moving-subject form, which no hand-written pixel list can hold (see `capture::depth_probe`).
if std::env::var("WOW_DEPTH").is_ok() || std::env::var("WOW_DEPTH_QUADS").is_ok() {
app.add_plugins(crate::capture::DepthProbePlugin);
}
// The bevy_ui node census — "who owns this rectangle" for UI outside the FrameXML quad pass
// (see `capture::NodeProbePlugin`).
if std::env::var("WOW_NODE_PROBE").is_ok() {
app.add_plugins(crate::capture::NodeProbePlugin);
}
// The mid-run window resize: `WOW_PROBE_RESIZE="<secs>:<W>x<H>"` — the headless fullscreen-
// toggle stand-in for resize-reactive layout (see `capture::ProbeResizePlugin`).
if std::env::var("WOW_PROBE_RESIZE").is_ok() {
app.add_plugins(crate::capture::ProbeResizePlugin);
}
// The particle census: `WOW_PARTICLE_CENSUS=<secs>` prints per-emitter live counts once —
// the trace-comparable coverage number (see `capture::ParticleCensusPlugin`).
if std::env::var("WOW_PARTICLE_CENSUS").is_ok() {
app.add_plugins(crate::capture::ParticleCensusPlugin);
}
// The entity census: `WOW_ENTITY_CENSUS=<secs>` prints per-archetype entity counts once —
// what the resident entity count is made of (see `capture::EntityCensusPlugin`).
if std::env::var("WOW_ENTITY_CENSUS").is_ok() {
app.add_plugins(crate::capture::EntityCensusPlugin);
}
// The melee live probe: `WOW_PROBE=melee` auto-fights the nearest enemy so the dbg-trace
// sink can record the combat-text timeline (see `capture::ProbeMeleePlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("melee") {
app.add_plugins(crate::capture::ProbeMeleePlugin);
}
// The partner live probe: `WOW_PROBE=partner` auto-accepts group invites — the party arc's
// second-client instrument (decision 0434; see `capture::ProbePartnerPlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("partner") {
app.add_plugins(crate::capture::ProbePartnerPlugin);
}
// The sea-crossing live probe: `WOW_PROBE=crossing` boards a cross-continent boat and reports
// the map seam surviving — decision 0455's instrument (see `capture::ProbeCrossingPlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("crossing") {
app.add_plugins(crate::capture::ProbeCrossingPlugin);
}
// The taxi-flight live probe: `WOW_PROBE=taxi` opens the flight-master menu on the real wire
// and rides Stormwind → Sentinel Hill to a measured verdict — decision 0484's end-to-end
// instrument (see `capture::ProbeTaxiPlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("taxi") {
app.add_plugins(crate::capture::ProbeTaxiPlugin);
}
// The mail-arc live probe: `WOW_PROBE_MAIL=1` GM-mails the probe's own character, opens the
// Goldshire mailbox on the real wire, and drives the inbox/take/send/delete surface through
// the live Lua VM — decisions 0544/0548's end-to-end instrument (see `capture::ProbeMailPlugin`).
if std::env::var("WOW_PROBE_MAIL").is_ok() {
app.add_plugins(crate::capture::ProbeMailPlugin);
}
// The bank-arc live probe: `WOW_PROBE_BANK=1` GM-hops to a pure banker, drives the whole
// six-opcode bank wire (activate/deposit/withdraw/buy-slot/refusal) — decision 0604's
// end-to-end instrument (see `capture::ProbeBankPlugin`).
if std::env::var("WOW_PROBE_BANK").is_ok() {
app.add_plugins(crate::capture::ProbeBankPlugin);
}
// The cast-cancel live probe: `WOW_PROBE=castcancel` hearths and presses W mid-cast — the
// local self-cancel's end-to-end timing instrument (see `capture::ProbeCastCancelPlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("castcancel") {
app.add_plugins(crate::capture::ProbeCastCancelPlugin);
}
// The char-create live probe: `WOW_PROBE_CHARCREATE="<name>[,race,class,gender,…]"` creates (and
// cleans up) a character at select to verify the char-create/delete wire (decision 0423 phase 1;
// see `capture::ProbeCharCreatePlugin`).
if std::env::var("WOW_PROBE_CHARCREATE").is_ok() {
app.add_plugins(crate::capture::ProbeCharCreatePlugin);
}
// The live FPS probe: `WOW_LIVE_FPS=<frames>` samples frame times on a NORMAL connected run
// and exits — the harness probe's numbers with the live world in (see `capture::LiveFpsPlugin`).
if std::env::var("WOW_LIVE_FPS").is_ok() {
app.add_plugins(crate::capture::LiveFpsPlugin);
}
// The two scripted probe drivers that lived in `player/` until decision 1174 — the mouse-turn
// (`WOW_PROBE_LOOK`, decision 0621) and the camera park (`WOW_PROBE_CAM`, decision 0653). Added
// unconditionally because each plugin's own `from_env` is its gate, so the variable's name is
// spelled in exactly one place; both order themselves before `player::PlayerControlSet`.
app.add_plugins(crate::capture::ProbeLookPlugin);
app.add_plugins(crate::capture::ProbeCamPlugin);
// The FPS journal: `WOW_FPS_JOURNAL=<csv>` appends per-second position + frame-time rows on a
// director-driven run — "where does it dip" as coordinates (see `perf::FpsJournalPlugin`).
if std::env::var("WOW_FPS_JOURNAL").is_ok() {
app.add_plugins(crate::perf::FpsJournalPlugin);
}
}
}
}
+31 -9
View File
@@ -32,7 +32,7 @@ use benilla_world::schedule::WorldStage;
/// Resolving a display id to its [`DisplayModel`] and building its spawn parts once the model asset
/// loads (materials, skeleton, collider, camera/selection metrics) — the front half of this subsystem,
/// kept in its own file as it carries the bulk of the per-display cache/build logic.
mod display;
pub(crate) mod display;
use display::{
build_parts, empty_display, empty_shell, new_creature_display, new_gameobject_display,
DisplayModel, EntityPart, ModelHandle,
@@ -93,7 +93,7 @@ use missile::{attach_missile_models, move_missiles, spawn_missiles};
/// M2s spawned as children of the streamed gameobject, so they ride a moving transport.
mod wmo_props;
use wmo_props::{resolve_wmo_gameobject_props, spawn_wmo_gameobject_props};
mod spell_fx;
pub(crate) mod spell_fx;
use spell_fx::{attach_spell_fx, resolve_spell_fx};
/// Dest-anchored spell effects (decision 0797): a DynamicObject's persistent area visuals
@@ -347,6 +347,16 @@ pub(crate) struct VisualAttached;
#[derive(SystemSet, Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(crate) struct EntityVisualsSet;
/// [`update_display_models`]' ordering handle: anything that must **create** a display-cache entry
/// in time for this frame's build orders before it.
///
/// It exists for one caller, the `fxview` fixture's driver — which registers itself against this
/// from `capture` rather than being listed in the chain above, because a fixture's driver belongs
/// with its fixture and gameplay may not name the harness (decisions 1173/1174). Same shape as the
/// `waterfx` fixture's registration against the engine's `WaterFoamSet`.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct DisplayBuildSet;
/// Drop every display/material dedup on a cross-map transition (`world_map::MapChange` — see its
/// doc for why a clear is always safe mid-session). These caches are get-or-insert at every use
/// site, so a cleared entry rebuilds on the next spawn that wants it; without this, every display
@@ -427,7 +437,11 @@ fn publish_world_units(
) {
for (entity, net, height, current) in &bodies {
let want = benilla_world::world_unit::WorldUnit {
kind: net.kind,
// The wire kind is answered HERE and never handed over (1177): the engine asks "does
// this body displace water", and translating its own vocabulary into that answer is
// the game's job. A live body wades; a GameObject, a dynamic-object spell anchor or
// anything else standing in a lake makes no ripple.
wades: matches!(net.kind, EntityKind::Unit | EntityKind::Player),
scale: net.scale,
// `unwrap_or_default`, NOT zero: `CollisionHeight`'s Default is the client's own
// ctor value, and its doc says why — at 0.0 "every depth line collapses and the unit
@@ -438,7 +452,7 @@ fn publish_world_units(
// Only write on a real change: the component is change-detected downstream, and a
// per-frame rewrite would mark every body dirty for every reader every frame.
let same = current.is_some_and(|c| {
c.kind == want.kind && c.scale == want.scale && c.height == want.height
c.wades == want.wades && c.scale == want.scale && c.height == want.height
});
if !same {
commands.entity(entity).insert(want);
@@ -515,10 +529,7 @@ impl Plugin for EntitiesPlugin {
// One nested (unordered) element: three independent producers, and the
// outer tuple is at `chain()`'s 20-element ceiling.
(arm_ground_effects, spawn_ground_bursts, tick_shard_emitters),
// The fxview capture fixture (inert outside `WOW_CAPTURE=fxview`): creates
// its cache entry before the same-frame build, attaches after it.
spell_fx::drive_fx_view,
update_display_models,
update_display_models.in_set(DisplayBuildSet),
// The char-create preview (decision 0423): assemble the selected look's parts from
// the freshly-built display model, for the create booth to bake. After
// `update_display_models` (its want-list built the body) — server-less, at char select.
@@ -1063,6 +1074,7 @@ mod world_unit_tests {
fn every_wire_body_becomes_a_world_unit_and_the_viewer_is_marked() {
let mut app = App::new();
let plain = app.world_mut().spawn(net(EntityKind::Unit, 1.25)).id();
let chest = app.world_mut().spawn(net(EntityKind::GameObject, 1.0)).id();
let me = app
.world_mut()
.spawn((
@@ -1080,7 +1092,7 @@ mod world_unit_tests {
let unit = w
.get::<benilla_world::world_unit::WorldUnit>(plain)
.expect("a wire body is a world unit");
assert_eq!(unit.kind, EntityKind::Unit);
assert!(unit.wades, "a creature displaces water");
assert_eq!(unit.scale, 1.25);
assert_eq!(
unit.height,
@@ -1089,9 +1101,19 @@ mod world_unit_tests {
depth line collapses and the body swims on dry land (see the type's own doc)"
);
// The wire→engine translation 1177 moved here: `benilla-world` no longer knows what a
// `TYPEID` is, so this is the only place the creature-vs-GameObject question is answered.
assert!(
!w.get::<benilla_world::world_unit::WorldUnit>(chest)
.expect("a GameObject is still a body the world can shade and room-claim")
.wades,
"…but a chest standing in a lake makes no ripple"
);
let mine = w
.get::<benilla_world::world_unit::WorldUnit>(me)
.expect("so is the avatar's");
assert!(mine.wades, "and so does a player");
assert_eq!(mine.height, 2.5, "the collision cylinder travels with it");
assert!(
w.get::<benilla_world::world_unit::ViewerUnit>(me).is_some(),
@@ -527,11 +527,7 @@ mod tests {
/// the same set, which is what this pins.)
#[test]
fn worn_gloves_replace_the_glove_geoset_in_place() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let cg = CharacterGeosets::load(&mut chain).expect("customization tables");
+5 -5
View File
@@ -21,7 +21,7 @@ use benilla_assets::{m2_url, skin_url, wmo_url};
/// A loaded display model's asset handle — an M2 (creatures, most GameObjects) or a WMO (building
/// GameObjects). `None` ⇒ the display resolved to no model (invisible trigger / missing) → cube/none.
pub(super) enum ModelHandle {
pub(crate) enum ModelHandle {
M2(Handle<M2Model>),
Wmo(Handle<WmoModel>),
None,
@@ -136,8 +136,8 @@ pub(super) struct EntityPart {
/// Everything needed to render one display id, cached so a model is loaded + built once and shared by
/// every entity of that display. `parts` is `None` while the asset loads, then `Some` — empty if the
/// model failed / has no geometry (→ cube/none fallback), else the spawn parts.
pub(super) struct DisplayModel {
pub(super) handle: ModelHandle,
pub(crate) struct DisplayModel {
pub(crate) handle: ModelHandle,
/// The model's directory (for creature skin paths); empty for WMOs / no-skin models.
pub(super) dir: String,
/// Creature `Monster1/2/3` skin variation names; all `None` for GameObjects.
@@ -193,7 +193,7 @@ pub(super) struct DisplayModel {
/// The file-order-first sequence's authored duration ([`M2Model::first_seq_span`]), captured with
/// `parts` on load — the spell-fx self-termination clock for a model whose sequences build no clip
/// (the eat/drink tankard: a 6.667 s sequence, zero bone keys). `None` for WMO / sequence-less.
pub(super) first_seq_span: Option<f32>,
pub(crate) first_seq_span: Option<f32>,
/// Camera framing-pivot height in **model-local yards, pre-scale** — `0.9 × bbox_z_extent` from the
/// M2 authored bounds, captured in [`build_parts`]. Stamped onto each instance as [`CameraPivot`] so
/// the third-person camera targets ~neck height rather than a fixed offset (wow-re `follow-camera`).
@@ -304,7 +304,7 @@ pub(super) fn new_gameobject_display(
/// A blank [`DisplayModel`] shell — `parts: None` (awaiting [`build_parts`]) and every capture field
/// at its default — for constructors that override only what their source provides.
pub(super) fn empty_shell() -> DisplayModel {
pub(crate) fn empty_shell() -> DisplayModel {
DisplayModel {
handle: ModelHandle::None,
dir: String::new(),
+5 -221
View File
@@ -72,15 +72,7 @@ const ATTACH_FALLBACKS: [u16; 2] = [0xf, 0x13];
/// fallback and kin) — long enough to read as a flash, short enough never to linger. Shared
/// with the dest-anchored lane's one-shots ([`super::dest_fx`]) — same completion-callback
/// stand-in.
pub(super) const FALLBACK_SPAN: f32 = 1.0;
/// The `fxview` unit lane's synthetic guid (`WOW_FX_DISPLAY`) — a high-word `0xF130` creature guid
/// like the wire's, in a serverless capture where nothing else claims one.
const FXVIEW_UNIT_GUID: u64 = (0xF130u64 << 48) | 0xFC0FEE;
/// The `fxview` GameObject lane's synthetic guid (`WOW_FX_GO`) — the wire's `0xF110` high word,
/// which is what `crate::net` reads a GO's identity out of.
const FXVIEW_GO_GUID: u64 = (0xF110u64 << 48) | 0xFC0FEE;
pub(crate) const FALLBACK_SPAN: f32 = 1.0;
/// How long a self-terminating instance may wait unspawned (model/skeleton never loading — the
/// cube-fallback caster) before it is dropped instead of pending forever.
@@ -90,8 +82,8 @@ const PENDING_TIMEOUT: f32 = 10.0;
/// is created here on first use; `super::update_display_models` builds `parts` once the asset
/// loads, shared by every instance of that path).
#[derive(Resource, Default)]
pub(super) struct SpellFx {
pub(super) models: HashMap<String, DisplayModel>,
pub(crate) struct SpellFx {
pub(crate) models: HashMap<String, DisplayModel>,
}
/// The live **per-instance tint clones**: an effect part whose M2Color RGB animates gets its own
@@ -155,220 +147,12 @@ fn fx_part_material(
// texture, blend, fog policy, RGB/alpha loops — rides its `GroundFxDecal` record and the
// effect stream's per-vertex tint instead of a per-instance `WowModelMaterial`.)
/// Drive the `fxview` capture fixture (see [`crate::capture::FxViewRequest`]) — the effect-
/// viewer instrument: once the capture driver arms it (scene settled), spawn a root at the
/// fixture point, create the model's [`SpellFx`] cache entry, attach the full visual set
/// through the same [`attach_effect_visuals`] body the game uses, and (for missiles) fly the
/// root along its facing so trails extend. Inert outside fxview captures (the request resource
/// only exists then).
#[allow(clippy::too_many_arguments)]
pub(crate) fn drive_fx_view(
req: Option<Res<crate::capture::FxViewRequest>>,
state: Option<ResMut<crate::capture::FxViewState>>,
mut commands: Commands,
fx: Option<ResMut<SpellFx>>,
asset_server: Res<AssetServer>,
time: Res<Time>,
mut wow_materials: ResMut<Assets<WowModelMaterial>>,
mut tint_reg: ResMut<FxTintAnims>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
spatial: avian3d::prelude::SpatialQuery,
mut transforms: Query<&mut Transform>,
) {
let (Some(req), Some(mut state)) = (req, state) else {
return;
};
if !state.armed {
return;
}
let Some(mut fx) = fx else {
// Server-less capture boot: the cache resource normally rides the net session.
commands.init_resource::<SpellFx>();
return;
};
let root = *state.root.get_or_insert_with(|| {
let mut pos = benilla_assets::coords::wow_to_bevy(crate::capture::FXVIEW_POS);
// `WOW_FX_DISPLAY`: the UNIT lane. Spawn the live component set a streamed creature gets
// (`net::apply`'s, the same one the `vplates` fixture's wolf uses) and let the ordinary
// unit pipeline build the model — so what this shoots is a creature, not a model, and
// every unit-only term (tag alpha, the fade gate, anim LOD, the emitters' sequence host)
// is in the picture. Always seated on the terrain: a creature stands on the ground.
if let Some(display) = req.display {
if let Some(hit) = spatial.cast_ray(
pos,
Dir3::NEG_Y,
500.0,
true,
&benilla_world::collision::WorldCollision::body_filter(),
) {
pos.y -= hit.distance;
}
pos.y += req.up;
return commands
.spawn((
crate::net::Guid(FXVIEW_UNIT_GUID),
crate::net::NetEntity {
kind: benilla_protocol::EntityKind::Unit,
display_id: Some(display),
scale: req.scale,
},
crate::net::ObjectStore(benilla_protocol::messages::ObjectFields::from_pairs(
&[
(22, 100), // UNIT_FIELD_HEALTH
(28, 100), // UNIT_FIELD_MAXHEALTH
(34, 60), // UNIT_FIELD_LEVEL
(35, 35), // UNIT_FIELD_FACTIONTEMPLATE — friendly, so no combat pose
],
)),
Transform::from_translation(pos)
.with_rotation(Quat::from_rotation_y(req.yaw_deg.to_radians())),
Visibility::default(),
))
.id();
}
// `WOW_FX_GO`: the GAMEOBJECT lane. A placed trap/door/chest reaches the screen through
// `crate::go_anim`'s §243 state machine, never through the effect pool, so this is the
// only lane that reproduces one. The descriptor carries the three fields the machine
// reads — display, TYPE_ID (the `go_animates` gate) and STATE (the substate) — and
// nothing else, so an unset knob renders exactly what an omitted wire field renders.
// Always seated on the terrain: a GO stands on the ground.
if let Some(display) = req.go {
if let Some(hit) = spatial.cast_ray(
pos,
Dir3::NEG_Y,
500.0,
true,
&benilla_world::collision::WorldCollision::body_filter(),
) {
pos.y -= hit.distance;
}
pos.y += req.up;
return commands
.spawn((
crate::net::Guid(FXVIEW_GO_GUID),
crate::net::NetEntity {
kind: benilla_protocol::EntityKind::GameObject,
display_id: Some(display),
scale: req.scale,
},
crate::net::ObjectStore(benilla_protocol::messages::ObjectFields::from_pairs(
&[
(14, req.go_state), // GAMEOBJECT_STATE
(21, req.go_type), // GAMEOBJECT_TYPE_ID
],
)),
Transform::from_translation(pos)
.with_rotation(Quat::from_rotation_y(req.yaw_deg.to_radians())),
Visibility::default(),
))
.id();
}
// `WOW_FX_GROUND=1`: seat the fixture ON the terrain — a ground-anchored effect's flat
// quads render as projected surface decals and need ground inside their vertical slab.
// The scene settled before arming, so the streamed tile's collider is there to hit.
if req.ground {
if let Some(hit) = spatial.cast_ray(
pos,
Dir3::NEG_Y,
500.0,
true,
&benilla_world::collision::WorldCollision::body_filter(),
) {
pos.y -= hit.distance;
}
}
pos.y += req.up;
commands
.spawn((
Transform::from_translation(pos)
.with_rotation(Quat::from_rotation_y(req.yaw_deg.to_radians())),
Visibility::default(),
))
.id()
});
// A missile only trails in motion: fly the root along its model-forward (local −Z).
// WOW_FX_TURN keeps the fixture rotating — the attached-model heading-since-birth fan.
if req.fly > 0.0 || req.turn != 0.0 {
if let Ok(mut t) = transforms.get_mut(root) {
let fwd = t.rotation * -Vec3::Z;
t.translation += fwd * req.fly * time.delta_secs();
if req.turn != 0.0 {
t.rotation =
Quat::from_rotation_y((req.turn * time.delta_secs()).to_radians()) * t.rotation;
}
}
}
// The one-pass reap (the game's completion callback, `fx_attach`): a discrete kit
// instance dies at exactly one pass of sequence 0 and its emitters DRAIN — the fixture
// mirrors it so a capture past the span shows the truth. `WOW_FX_HOLD=1` previews a
// persistent hold instead (reaped by its spell edge in game, so no clock here).
// The one-pass reap is an effect-instance rule; a unit — or a placed GameObject — stands there.
if !req.hold && !state.expired && req.display.is_none() && req.go.is_none() {
if let Some(at) = state.attached_at {
let span = fx
.models
.get(&req.model_path)
.and_then(|dm| dm.first_seq_span)
.unwrap_or(FALLBACK_SPAN);
if time.elapsed_secs() >= at + span {
commands.entity(root).despawn();
state.expired = true;
}
}
}
if state.attached_at.is_none() {
// The unit and GameObject lanes have no attach step — the entity pipeline builds the model
// on its own. Their "age" is therefore seconds since the entity appeared, which is what the
// animation phase is measured in.
if req.display.is_some() || req.go.is_some() {
state.attached_at = Some(time.elapsed_secs());
return;
}
fx.models
.entry(req.model_path.clone())
.or_insert_with(|| DisplayModel {
handle: ModelHandle::M2(asset_server.load(m2_url(&req.model_path))),
..super::empty_shell()
});
if attach_effect_visuals(
&mut commands,
root,
&fx.models[&req.model_path],
time.elapsed_secs(),
true, // the fixture plants at a world point — ground quads decal onto the terrain
// The fixture previews kit effects, which are attached models — but it hangs on
// nothing (there is no host model in a preview), so its pool keeps the drain.
EffectHost {
attached: true,
parent: None,
},
// `WOW_FX_HOLD=1` previews a PERSISTENT instance, so it must show the persistent
// lifecycle — birth then `Hold` — or the instrument would report a freeze the game
// does not have. Without it the fixture previews a one-shot, which runs its birth once
// and is reaped by the fixture's own span clock below.
Some(if req.hold {
FxStage::State
} else {
FxStage::OneShot
}),
&mut wow_materials,
&mut tint_reg,
&ibps,
&mut palettes,
None, // the fixture previews a kit effect on its model's own `Stand`
) {
state.attached_at = Some(time.elapsed_secs());
}
}
}
/// Where an effect-model instance sits in the client's **model graph** — the two facts every
/// caller of [`attach_effect_visuals`] states, together, because they are one fact seen from two
/// sides and a lane that answered only the first is what left a weapon's enchant glow with no
/// alpha source and no owner (decision 0833).
#[derive(Clone, Copy, Default)]
pub(super) struct EffectHost {
pub(crate) struct EffectHost {
/// Is this an **attached** model in the client's sense (`[model+0x17c] ≠ 0`)? It sets the
/// emitters' attach frame `A`: a kit effect on a unit and the `fxview` fixture are attached
/// (the cloud fans with the host's motion), a missile is not — its trail stays world-frozen
@@ -404,7 +188,7 @@ pub(super) struct EffectHost {
/// advance — a missile (the separate `CMissile` TU), an item glow, the `fxview` preview — which
/// keep the pinned single-clip arm they have always had.
#[allow(clippy::too_many_arguments)]
pub(super) fn attach_effect_visuals(
pub(crate) fn attach_effect_visuals(
commands: &mut Commands,
root: Entity,
dm: &DisplayModel,
+1 -1
View File
@@ -24,7 +24,7 @@ pub(crate) struct AddUiMaterial {
impl UiMaterial for AddUiMaterial {
fn fragment_shader() -> ShaderRef {
"game://shaders/ui_add.wgsl".into()
"embedded://benilla_app/shaders/ui_add.wgsl".into()
}
fn specialize(descriptor: &mut RenderPipelineDescriptor, _key: UiMaterialKey<Self>) {
+7 -18
View File
@@ -24,24 +24,9 @@ use bevy::time::Real;
use benilla_ui::script::UiScript;
/// One frame's UI-pass phase split, in μs — written by [`crate::ui_script::extract::drive_script`]
/// under the same marks the `[ui-cost]` line prints, read here.
#[derive(Resource, Default, Clone)]
pub struct UiFrameCost {
/// How many FontStrings the layout asked the font engine to shape this frame, and the first
/// few by name — a steady hover that keeps asking is the churn this recorder exists to catch.
pub measured: usize,
pub measured_texts: Vec<String>,
pub tick: u128,
pub resolve: u128,
pub measure: u128,
pub extract: u128,
pub convert: u128,
pub diff: u128,
pub quads: usize,
pub solves: u64,
pub skipped: bool,
}
/// The per-frame phase split this recorder writes to the CSV. Produced and owned by the UI pass
/// itself (decision 1174) — an instrument reads the fact, it does not define it.
use crate::ui_script::{UiCostWanted, UiFrameCost};
/// Where to write, from `$WOW_HOVER_LOG`: unset ⇒ off, `1` ⇒ the default path, anything else ⇒
/// that path.
@@ -86,6 +71,10 @@ impl Plugin for HoverLogPlugin {
return;
}
let path = log_path().expect("enabled() checked");
// Ask the UI pass for its phase split. `init_resource` first so this holds whichever
// plugin builds first — `UiScriptPlugin`'s own init is then a no-op (decision 1174).
app.init_resource::<UiCostWanted>();
app.world_mut().resource_mut::<UiCostWanted>().0 = true;
if let Some(dir) = std::path::Path::new(&path).parent() {
let _ = std::fs::create_dir_all(dir);
}
+69 -204
View File
@@ -1,6 +1,7 @@
//! `benilla` — a from-scratch World of Warcraft 1.12.1 client on Bevy, talking to a local vmangos server.
//!
//! Opens the vanilla patch chain from `$WOW_DATA` (default `WoW/Data`) and streams the world around the
//! Opens the vanilla patch chain from wherever the install is (`$WOW_DATA`, the project folder on a
//! dev build, else beside the binary — decision 1175) and streams the world around the
//! player through Bevy's `AssetServer` (the `benilla-assets` `mpq://` pipeline): ADT terrain tiles within
//! `$WOW_TILE_RADIUS` — their splat-blended ground (tiling `$WOW_TEX_TILES`), doodads/WMOs, water, and
//! ground clutter — plus the avian colliders the character controller walks on. Lit by a time-of-day WoW
@@ -22,13 +23,23 @@
//! (`Ctrl`+`Shift`) + `F` toggles free-fly, then WASD flies the camera with Space up / C down and
//! `Ctrl` boosts — a boost that exists only inside free-fly, itself behind the chord.
// **A player build's dead code is the seam working, not a defect.** With `--no-default-features`
// every symbol whose only caller is an instrument goes unused — the camera-park seam, the aim
// seam, the pool-slot identity, a dozen accessors the panel and the probes read. Warning about
// each one would bury a REAL warning in the one build nobody looks at daily, and the alternative
// (a `cfg` attribute per item) would spread seam knowledge back across the gameplay modules 1174
// spent its whole diff clearing it out of. Dev builds still warn normally.
#![cfg_attr(not(feature = "dev"), allow(dead_code))]
mod area;
mod area_trigger;
#[cfg(feature = "dev")]
mod asset_churn;
mod aura_visual;
mod bindings;
mod blob_shadow;
mod bowstring;
#[cfg(feature = "dev")]
mod capture;
mod char_create;
mod char_select;
@@ -39,7 +50,11 @@ mod creature_anim;
mod cursor;
mod cvars;
mod death;
#[cfg(feature = "dev")]
mod debug_panel;
/// **The dev/player seam** (decisions 0026/1173, built in 1174) — the group, and the boundary
/// rule, in one file. Always compiled; what it *holds* is not.
mod dev;
mod entities;
mod fishing_line;
mod footprints;
@@ -47,6 +62,7 @@ mod glue;
mod glue_strings;
mod go_anim;
mod go_templates;
#[cfg(feature = "dev")]
mod hover_log;
mod items;
mod loading_screen;
@@ -58,14 +74,19 @@ mod names;
mod net;
mod npc_text;
mod pending_item_ops;
#[cfg(feature = "dev")]
mod perf;
mod pipe_warm;
mod player;
mod portrait;
#[cfg(feature = "dev")]
mod preflight;
#[cfg(feature = "dev")]
mod probe_shield;
mod quest_markers;
mod raid_marks;
mod run_mode;
mod shaders;
mod smart_rect;
mod sound;
mod target;
@@ -126,13 +147,11 @@ use blob_shadow::BlobShadowPlugin;
use bowstring::BowstringPlugin;
use creature_anim::CreatureAnimPlugin;
use cursor::CursorPlugin;
use debug_panel::DebugPanelPlugin;
use entities::EntitiesPlugin;
use fishing_line::FishingLinePlugin;
use footprints::FootprintsPlugin;
use loading_screen::LoadingScreenPlugin;
use net::NetPlugin;
use perf::PerfPlugin;
use player::PlayerPlugin;
use portrait::PortraitPlugin;
use quest_markers::QuestMarkersPlugin;
@@ -190,11 +209,6 @@ pub use benilla_world::build_id::BuildId;
pub use benilla_world::worldview::run as run_worldview;
pub use bevy::app::AppExit;
/// The asset-source name for the game's own files (`crates/benilla-app/assets`) — the UI shaders
/// today, anything else the *game* ships tomorrow. The engine's files stay on Bevy's default
/// source, which `benilla-world` owns; a path with no scheme is the engine's by construction.
const GAME_SOURCE: &str = "game";
/// Build and run the client app. `build` is the launcher shim's compile-time git stamp
/// ([`build_id`]) — passed in as plain data so the sha lives in the shim's fingerprint, not
/// this crate's, and a commit stops recompiling the app (decision 0993).
@@ -204,21 +218,28 @@ pub fn run(build: BuildId) -> AppExit {
// `WOW_HOVER_LOG_REPORT=<csv>` re-reads a recorded run and prints its report, then exits —
// no window, no game. New analysis lands on runs already captured (see `hover_log`).
if let Ok(path) = std::env::var("WOW_HOVER_LOG_REPORT") {
hover_log::report_recorded_file(&path);
dev::report_recorded_hover_log(&path);
return AppExit::Success;
}
if std::env::var("WOW_CAPTURE").as_deref() == Ok("list") {
capture::print_scenario_names();
dev::print_scenario_names();
return AppExit::Success;
}
let mut app = App::new();
// The stamp is plain data from here on — the panel footer and preflight banner read it back.
app.insert_resource(build);
// …and one startup line says which build produced this log. Registered HERE, beside the stamp
// it prints, rather than in `preflight` where it sat until decision 1179: **which build is
// this** is the first thing a bug report from someone else's machine has to establish, and a
// player build is the one whose logs always come from someone else's machine. Gating it out
// with the instruments got the argument exactly backwards (`preflight`'s own module doc makes
// the case; 1174 moved the file without re-reading it).
app.add_systems(Startup, benilla_world::build_id::banner);
// Visual A/B harness (decision 0008): with `$WOW_CAPTURE` set, the app runs a deterministic,
// server-less capture (net off so no NPCs stream in nondeterministically) and exits. See `capture`.
let capturing = capture::scenario_active();
let capturing = run_mode::scenario_active();
// Any instrumented run — captures AND the live-probe fleet — opens in the background so it
// never fights the director's screen (decision 0703; `WOW_BG` overrides). See `bgwin`.
let background = benilla_world::bgwin::background_run();
@@ -248,28 +269,27 @@ pub fn run(build: BuildId) -> AppExit {
}
// The `mpq://` asset source must be registered BEFORE `AssetPlugin` (inside `DefaultPlugins`)
// builds. Reads the same `$WOW_DATA` dir as the `WorldAssets` foundation; on failure the source is
// simply absent and the AdtTile-pipeline loads fail gracefully (the terrain just doesn't appear).
let data_dir =
std::path::PathBuf::from(std::env::var("WOW_DATA").unwrap_or_else(|_| "WoW/Data".into()));
if let Err(e) = benilla_assets::register_mpq_source(&mut app, &data_dir) {
eprintln!("benilla-assets: mpq:// source unavailable ({e:#})");
// builds. Finds the install the one way anything does (`benilla_formats::wow_data`, decision
// 1175) — the same answer the `WorldAssets` foundation gets. On failure the source is simply
// absent and the AdtTile-pipeline loads fail gracefully (the terrain just doesn't appear).
match benilla_formats::wow_data() {
Some(data_dir) => {
if let Err(e) = benilla_assets::register_mpq_source(&mut app, &data_dir) {
eprintln!("benilla-assets: mpq:// source unavailable ({e:#})");
}
}
None => eprintln!(
"benilla: no WoW install found — looked in {:?}",
benilla_formats::candidates()
),
}
// The `game://` asset source — the GAME's own files, beside THIS crate (decision 1171).
// `boot::tuned_default_plugins` points Bevy's default source at `benilla-world/assets`,
// because both binaries need shaders and only one of them has a UI; the game's five UI
// shaders live here instead and load as `game://shaders/…`. Registered before `AssetPlugin`
// builds, like `mpq://` above. The absolute path is baked at compile time for the same reason
// the engine's is: the binary is built by a shim package, so Bevy's runtime
// `CARGO_MANIFEST_DIR` fallback would resolve `assets/` in the shim's directory (0993).
app.register_asset_source(
GAME_SOURCE,
bevy::asset::io::AssetSourceBuilder::platform_default(
concat!(env!("CARGO_MANIFEST_DIR"), "/assets"),
None,
),
);
// NOTE: there is deliberately no `game://` asset source here (decision 1175). 1171 gave the
// game's five UI shaders their own source pointed at this crate's `assets/` — with the path
// baked from `CARGO_MANIFEST_DIR`, so it named the build machine's source tree and resolved
// to nothing anywhere else. The line 1171 drew survives: those five are still this crate's,
// now compiled in by `crate::shaders` and addressed `embedded://benilla_app/shaders/…`,
// because `embedded_asset!` is per-crate by construction. Only the directory is gone.
app.add_plugins(benilla_world::boot::tuned_default_plugins(Window {
title: "benilla".into(),
@@ -368,6 +388,9 @@ 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
@@ -381,20 +404,18 @@ pub fn run(build: BuildId) -> AppExit {
// The instruments, which the engine group deliberately does not carry (1160: instruments at
// the top of the stack). The panel first — `PerfPlugin` needs the egui plugin/context it sets
// up. Toggles: the dev chord + D, and P.
.add_plugins(DebugPanelPlugin)
.add_plugins(PerfPlugin)
// `WOW_FX_CENSUS=1`: where this frame's particle draws are addressed, and whether the view
// they name is switched on (decision 0775). An instrument, and one that reads the portrait
// booths — so it belongs on this side of the line, not in the group.
.add_plugins(capture::fx_draw_census_plugin)
// **The instruments** (decisions 1173/1174): the debug panel, the perf HUD, the object
// inspector, the hover-cost recorder, the asset-churn meter, the session preflight and the
// probe shield — one group, in the slot the panel has always held (it sets up the egui
// context the perf pill needs). `--no-default-features` compiles every one of them out; see
// `dev.rs` for what is in the group and the one rule that governs the boundary.
.add_plugins(dev::DevToolsPlugin)
.add_plugins(BowstringPlugin)
.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)
.add_plugins(hover_log::HoverLogPlugin)
.add_plugins(asset_churn::AssetChurnPlugin)
// 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).
@@ -424,23 +445,16 @@ pub fn run(build: BuildId) -> AppExit {
// 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. Captures boot straight InWorld (no net, no picker).
// 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_in_world: capturing,
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 character-creation screen + its live preview booth (decision 0423).
.add_plugins(char_create::CharCreatePlugin)
// The session preflight (decision 0649): one banner per world entry naming the body we logged
// into, and loud warnings for the states — dead/ghost, GM mode, server-blocked movement — that
// silently invalidate a session's readings. Never env-gated; a warning nobody switches on isn't one.
.add_plugins(preflight::PreflightPlugin)
// The probe shield (decision 0677): a body on a probe account is put into vmangos's `.cheat
// god` on every world entry — damage clamps at 1 hp instead of killing — and GM mode is turned
// OFF, because the shield replaces the only reason it was ever on. Inert on any other account.
.add_plugins(probe_shield::ProbeShieldPlugin)
// 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.
@@ -512,7 +526,7 @@ pub fn run(build: BuildId) -> AppExit {
// 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/macros/`
// 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.
@@ -608,160 +622,11 @@ pub fn run(build: BuildId) -> AppExit {
// Register benilla-assets' loaders AFTER `AssetPlugin` (they go into the live `AssetServer`).
benilla_assets::register_asset_loaders(&mut app);
// The capture harness drives one deterministic screenshot then exits — added last so it observes
// the fully-built app. Inert unless `$WOW_CAPTURE` is set.
if capturing {
app.add_plugins(capture::CapturePlugin);
}
// The LIVE probe shot (orthogonal to the harness): `WOW_LIVE_SHOT=<png>` on a NORMAL connected
// run writes one screenshot `WOW_LIVE_SHOT_AT` seconds (default 12) after startup and keeps
// running — the agent-side instrument for seeing a live server scene (NPCs, GameObjects, event
// spawns) without a scenario. Pair with `WOW_USER`/`WOW_CHAR` + an outer `timeout`.
if std::env::var("WOW_LIVE_SHOT").is_ok() {
app.add_plugins(capture::LiveShotPlugin);
}
// The probe RIG (decision 0651): `WOW_RIG="tauren druid 60 gear:heal-preraid-bis"` finds-or-
// creates that body on this slot's probe account, logs in as it, and applies level/spells/gear/
// spec/place — the one verb that replaces the hand-assembled GM recipe every session used to
// re-derive (see `capture::ProbeRigPlugin`).
if std::env::var("WOW_RIG").is_ok() {
app.add_plugins(capture::ProbeRigPlugin);
}
// Any scripted probe keeps its window un-occludable: a fully covered macOS window drops to
// ~1 fps drawables, and every probe schedule is wall-clock — a throttled run doesn't measure
// slowly, it runs the wrong script (see `capture::ProbeFocusPlugin`, decision 0906).
// (`WOW_LIVE_FPS` is in the list because an occluded SETTLE phase streams the world at ~1 fps
// and under-warms the scene before sampling even starts — the assertion has to be live from
// the first tick, not at the uncap.)
if [
"WOW_PROBE",
"WOW_PROBE_CHAT",
"WOW_PROBE_KEY",
"WOW_PROBE_LUA",
"WOW_RIG",
"WOW_LIVE_FPS",
]
.iter()
.any(|k| std::env::var(k).is_ok())
{
app.add_plugins(capture::ProbeFocusPlugin);
}
// The probe-chat one-shot: `WOW_PROBE_CHAT=".go xyz …"` sends GM/chat lines once in-world —
// the "park the probe character anywhere" instrument (see `capture::ProbeChatPlugin`).
if std::env::var("WOW_PROBE_CHAT").is_ok() {
app.add_plugins(capture::ProbeChatPlugin);
}
// The probe-lua one-shot: `WOW_PROBE_LUA="CastSpell(…)"` runs a chunk in the live UI VM once
// in-world — the "press the button headlessly" instrument (see `capture::ProbeLuaPlugin`).
if std::env::var("WOW_PROBE_LUA").is_ok() {
app.add_plugins(capture::ProbeLuaPlugin);
}
// The probe-key taps: `WOW_PROBE_KEY="Space@14"` presses keys once in-world — the "press
// space headlessly" instrument for input-gated behavior (see `capture::ProbeKeyPlugin`).
if std::env::var("WOW_PROBE_KEY").is_ok() {
app.add_plugins(capture::ProbeKeyPlugin);
}
// The probe self-termination: `WOW_PROBE_EXIT_AT=<secs>` bounds any scripted live probe's
// lifetime — its own knob, not a rider on the Lua probe (see `capture::ProbeExitPlugin`).
if std::env::var("WOW_PROBE_EXIT_AT").is_ok() {
app.add_plugins(capture::ProbeExitPlugin);
}
// The ray pick: `WOW_PICK="<x>,<y>"` names every surface along the ray through a screenshot
// pixel, nearest first — "what is at the spot `benilla-visual hotspot` flagged, and what is
// right behind it" (see `capture::PickProbePlugin`).
if std::env::var("WOW_PICK").is_ok() {
app.add_plugins(capture::PickProbePlugin);
}
// The render-phase census: `WOW_PHASE=<uniqueId>` reports, per frame, which phase each of one
// placement's batches landed in and where in the draw order — the one thing every scene-side
// instrument is blind to, namely whether a surface was submitted at all (see
// `capture::PhaseProbePlugin`).
if std::env::var("WOW_PHASE").is_ok() {
app.add_plugins(capture::PhaseProbePlugin);
}
// The depth readback: `WOW_DEPTH="<x>,<y>"` reports what depth actually won each named pixel,
// per frame, as a distance in yards — the link past submission that decides the pixel. Pair it
// with `WOW_PICK` at the same pixels to turn "what won" into "whose it was" (see
// `capture::DepthProbePlugin`).
// `WOW_DEPTH_QUADS=<bone>…` is the same readback taken at a particle quad's OWN pixels — the
// moving-subject form, which no hand-written pixel list can hold (see `capture::depth_probe`).
if std::env::var("WOW_DEPTH").is_ok() || std::env::var("WOW_DEPTH_QUADS").is_ok() {
app.add_plugins(capture::DepthProbePlugin);
}
// The bevy_ui node census — "who owns this rectangle" for UI outside the FrameXML quad pass
// (see `capture::NodeProbePlugin`).
if std::env::var("WOW_NODE_PROBE").is_ok() {
app.add_plugins(capture::NodeProbePlugin);
}
// The mid-run window resize: `WOW_PROBE_RESIZE="<secs>:<W>x<H>"` — the headless fullscreen-
// toggle stand-in for resize-reactive layout (see `capture::ProbeResizePlugin`).
if std::env::var("WOW_PROBE_RESIZE").is_ok() {
app.add_plugins(capture::ProbeResizePlugin);
}
// The particle census: `WOW_PARTICLE_CENSUS=<secs>` prints per-emitter live counts once —
// the trace-comparable coverage number (see `capture::ParticleCensusPlugin`).
if std::env::var("WOW_PARTICLE_CENSUS").is_ok() {
app.add_plugins(capture::ParticleCensusPlugin);
}
// The entity census: `WOW_ENTITY_CENSUS=<secs>` prints per-archetype entity counts once —
// what the resident entity count is made of (see `capture::EntityCensusPlugin`).
if std::env::var("WOW_ENTITY_CENSUS").is_ok() {
app.add_plugins(capture::EntityCensusPlugin);
}
// The melee live probe: `WOW_PROBE=melee` auto-fights the nearest enemy so the dbg-trace
// sink can record the combat-text timeline (see `capture::ProbeMeleePlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("melee") {
app.add_plugins(capture::ProbeMeleePlugin);
}
// The partner live probe: `WOW_PROBE=partner` auto-accepts group invites — the party arc's
// second-client instrument (decision 0434; see `capture::ProbePartnerPlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("partner") {
app.add_plugins(capture::ProbePartnerPlugin);
}
// The sea-crossing live probe: `WOW_PROBE=crossing` boards a cross-continent boat and reports
// the map seam surviving — decision 0455's instrument (see `capture::ProbeCrossingPlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("crossing") {
app.add_plugins(capture::ProbeCrossingPlugin);
}
// The taxi-flight live probe: `WOW_PROBE=taxi` opens the flight-master menu on the real wire
// and rides Stormwind → Sentinel Hill to a measured verdict — decision 0484's end-to-end
// instrument (see `capture::ProbeTaxiPlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("taxi") {
app.add_plugins(capture::ProbeTaxiPlugin);
}
// The mail-arc live probe: `WOW_PROBE_MAIL=1` GM-mails the probe's own character, opens the
// Goldshire mailbox on the real wire, and drives the inbox/take/send/delete surface through
// the live Lua VM — decisions 0544/0548's end-to-end instrument (see `capture::ProbeMailPlugin`).
if std::env::var("WOW_PROBE_MAIL").is_ok() {
app.add_plugins(capture::ProbeMailPlugin);
}
// The bank-arc live probe: `WOW_PROBE_BANK=1` GM-hops to a pure banker, drives the whole
// six-opcode bank wire (activate/deposit/withdraw/buy-slot/refusal) — decision 0604's
// end-to-end instrument (see `capture::ProbeBankPlugin`).
if std::env::var("WOW_PROBE_BANK").is_ok() {
app.add_plugins(capture::ProbeBankPlugin);
}
// The cast-cancel live probe: `WOW_PROBE=castcancel` hearths and presses W mid-cast — the
// local self-cancel's end-to-end timing instrument (see `capture::ProbeCastCancelPlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("castcancel") {
app.add_plugins(capture::ProbeCastCancelPlugin);
}
// The char-create live probe: `WOW_PROBE_CHARCREATE="<name>[,race,class,gender,…]"` creates (and
// cleans up) a character at select to verify the char-create/delete wire (decision 0423 phase 1;
// see `capture::ProbeCharCreatePlugin`).
if std::env::var("WOW_PROBE_CHARCREATE").is_ok() {
app.add_plugins(capture::ProbeCharCreatePlugin);
}
// The live FPS probe: `WOW_LIVE_FPS=<frames>` samples frame times on a NORMAL connected run
// and exits — the harness probe's numbers with the live world in (see `capture::LiveFpsPlugin`).
if std::env::var("WOW_LIVE_FPS").is_ok() {
app.add_plugins(capture::LiveFpsPlugin);
}
// The FPS journal: `WOW_FPS_JOURNAL=<csv>` appends per-second position + frame-time rows on a
// director-driven run — "where does it dip" as coordinates (see `perf::FpsJournalPlugin`).
if std::env::var("WOW_FPS_JOURNAL").is_ok() {
app.add_plugins(perf::FpsJournalPlugin);
}
// **The probe fleet** — the capture harness and every scripted live probe, each armed by its
// own environment variable and inert without it. Added last so they observe the fully-built
// app; compiled out entirely by `--no-default-features` (decisions 1173/1174), which is why
// this is one line and the twenty env checks behind it live in `dev.rs`.
app.add_plugins(dev::DevProbesPlugin);
// Return the app's own exit status instead of dropping it: a failed capture writes
// `AppExit::error()` (see `capture::drive_capture`), and discarding it made the process exit 0
+212 -52
View File
@@ -1,36 +1,56 @@
//! Where benilla keeps **local state** — THE LAW (decision 0954). Every file benilla persists on
//! a player's machine — config today; realmlist, caches, per-character state as they arrive —
//! lives in **one visible folder, `benilla/`, beside the WoW install's own `Data/`**. Never
//! scattered into the install, never hidden in a platform config dir: the 1.12 client is itself
//! fully portable (`WTF/`, `realmlist.wtf`, `Cache/` all in-folder — verified on the live
//! Where benilla keeps **local state** — THE LAW (decision 0954, placement amended by 1175). Every
//! file benilla persists on a player's machine — config today; realmlist, caches, per-character
//! state as they arrive — lives in **one visible folder, `benilla-config/`, beside the benilla binary**.
//! Never scattered into the install, never hidden in a platform config dir: the 1.12 client is
//! itself fully portable (`WTF/`, `realmlist.wtf`, `Cache/` all in-folder — verified on the live
//! install), its community expects the drop-in-a-folder shape, and one folder answers "what here
//! is benilla's?" at a glance (the director's call, 2026-08-04; prior art: Dolphin's
//! `portable.txt`, DREAMM's config-next-to-exe — we skip the marker file because benilla always
//! knows the install root from `$WOW_DATA`).
//! `portable.txt`, DREAMM's config-next-to-exe).
//!
//! **Every feature with local state resolves its paths through this module** — nothing else in
//! the tree may compute a persistence path (grep `local_state::` to see every resident). New
//! files get a name here and a line in decision 0954's layout table.
//! **0954 put this folder inside the WoW install and 1175 moved it out**, on the director's word:
//! *the benilla folder should be outside the wow folder … in root of the project or same folder as
//! the benilla binary.* 0954's reasoning was sound for what it knew — `$WOW_DATA` was the only
//! root the client reliably had — but it made benilla's own state a *guest* of the install, so
//! pointing at a second install silently swapped your keybinds and a data path that failed to
//! resolve took your config with it. benilla reads a WoW install; it does not live in one. One
//! behaviour improves as a result: the state folder no longer depends on finding the install at
//! all. Everything else about 0954 stands, including this module being the only place in the tree
//! that may compute a persistence path (grep `local_state::` for every resident) — new files get a
//! name here and a line in 0954's layout table.
//!
//! Resolution, in order:
//! 1. **`$BENILLA_HOME`** — explicit override (tests point it at a tempdir; a second install or
//! a shared-config setup points it wherever it likes).
//! 2. **`<install root>/benilla/`** — the parent of `$WOW_DATA` (default `WoW/Data` → `WoW/
//! benilla/`). Gated on the Data dir actually existing, so a dev run without an install never
//! plants a phantom folder in some working directory.
//! Resolution, in order — deliberately the **same three-step shape** as
//! [`benilla_formats::wow_data`], because it is one law over two folders:
//! 1. **`$BENILLA_HOME`** — explicit override (tests point it at a tempdir; a shared-config setup
//! points it wherever it likes).
//! 2. **`<project folder>/benilla-config/`** — `#[cfg(feature = "dev")]` only, so dev runs across the
//! worktree pool keep one predictable place. Gated for the same reason the install resolver's
//! project-folder probe is: a shipped binary must not carry the build machine's source tree.
//! (`.gitignore` carries `/benilla-config` for it.)
//! 3. **`<exe dir>/benilla-config/`** — the release answer: your settings sit next to the program that
//! wrote them.
//!
//! **Capture/probe runs are hermetic**: with `$WOW_CAPTURE` set every path resolves to `None` —
//! a deterministic capture must not read one machine's saved settings or write anything back
//! (the harness owns its inputs; decision 0008's reproducibility posture). `None` is also the
//! honest answer when no install exists: features degrade to session-only state, warn once, and
//! keep running.
//! (the harness owns its inputs; decision 0008's reproducibility posture).
use std::io::Write;
use std::path::{Path, PathBuf};
/// The one benilla-state folder, or `None` when persistence is off (hermetic capture run, or no
/// resolvable install). Existence is NOT guaranteed — [`write_atomic`] creates it on first write;
/// readers just try their file and treat absent as defaults.
/// The folder's name, everywhere. **Not** `benilla/`, which is what 0954 called it and what 1175
/// §4 assumed it would keep: beside the binary that name collides with the binary itself, which is
/// also `benilla`. The bare-directory falsifier caught it immediately — `<exe dir>/benilla/`
/// resolved onto the executable and every read failed with `Not a directory (os error 20)`, so a
/// player's settings had nowhere to live. 0954 could name it for the program because it sat inside
/// somebody else's folder and had to announce whose it was; beside our own binary that reason is
/// gone and only the collision is left. The reference client has the same shape and the same
/// answer — `WoW.exe` keeps its state in `WTF/`, named for what it is (and `WTF/` is exactly why we
/// cannot borrow that name: drop benilla into a real install and it is already taken).
/// The director's call, 2026-08-10.
const STATE_DIR: &str = "benilla-config";
/// The one benilla-state folder, or `None` when persistence is off (a hermetic capture run, or a
/// platform with no discoverable executable path). Existence is NOT guaranteed — [`write_atomic`]
/// creates it on first write; readers just try their file and treat absent as defaults.
pub(crate) fn home() -> Option<PathBuf> {
if std::env::var_os("WOW_CAPTURE").is_some() {
return None; // hermetic: captures neither read nor write player state
@@ -38,26 +58,74 @@ pub(crate) fn home() -> Option<PathBuf> {
if let Some(over) = std::env::var_os("BENILLA_HOME") {
return Some(PathBuf::from(over));
}
let data = PathBuf::from(std::env::var("WOW_DATA").unwrap_or_else(|_| "WoW/Data".into()));
if !data.is_dir() {
return None; // no install, nothing to sit beside — session-only state
// 2 · the project folder, dev builds only — see [`dev_project_root`] for why that is the
// PRIMARY checkout and not the worktree this binary was built in. `None` in a player build,
// where there is no source tree to name (`run_mode::dev_source_dir`).
if let Some(root) = dev_project_root() {
return Some(root.join(STATE_DIR));
}
Some(data.parent().unwrap_or(Path::new(".")).join("benilla"))
// 3 · beside the binary. A dev build never reaches here — that is what step 2 means by "one
// predictable place", and it is why the release path is proven with a player build, not a
// dev one.
std::env::current_exe()
.ok()
.and_then(|e| e.parent().map(Path::to_path_buf))
.map(|dir| dir.join(STATE_DIR))
}
/// `benilla/config.toml` — the CVar overrides (the `Config.wtf` analog; see `crate::cvars`).
/// The project folder a dev build keeps its state in: **the primary checkout**, whichever worktree
/// this binary was actually built in.
///
/// This is the one place 1175's §4 needed a correction on contact with how we work. `benilla/` used
/// to hang off `$WOW_DATA`, and every pool slot symlinks `WoW` to the same install — so there has
/// always been exactly ONE settings folder no matter which of the eight slots built the binary.
/// Resolving to `CARGO_MANIFEST_DIR` instead would give eight, and the director's keybinds, macros
/// and camera pose would appear to reset whenever a session happened to claim a different slot.
/// That is a silent, recurring surprise, and it is not what "one predictable place" meant.
///
/// A linked worktree's `.git` is a **file** reading `gitdir: <primary>/.git/worktrees/<slot>`, so
/// the primary is derivable with no git binary and no build script: walk up to the common dir and
/// take its parent. In the primary checkout `.git` is an ordinary directory and the answer is the
/// crate root's grandparent, unchanged. Anything unexpected falls back to that same answer rather
/// than to `None` — a wrong-but-present settings folder beats losing persistence entirely.
fn dev_project_root() -> Option<PathBuf> {
let here = crate::run_mode::dev_source_dir()?.ancestors().nth(2)?;
let dot_git = here.join(".git");
// A linked worktree: `.git` is a file pointing at `<primary>/.git/worktrees/<name>`.
if dot_git.is_file() {
if let Some(gitdir) = std::fs::read_to_string(&dot_git).ok().and_then(|s| {
s.trim()
.strip_prefix("gitdir:")
.map(|p| p.trim().to_owned())
}) {
// …/.git/worktrees/<name> → …/.git → the primary checkout.
if let Some(primary) = Path::new(&gitdir)
.ancestors()
.nth(2)
.and_then(|d| d.parent())
{
if primary.is_dir() {
return Some(primary.to_path_buf());
}
}
}
}
Some(here.to_path_buf())
}
/// `benilla-config/config.toml` — the CVar overrides (the `Config.wtf` analog; see `crate::cvars`).
pub(crate) fn config_path() -> Option<PathBuf> {
home().map(|h| h.join("config.toml"))
}
/// `benilla/macros/account.txt` — the account-wide macro tab (indices 1..=18; decision 0983). One
/// `benilla-config/macros/account.txt` — the account-wide macro tab (indices 1..=18; decision 0983). One
/// file per scope rather than one file with two sections, because the two have different lifetimes:
/// the account tab follows the install, a character tab dies with its character.
pub(crate) fn macros_account_path() -> Option<PathBuf> {
home().map(|h| h.join("macros/account.txt"))
}
/// `benilla/macros/<realm>-<character>.txt` — the per-character macro tab (indices 19..=36). The
/// `benilla-config/macros/<realm>-<character>.txt` — the per-character macro tab (indices 19..=36). The
/// reference nests these (`WTF/Account/<ACC>/<REALM>/<CHAR>/macros-cache.txt`); benilla flattens to
/// one `macros/` folder because [`home`] is already account-scoped by the install it sits beside.
pub(crate) fn macros_character_path(realm: &str, character: &str) -> Option<PathBuf> {
@@ -65,14 +133,14 @@ pub(crate) fn macros_character_path(realm: &str, character: &str) -> Option<Path
home().map(|h| h.join("macros").join(format!("{key}.txt")))
}
/// `benilla/bindings/account.txt` — the account-wide key bindings (decision 0997; the
/// `benilla-config/bindings/account.txt` — the account-wide key bindings (decision 0997; the
/// `bindings-cache.wtf` analog, command-centric diff-vs-defaults so a growing command set keeps
/// its new defaults).
pub(crate) fn bindings_account_path() -> Option<PathBuf> {
home().map(|h| h.join("bindings/account.txt"))
}
/// `benilla/bindings/<realm>-<character>.txt` — the character-specific binding set (decision
/// `benilla-config/bindings/<realm>-<character>.txt` — the character-specific binding set (decision
/// 0997). Its existence IS the "character specific key bindings" state: the window's checkbox
/// writes it, Okay-back-to-general deletes it (the reference's confirmed permanent delete).
pub(crate) fn bindings_character_path(realm: &str, character: &str) -> Option<PathBuf> {
@@ -80,16 +148,16 @@ pub(crate) fn bindings_character_path(realm: &str, character: &str) -> Option<Pa
home().map(|h| h.join("bindings").join(format!("{key}.txt")))
}
/// `benilla/saved-variables.lua` — the Lua saved-variables file (decision 1128; the
/// `benilla-config/saved-variables.lua` — the Lua saved-variables file (decision 1128; the
/// `WTF/Account/<ACC>/SavedVariables.lua` analog). Install-scoped, like the reference's account
/// scope: written whole at logout/exit, executed as a chunk at UI load. One file, because our
/// ported UI is one FrameXML tree rather than a set of addons — when third-party addons land they
/// get `benilla/saved/<Addon>.lua`, the reference's per-addon shape.
/// get `benilla-config/saved/<Addon>.lua`, the reference's per-addon shape.
pub(crate) fn saved_variables_path() -> Option<PathBuf> {
home().map(|h| h.join("saved-variables.lua"))
}
/// `benilla/camera/<realm>-<character>.txt` — the third-person camera pose (decision 1131; the
/// `benilla-config/camera/<realm>-<character>.txt` — the third-person camera pose (decision 1131; the
/// `<Char>/camera-settings.txt` analog, and character-scoped for the same reason it is there: a
/// gnome rogue and a tauren warrior want different zooms). Two lines, the reference's own keys and
/// order, so the file stays readable beside its ancestor.
@@ -98,6 +166,26 @@ pub(crate) fn camera_character_path(realm: &str, character: &str) -> Option<Path
home().map(|h| h.join("camera").join(format!("{key}.txt")))
}
/// `benilla-config/account` — the account name the login screen remembers (decision 0539 §4; the
/// reference's `GetSavedAccountName`/`SetSavedAccountName`, whose own store is `WTF/Config.wtf`'s
/// `accountName`). Install-scoped like every other resident here.
///
/// It resolved to `$HOME/.benilla/account` until decision 1181, through a second path function
/// that `login` had grown before this module's law existed (0539 predates 0954, whose layout table
/// never mentioned it). That split a player's state across two folders and — the part that
/// actually bit — skipped [`home`]'s hermetic guard, so a **capture read the account name off
/// whatever machine it ran on** and rendered it into the login screen it was photographing.
pub(crate) fn saved_account_path() -> Option<PathBuf> {
home().map(|h| h.join("account"))
}
/// `benilla-config/shots.txt` — the framing instrument's appended camera poses (decision 0600). A dev
/// affordance (`/shot`, compiled out by `--no-default-features` since 1179), but it persists on a
/// real machine, so it resolves here like everything else rather than through a private path.
pub(crate) fn shots_path() -> Option<PathBuf> {
home().map(|h| h.join("shots.txt"))
}
/// Make an arbitrary realm/character name safe as one path component: anything outside
/// `[A-Za-z0-9_]` becomes `_`, so a realm called `Hydraxian Waterlords` or one with a slash cannot
/// escape the folder or collide with the path separator.
@@ -165,61 +253,133 @@ mod tests {
use super::test_env::{EnvGuard, ENV_LOCK};
use super::*;
/// The residents' layout, exercised through the override — which is the *only* step of
/// [`home`] whose answer a test can state, since the other two are the machine's project
/// folder and the test runner's own exe directory. Those two get their own tests below; what
/// this one owns is 0954's layout table, unchanged by 1175's move.
#[test]
fn the_home_law_override_then_install_root_and_hermetic_captures() {
fn the_home_law_override_then_the_residents_and_hermetic_captures() {
let _l = ENV_LOCK.lock().unwrap();
let tmp = std::env::temp_dir().join(format!("benilla-ls-{}", std::process::id()));
std::fs::create_dir_all(tmp.join("Data")).unwrap();
std::fs::create_dir_all(&tmp).unwrap();
// 2 · install root: home = parent-of-Data /benilla.
// 1 · the explicit override, and every resident hanging off it.
let _c = EnvGuard::unset("WOW_CAPTURE");
let _h = EnvGuard::unset("BENILLA_HOME");
let _d = EnvGuard::set("WOW_DATA", tmp.join("Data").to_str().unwrap());
assert_eq!(home(), Some(tmp.join("benilla")));
assert_eq!(config_path(), Some(tmp.join("benilla/config.toml")));
let _h = EnvGuard::set("BENILLA_HOME", tmp.join(STATE_DIR).to_str().unwrap());
assert_eq!(home(), Some(tmp.join(STATE_DIR)));
assert_eq!(config_path(), Some(tmp.join("benilla-config/config.toml")));
// The macro residents (decision 0983): one account file, one per character, and every
// realm/character name reduced to a safe single path component.
assert_eq!(
macros_account_path(),
Some(tmp.join("benilla/macros/account.txt"))
Some(tmp.join("benilla-config/macros/account.txt"))
);
// The saved-variables resident (decision 1128) — one install-scoped file.
assert_eq!(
saved_variables_path(),
Some(tmp.join("benilla/saved-variables.lua"))
Some(tmp.join("benilla-config/saved-variables.lua"))
);
assert_eq!(
macros_character_path("Hydraxian Waterlords", "Probeone"),
Some(tmp.join("benilla/macros/Hydraxian_Waterlords-Probeone.txt"))
Some(tmp.join("benilla-config/macros/Hydraxian_Waterlords-Probeone.txt"))
);
assert_eq!(
macros_character_path("../evil", "a/b"),
Some(tmp.join("benilla/macros/___evil-a_b.txt")),
Some(tmp.join("benilla-config/macros/___evil-a_b.txt")),
"no name can escape the folder"
);
// The camera-pose resident (decision 1131) — character-scoped, same key shape.
assert_eq!(
camera_character_path("Hydraxian Waterlords", "Probeone"),
Some(tmp.join("benilla/camera/Hydraxian_Waterlords-Probeone.txt"))
Some(tmp.join("benilla-config/camera/Hydraxian_Waterlords-Probeone.txt"))
);
// 1 · the explicit override wins over the install root.
let _h2 = EnvGuard::set("BENILLA_HOME", tmp.join("elsewhere").to_str().unwrap());
assert_eq!(home(), Some(tmp.join("elsewhere")));
// The login screen's remembered account name (decision 0539 §4) and the framing
// instrument's pose log (0600) — the two residents that computed their own path in
// `login::config_base` (`$HOME/.benilla`) until decision 1181 folded them in here.
assert_eq!(
saved_account_path(),
Some(tmp.join("benilla-config/account"))
);
assert_eq!(shots_path(), Some(tmp.join("benilla-config/shots.txt")));
// 0 · hermetic: a capture run resolves nothing, even with an override set.
// 0 · hermetic: a capture run resolves nothing, even with an override set. The account
// name is the reason this line now matters more than it did: `config_base` had no such
// guard, so a login-screen capture read whatever account the host machine had saved and
// photographed it into the frame (decision 1181).
let _c2 = EnvGuard::set("WOW_CAPTURE", "ui-options");
assert_eq!(home(), None);
assert_eq!(
saved_account_path(),
None,
"a capture reads no saved account"
);
assert_eq!(shots_path(), None);
std::fs::remove_dir_all(&tmp).ok();
}
/// **The move itself** (1175 §4): the state folder no longer hangs off the WoW install, so a
/// missing or wrong `$WOW_DATA` cannot take a player's settings with it. This is the exact
/// case that used to return `None` — the one behaviour 1175 changes, and the reason it
/// changed.
#[test]
fn no_install_means_no_phantom_folder() {
fn the_state_folder_no_longer_depends_on_finding_the_install() {
let _l = ENV_LOCK.lock().unwrap();
let _c = EnvGuard::unset("WOW_CAPTURE");
let _h = EnvGuard::unset("BENILLA_HOME");
let _d = EnvGuard::set("WOW_DATA", "/nonexistent/benilla-test/Data");
assert_eq!(home(), None);
let h = home().expect("a broken install path must not cost the player their config");
assert!(
!h.starts_with("/nonexistent"),
"home() still reads $WOW_DATA: {}",
h.display()
);
assert!(h.ends_with(STATE_DIR), "{}", h.display());
}
/// **Where the folder lands, in both builds** — 1175's falsifier, as far as a test can take it.
///
/// One test rather than a `#[cfg]`-ed pair, because the seam is `run_mode::dev_source_dir()`'s
/// to know and nothing else's (1179): a player build has no source dir, so the state folder
/// must sit beside the binary; a dev build has one, and must resolve to the PRIMARY checkout
/// so the eight pool slots keep sharing a single settings folder — exactly as they did when it
/// hung off the shared install. The dev half is asserted structurally (a `.git` *file* means a
/// linked worktree, and then the answer must be somewhere else), so it says the same thing
/// whether it runs in the primary or in a slot.
#[test]
fn the_state_folder_lands_where_the_build_says() {
let _l = ENV_LOCK.lock().unwrap();
let _c = EnvGuard::unset("WOW_CAPTURE");
let _h = EnvGuard::unset("BENILLA_HOME");
let h = home().expect("home() always resolves outside a capture");
assert!(h.ends_with(STATE_DIR), "{}", h.display());
let Some(here) = crate::run_mode::dev_source_dir().and_then(|d| d.ancestors().nth(2))
else {
// Player build: beside the binary, and nowhere near a source tree.
let exe_dir = std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
assert_eq!(h, exe_dir.join(STATE_DIR));
return;
};
if here.join(".git").is_file() {
assert_ne!(
h,
here.join(STATE_DIR),
"a linked worktree must not get its own settings folder — the pool shares one"
);
assert!(
h.parent().unwrap().join(".git").is_dir(),
"resolved to {}, which is not a primary checkout",
h.display()
);
} else {
assert_eq!(h, here.join(STATE_DIR));
}
}
#[test]
+33 -32
View File
@@ -219,7 +219,7 @@ fn drive_policy(
// so an unattended run from a pool slot must not authenticate as the director's `one`
// or a neighbouring slot's probe. Only the *automated* path is gated — a typed login
// is the director's own and is never second-guessed.
match crate::preflight::account_guard(&user) {
match crate::run_mode::account_guard(&user) {
Ok(()) => {
info!("login: env fast path — auto-submitting as {user}");
intent.creds = Some((user, pass));
@@ -699,47 +699,43 @@ fn drive_dialog(
// ── The saved account name (decision 0539 §4) ────────────────────────────────────────────────────
/// The benilla-owned config base: `$BENILLA_HOME`, else `~/.benilla`. The runtime data dir is the
/// reference install and is never written. (Also `/shot`'s home for `shots.txt` — decision 0600.)
pub(crate) fn config_base() -> Option<std::path::PathBuf> {
if let Some(base) = std::env::var_os("BENILLA_HOME") {
return Some(std::path::PathBuf::from(base));
}
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".benilla"))
}
/// Read the saved account name from `base` (missing file/dir = empty).
fn load_saved_account_from(base: &std::path::Path) -> String {
std::fs::read_to_string(base.join("account"))
/// Read the saved account name from `base` (missing file/dir = empty). Takes the *file* rather
/// than resolving one, so the round-trip is testable from a tempdir.
fn load_saved_account_from(path: &std::path::Path) -> String {
std::fs::read_to_string(path)
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
/// Write (or, for an empty name, remove) the saved account name under `base`.
fn save_account_to(base: &std::path::Path, name: &str) {
let path = base.join("account");
/// Write (or, for an empty name, remove) the saved account name at `path`.
fn save_account_to(path: &std::path::Path, name: &str) {
if name.is_empty() {
let _ = std::fs::remove_file(path);
return;
}
if std::fs::create_dir_all(base).is_ok() {
if let Err(e) = std::fs::write(&path, name) {
warn!("login: saving account name failed: {e}");
if let Some(dir) = path.parent() {
if std::fs::create_dir_all(dir).is_err() {
return;
}
}
if let Err(e) = std::fs::write(path, name) {
warn!("login: saving account name failed: {e}");
}
}
/// The ref's `GetSavedAccountName`.
/// The ref's `GetSavedAccountName`. The path is [`crate::local_state`]'s — this module computed its
/// own until decision 1181, which is how the account name ended up in a different folder from every
/// other setting, and how a capture came to read one off the host machine.
fn load_saved_account() -> String {
config_base()
.map(|b| load_saved_account_from(&b))
crate::local_state::saved_account_path()
.map(|p| load_saved_account_from(&p))
.unwrap_or_default()
}
/// The ref's `SetSavedAccountName` (empty clears).
fn save_account(name: &str) {
if let Some(base) = config_base() {
save_account_to(&base, name);
if let Some(path) = crate::local_state::saved_account_path() {
save_account_to(&path, name);
}
}
@@ -808,18 +804,23 @@ mod tests {
/// Save → load → clear round-trips through the dot-file (the ref's Get/SetSavedAccountName).
#[test]
fn saved_account_round_trips() {
let base = std::env::temp_dir().join(format!(
// A FILE, not a folder: since decision 1181 these two take the resolved path
// (`local_state::saved_account_path`) rather than a base to join `account` onto.
let dir = std::env::temp_dir().join(format!(
"benilla-login-test-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&base);
assert_eq!(load_saved_account_from(&base), "");
save_account_to(&base, "ONE");
assert_eq!(load_saved_account_from(&base), "ONE");
save_account_to(&base, "");
assert_eq!(load_saved_account_from(&base), "");
let _ = std::fs::remove_dir_all(&base);
let path = dir.join("account");
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(load_saved_account_from(&path), "");
// The write creates the folder on its way, exactly as a first run must.
save_account_to(&path, "ONE");
assert_eq!(load_saved_account_from(&path), "ONE");
save_account_to(&path, "");
assert_eq!(load_saved_account_from(&path), "");
assert!(!path.exists(), "clearing the name removes the file");
let _ = std::fs::remove_dir_all(&dir);
}
/// The clock system toggles the FOCUSED box on the shared 0.5 s period and leaves the other
+1 -5
View File
@@ -483,11 +483,7 @@ mod tests {
/// and neither lights the other's node.
#[test]
fn real_find_minerals_lights_a_copper_vein_not_an_herb() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc");
let locks = benilla_formats::load_lock_catalog(&mut chain).expect("Lock.dbc");
+1 -5
View File
@@ -230,11 +230,7 @@ mod tests {
]
);
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
+19 -35
View File
@@ -22,8 +22,8 @@ use bevy::prelude::*;
use bevy::window::{CursorOptions, PrimaryWindow};
use crate::creature_anim::{move_flags, wrap_pi, BodyTwist, MovementState};
use crate::debug_panel::InspectMode;
use crate::net::{ClientCommand, NetCommands, SelfPlayer, TeleportMessage, WorldportMessage};
use crate::ui_script::InspectMode;
use crate::ui_script::PointerOverUi;
use benilla_assets::AssetSet;
use benilla_world::interact::{WorldClick, WorldRightClick, WorldRightPress};
@@ -49,8 +49,6 @@ mod movement_net;
// step through the very same code, the way the reference runs every mover through one controller
// (decision 0059's byte trail).
pub(crate) mod mover;
mod probe_cam;
mod probe_look;
mod server_ride;
mod setup;
mod state;
@@ -126,18 +124,21 @@ pub(crate) struct StandStateRequest {
pub(crate) state: u8,
}
/// The player controller's **ordering handle**. Anything that must write the aim or the camera rig
/// before the controller reads them orders against this — the two scripted probe drivers do
/// (`capture::probe_look` / `capture::probe_cam`, decision 1174). A set rather than the `control`
/// symbol itself: an instrument may name the gameplay system it runs against, but exporting
/// `control` would drag its private parameter types (`MoveSpeed`, `CameraProbe`, `PressGesture`)
/// out with it, which is exactly the internals-publishing 1173 rejected a crate wall to avoid.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct PlayerControlSet;
/// The player/camera subsystem: spawns the camera + move/avatar resources at startup, drives the
/// third-person/free-fly controller each frame. (The cursor is the [`crate::cursor`] subsystem.)
pub(crate) struct PlayerPlugin;
impl Plugin for PlayerPlugin {
fn build(&self, app: &mut App) {
if let Some(look) = probe_look::from_env() {
app.insert_resource(look);
}
if let Some(cam) = probe_cam::from_env() {
app.insert_resource(cam);
}
follow::plugin(app);
camera_saved::plugin(app);
// 1160's wire (a), both directions (see `world_focus`): the game answers the world's
@@ -167,8 +168,9 @@ impl Plugin for PlayerPlugin {
.add_systems(
Update,
control
.in_set(PlayerControlSet)
.in_set(WorldStage::Input)
.run_if(not(resource_exists::<crate::capture::CaptureMode>))
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>))
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
// The posture setter's queue (the `/sit` family — decision 0881; `control` is the sole
@@ -185,28 +187,10 @@ impl Plugin for PlayerPlugin {
.before(control)
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
// The scripted mouse-turn (`WOW_PROBE_LOOK`, decision 0621): rotates the aim before
// `control` reads it, so the frame that sees the new facing is the frame that streams
// it. Registered only when the env var asks for it — inert otherwise.
.add_systems(
Update,
probe_look::drive_probe_look
.in_set(WorldStage::Input)
.before(control)
.run_if(resource_exists::<probe_look::ProbeLook>)
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
// The scripted camera park (`WOW_PROBE_CAM`, decision 0653): holds yaw/pitch/zoom so an
// unattended burst can frame a subject that isn't on the ground. Same slot as the turn
// above — `control` reads the rig right after — and equally inert without the env var.
.add_systems(
Update,
probe_cam::drive_probe_cam
.in_set(WorldStage::Input)
.before(control)
.run_if(resource_exists::<probe_cam::ProbeCam>)
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
// (The two scripted probe drivers that used to sit here — `WOW_PROBE_LOOK`'s
// mouse-turn and `WOW_PROBE_CAM`'s camera park — are the harness's now, and register
// themselves against `control` from there: decision 1174 moved every instrument out of
// this module so a player build carries none of them.)
// A server-authored spline (Charge/knockback/taxi) driving our own player is mirrored into
// `Player` here, *before* `control` reads `pos` to seat the camera and skip input. Same
// gates as `control` (not while capturing; in-world only).
@@ -215,7 +199,7 @@ impl Plugin for PlayerPlugin {
server_ride::drive_self_ride
.in_set(WorldStage::Input)
.before(control)
.run_if(not(resource_exists::<crate::capture::CaptureMode>))
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>))
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
// A confirmed `/logout` releases the avatar: the streamed entity is despawned by the
@@ -259,7 +243,7 @@ impl Plugin for PlayerPlugin {
.after(benilla_world::interior::classify_entity_interior)
.after(benilla_world::model_fade::apply_render_fade)
.after(benilla_world::model_render::ModelVisSet)
.run_if(not(resource_exists::<crate::capture::CaptureMode>)),
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>)),
);
}
}
@@ -529,7 +513,7 @@ fn control(
// A bare `F` is a key the reference lets a player bind — our own store test binds it to JUMP —
// and a dev doesn't get to squat on the game's namespace (0585, the same rule that moved the
// perf HUD off bare `P`). Ungated on `typing` like every chord: it can't be mistaken for text.
if benilla_world::modkeys::dev_chord(&keys, KeyCode::KeyF) {
if crate::run_mode::dev_chord(&keys, KeyCode::KeyF) {
player.detached = !player.detached;
}
+27 -1
View File
@@ -261,6 +261,20 @@ pub(crate) struct CameraControl {
}
impl CameraControl {
/// Park the orbit distance at `d` — **both** the live value and the wheel target.
///
/// Both, or the wheel glide eases `distance` back toward the old target every frame and a
/// parked shot drifts through the whole zoom while the burst is running.
///
/// The scripted camera park (`capture::probe_cam`, decision 0653) is the only caller. It gets a
/// named method rather than `pub(crate)` fields because an instrument reaching into gameplay is
/// the allowed direction but not a licence to open gameplay's internals to the whole crate
/// (decision 1174) — this is the entire surface the probe needs.
pub(crate) fn park_distance(&mut self, d: f32) {
self.distance = d;
self.target_distance = d;
}
/// True while a mouse-look drag is active (right- or left-button). The cursor is hidden then.
pub(crate) fn is_looking(&self) -> bool {
self.look.is_some()
@@ -292,12 +306,24 @@ impl LookButton {
}
#[derive(Component)]
pub(super) struct FlyCam {
// `pub(crate)` on the TYPE only — the scripted camera park has to name it in a query. Its fields
// stay `pub(super)`; [`FlyCam::park`] is the whole surface an instrument gets (decision 1174).
pub(crate) struct FlyCam {
pub(super) yaw: f32,
pub(super) pitch: f32,
pub(super) speed: f32,
}
impl FlyCam {
/// Point the rig at an absolute world yaw/pitch — the scripted camera park's one lever
/// (`capture::probe_cam`, decision 0653). From here on this is the identical path a mouse-drag
/// takes.
pub(crate) fn park(&mut self, yaw: f32, pitch: f32) {
self.yaw = yaw;
self.pitch = pitch;
}
}
/// The per-model camera-pivot height in **model-local yards, pre-scale** — `attach17.z + 0.0972` (M2
/// attachment id 17) for a character, else `0.9 × vertex-box Z-extent`; the reference's camera-target
/// height (`0x50cbc0`, wow-re `follow-camera`). Stamped on every modeled unit at attach
@@ -17,7 +17,7 @@
//! 1138 corrects it. benilla has no `SaveView`, so nothing is owed here yet.)
//!
//! benilla writes the same two keys in the same order to
//! `benilla/camera/<realm>-<character>.txt` ([`crate::local_state::camera_character_path`]) — a file
//! `benilla-config/camera/<realm>-<character>.txt` ([`crate::local_state::camera_character_path`]) — a file
//! that stays readable beside its ancestor.
//!
//! **Character-scoped, deliberately.** It is where the reference puts it, and it is what the setting
+1 -1
View File
@@ -61,7 +61,7 @@ pub(crate) fn land_here(
// Drain both teleport readers every frame (own cursors — `wire_in` reads the same messages
// through its own): any teleport while an ask is out is our landing.
let arrived = teleports.read().count() > 0 || worldports.read().count() > 0;
let asked = asks.read().count() > 0 || benilla_world::modkeys::dev_chord(&keys, KeyCode::KeyG);
let asked = asks.read().count() > 0 || crate::run_mode::dev_chord(&keys, KeyCode::KeyG);
if let Some(deadline) = *pending {
if arrived {
+1 -1
View File
@@ -132,7 +132,7 @@ pub(super) fn setup_player(
/// of entities, MSAA 4×) otherwise renders unseen behind an opaque fullscreen glue scene every
/// frame. The loading-screen case is load-bearing: that covered render is what compiles the
/// world's pipelines, so the first visible in-world frame doesn't hitch on shader builds.
/// Capture runs boot straight `InWorld` (`CharSelectPlugin::start_in_world`) — always active there.
/// Capture runs boot straight `InWorld` (`CharSelectPlugin::start`) — always active there.
///
/// This gate stays deliberately WIDER than [`benilla_world::schedule::world_is_live`], which decides
/// whether the world is *loaded* at all (decision 0777): the camera must also render while the
+10
View File
@@ -660,6 +660,16 @@ pub(super) struct PlayerRide {
}
impl Player {
/// Turn the avatar's **aim** by `radians` — the scripted mouse-turn's one lever
/// (`capture::probe_look`, decision 0621). Writing `face_yaw` is deliberately the whole of it:
/// from here on this is the identical path a real mouse-turn takes, and it is the same value
/// `stream_self_movement` diffs to decide on a `MSG_MOVE_SET_FACING`. A named method rather
/// than a `pub(crate)` field for decision 1174's reason — an instrument may reach into
/// gameplay, but not by opening gameplay's internals to the whole crate.
pub(crate) fn turn_aim(&mut self, radians: f32) {
self.face_yaw += radians;
}
/// The character's *facing* (Bevy yaw, radians) — the aim, kept in sync with the camera by
/// right-drag/movement. This is the unit's orientation as sent to the server, distinct from the
/// rendered body heading (`model_yaw`, which a strafe rotates). The 3D-audio listener panning
@@ -96,6 +96,17 @@ pub(super) fn watch(
dt: f32,
now: f32,
) {
// **Nobody is reading this in a player build** (decision 1179). Unlike its neighbour
// `move_trace`, which gates on `trace::enabled()` in its first line, this ran unconditionally:
// every blocked walk frame — routine play, walking into a wall — paid a body shape-cast, eight
// down-rays and seven full re-runs of `step_up`, up to 5×/s, to fill a `static` whose only
// reader is the debug panel. 1174 filed this file as residue, "weight, not behaviour"; that was
// wrong, and this is the correction. (Narrowing it further in a DEV build — to "the panel is
// open or the trace is on" — is a live question the panel's readout has an opinion about, and
// is deliberately not done here.)
if !crate::run_mode::dev_affordances() {
return;
}
let speed = horiz_vel.length();
let wanted = speed * dt;
// Below a millimetre of intent there is no "blocked" to speak of — standing still, or a frame
+2 -2
View File
@@ -246,10 +246,10 @@ pub(super) fn apply_server_moves(
// cross-map worldport despawns and re-streams this entity while `player.active` stays
// true — per-entity state attached only on this one-shot edge would be lost on transfer.
info!(
"took control of player @ {:?} facing {:.3} ({} + F toggles free-fly)",
"took control of player @ {:?} facing {:.3}{}",
player.pos,
yaw.rem_euclid(std::f32::consts::TAU),
benilla_world::modkeys::DEV_CHORD
crate::run_mode::free_fly_hint()
);
}
}
+10 -134
View File
@@ -36,16 +36,16 @@
//! one line per world entry when everything is fine, and it re-fires on every re-entry (a relog, a
//! `.character race` forced logout, a reconnect) because the state can have changed.
//!
//! The same audience — the reader of the log — is why [`benilla_world::build_id::banner`] is registered
//! here: **which build produced this log** is the first thing a report from someone else's machine
//! has to establish, and one startup line puts it in the output they already paste.
//! The same audience — the reader of the log — is why [`benilla_world::build_id::banner`] exists.
//! It is **not** registered here any more (decision 1179): this module is dev-only since 1174, and
//! "which build produced this log" is the first thing a report from *someone else's machine* has to
//! establish — which is precisely the player build. The banner now registers beside the stamp
//! itself, in `lib::run`, where it is always compiled.
//!
//! The other half is [`account_guard`] — the pre-connect check that a session running inside a
//! worktree pool slot is logging in as **its own** probe account. A vmangos login kicks whoever
//! holds the account, so the default `one` credentials fire the director out of their live session
//! and another slot's `probeN` fires a parallel session's probe out of the world mid-sample
//! (method.md "The local vmangos server"). It gates the **env fast path only** — a human typing at
//! the login screen is never blocked.
//! The other half of decision 0649 — the pre-connect **account guard**, which keeps a session
//! running inside a worktree pool slot from logging in as somebody else's account — lives in
//! [`crate::run_mode`] now, not here: it is consulted by the login policy, and 1174's seam does not
//! let gameplay call an instrument. Its reasoning went with it.
use bevy::prelude::*;
@@ -99,7 +99,7 @@ pub(crate) struct PreflightPlugin;
impl Plugin for PreflightPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<Preflight>()
.add_systems(Startup, (benilla_world::build_id::banner, offline_notice))
.add_systems(Startup, offline_notice)
.add_systems(
Update,
report_session.after(benilla_world::schedule::WorldStage::Net),
@@ -329,88 +329,6 @@ fn findings(
out
}
/// The pre-connect account guard, consulted by the login policy's env fast path
/// ([`crate::login`]). Returns `Err(explanation)` when this build lives in a worktree pool slot and
/// the fast path is about to authenticate as an account that belongs to somebody else — the
/// director's `one` (a login KICKS their live session mid-play) or another slot's `probeN` (the
/// kicked client's 0065 teardown despawns every net entity, so a parallel session's probe reads a
/// unit-less world and prints garbage; it happened, method.md records it).
///
/// Slot identity comes from the compiled-in manifest path, because that is what the pool guarantees
/// is unique per session: every slot has its own checkout and its own `target/`. Outside a pool slot
/// (the primary checkout, which is the director's) the guard is inert — it has no business having an
/// opinion about a login it cannot attribute.
///
/// `WOW_ALLOW_ACCOUNT=1` is the escape hatch for the rare deliberate cross-account run; it turns the
/// refusal into a warning rather than silence, because the kick still happens.
pub(crate) fn account_guard(user: &str) -> Result<(), String> {
guard_for(pool_slot(env!("CARGO_MANIFEST_DIR")), user)
}
/// [`account_guard`]'s decision, with the slot passed in so the ladder is testable from any
/// checkout (the real one reads a compile-time path that differs per worktree).
fn guard_for(slot: Option<u32>, user: &str) -> Result<(), String> {
let Some(slot) = slot else {
return Ok(());
};
let mine = format!("probe{slot}");
let user_lc = user.to_ascii_lowercase();
if user_lc == mine {
return Ok(());
}
let whose = if user_lc == "one" {
"the DIRECTOR's account — logging in on it kicks them out of their live session mid-play"
} else if user_lc.starts_with("probe") && user_lc[5..].chars().all(|c| c.is_ascii_digit()) {
"ANOTHER worktree slot's probe account — logging in on it kicks that session's probe out \
of the world, and its next sample reads a unit-less world"
} else {
return Ok(()); // a bystander account (`two`, a fresh test account): not ours to police
};
Err(format!(
"the env fast path is about to log in as `{user}` from pool-{slot}, and that is {whose}. \
This slot's identity is WOW_USER=probe{slot} WOW_PASS=pprobe{slot} \
WOW_CHAR=Probe{spelled} (method.md \"The local vmangos server\").",
spelled = spell_digit(slot)
))
}
/// This slot's index as the word the probe identity spells it with (`pool-4` → `"four"`), or `None`
/// outside a pool slot. The one place anything else should ask "which session am I?" — the rig keys
/// its derived character names off it ([`crate::capture::rig_char_name_from_env`]).
pub(crate) fn slot_word() -> Option<&'static str> {
pool_slot(env!("CARGO_MANIFEST_DIR")).map(spell_digit)
}
/// The pool slot index this build was compiled in, from a `…/benilla-wt/pool-<N>/…` manifest path.
/// `None` for the primary checkout and any non-pool worktree.
fn pool_slot(manifest_dir: &str) -> Option<u32> {
let mut parts = manifest_dir.split('/');
while let Some(part) = parts.next() {
if part == "benilla-wt" {
return parts.next()?.strip_prefix("pool-")?.parse().ok();
}
}
None
}
/// The probe character's name suffix for slot `n` — vmangos names carry no digits, so the pool
/// index is spelled (`pool-4` → `Probefour`).
fn spell_digit(n: u32) -> &'static str {
match n {
0 => "zero",
1 => "one",
2 => "two",
3 => "three",
4 => "four",
5 => "five",
6 => "six",
7 => "seven",
8 => "eight",
9 => "nine",
_ => "<n>",
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -542,46 +460,4 @@ mod tests {
assert_eq!(out.len(), 1);
assert!(out[0].contains("STUNNED") && out[0].contains("TAXI FLIGHT"));
}
#[test]
fn the_guard_only_polices_accounts_that_belong_to_someone() {
// Our own slot's probe: the whole point of the identity.
assert!(guard_for(Some(4), "probe4").is_ok());
assert!(guard_for(Some(4), "PROBE4").is_ok()); // vmangos accounts are case-insensitive
// The director's account, and a neighbouring slot's probe: both kick a live session.
let director = guard_for(Some(4), "one").unwrap_err();
assert!(director.contains("DIRECTOR") && director.contains("WOW_USER=probe4"));
// The override hint belongs to the caller that can act on it, not to the reason.
assert!(!director.contains("WOW_ALLOW_ACCOUNT"));
assert!(guard_for(Some(4), "probe7")
.unwrap_err()
.contains("ANOTHER worktree slot"));
// A bystander account is nobody's to police, and outside a pool slot we have no standing.
assert!(guard_for(Some(4), "two").is_ok());
assert!(guard_for(None, "one").is_ok());
}
#[test]
fn the_probe_character_name_spells_the_slot() {
// vmangos player names carry no digits, so `Probe4` cannot exist — the pool index is spelled.
assert!(guard_for(Some(0), "one")
.unwrap_err()
.contains("WOW_CHAR=Probezero"));
assert!(guard_for(Some(9), "one")
.unwrap_err()
.contains("WOW_CHAR=Probenine"));
}
#[test]
fn the_slot_is_read_off_the_manifest_path() {
assert_eq!(
pool_slot("/Users/sam/dev/benilla-wt/pool-7/crates/benilla"),
Some(7)
);
assert_eq!(pool_slot("/Users/sam/dev/benilla-wow/crates/benilla"), None);
assert_eq!(
pool_slot("/Users/sam/dev/benilla-wow/.claude/worktrees/x/crates/benilla"),
None
);
}
}
+389
View File
@@ -0,0 +1,389 @@
//! **What kind of run is this, and whose** — the always-present layer, on the player's side of the
//! `dev` seam (decisions 0026 / 1173, built in 1174).
//!
//! Everything here answers a question about *the run itself*, and every answer has a
//! **player-faithful default**: nobody is driving the camera, the client starts at the login
//! screen, no rig owns the character pick, this checkout belongs to nobody in particular. A dev
//! build's instruments then move those answers off their defaults — which is the one direction the
//! seam allows. Gameplay reads this module; gameplay never reads `capture`, `debug_panel` or
//! `perf`.
//!
//! It exists because the alternative kept losing. 0026 asked for "a player-safe home or default"
//! for exactly this class of fact in June 2026 and named two of them; by August there were 24
//! references from non-dev code into the instruments (1173), because the facts had no home and the
//! nearest thing that already knew the answer was the instrument itself.
//!
//! The engine drew the same line for itself one layer down: [`benilla_world::dev_state`]'s
//! `deterministic_run` reads `$WOW_CAPTURE` on its own account, because "run deterministically" is
//! a property of the world and the world must be able to ask it with no harness above it at all.
//! **One environment variable, several readers, no shared symbol** — that is the pattern, and
//! [`scenario_active`] below is its app-side twin. Keep the two in step.
use bevy::prelude::*;
/// **Something other than the player is authoring the camera and the avatar this run.**
///
/// Inserted by the capture harness, which is the only thing that ever drives them; a player build
/// compiles no inserter, so the resource simply never exists and every `run_if` below reads as
/// "the player is in charge" for free. The *type* lives here rather than in `capture` for one
/// reason: `player::control`, `player::server_ride`, the self-model fade and the UI pass all name
/// it in a run condition, and a run condition that names a dev type is a dependency on dev.
#[derive(Resource)]
pub(crate) struct CaptureMode;
/// The rig's derived character name (decision 0651), when `$WOW_RIG` names a body.
///
/// Inserted by `capture::ProbeRigPlugin` at build time; absent otherwise — which is the player
/// answer, and the answer in any run the rig is not driving. The character-select roster reads it
/// to know that the pick is already spoken for, so it must not also honour `$WOW_CHAR`: the rig may
/// have to *create* its body first, and the `WOW_CHAR` fast path is a one-shot that structurally
/// cannot wait for that. Before 1174 the roster asked the harness directly.
#[derive(Resource)]
pub(crate) struct RigCharacter(pub(crate) String);
/// **Is a capture running?** (`$WOW_CAPTURE` set.)
///
/// Read before any plugin builds — it decides whether the net thread starts, what size the window
/// opens at, and whether clutter/anim-LOD randomness is pinned. All of those are properties of the
/// run, not of the harness, and all of them must have an answer in a build with no harness in it.
/// See the module doc for why this reads the variable rather than asking `capture`.
pub(crate) fn scenario_active() -> bool {
std::env::var("WOW_CAPTURE").is_ok()
}
/// **Which screen the client starts on.** A player starts at the login screen, always; a capture
/// boots straight into the world (no net, no picker), and a *glue* capture boots onto the very
/// screen it photographs.
///
/// The one place this module forwards into the dev half, and deliberately the only one: the answer
/// is needed *before the plugins build* (it is `CharSelectPlugin`'s `start`), so it cannot be a
/// resource an instrument inserts the way [`CaptureMode`] and [`RigCharacter`] are. The forward is
/// one call, in one direction, and the player arm names nothing at all.
pub(crate) fn start_state() -> crate::char_select::ClientState {
#[cfg(feature = "dev")]
{
crate::capture::start_state()
}
#[cfg(not(feature = "dev"))]
{
crate::char_select::ClientState::Login
}
}
/// **Does this build offer developer affordances at all?** `false` in a player build.
///
/// The generalisation of 1176's one-door rule, and the thing that record should have written
/// (decision 1179). A dev affordance does not have to live in a dev module: the world map's
/// Alt-click jump is in `ui_world_map`, the `/castvis` family is in `ui_chat`, the dev key plane's
/// cost is in `bindings`. None of them names a dev root, so none of them fails to compile, and all
/// of them shipped to players in 1174 exactly as free-fly did.
///
/// So the question a gameplay module asks is not "is the debug panel compiled in" — it is **"may I
/// offer this at all"**, and there is one place to ask it. Held by
/// [`tests::the_dev_plane_has_exactly_one_door`].
pub(crate) fn dev_affordances() -> bool {
cfg!(feature = "dev")
}
/// **Did a dev-chord affordance just fire?** `Ctrl`+`Shift`+*key*, and `false` in a player build.
///
/// The one door onto the dev plane, and the reason it exists: the plane is
/// [`benilla_world::modkeys::dev_chord`], which lives in the **engine** (1160 moved it there —
/// "nothing about which two modifiers are the dev plane is a debug-panel opinion"), and the engine
/// is always compiled. So a gameplay module asking for a dev chord creates **no symbol into a dev
/// module**, compiles clean with `--no-default-features`, and ships a live dev affordance to a
/// player. That is exactly what happened: 1174 landed a green player build in which `Ctrl+Shift+F`
/// still flew the camera through the world, `+G` still teleported the avatar, and `+M` still muted
/// the game (decision 1176).
///
/// Routing every non-dev reader through here makes the whole plane dark at once rather than five
/// keys at a time, and makes the next dev chord dark for free. The build gate cannot see this class
/// — nothing fails to compile — so it is held by [`tests::the_dev_plane_has_exactly_one_door`]
/// instead, in the suite the gates already run.
pub(crate) fn dev_chord(keys: &ButtonInput<KeyCode>, key: KeyCode) -> bool {
dev_affordances() && benilla_world::modkeys::dev_chord(keys, key)
}
/// The free-fly hint the take-control line carries, or empty when there is no chord to advertise.
/// A player build must not offer a key that does nothing. Spelled from
/// [`benilla_world::modkeys::DEV_CHORD`] like every other surface that names the plane, so it
/// cannot drift from what the chord actually listens for.
pub(crate) fn free_fly_hint() -> String {
#[cfg(feature = "dev")]
{
format!(
" ({} + F toggles free-fly)",
benilla_world::modkeys::DEV_CHORD
)
}
#[cfg(not(feature = "dev"))]
{
String::new()
}
}
/// **This crate's source directory on a dev build, `None` in a player build** — the seam's second
/// door, and the one [`dev_affordances`] cannot be.
///
/// `dev_affordances()` answers "may I offer this", at runtime. Part of the seam is not about
/// offering anything: it is about a **string being absent from the binary**.
/// `env!("CARGO_MANIFEST_DIR")` names the build machine's home directory, and a runtime `if` does
/// not remove it — the literal is still compiled in, still in `strings`, still shipped. 1174's
/// pool-slot guard made exactly that mistake one level down ("inert in a player build" read as
/// "absent from a player build"), and decision 1175 is entirely about a binary that stops
/// depending on the machine that built it. So this `cfg` has to be real, and it lives here, once,
/// behind a value instead of scattered across the modules that need it.
///
/// Two callers, both resolving content or state and neither of them an affordance:
/// [`crate::ui_script`]'s dev probe into `assets/ui` (so editing FrameXML costs no recompile) and
/// [`crate::local_state`]'s project-folder home. Both get `None` in a player build and fall through
/// to the compiled-in copy and to `<exe dir>` respectively, with no path literal in the binary.
///
/// Held by [`tests::the_dev_plane_has_exactly_one_door`] like the rest of the seam.
pub(crate) fn dev_source_dir() -> Option<&'static std::path::Path> {
#[cfg(feature = "dev")]
{
Some(std::path::Path::new(env!("CARGO_MANIFEST_DIR")))
}
#[cfg(not(feature = "dev"))]
{
None
}
}
/// The pre-connect **account guard**, consulted by the login policy's env fast path
/// ([`crate::login`]). Returns `Err(explanation)` when this build lives in a worktree pool slot and
/// the fast path is about to authenticate as an account that belongs to somebody else — the
/// director's `one` (a login KICKS their live session mid-play) or another slot's `probeN` (the
/// kicked client's 0065 teardown despawns every net entity, so a parallel session's probe reads a
/// unit-less world and prints garbage; it happened, method.md records it).
///
/// Slot identity comes from the compiled-in manifest path, because that is what the pool guarantees
/// is unique per session: every slot has its own checkout and its own `target/`. Outside a pool slot
/// (the primary checkout, which is the director's) the guard is inert — it has no business having an
/// opinion about a login it cannot attribute. That is why this lives here and not in `preflight`
/// (decision 0649's home for it, until 1174 made `preflight` dev-only): gameplay's login policy
/// calls it, and gameplay may not call an instrument.
///
/// `WOW_ALLOW_ACCOUNT=1` is the escape hatch for the rare deliberate cross-account run; it turns the
/// refusal into a warning rather than silence, because the kick still happens.
pub(crate) fn account_guard(user: &str) -> Result<(), String> {
guard_for(compiled_slot(), user)
}
/// The pool slot this binary was *compiled* in, or `None`.
///
/// A player build is never in a pool slot, so 1174 read `env!("CARGO_MANIFEST_DIR")`
/// unconditionally on the grounds that the ladder collapses to `Ok(())` anyway. It does — but the
/// path is still a **string in the shipped binary**, naming the build machine's home directory,
/// which is the one thing decision 1175 exists to end (its falsifier is literally
/// `strings <player binary> | grep -c '/Users/…'`). It costs one `cfg` to not ship it, and it is
/// what makes that falsifier readable: after this, the only source-tree paths left in a player
/// binary are debuginfo and panic metadata, never data the program acts on.
#[cfg(feature = "dev")]
fn compiled_slot() -> Option<u32> {
pool_slot(env!("CARGO_MANIFEST_DIR"))
}
#[cfg(not(feature = "dev"))]
fn compiled_slot() -> Option<u32> {
None
}
/// [`account_guard`]'s decision, with the slot passed in so the ladder is testable from any
/// checkout (the real one reads a compile-time path that differs per worktree).
fn guard_for(slot: Option<u32>, user: &str) -> Result<(), String> {
let Some(slot) = slot else {
return Ok(());
};
let mine = format!("probe{slot}");
let user_lc = user.to_ascii_lowercase();
if user_lc == mine {
return Ok(());
}
let whose = if user_lc == "one" {
"the DIRECTOR's account — logging in on it kicks them out of their live session mid-play"
} else if user_lc.starts_with("probe") && user_lc[5..].chars().all(|c| c.is_ascii_digit()) {
"ANOTHER worktree slot's probe account — logging in on it kicks that session's probe out \
of the world, and its next sample reads a unit-less world"
} else {
return Ok(()); // a bystander account (`two`, a fresh test account): not ours to police
};
Err(format!(
"the env fast path is about to log in as `{user}` from pool-{slot}, and that is {whose}. \
This slot's identity is WOW_USER=probe{slot} WOW_PASS=pprobe{slot} \
WOW_CHAR=Probe{spelled} (method.md \"The local vmangos server\").",
spelled = spell_digit(slot)
))
}
/// This slot's index as the word the probe identity spells it with (`pool-4` → `"four"`), or `None`
/// outside a pool slot. The one place anything else should ask "which session am I?" — the rig keys
/// its derived character names off it, and the preflight banner names it.
pub(crate) fn slot_word() -> Option<&'static str> {
compiled_slot().map(spell_digit)
}
/// The pool slot index in a `…/benilla-wt/pool-<N>/…` manifest path; `None` for the primary
/// checkout and any non-pool worktree. Dev-only, because [`compiled_slot`] is its only caller and
/// a player build must not name a manifest path at all (1175).
#[cfg(any(feature = "dev", test))]
fn pool_slot(manifest_dir: &str) -> Option<u32> {
let mut parts = manifest_dir.split('/');
while let Some(part) = parts.next() {
if part == "benilla-wt" {
return parts.next()?.strip_prefix("pool-")?.parse().ok();
}
}
None
}
/// The probe character's name suffix for slot `n` — vmangos names carry no digits, so the pool
/// index is spelled (`pool-4` → `Probefour`).
fn spell_digit(n: u32) -> &'static str {
match n {
0 => "zero",
1 => "one",
2 => "two",
3 => "three",
4 => "four",
5 => "five",
6 => "six",
7 => "seven",
8 => "eight",
9 => "nine",
_ => "<n>",
}
}
#[cfg(test)]
mod tests {
use super::*;
/// **The dev plane has exactly one door** (decision 1176).
///
/// The player-build gate (`cargo build -p benilla --no-default-features`) catches a *symbol*
/// crossing into a dev module. It cannot catch this: `benilla_world::modkeys::dev_chord` is
/// engine, always compiled, so a gameplay module that asks it for `Ctrl+Shift+F` compiles
/// perfectly in a player build and ships free-fly to a player. 1174 landed exactly that, green.
///
/// So the rule is checked instead of remembered, the way 0789 checks the probe clock: outside
/// the dev roots, `dev_chord` is [`super::dev_chord`]'s to call and nobody else's. A dev module
/// may call the engine's directly — it does not exist in a player build to be reached.
///
/// This does NOT police [`benilla_world::modkeys::DEV_CHORD`], the display string: naming the
/// plane in a label is not offering a key.
#[test]
fn the_dev_plane_has_exactly_one_door() {
// Everything that is compiled out by `--no-default-features`, plus this module, which is
// the door itself. Anything else asking the engine for a dev chord is the bug.
const DEV_ROOTS: &[&str] = &[
"capture/",
"debug_panel/",
"asset_churn.rs",
"dev.rs",
"hover_log.rs",
"perf.rs",
"preflight.rs",
"probe_shield.rs",
"lib.rs",
"run_mode.rs",
];
// Assembled at runtime so the checker does not flag its own source — the same trick, and
// the same proof of teeth, as `capture::probes`' clock test.
let needle = format!("modkeys::{}(", "dev_chord");
// The second clause: `#[cfg(feature = "dev")]` itself. Seam knowledge has exactly three
// addresses — this module (the always-present layer), `dev.rs` (the group), and `lib.rs`
// (the module declarations). A `cfg` attribute anywhere else means a gameplay module has
// learned the seam exists, which is the state 1174's whole diff cleared the tree out of,
// and which spreads one file at a time if nothing objects.
let cfg_needle = format!("feature = {}dev{}", '"', '"');
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = Vec::new();
let mut stack = vec![src.clone()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).expect("src is readable") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
stack.push(path);
continue;
}
if path.extension().is_none_or(|e| e != "rs") {
continue;
}
let rel = path
.strip_prefix(&src)
.expect("under src")
.to_string_lossy()
.replace('\\', "/");
if DEV_ROOTS.iter().any(|r| rel.starts_with(r)) {
continue;
}
let text = std::fs::read_to_string(&path).expect("source is readable");
for (n, line) in text.lines().enumerate() {
// Doc comments name the function constantly; only real calls matter.
let t = line.trim();
if t.starts_with("//") {
continue;
}
if t.contains(&needle) || t.contains(&cfg_needle) {
offenders.push(format!("{rel}:{} {t}", n + 1));
}
}
}
}
assert!(
offenders.is_empty(),
"a dev-chord affordance outside the dev roots must go through `run_mode::dev_chord`, \
which is `false` in a player build — the engine's `modkeys::dev_chord` is always \
compiled, so calling it directly ships a live dev key to a player and the build gate \
cannot see it (decision 1176). And a `#[cfg(feature = \"dev\")]` outside `run_mode`, \
`dev.rs` and `lib.rs` means a gameplay module has learned the seam exists — ask \
`run_mode::dev_affordances()` instead (decision 1179). Offenders:\n {}",
offenders.join("\n "),
);
}
#[test]
fn the_guard_only_polices_accounts_that_belong_to_someone() {
// Our own slot's probe: the whole point of the identity.
assert!(guard_for(Some(4), "probe4").is_ok());
assert!(guard_for(Some(4), "PROBE4").is_ok()); // vmangos accounts are case-insensitive
// The director's account, and a neighbouring slot's probe: both kick a live session.
let director = guard_for(Some(4), "one").unwrap_err();
assert!(director.contains("DIRECTOR") && director.contains("WOW_USER=probe4"));
// The override hint belongs to the caller that can act on it, not to the reason.
assert!(!director.contains("WOW_ALLOW_ACCOUNT"));
assert!(guard_for(Some(4), "probe7")
.unwrap_err()
.contains("ANOTHER worktree slot"));
// A bystander account is nobody's to police, and outside a pool slot we have no standing.
assert!(guard_for(Some(4), "two").is_ok());
assert!(guard_for(None, "one").is_ok());
}
#[test]
fn the_probe_character_name_spells_the_slot() {
// vmangos player names carry no digits, so `Probe4` cannot exist — the pool index is spelled.
assert!(guard_for(Some(0), "one")
.unwrap_err()
.contains("WOW_CHAR=Probezero"));
assert!(guard_for(Some(9), "one")
.unwrap_err()
.contains("WOW_CHAR=Probenine"));
}
#[test]
fn the_slot_is_read_off_the_manifest_path() {
assert_eq!(
pool_slot("/Users/sam/dev/benilla-wt/pool-7/crates/benilla"),
Some(7)
);
assert_eq!(pool_slot("/Users/sam/dev/benilla-wow/crates/benilla"), None);
assert_eq!(
pool_slot("/Users/sam/dev/benilla-wow/.claude/worktrees/x/crates/benilla"),
None
);
}
}
+64
View File
@@ -0,0 +1,64 @@
//! **The game's WGSL, compiled into the binary** (decision 1175).
//!
//! The five shaders under `src/shaders/` draw the *game's* own surface — the UI quad lane, the
//! glue screen's additive pass, and the three gamma passes. 1171 moved them out of the engine's
//! asset root and into this crate; 1175 stops serving them from a directory at all.
//!
//! They used to reach the asset server through a `game://` source registered with
//! `concat!(env!("CARGO_MANIFEST_DIR"), "/assets")` — the build machine's source tree, baked into
//! the binary. `embedded_asset!` keeps 1171's line (it is per-crate by construction) and drops the
//! path: the interface cannot go missing because there is nothing to miss.
//!
//! The registration lives here, at the root of `src/`, for the path-arithmetic reason spelled out
//! in `benilla_world::shaders` — the macro does not normalize, so a call from a submodule would
//! bake that submodule's directory into the served path.
use bevy::prelude::*;
/// Compile the game's five WGSL files in and register them under
/// `embedded://benilla_app/shaders/…`. Runs before anything can ask for one — added with the
/// client's other plugins, after `DefaultPlugins` has created the registry it fills.
pub(crate) fn plugin(app: &mut App) {
bevy::asset::embedded_asset!(app, "shaders/ui_quad.wgsl");
bevy::asset::embedded_asset!(app, "shaders/ui_add.wgsl");
bevy::asset::embedded_asset!(app, "shaders/ui_gamma.wgsl");
bevy::asset::embedded_asset!(app, "shaders/ui_node_gamma.wgsl");
bevy::asset::embedded_asset!(app, "shaders/ui_slice_gamma.wgsl");
}
#[cfg(test)]
mod tests {
use bevy::asset::io::AssetSourceId;
use bevy::prelude::*;
/// The game's twin of `benilla_world::shaders`' test — see it for why the path is read back
/// rather than reasoned about, and why the loop is driven off the directory listing.
#[test]
fn every_game_shader_answers_at_its_embedded_path() {
let mut app = App::new();
app.add_plugins(bevy::asset::AssetPlugin::default());
app.add_plugins(super::plugin);
let server = app.world().resource::<AssetServer>();
let source = server.get_source(AssetSourceId::from("embedded")).unwrap();
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/shaders");
let mut found = 0;
for entry in std::fs::read_dir(dir).unwrap() {
let name = entry.unwrap().file_name().to_string_lossy().into_owned();
if !name.ends_with(".wgsl") {
continue;
}
found += 1;
let path = format!("benilla_app/shaders/{name}");
let read = bevy::tasks::block_on(async {
source.reader().read(std::path::Path::new(&path)).await
});
assert!(
read.is_ok(),
"embedded://{path} does not resolve — {name} is on disk but not registered in \
`shaders::plugin`, or was registered from a file that is not directly under src/"
);
}
assert_eq!(found, 5, "the game's shader set changed size");
}
}
+1 -9
View File
@@ -684,15 +684,7 @@ mod tests {
/// without `WoW/Data` stays green.
#[test]
fn real_wav_and_mp3_decode() {
let data = std::path::PathBuf::from(
std::env::var("WOW_DATA").unwrap_or_else(|_| "WoW/Data".into()),
);
// The test runs with CWD = crates/benilla; the install lives at the workspace root.
let data = if data.is_relative() {
std::path::Path::new("../..").join(data)
} else {
data
};
let data = benilla_formats::wow_data_or_skip!();
let Ok(chain) = benilla_formats::open_chain(&data) else {
eprintln!("skipping: no client data at {}", data.display());
return;
+1 -1
View File
@@ -298,7 +298,7 @@ fn update_audio_listener(
/// ([`benilla_world::modkeys::dev_chord`], decision 0585) so it can never collide with a game binding
/// and stays reachable with the chat bar open.
fn toggle_mute(keys: Res<ButtonInput<KeyCode>>, mut config: ResMut<SoundConfig>) {
if benilla_world::modkeys::dev_chord(&keys, KeyCode::KeyM) {
if crate::run_mode::dev_chord(&keys, KeyCode::KeyM) {
config.muted = !config.muted;
info!("sound {}", if config.muted { "muted" } else { "unmuted" });
}
+1 -1
View File
@@ -39,8 +39,8 @@ use bevy::prelude::*;
use benilla_ui::script::UiScript;
use crate::creature_anim::Engaged;
use crate::debug_panel::InspectMode;
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_script::InspectMode;
use benilla_assets::AssetSet;
use benilla_world::interact::{WorldClick, WorldRightClick};
use benilla_world::schedule::WorldStage;
@@ -387,11 +387,7 @@ mod tests {
/// client data.
#[test]
fn the_real_boot_resolves_the_real_strings() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
+3 -15
View File
@@ -283,11 +283,7 @@ mod mount_error_tests {
/// against a typo'd key silently swallowing the red line. Skips without client data.
#[test]
fn every_mount_key_resolves_in_the_real_global_strings() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
@@ -359,11 +355,7 @@ mod ui_error_tests {
/// client data.
#[test]
fn every_lock_refusal_key_resolves_in_the_real_global_strings() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
@@ -511,11 +503,7 @@ mod attack_actor_tests {
/// one — a veto that shows nothing is indistinguishable from a click that did nothing.
#[test]
fn every_attack_error_key_resolves_in_the_real_global_strings() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
+1 -5
View File
@@ -97,11 +97,7 @@ mod tests {
/// These run the walk against the **real** `SkillLineAbility.dbc` — the chain is the thing
/// under test, and a hand-built fake chain would only test the walk against itself.
fn client_data() -> Option<std::path::PathBuf> {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return None;
}
let data = benilla_formats::wow_data_or_skip!(None);
Some(data)
}
+1 -5
View File
@@ -872,11 +872,7 @@ mod tests {
/// Skips without client data.
#[test]
fn the_water_bits_split_the_5875_data_the_way_the_gate_assumes() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc");
+2 -10
View File
@@ -878,11 +878,7 @@ mod tests {
/// frame" report), while Battle Shout is a real buff that stays. Skips without client data.
#[test]
fn the_aura_display_filter_hides_a_real_battle_stance_but_keeps_battle_shout() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc");
@@ -908,11 +904,7 @@ mod tests {
/// aura wins the global. Skips without client data.
#[test]
fn a_real_tracking_aura_is_diverted_from_the_bar_to_the_tracking_state() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc");
+26 -10
View File
@@ -219,7 +219,7 @@ pub(crate) struct SlashCommands {
/// How many aliases each source contributed — the boot line that makes a broken table (an
/// unreadable `GlobalStrings.lua`, a missing token table) visible immediately instead of at
/// the first command someone types.
counts: (usize, usize),
counts: (usize, usize, usize),
}
impl SlashCommands {
@@ -271,21 +271,34 @@ impl SlashCommands {
let emote_aliases = by_alias.len() - slash_aliases;
// 3 · benilla's instruments, last so they can never shadow a shipped command.
for dev in DevCmd::ALL {
for alias in dev.aliases() {
insert(&mut by_alias, alias, Command::Dev(dev));
// benilla's own instruments — `/castvis`, `/partytest`, `/chattest`, `/shot`, `/liquid`,
// `/reaction`. A player build claims none of the aliases, so typing one falls through to
// the reference's "unknown command" exactly as it should (decision 1179). They are gated
// HERE, at the table, rather than at each dispatch arm: one door, like the dev chord's.
let before_dev = by_alias.len();
if crate::run_mode::dev_affordances() {
for dev in DevCmd::ALL {
for alias in dev.aliases() {
insert(&mut by_alias, alias, Command::Dev(dev));
}
}
}
let dev_aliases = by_alias.len() - before_dev;
Self {
by_alias,
counts: (slash_aliases, emote_aliases),
counts: (slash_aliases, emote_aliases, dev_aliases),
}
}
/// `(slash aliases, emote aliases)` — how many DISTINCT commands each source made reachable
/// (the shipped strings repeat: `EMOTE87_CMD1` and `_CMD2` are both `"/sit"`). The boot report.
pub(super) fn counts(&self) -> (usize, usize) {
/// `(slash aliases, emote aliases, dev aliases)` — how many DISTINCT commands each source made
/// reachable (the shipped strings repeat: `EMOTE87_CMD1` and `_CMD2` are both `"/sit"`). The
/// boot report.
///
/// The third number is the seam, **made observable** (decision 1179): benilla's own instrument
/// commands are gated on `run_mode::dev_affordances()`, and a gate nobody can see the effect of
/// is a gate nobody checks. A player build must print `0`, and one line of its own log says so.
pub(super) fn counts(&self) -> (usize, usize, usize) {
self.counts
}
}
@@ -317,11 +330,14 @@ pub(crate) fn build_slash_commands(
|name| globals.get::<String>(name).ok().filter(|s| !s.is_empty()),
|token| emotes.text_id(token),
);
let (slash, emote) = table.counts();
let (slash, emote, dev) = table.counts();
// The "it loaded" signal: the shipped 1.12 data yields 55 distinct aliases across the
// registered indices and 225 emote commands (pinned by `real_alias_table_resolves_the_shipped_
// commands`).
info!("chat: slash table — {slash} command aliases, {emote} emote aliases");
info!(
"chat: slash table — {slash} command aliases, {emote} emote aliases, \
{dev} instrument aliases"
);
if emote == 0 {
error!("chat: NO emote aliases — every /wave-style command is dead (GlobalStrings?)");
}
+5 -4
View File
@@ -256,7 +256,7 @@ pub(super) fn drain_chat_input(
ParsedChat::Shot => {
// The framing instrument (decision 0600): the CURRENT camera pose, in the raw WoW
// coords a capture `Scenario` takes, echoed to chat and appended to
// `config_base()`/shots.txt so a chosen spot survives the session.
// `benilla-config/shots.txt` so a chosen spot survives the session.
let Ok(cam) = world_camera.single() else {
continue;
};
@@ -272,9 +272,10 @@ pub(super) fn drain_chat_input(
super::event::ChatEventKind::System,
format!("shot: {snippet}"),
));
if let Some(base) = crate::login::config_base() {
let _ = std::fs::create_dir_all(&base);
let path = base.join("shots.txt");
if let Some(path) = crate::local_state::shots_path() {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let line = format!("{snippet}\n");
let appended = std::fs::OpenOptions::new()
.create(true)
@@ -77,7 +77,7 @@ pub(in crate::ui_chat) enum ParsedChat {
Played,
/// `/shot` — the director's framing instrument (decision 0600): dump the CURRENT world-camera
/// pose as a ready-to-paste capture `Scenario` (raw WoW eye/look + the rendered game minute)
/// into chat and `config_base()`/shots.txt. Client-local, no wire traffic — how a chosen spot
/// into chat and `benilla-config/shots.txt`. Client-local, no wire traffic — how a chosen spot
/// travels from the director's eye to the golden set as exact numbers.
Shot,
/// `/liquid` — the swim diagnostic (decision 0634 follow-up): dump the interior claim, the
+30 -6
View File
@@ -480,11 +480,24 @@ fn one_line_reference_bodies_run_in_the_vm() {
assert_eq!(parse_line("/script"), ParsedChat::Unknown);
}
/// `/castvis` is one of benilla's own instruments, so 1179 gates the whole dev alias table behind
/// `run_mode::dev_affordances()` — in a player build the alias is never claimed and the line falls
/// through to the reference's "unknown command". This test therefore asserts the grammar in a dev
/// build and the *absence* of the grammar in a player one, rather than assuming the configuration
/// it happens to run in. (It assumed, until 1180's `player-tests` gate ran it the other way.)
#[test]
fn castvis_parses_id_and_phase() {
use crate::creature_anim::CastEventKind;
let t = stub_table();
let parse_line = |line: &str| super::input::parse_line(&t, line);
if !crate::run_mode::dev_affordances() {
assert_eq!(
parse_line("/castvis 133"),
ParsedChat::Unknown,
"a player build must not claim an instrument's alias"
);
return;
}
assert_eq!(
parse_line("/castvis 133"),
ParsedChat::CastVis {
@@ -539,11 +552,7 @@ fn unknown_slash_command_is_dropped_not_said_aloud() {
/// Skips without client data.
#[test]
fn real_alias_table_resolves_the_shipped_commands() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let s = benilla_ui::script::UiScript::new().expect("VM");
for file in ["GlobalStrings.lua", "ChatFrame.lua"] {
@@ -698,7 +707,22 @@ fn real_alias_table_resolves_the_shipped_commands() {
// aliases** across the 34 registered `SlashCmdList` indices (0886 added TARGET's `/target`
// `/tar` and ASSIST's `/assist` `/a` to 0881's 55; 0890 added FOLLOW's `/f` `/follow` `/fol`;
// 0983 added CAST's `/cast` `/spell`, MACRO's `/macro` `/m`, and MACROHELP's `/macrohelp`).
assert_eq!(table.counts(), (67, 225), "(slash, emote) aliases");
//
// The third number is the **seam** (decision 1179): benilla's own instrument commands
// (`/castvis` `/chattest` `/partytest` `/shot` `/liquid` `/reaction` `/react` — 7 aliases over 6
// commands) are registered only when `run_mode::dev_affordances()`, so a player build claims
// none of them and `/partytest` falls through to the reference's "unknown command". Asserted
// against the predicate rather than a literal, so the row states the rule in both builds.
let instruments = if crate::run_mode::dev_affordances() {
7
} else {
0
};
assert_eq!(
table.counts(),
(67, 225, instruments),
"(slash, emote, instrument) aliases"
);
}
// ── The send-side posture-eligibility gate (`emote_send_eligible`) — the director-verified rows
+2 -6
View File
@@ -109,7 +109,7 @@ impl DuelState {
}
/// Take the challenger owed a response, if any — the partner probe's accept hook
/// ([`crate::capture::ProbePartnerPlugin`], decision 0637). Taking it discharges the popup
/// (`WOW_PROBE=partner`, decision 0637). Taking it discharges the popup
/// debt exactly as the feed's `DUEL_REQUESTED` edge does, so the probe never leaves a dialog
/// owed to a UI it isn't driving.
pub(crate) fn take_challenger(&mut self) -> Option<u64> {
@@ -489,11 +489,7 @@ mod tests {
/// Skips without client data.
#[test]
fn the_duel_spell_resolves_to_7266_on_real_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc");
let mut hits: Vec<u32> = catalog
+4 -4
View File
@@ -1,7 +1,7 @@
//! The player-UI **gamma composite lane**'s single decode (decision 0254) — the UI-arc twin of
//! [`benilla_world::ffx_glow`], which owns the world lane's one decode (0161).
//!
//! [`ui_quad.wgsl`](../assets/shaders/ui_quad.wgsl) composites the UI in gamma bytes, the way the
//! [`ui_quad.wgsl`](shaders/ui_quad.wgsl) composites the UI in gamma bytes, the way the
//! reference's fixed-function device composites into its 8-bit backbuffer: every tint is a gamma
//! multiply, every blend is gamma arithmetic clamped at each write, and `alphaMode="ADD"` is the
//! byte add `dst + texel·α` (EGxBlend 3 = `glBlendFunc(GL_SRC_ALPHA, GL_ONE)`; wow-re
@@ -83,7 +83,7 @@ fn init_pipeline(
layout: vec![layout.clone()],
vertex: fullscreen_shader.to_vertex_state(),
fragment: Some(FragmentState {
shader: asset_server.load("game://shaders/ui_gamma.wgsl"),
shader: asset_server.load("embedded://benilla_app/shaders/ui_gamma.wgsl"),
shader_defs: vec![],
entry_point: Some("fs_decode".into()),
// The UI camera is not `Hdr`, so its `ViewTarget` carries Bevy's default 8-bit sRGB
@@ -175,8 +175,8 @@ fn use_gamma_ui_shaders(
mut node: ResMut<UiPipeline>,
mut slice: ResMut<UiTextureSlicePipeline>,
) {
node.shader = asset_server.load("game://shaders/ui_node_gamma.wgsl");
slice.shader = asset_server.load("game://shaders/ui_slice_gamma.wgsl");
node.shader = asset_server.load("embedded://benilla_app/shaders/ui_node_gamma.wgsl");
slice.shader = asset_server.load("embedded://benilla_app/shaders/ui_slice_gamma.wgsl");
}
pub(crate) struct UiGammaPlugin;
@@ -180,11 +180,7 @@ mod tests {
/// Skips without client data.
#[test]
fn every_equip_error_key_resolves_in_the_real_global_strings() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
+1 -5
View File
@@ -988,11 +988,7 @@ mod tests {
/// spell with a real name and NO description, and the old code fell back to the name.
#[test]
fn an_undescribed_spell_prints_no_trigger_line_on_real_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = crate::ui_action::Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
+1 -1
View File
@@ -1,5 +1,5 @@
//! The **macros** feed (decision 0983) — the app half of `benilla_ui::script::macros`: the icon
//! chooser's catalog, persistence under `benilla/macros/`, and `UPDATE_MACROS`.
//! chooser's catalog, persistence under `benilla-config/macros/`, and `UPDATE_MACROS`.
//!
//! Unlike every other window feed in this tree there is **no wire traffic here at all**. 1.12
//! macros are pure client state — no opcode carries them (vmangos has none), and the reference
+1 -1
View File
@@ -13,7 +13,7 @@
//! ```
//!
//! benilla writes exactly that. Not nostalgia: it means **a player can drop their vanilla
//! `macros-cache.txt` into `benilla/macros/` and their macros are there** — and it keeps the file
//! `macros-cache.txt` into `benilla-config/macros/` and their macros are there** — and it keeps the file
//! hand-editable, which a serialized blob would not be. The icon column is the texture's *basename*
//! (`Ability_Ambush`); the client prepends `Interface\Icons\` (`0x44ca64`), which is why the stored
//! token survives an icon list changing under it where a chooser index would silently re-point.
+3 -7
View File
@@ -1,4 +1,4 @@
//! The app half of the macro system (decision 0983): persistence under `benilla/macros/`, the
//! The app half of the macro system (decision 0983): persistence under `benilla-config/macros/`, the
//! runner's route into the chat drain, and the seed/dirty contract the plugin's systems rely on.
//!
//! The file FORMAT has its own tests in [`super::store`] (including the director's real 1.12
@@ -18,7 +18,7 @@ fn macro_view(name: &str, body: &str) -> MacroView {
}
}
/// A save writes the reference's own format under `benilla/macros/`, and a load brings the same
/// A save writes the reference's own format under `benilla-config/macros/`, and a load brings the same
/// macros back — the whole persistence loop over the real `local_state` law.
#[test]
fn a_saved_macro_table_round_trips_through_benilla_macros() {
@@ -243,11 +243,7 @@ fn every_macro_chooser_icon_resolves_in_the_client_archives() {
/// different set: it included five names with no file and missed art the archive has.
const CHOOSER_ICONS_5875: usize = 517;
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: no client data at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let icons = benilla_formats::load_macro_icons(&mut chain).expect("load chooser catalog");
assert_eq!(
+1 -1
View File
@@ -291,7 +291,7 @@ pub(crate) struct UiQuadMaterial {
impl Material2d for UiQuadMaterial {
fn fragment_shader() -> ShaderRef {
"game://shaders/ui_quad.wgsl".into()
"embedded://benilla_app/shaders/ui_quad.wgsl".into()
}
fn alpha_mode(&self) -> bevy::sprite_render::AlphaMode2d {
+2 -10
View File
@@ -362,11 +362,7 @@ mod tests {
}
fn chain() -> Option<benilla_formats::Chain> {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return None;
}
let data = benilla_formats::wow_data_or_skip!(None);
Some(benilla_formats::open_chain(&data).expect("open chain"))
}
@@ -520,11 +516,7 @@ mod tests {
/// here is silent — `getglobal` answers nil and the tooltip simply loses a line.
#[test]
fn every_happiness_string_resolves_in_the_real_global_strings() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
+1 -5
View File
@@ -880,11 +880,7 @@ mod tests {
/// Skips without client data.
#[test]
fn every_quest_refusal_key_resolves_in_the_real_global_strings() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
+114
View File
@@ -0,0 +1,114 @@
//! **The shipped UI's files, compiled into the binary** (decision 1175 §2).
//!
//! One question, one answer: *give me the text of `ui/<name>.xml`*. [`load_ui_files`] and the
//! `provider` closure it hands the loader (for `<Include>` / `<Script file=>` references) both ask
//! here instead of touching `std::fs`.
//!
//! Until 1175 they read `concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui")` — the *build*
//! machine's source tree. On a player's machine those 61 files resolve to nothing and the client
//! boots with no interface at all: not a crash, just an empty screen and 61 log lines nobody sees.
//! `assets/ui` is our own content (MIT/Apache, not Blizzard's — the contract's hard rule is
//! untouched), so it can simply be part of the program.
//!
//! ## Resolution order — the same in every build
//!
//! 1. **`#[cfg(feature = "dev")]`** — the crate's own `assets/ui/` on disk. Editing a FrameXML file
//! and re-running still costs no recompile, which is the whole reason the source tree is worth
//! probing at all. This is the only place a source path survives, and 1174's feature is what
//! keeps it out of a player binary.
//! 2. **The compiled-in copy** — `include_dir!` over the same directory.
//!
//! A dev build therefore prefers disk and silently falls back to the embedded copy, so a file
//! deleted from `assets/ui` does not *appear* to still work: [`tests`] pins that the two agree.
//!
//! ## Why not the asset server
//!
//! [`super::manifest::MANIFEST`] is a 61-entry **dependency-ordered** manifest loaded synchronously
//! at a known point (a template must exist before the frame that inherits it), and the asset server
//! is async. Serving these through it would mean re-deriving a dependency graph that a hand-ordered
//! list already encodes correctly. This stays a byte lookup.
//!
//! ## The override layer this is not
//!
//! A `content/` folder beside the executable — addon and content-pack territory — is deliberately
//! *not* built here (1175 "Rejected"), but the shape leaves room for it: it would be one more arm
//! at the front of [`read`]. Building it now would be a mod system with no mods.
/// The shipped UI tree, compiled in: `benilla.toc` and the 61 files it names, ~2.8 MB — under
/// 2.5 % of the release binary, and what it buys is "the interface cannot be missing".
static UI: include_dir::Dir<'_> = include_dir::include_dir!("$CARGO_MANIFEST_DIR/assets/ui");
/// The text of one shipped UI file, by path relative to `assets/ui` — `None` if we do not ship it.
///
/// `req` may arrive in Blizzard's backslash form and may name a directory it does not have; the
/// basename fallback is [`super::load_ui_files`]'s and lives at the call site, not here, because
/// it is a property of FrameXML references rather than of the content store.
pub(super) fn read(req: &str) -> Option<String> {
// 1 · the source tree, dev builds only — so editing a FrameXML file needs no recompile.
if let Some(text) = read_source_tree(req) {
return Some(text);
}
// 2 · the compiled-in copy.
UI.get_file(req)?.contents_utf8().map(str::to_owned)
}
/// `assets/ui/<req>` on disk, or `None` in a player build.
///
/// The source directory comes from [`crate::run_mode::dev_source_dir`] rather than a `cfg` here:
/// the seam has one door per kind (1179), and a path literal must be *absent* from a player
/// binary, not merely unreached (1175). With no source dir this is a cheap `None` and the caller
/// falls through to the compiled-in copy.
fn read_source_tree(req: &str) -> Option<String> {
let dir = crate::run_mode::dev_source_dir()?.join("assets/ui");
std::fs::read_to_string(dir.join(req)).ok()
}
/// Every shipped file's path, relative to `assets/ui`. The compiled-in set is the authority —
/// a dev build must not appear to ship a file that is only on this machine.
#[cfg(test)]
pub(super) fn shipped_files() -> impl Iterator<Item = &'static str> {
UI.files().filter_map(|f| f.path().to_str())
}
#[cfg(test)]
mod tests {
/// Every entry of the manifest the client actually loads is a file we actually ship.
///
/// This is the check the old `std::fs` loader could not make: a typo'd manifest entry used to
/// be one `error!` line in a log at boot, and capture runs skip the loader entirely unless
/// `WOW_CAPTURE_UI=1`, so nothing failed. Now a name that is not in the binary fails here.
#[test]
fn every_manifest_entry_is_compiled_in() {
for name in crate::ui_script::manifest::manifest_files() {
assert!(
super::read(&name).is_some(),
"benilla.toc names {name}, which is not in assets/ui"
);
}
}
/// The dev probe and the compiled-in copy describe the same tree.
///
/// A dev build prefers disk, so without this a file deleted from `assets/ui` would keep
/// working from the embedded copy (and a file added there would keep working from disk) until
/// someone built a release. Either way the two would silently disagree about what benilla
/// ships, which is the failure mode 1175 is about.
#[test]
fn the_source_tree_and_the_compiled_in_copy_agree() {
// A player build has no source dir to disagree with — that IS the property there.
let Some(dir) = crate::run_mode::dev_source_dir().map(|d| d.join("assets/ui")) else {
return;
};
let mut on_disk: Vec<String> = std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
let mut embedded: Vec<String> = super::shipped_files().map(str::to_owned).collect();
on_disk.sort();
embedded.sort();
assert_eq!(
on_disk, embedded,
"assets/ui on disk and the compiled-in copy disagree — rebuild, or one of them is stale"
);
}
}
+28 -15
View File
@@ -62,9 +62,15 @@ pub(super) fn drive_script(
// `SetPortraitTexture`-bound region samples, and the pane geometry this pass publishes back
// for the booths' projection aspect + render gate (decision 1069).
mut booths: crate::portrait::BoothBridge,
// Gates the held-cursor icon quad (decision 0216 §5): CAPTURE-ONLY, the same presence check
// every other capture-only system uses (`ui_script::capture_ui_active`'s sibling pattern).
capture: Option<Res<crate::capture::CaptureMode>>,
// Two facts about the RUN, tupled to stay inside Bevy's 16-element system-param limit (the
// same squeeze `player::control` and `GateInputs` above record):
// · the held-cursor icon quad (decision 0216 §5) is CAPTURE-ONLY, the same presence check
// every other capture-only system uses (`ui_script::capture_ui_active`'s sibling pattern);
// · whether anything wants this pass's phase split at all ([`super::UiCostWanted`]).
run: (
Option<Res<crate::run_mode::CaptureMode>>,
Res<super::UiCostWanted>,
),
// This frame's `<Minimap>` widget slot, parked for `minimap::emit_minimap` (the UiQuadAppend
// producer that fills the hole with tile/arrow quads — decision 0203 phase 1).
mut minimap_widget: ResMut<crate::minimap::MinimapWidget>,
@@ -84,9 +90,10 @@ pub(super) fn drive_script(
// would then discard. Capture mode never skips (the harness wants exact per-frame output,
// including the cursor-icon quad's live mouse position).
mut prev: Local<GateInputs>,
// This frame's phase split, published for the hover recorder (`hover_log`).
mut ui_cost: ResMut<crate::hover_log::UiFrameCost>,
// This frame's phase split, published for whoever asked (the `[ui-cost]` line, `hover_log`).
mut ui_cost: ResMut<super::UiFrameCost>,
) {
let (capture, ui_cost_wanted) = run;
let Some(mut script) = script else {
return;
};
@@ -122,7 +129,7 @@ pub(super) fn drive_script(
// The phase marks feed two consumers now: the `[ui-cost]` line and the hover recorder
// (`hover_log`), which writes the same split per frame to a file. Either one arms them.
let printing = ui_cost_enabled();
let cost_on = printing || crate::hover_log::enabled();
let cost_on = printing || ui_cost_wanted.0;
let solves_before = cost_on.then(|| script.layout_solves());
// The measure counters are PER FRAME: `measure_fontstrings` adds to them, and both publish
// sites below carry them forward so the two measure passes sum into one frame's row. Nothing
@@ -172,7 +179,7 @@ pub(super) fn drive_script(
// one that did not (`WOW_HOVER_LOG`, director's run).
let mut measured_any = false;
if let Some(atlas) = font_atlas.as_deref_mut() {
measured_any = measure_fontstrings(&mut script, atlas, s, &mut ui_cost);
measured_any = measure_fontstrings(&mut script, atlas, s, &mut ui_cost, ui_cost_wanted.0);
}
{
let _span = bevy::log::info_span!("ui_script: resolve").entered();
@@ -183,7 +190,7 @@ pub(super) fn drive_script(
// otherwise keeps the quiet frame at ONE sweep of the 6,297-region map, not two.
if measured_any {
if let Some(atlas) = font_atlas.as_deref_mut() {
if measure_fontstrings(&mut script, atlas, s, &mut ui_cost) {
if measure_fontstrings(&mut script, atlas, s, &mut ui_cost, ui_cost_wanted.0) {
script.resolve();
}
}
@@ -333,7 +340,7 @@ pub(super) fn drive_script(
let us_cmp = lap();
if cost_on {
let solves = script.layout_solves() - solves_before.unwrap_or(0);
*ui_cost = crate::hover_log::UiFrameCost {
*ui_cost = super::UiFrameCost {
measured: ui_cost.measured,
measured_texts: std::mem::take(&mut ui_cost.measured_texts),
tick: us_tick,
@@ -668,7 +675,7 @@ pub(super) fn drive_script(
if cost_on {
let us_diff = lap();
let solves = script.layout_solves() - solves_before.unwrap_or(0);
*ui_cost = crate::hover_log::UiFrameCost {
*ui_cost = super::UiFrameCost {
measured: ui_cost.measured,
measured_texts: std::mem::take(&mut ui_cost.measured_texts),
tick: us_tick,
@@ -699,13 +706,16 @@ fn measure_fontstrings(
script: &mut UiScript,
atlas: &mut UiFontAtlas,
s: f32,
ui_cost: &mut crate::hover_log::UiFrameCost,
ui_cost: &mut super::UiFrameCost,
// The churn column is the recorder's alone ([`super::UiCostWanted`]) — the `[ui-cost]` line
// reports counts, not the strings behind them, and collecting them is not free.
record_texts: bool,
) -> bool {
let requests = script.fontstrings_needing_measure();
// The recorder's churn column: WHICH strings a frame had to re-shape. A steady hover that
// keeps asking is the whole question (`hover_log`), and the answer is a string, not a count —
// so the first few come along by name.
if crate::hover_log::enabled() {
if record_texts {
ui_cost.measured += requests.len();
ui_cost.measured_texts.extend(
requests
@@ -842,7 +852,8 @@ mod clip_plumb_tests {
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiClock>();
@@ -926,7 +937,8 @@ mod clip_plumb_tests {
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiClock>();
@@ -983,7 +995,8 @@ mod extract_gate_tests {
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiClock>();
+107 -317
View File
@@ -1,321 +1,70 @@
//! The shipped FrameXML manifest and the three ways it is loaded.
//!
//! Split out of `ui_script/mod.rs` as its own concern: a 250-entry ordered data table plus the
//! loader that walks it. The interesting thing here is the **seam at index 0** — see
//! [`load_default_ui`].
//! Split out of `ui_script/mod.rs` as its own concern: the loader that walks an ordered manifest.
//! The interesting thing here is the **seam at index 0** — see [`load_default_ui`].
//!
//! The manifest itself is not here. It is [`MANIFEST`] — `assets/ui/benilla.toc`, an ordinary
//! addon manifest read by the ordinary `.toc` parser ([`benilla_ui::toc`]), exactly as a
//! third-party addon's will be (decision 1178). Until then it was a hand-ordered `&[&str]` in
//! this file, which meant our own interface loaded by a private door and the addon path was
//! untested by construction.
use bevy::prelude::*;
use benilla_ui::script::UiScript;
use benilla_ui::toc::Toc;
/// The shipped FrameXML manifest, in load order. Index 0 (`Fonts.xml`) is the **font-object
/// registry** and is deliberately separable from the rest: it materializes zero frames, and the
/// glyph atlas bake ([`crate::ui_text`]) reads its objects to build the bake plan. Splitting the
/// manifest here is what lets the in-game UI load at world entry while the glue screens — which
/// share our one atlas — still get every (face, size, outline) combination they need (1051).
const UI_MANIFEST: &[&str] = &[
// The named virtual Font objects (decision 0084) — must load first so any FontString's
// `inherits="GameFontNormal"` resolves against a registered object.
"Fonts.xml",
// The UIPanel slot manager (decision 0084 §2): script-only, no frames of its own — must load
// before any panel frame (Gossip/Merchant today) so ShowUIPanel/HideUIPanel already exist
// when those frames' OnLoad/OnEvent reference them.
"UiPanels.xml",
// The managed bottom-stack positions (decision 0272): script-only —
// UIPARENT_MANAGED_FRAME_POSITIONS + UIParent_ManageFramePositions, the ref
// UIParent.lua mechanism that re-anchors the cast bar / chat over the bottom bars.
// Loaded early so the stance bar's OnShow/OnHide can call it; applied once by the
// post-load bootstrap below.
"UIParent.xml",
// The tooltip: a hover window the bag/merchant/loot/unit frames drive. Loaded before them so
// `GameTooltip` + its verbs exist when their OnEnter handlers fire (runtime, so order
// is not strictly required — but it keeps the dependency legible).
"GameTooltip.xml",
// The dropdown kit (0203 phase 2's closing sub-slice, widened for 0434 phase 5):
// UIDropDownMenuTemplate + DropDownList1/2 + the UIDropDownMenu_* API — before its
// first customers (the unit popups below, the world map's pickers, the trainer filter).
"UIDropDownMenu.xml",
// The unit right-click popups (0434 phase 5): UnitPopupMenus/Buttons + UnitPopup_ShowMenu
// — after the dropdown kit it drives, before the unit/party frames whose DropDown
// children's OnLoad initialize into it.
"UnitPopup.xml",
// The chat-link click router (SetItemRef): item links → ItemRefTooltip:SetHyperlink; a
// player name → left-click whisper / right-click FRIEND dropdown. After GameTooltip.xml
// (GameTooltip_OnLoad) AND the dropdown kit + UnitPopup — its FriendsDropDown inherits
// UIDropDownMenuTemplate and opens a UnitPopup FRIEND menu.
"ItemRef.xml",
// The shared status-bar numerals machinery (decision 1082): TextStatusBar_* + the opt-in
// template — before its customers (the XP bar in ActionBar.xml today; the unit-frame
// health/mana numerals are the ref's other users, when that slice lands). The ref's own TOC
// loads TextStatusBar right before its unit frames, same seat as here.
"TextStatusBar.xml",
"UnitFrames.xml",
// The combo-point dots (decision 0869) — anchored to BenillaTargetFrame, so after
// UnitFrames.xml; its fade chain needs UiPanels.xml's UIFrameFade above.
"ComboFrame.xml",
// The center-screen scrolling combat text (decision 0578) — the Blizzard_CombatText
// transcription; consumes COMBAT_TEXT_UPDATE + the regen/health/power events, needs
// only Fonts.xml (CombatTextFont) + UIParent before it.
"CombatText.xml",
// The party member frames + the PARTY_INVITE popup + its event driver (decision 0434
// phase 2): a StaticPopup registry entry, so after UiPanels.xml.
"PartyFrame.xml",
// The death arc's dialogs + event driver (decision 0308): registry entries on the shared
// StaticPopup engine, so after UiPanels.xml.
"DeathFrame.xml",
// The two duel dialogs + their event driver (decision 0633): registry entries on the same
// StaticPopup engine, and DUEL_OUTOFBOUNDS leans on its per-tick countdown branch.
"DuelFrame.xml",
// The two enchant-apply confirms + their event driver (decision 0928): the same StaticPopup
// registry again. Not CraftFrame's — the gate that raises them is the generic item-target
// bind, reached by a poison or a sharpening stone with no profession window open.
"EnchantConfirm.xml",
// The shared CooldownFrame_SetTimer (the ref's Cooldown.lua file split) — before its
// consumers (ActionBar's buttons, the multibars, the stance bar, BagFrame's slots).
"Cooldown.xml",
"ActionBar.xml",
// The two always-on bottom multibars (actions 61-72 / 49-60): pure XML/Lua over
// ActionBar.xml's shared button template + handler set — loads right after it
// (inherits BenillaActionButtonTemplate, anchors to BenillaActionButton1, and its
// buttons join the BENILLA_ACTION_BUTTONS roster ActionBar.xml declares).
"MultiBars.xml",
// The stance/shapeshift bar: hidden until `crate::ui_shapeshift`'s feed pushes a
// non-empty form list (a formless class never shows it). After Cooldown.xml
// (CooldownFrame_SetTimer) + ActionBar.xml (BENILLA_FALLBACK_ICON, the BenillaActionBar
// anchor target).
"StanceBar.xml",
// The pet action bar (decision 0982): hidden until `crate::ui_pet`'s feed reports a bar,
// i.e. until the server sends `SMSG_PET_SPELLS` for a live pet or charm — so a class that
// never has one never sees it. Right after the stance bar, whose row it shares (either
// one showing raises UIParent.xml's managed "pet" delta), and after Cooldown.xml
// (CooldownFrame_SetTimer), ActionBar.xml (the `BenillaActionBar` anchor target) and
// GameTooltip.xml (its hover). It is also the pet ARC's file, so decision 1066's three
// right-click-menu dialogs (ABANDON_PET / RENAME_PET / PETRENAMECONFIRM) register here —
// which adds UiPanels.xml to its dependency set, already far above.
"PetActionBar.xml",
// The micro-button row in the bar's right-hand recess (the ref's own file split, its TOC
// loads MainMenuBarMicroButtons right after MainMenuBar). After ActionBar.xml: it anchors
// into BenillaActionBarArtFrame by name and seats itself with that file's
// BenillaActionBarArt_SeatAbove. It must come BEFORE the panels it toggles, because each of
// their OnShow/OnHide calls the `UpdateMicroButtons` this file defines (the panel frames
// themselves are read back through getglobal, so the reverse dependency is call-time).
"MicroMenu.xml",
// The HUD minimap cluster (decision 0203 phase 1): the chrome around the engine-rendered
// <Minimap> widget hole (`crate::minimap` fills it). After GameTooltip (its zone-text
// hover uses the shared tooltip).
"MinimapCluster.xml",
// The minimap's time-of-day indicator (GameTimeFrame): the reference's own file split — its
// TOC loads GameTime.xml right after Minimap.xml. After MinimapCluster (its parent) and
// GameTooltip (the hover tooltip); driven by the app's game clock (`minimap::feed_game_time`).
"GameTime.xml",
// The zone-entry splash (decision 0287): ZoneTextFrame/SubZoneTextFrame + the inlined
// FadingFrame kit, driven by the ZONE_CHANGED family `crate::area` fires. Needs only
// Fonts (ZoneTextFont/SubZoneTextFont) + UIParent; kept by the minimap cluster — the
// other consumer of the zone-text host globals.
"ZoneText.xml",
// The faux-scroll kit (decisions 0247/0250/0251): BenillaScrollBarTemplate (the draggable
// Slider scroll bar) + BenillaFauxScrollFrameTemplate + the BenillaFauxScrollFrame_* API —
// before its first customer (the trainer list) below.
"ScrollTemplates.xml",
// The fullscreen world map (decision 0203 phase 2), over the worldmap host API
// (`crate::ui_world_map` feeds it); the 'M' binding below calls its ToggleWorldMap().
"WorldMapFrame.xml",
// The cast bar (decision 0137 phase 1) — the extracted 1.12 CastingBarFrame, driven by
// the SPELLCAST_* events `crate::ui_cast` fires. Only needs Fonts before it.
"CastingBar.xml",
// The mirror timers (decision 0874): the breath/fatigue bars at top-center, driven by the
// MIRROR_TIMER_* events `crate::ui_mirror` fires. Needs only Fonts (GameFontHighlight)
// before it; kept beside the cast bar, whose border art it shares.
"MirrorTimer.xml",
// The player buff/debuff bar (decisions 0255/0257 — the player aura arc): the HUD row of
// aura icons under the minimap, driven by the UnitAura feed (`crate::ui_aura`). Needs Fonts
// (its FontStrings) and ActionBar's `BENILLA_FALLBACK_ICON` global (both already loaded).
"BuffFrame.xml",
// The durability alert (the "armor guy"): under the minimap beside BuffFrame, driven by
// UPDATE_INVENTORY_ALERTS off the same inventory push the character window reads. Needs
// MinimapCluster (its anchor) — loaded above.
"DurabilityFrame.xml",
"ErrorsFrame.xml",
"BagFrame.xml",
// The stack-split spinner (decision 0216 §6/slice 2): loaded beside BagFrame, whose slot
// click handler (`BenillaBagSlot_OnClick`) calls `OpenStackSplitFrame`. Order between the
// two doesn't matter for correctness (Lua globals resolve at call time, and neither file's
// XML `inherits=` reaches into the other's templates) — kept adjacent for legibility.
"StackSplit.xml",
// The character window (decision 0208 phase 1a): the C-key paper doll. Loaded after BagFrame
// (needs nothing from it, but keeps player-state windows grouped) and before the NPC session
// windows (Gossip/Merchant/Quest*) — it depends only on Fonts/UiPanels/GameTooltip, all
// already loaded above.
"CharacterFrame.xml",
// The Pet tab (decision 1057): the same separate-top-level-file shape as SkillFrame below,
// and ahead of it because it is the earlier tab. It leans on CharacterFrame.xml for real —
// the `(unit, prefix)` stat setters, the `BenillaStatRow`/`BenillaMagicResistanceFrame`
// templates and the tab row it raises tab 2 of — all of which only exist once that file has
// loaded, so this order is a requirement here, not just tidiness.
"PetPaperDollFrame.xml",
// The Skills tab (decision 0437 phase 4): a CharacterFrame child page, but a separate
// top-level file (SkillFrame.xml's own header comment — this engine has no cross-file
// `parent=` attachment, so it's positioned by name via `<Anchors relativeTo=
// "BenillaCharacterFrame">` instead). Loaded immediately after CharacterFrame.xml so it
// paints ON TOP of it (later file = higher z) and so `BenillaCharacterFrame` already exists
// for that anchor to resolve against.
"SkillFrame.xml",
// The inspect window (decision 0631): another player's paper doll. Beside the character
// window it mirrors — it needs the same three (Fonts/UiPanels/GameTooltip) plus
// `CharacterFrameTabButtonTemplate` from UiPanels.xml, and nothing from CharacterFrame.xml
// itself (its slot template, handlers, and booth slot are all its own — the two windows
// share only the reference's art).
"InspectFrame.xml",
// The dressing room (decision 1060): the ctrl-click item preview. Loaded right after the
// windows whose slots feed it (the character window above, the bags before that) and before
// everything else that ctrl-clicks into it — order is legibility only, since `DressUpItemLink`
// is a global resolved at call time. Needs UiPanels (the panel manager, its left-slot row) and
// Fonts; its booth pane needs nothing loaded at all.
"DressUpFrame.xml",
// The spellbook window (decision 0216 §8, slice 5): the P-key window, the spell SOURCE
// for the cursor-payload arc. Loaded right after CharacterFrame (the same "player-state
// windows grouped" posture, same Fonts/UiPanels/GameTooltip-only dependency set); the
// 'P' binding below calls its bare ToggleSpellBook(BOOKTYPE_SPELL).
"SpellBookFrame.xml",
// The talent window (decision 0304 §4): the N-key window, the class talent grid over the
// `benilla-ui::script::talent` engine seam. Loaded right after SpellBookFrame (the same
// "player-state windows grouped" posture; needs Fonts/UiPanels/GameTooltip plus
// ScrollTemplates.xml — its real ScrollFrame's scrollbar reuses BenillaScrollBarTemplate —
// all already loaded above). The 'N' binding (ui_script/input.rs) calls its bare
// ToggleTalentFrame().
"TalentFrame.xml",
// The social window (decision 0668): the O-key friends/ignore/who window over the social
// API (`benilla-ui/src/script/social.rs`) + `crate::ui_social`'s feed/drain. Needs
// UiPanels (the panel manager, `CharacterFrameTabButtonTemplate`, and the StaticPopup
// engine its two name-entry dialogs register into), ScrollTemplates (three faux lists),
// GameTooltip (the newbie tips) and UIDropDownMenu (the who list's variable column) —
// all loaded above.
"FriendsFrame.xml",
"GossipFrame.xml",
"MerchantFrame.xml",
// The mail window (decision 0544 P1/P2): the mailbox inbox, open-letter, and send tab over
// the Era mail API (`benilla-ui/src/script/mail.rs`) + `crate::ui_mail`'s feed/drain. Loaded
// right after MerchantFrame because it REUSES that file's global BenillaMoney_Set/_Clear
// coin helpers (postage/enclosed/COD displays) — they must be defined first.
"MailFrame.xml",
// The player-to-player trade window (decision 0592 P1): the two-sided offer over the trade
// API (`benilla-ui/src/script/trade.rs`) + `crate::ui_trade`'s feed/drain. Loaded after
// MerchantFrame because it REUSES that file's global BenillaMoney_Set coin helpers (the two
// gold displays) — they must be defined first.
"TradeFrame.xml",
// The item-text reader: letters (mail-made permanent copies) and, later, books/plaques —
// the ItemTextGet* seam (`benilla-ui/src/script/item_text.rs`) over `crate::ui_item_text`'s
// feed/drain. Loaded right after MailFrame (the same arc; needs only Fonts/UiPanels, both
// loaded above).
"ItemTextFrame.xml",
// The trainer window (decision 0237 phase 3): the read-only class/profession trainer list.
// Loaded right after MerchantFrame per the director's brief (kept adjacent even though this
// v1 uses plain-text costs, not MerchantFrame's coin helpers) and before the other NPC
// session windows below.
"TrainerFrame.xml",
// The bank window (decision 0604 phase 4): the vault, purchase ladder, and the 6 bank-bag
// popouts, over the C_Container feed already carrying container -1/5..10 (ui_items.rs) +
// the bank Lua surface (benilla-ui/src/script/bank.rs) + crate::ui_bank's feed/drain.
// Loaded AFTER MerchantFrame.xml (reuses its BenillaMoney_* coin helpers for the purchase
// row + purse) and AFTER BagFrame.xml, already above (its BenillaBagSlotTemplate/
// BenillaBagWindowTemplate + the BenillaBagSlot_*/BenillaBagFrame_* function family are
// reused verbatim — the 24 generic slots and the 6 bank-bag popouts are, respectively, a
// container-slot grid and ordinary container windows, exactly like the bag arc's own).
// Kept in the "NPC session windows grouped" cluster beside Trainer/Taxi.
"BankFrame.xml",
// The taxi-map window (decision 0484 phase 2): the flight-master session, driven by the
// taxi engine seam (`benilla-ui/src/script/taxi.rs`) over `crate::ui_taxi`'s feed/drain.
// Loaded right after TrainerFrame per the same "NPC session windows grouped" posture — it
// needs only Fonts/UiPanels/GameTooltip (SetTooltipMoney), both already loaded above.
"TaxiFrame.xml",
// The crafting window (decision 0437 phase 2): NOT an NPC session (opens off your own cast of
// an effect-47 opener spell, TradeSkillFrame.xml's own header comment) — loaded right after
// TrainerFrame per the same "player-state/profession window" grouping and because it depends
// on nothing TrainerFrame doesn't already need (Fonts/UiPanels/GameTooltip/ScrollTemplates,
// all loaded above).
"TradeSkillFrame.xml",
// The Enchanting window (decision 0437 phase 3): the exact twin of TradeSkillFrame above —
// same "opens off your own cast, not an NPC session" shape (CraftFrame.xml's own header
// comment) — loaded right after it so `BuildColoredListString` (a guarded GLOBAL
// TradeSkillFrame.xml defines) already exists when CraftFrame.xml reuses it without
// redefining (CraftFrame.xml's own header comment, deviation 2).
"CraftFrame.xml",
"LootFrame.xml",
// The group-loot roll popups (decision 0591): the Need/Greed/Pass dialogs, a sibling of
// LootFrame.xml (loaded right after it) over the `benilla-ui::script::loot_roll` seam
// (`crate::ui_loot_roll`'s feed/drain). NOT a UIPanel — four `frameStrata="DIALOG"` toplevel
// popups with their own hardcoded anchors — so it needs only Fonts (GameFontNormalSmall) +
// GameTooltip (the item/PASS/NEED/GREED hovers), both already loaded above.
"GroupLootFrame.xml",
// The questgiver window (decision 0088): four sub-panels over the Era quest API. Loaded after
// MerchantFrame (the BenillaMoney coin helpers live there) and GossipFrame (shared parchment
// art conventions); its title/panel-button labels use the auto-centred ButtonText slot.
"QuestFrame.xml",
// The quest log window (decision 0088 arc, the quest-log slice): durable player state, not an
// NPC session — loaded after MerchantFrame (the BenillaMoney coin helpers it reuses) and
// alongside QuestFrame (same arc); the 'L' binding below calls its ToggleQuestLog().
"QuestLogFrame.xml",
// The timed-quest countdown window under the minimap (decision 1150): the reference's own
// file split. After MinimapCluster (its anchor), UIParent (the manage pass that re-seats it,
// and SecondsToTime), GameTooltip (its row hover), and QuestLogFrame — whose window a row
// click opens, and whose MAX_QUESTS it shares.
"QuestTimerFrame.xml",
"ChatFrame.xml",
// The macro window (decision 0983): the editor + its name/icon popup, driven over the
// engine's OWN macro table (`benilla_ui::script::macros` — 1.12 macros have no server
// side, so this is the one window whose model is not an app feed). Needs UiPanels (the
// panel manager + `TabButtonTemplate`), ScrollTemplates (BOTH kits — the real one scrolls
// the body box, the faux one the icon grid), MicroMenu (`UpdateMicroButtons` on
// show/hide), and it must come AFTER SpellBookFrame, whose shift-click reaches
// `BenillaMacroFrame_AddMacroLine`. Before GameMenuFrame, whose Macros button opens it.
"MacroFrame.xml",
// The Keybindings page MODULE (decision 1008, superseding 0997's standalone window):
// the templates + script of the Options window's Keybindings category, over the
// engine's binding table (benilla_ui::script::keybind + crate::bindings — the 1.12
// GetBinding/SetBinding Lua API). Needs UiPanels (popup engine) and GameTooltip (the
// character-specific checkbox's hover); MUST load before OptionsFrame.xml, whose
// Keybindings body inherits these templates and calls this script at OnLoad.
"KeyBindingsPage.xml",
// The options window (the 0985 provenance split: 0950's era structure, 0978's
// 1.12-native art, 0981/0984's 1.14 chrome + working era search, 0989's directed
// cuts — bare fully-live sliders, no corner X; 0992's dropdown rows + the Nameplates
// page): the era-shaped OptionsFrame the game menu's Options button opens. Needs
// Fonts' objects, UiPanels' panel manager, and UIDropDownMenu's capsule template
// (all far above); sits right before GameMenuFrame so the menu — which reaches it by
// name at click time — stays the last, outermost layer.
"OptionsFrame.xml",
// The game menu (decision 0674): the frame ESC opens, plus the CAMP/QUIT dialogs and their
// event driver. LAST on purpose — it is the outermost layer of the shell, it depends on
// everything it touches being loaded (UiPanels' popup engine + panel manager, MicroMenu's
// UpdateMicroButtons, BagFrame's Disable_BagButtons), and nothing depends on it: the ESC
// chain and the micro button both reach it by name at call time.
"GameMenuFrame.xml",
];
use super::content;
/// Load a slice of [`UI_MANIFEST`] into the VM, in the order given. Returns per-file errors.
/// The built-in interface's manifest, relative to `assets/ui` — parsed for its ordered file list
/// by [`manifest_files`]. Its `## Interface:`/`## Title:` directives are what `GetAddOnInfo` will
/// read once the AddOn API lands (1178 step 4); nothing consumes them yet.
pub(super) const MANIFEST: &str = "benilla.toc";
/// The manifest's file list, in load order.
///
/// Re-read and re-parsed per call rather than cached: it is two calls per process (the font
/// registry at startup, the rest at world entry) over a 3 kB file, and a dev build prefers the
/// copy on disk ([`content`]) — so editing `benilla.toc` costs no recompile, same as editing any
/// FrameXML file it names.
///
/// A missing or empty manifest is an interface-less client, which is why it is `error!` and not a
/// silent empty list; [`tests::the_manifest_lists_every_shipped_file_and_nothing_else`] is what
/// stops it ever reaching a run.
pub(super) fn manifest_files() -> Vec<String> {
let Some(text) = content::read(MANIFEST) else {
error!("ui_script: {MANIFEST} is not in the shipped UI — no interface will load");
return Vec::new();
};
let files = Toc::parse(&text).files;
if files.is_empty() {
error!("ui_script: {MANIFEST} lists no files — no interface will load");
}
files
}
/// Load a slice of the manifest into the VM, in the order given. Returns per-file errors.
///
/// `bootstrap_positions` runs decision 0272's load-time `UIParent_ManageFramePositions()` pass —
/// only meaningful once the frames that table names exist, so the font-registry-only load
/// ([`load_font_registry`]) skips it. It is defined in `UIParent.xml`, which is in the deferred
/// half; calling it after `Fonts.xml` alone is a nil-global error, not a no-op.
fn load_ui_files(script: &UiScript, files: &[&str], bootstrap_positions: bool) -> Vec<String> {
fn load_ui_files(script: &UiScript, files: &[String], bootstrap_positions: bool) -> Vec<String> {
let mut failures = Vec::new();
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
// Provider for FrameXML/Lua references: try the path as given and by basename (Blizzard-style
// backslash paths, dir-relative), resolved against our own assets/ui dir.
// backslash paths, dir-relative). The *content* comes from `super::content` — the shipped tree
// is compiled into the binary (1175), so this resolves on a machine that has never seen our
// source; the two-try shape is a property of FrameXML references and stays here.
let provider = |req: &str| -> Option<String> {
let norm = req.replace('\\', "/");
let base = norm.rsplit('/').next().unwrap_or(&norm);
std::fs::read_to_string(dir.join(&norm))
.or_else(|_| std::fs::read_to_string(dir.join(base)))
.ok()
content::read(&norm).or_else(|| content::read(base))
};
for file in files {
let entry = dir.join(file);
let text = match std::fs::read_to_string(&entry) {
Ok(t) => t,
Err(e) => {
error!("ui_script: reading {}: {e}", entry.display());
let text = match content::read(file) {
Some(t) => t,
None => {
error!("ui_script: {file} is not in the shipped UI");
continue;
}
};
@@ -353,28 +102,28 @@ fn load_ui_files(script: &UiScript, files: &[&str], bootstrap_positions: bool) -
failures
}
/// Load benilla's own default UI (`assets/ui/*.xml` — the unit frames and the action bar) through
/// the engine-free loader, resolving any `<Include>`/`<Script file=>` references against the
/// crate's `assets/ui` dir. This is our content (MIT/Apache), committed and read from source —
/// `CARGO_MANIFEST_DIR` (compile-time) points at this crate. Textures (`Interface\…`) still resolve at
/// render through the MPQ `sprite_texture` path; the loader only needs the XML/Lua text.
/// Load benilla's own default UI — every file [`MANIFEST`] names — through the engine-free loader,
/// resolving any `<Include>`/`<Script file=>` references against the same content store. This is
/// our content (MIT/Apache), committed and **compiled into the binary** ([`super::content`],
/// decision 1175) — a dev build still prefers the copy on disk, so editing a FrameXML file costs no
/// recompile. Textures (`Interface\…`) still resolve at render through the MPQ `sprite_texture`
/// path; the loader only needs the XML/Lua text.
///
/// Returns every loader error, tagged `"<file>: <error>"` — the app ignores the value (each is
/// already logged as it happens) and [`shipped_xml_tests`] asserts it empty. The manifest is an
/// inline array no other test walks, so before that assertion a broken entry — a bad file name, a
/// frame that collides with a later window's, a template referenced before its definer — reached a
/// real run with nothing but a log line. Capture runs cannot cover it either: they skip this
/// function entirely unless `WOW_CAPTURE_UI=1`.
/// already logged as it happens) and [`shipped_xml_tests`] asserts it empty. Before that assertion
/// a broken entry — a bad file name, a frame that collides with a later window's, a template
/// referenced before its definer — reached a real run with nothing but a log line. Capture runs
/// cannot cover it either: they skip this function entirely unless `WOW_CAPTURE_UI=1`.
///
/// **Split across the boot boundary (1051).** `Fonts.xml` — index 0, zero frames materialized —
/// is the font-object registry the glyph atlas bakes its plan from, and our native glue screens
/// share that one atlas, so it must exist before the login screen. Everything after it is in-game
/// UI and loads at world entry ([`load_ingame_ui`]). This whole-manifest entry point stays for the
/// tests, which assert over the complete shipped set — production now loads in two phases, so this
/// has no non-test caller.
/// **Split across the boot boundary (1051).** `Fonts.xml` — the manifest's first entry, zero frames
/// materialized — is the font-object registry the glyph atlas bakes its plan from, and our native
/// glue screens share that one atlas, so it must exist before the login screen. Everything after it
/// is in-game UI and loads at world entry ([`load_ingame_ui`]). This whole-manifest entry point
/// stays for the tests, which assert over the complete shipped set — production now loads in two
/// phases, so this has no non-test caller.
#[cfg(test)]
pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
load_ui_files(script, UI_MANIFEST, true)
load_ui_files(script, &manifest_files(), true)
}
/// The font-object registry alone (`Fonts.xml`), loaded at `Startup` — see [`load_default_ui`].
@@ -384,7 +133,8 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
/// (`GameFontNormalMed1` 13, `OptionsFontHighlightMedium` 14, `OptionsFontHighlightHuge` 20) are
/// un-outlined and their heights are already declared here, so they add nothing to the bake plan.
pub(crate) fn load_font_registry(script: &UiScript) -> Vec<String> {
load_ui_files(script, &UI_MANIFEST[..1], false)
let files = manifest_files();
load_ui_files(script, files.get(..1).unwrap_or_default(), false)
}
/// The in-game UI — everything after the font registry — loaded on entering the world.
@@ -393,5 +143,45 @@ pub(crate) fn load_font_registry(script: &UiScript) -> Vec<String> {
/// (`0x401570` ← `0x46c236`); its glue screens run GlueXML with their own `GlueFonts.xml` registry,
/// which is why the reference has no equivalent of our shared-atlas coupling (wow-5875-re, 1051).
pub(crate) fn load_ingame_ui(script: &UiScript) -> Vec<String> {
load_ui_files(script, &UI_MANIFEST[1..], true)
let files = manifest_files();
load_ui_files(script, files.get(1..).unwrap_or_default(), true)
}
#[cfg(test)]
mod tests {
use super::*;
/// The manifest parses as a `.toc`, declares the build it targets, and splits where the loader
/// splits it. `Fonts.xml` first is not tidiness: [`load_font_registry`] takes entry 0 and
/// [`load_ingame_ui`] takes the rest, so a reordering here silently moves a real file across
/// the boot boundary (1051) — into the glue screens' phase, or out of the atlas bake plan.
#[test]
fn the_manifest_is_a_toc_that_starts_with_the_font_registry() {
let toc = Toc::parse(&content::read(MANIFEST).expect("benilla.toc is shipped"));
assert_eq!(toc.interface_versions(), vec![11200]);
assert_eq!(toc.directive("Title"), Some("benilla"));
assert_eq!(
toc.files.first().map(String::as_str),
Some("Fonts.xml"),
"the font registry is the manifest's first entry — the loader splits there"
);
}
/// The manifest and `assets/ui` describe the same interface, both ways.
///
/// An entry naming a file we do not ship is 61 log lines and an empty screen (what
/// `content::tests::every_manifest_entry_is_compiled_in` catches). The other direction is the
/// one nothing caught before: a FrameXML file added to `assets/ui` and never listed here is
/// simply never loaded, and the symptom is a window that does not exist rather than an error.
#[test]
fn the_manifest_lists_every_shipped_file_and_nothing_else() {
let mut listed = manifest_files();
let mut shipped: Vec<String> = content::shipped_files()
.filter(|f| f.ends_with(".xml"))
.map(str::to_owned)
.collect();
listed.sort();
shipped.sort();
assert_eq!(listed, shipped);
}
}
+61 -5
View File
@@ -22,11 +22,11 @@ use bevy::prelude::*;
use benilla_ui::script::{ActionSlot, ScriptValue, UiScript, UnitState};
use crate::debug_panel::EguiPointerOver;
use crate::ui_unit::UnitFeed;
use benilla_assets::LockRecover;
use benilla_world::schedule::WorldStage;
mod content;
mod extract;
mod input;
mod manifest;
@@ -39,13 +39,66 @@ pub(crate) use manifest::{load_font_registry, load_ingame_ui};
/// Is the pointer over *any* UI this frame — the egui dev overlay OR a mouse-enabled player-UI
/// frame? The single source of truth for "the mouse is talking to the UI, not the world",
/// combined by [`arbitrate_pointer_over_ui`] from both contributors (dev overlay =
/// [`crate::debug_panel::EguiPointerOver`]; player UI = [`PlayerUiHover`]). Gameplay reads it so
/// [`EguiPointerOver`]; player UI = [`PlayerUiHover`]). Gameplay reads it so
/// a drag doesn't start mouse-look; the inspector reads it so a pick doesn't fire behind an
/// overlaid frame. Owned HERE, not by the dev plugin (decision 0026): gameplay's read must
/// survive a build without the dev overlays, so the combiner treats the egui half as optional.
#[derive(Resource, Default)]
pub(crate) struct PointerOverUi(pub(crate) bool);
/// The egui dev overlay's half of the pointer arbitration, written each egui pass by the debug
/// panel's `track_pointer_over_ui`. **Defined here, with the arbiter that reads it** (decision
/// 1174 finishing 0026): the type has to exist in a build with no dev overlays compiled in, and
/// the arbiter takes it as `Option<Res<…>>` so its absence simply means "nothing is hovering the
/// dev UI" — which is the player-faithful answer. The writer lives in `debug_panel`; a dev
/// module writing an always-present fact is the allowed direction.
#[derive(Resource, Default)]
pub(crate) struct EguiPointerOver(pub(crate) bool);
/// Whether mouseover **world picking** is armed — the dev-chord `I` inspector's mode, toggled by
/// `debug_panel::inspect`.
///
/// The second of the two resources 0026 named as needing "a player-safe home", and here for the
/// same reason as [`EguiPointerOver`]: `player::control` and `target::click` read it every frame
/// to decide whether a left-click is the inspector's or the game's, and those reads must compile
/// and behave in a build with no inspector. The [`Default`] — **disarmed** — *is* the player
/// behaviour, so a player build's readers take the ordinary branch with nothing to switch them.
#[derive(Resource, Default)]
pub(crate) struct InspectMode {
pub(crate) enabled: bool,
}
/// One frame's UI-pass phase split, in μs — written by [`extract::drive_script`] under the same
/// marks the `[ui-cost]` line prints. **Owned by the producer** (decision 1174): the split is a
/// fact this pass publishes about itself, so it must exist whether or not the recorder that reads
/// it (`hover_log`) is compiled in. Its consumers are instruments; its writer is not.
#[derive(Resource, Default, Clone)]
pub(crate) struct UiFrameCost {
/// How many FontStrings the layout asked the font engine to shape this frame, and the first
/// few by name — a steady hover that keeps asking is the churn the recorder exists to catch.
pub(crate) measured: usize,
pub(crate) measured_texts: Vec<String>,
pub(crate) tick: u128,
pub(crate) resolve: u128,
pub(crate) measure: u128,
pub(crate) extract: u128,
pub(crate) convert: u128,
pub(crate) diff: u128,
pub(crate) quads: usize,
pub(crate) solves: u64,
pub(crate) skipped: bool,
}
/// Does anything want [`UiFrameCost`] filled in this run? Measuring the split costs a clock read
/// per phase plus the churn strings, so the pass only pays when asked.
///
/// `WOW_UI_COST=1` (this module's own `[ui-cost]` line) arms it inline; the hover recorder arms it
/// by setting this — the direction that keeps the pass free of the instrument's name, and the
/// reason the flag is a resource rather than an env read here (decision 1174). Default `false` is
/// the player answer.
#[derive(Resource, Default)]
pub(crate) struct UiCostWanted(pub(crate) bool);
/// The frame the cursor is currently over (a mouse-enabled, visible player-UI frame), or `None`.
/// Written by [`feed_ui_input`], read by the pointer arbiter below so world-pick
/// and camera-look yield to the UI (decision 0026's single-source `PointerOverUi`).
@@ -175,8 +228,11 @@ impl Plugin for UiScriptPlugin {
// The UI pass publishes its per-frame phase split here every frame the cost meter or
// the hover recorder is armed; the producer owns the resource so any minimal app that
// runs `drive_script` (the extract tests) has it.
.init_resource::<crate::hover_log::UiFrameCost>()
.init_resource::<UiFrameCost>()
.init_resource::<UiCostWanted>()
.init_resource::<PointerOverUi>()
.init_resource::<EguiPointerOver>()
.init_resource::<InspectMode>()
.init_resource::<PlayerUiHover>()
.init_resource::<UiKeyboardCapture>()
.init_resource::<PlayerUiClickConsumed>()
@@ -238,7 +294,7 @@ impl Plugin for UiScriptPlugin {
/// Should this CAPTURE include the player UI (+ the synthetic unit snapshot)? The visual harness's
/// baselines must stay UI-free (they regression-test the WORLD render), so captures skip the UI
/// unless `WOW_CAPTURE_UI=1` opts in. Normal runs always load the UI and never take synthetic data.
fn capture_ui_active(capture: Option<Res<crate::capture::CaptureMode>>) -> bool {
fn capture_ui_active(capture: Option<Res<crate::run_mode::CaptureMode>>) -> bool {
capture.is_some() && std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1")
}
@@ -281,7 +337,7 @@ fn setup_script(world: &mut World) {
/// Does this run want the player UI at all? Captures stay pristine — their baselines regression-test
/// the WORLD render — unless `WOW_CAPTURE_UI=1` opts the UI in.
fn ui_wanted(world: &World) -> bool {
!world.contains_resource::<crate::capture::CaptureMode>()
!world.contains_resource::<crate::run_mode::CaptureMode>()
|| std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1")
}
@@ -1599,11 +1599,7 @@ fn a_row_with_no_1_12_string_raises_no_plate() {
/// Pins the reporter's own row end to end (B223's screenshot text). Skips without client data.
#[test]
fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
@@ -2196,7 +2192,7 @@ fn what_the_interface_page_writes_survives_a_restart() {
}
/// **A saved value that has a SIDE EFFECT has to be applied when the chunk lands.** The whole UI's
/// XML runs before `benilla/saved-variables.lua` executes over it (1128), so every file-scope
/// XML runs before `benilla-config/saved-variables.lua` executes over it (1128), so every file-scope
/// consumer of a global ran against the *shipped* default: `CombatText_OnLoad` armed its six event
/// registrations from `SHOW_COMBAT_TEXT = "1"`, and nothing re-ran when the saved `"0"` replaced it.
/// The player's "off" came back on at every restart. 1.12 closes this with a hand-written ladder in
@@ -153,14 +153,7 @@ fn every_shipped_texture_path_resolves_in_the_client_archives() {
);
}
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!(
"skipping the resolve half: no client data at {}",
data.display()
);
return;
}
let data = benilla_formats::wow_data_or_skip!();
let chain = benilla_formats::open_chain(&data).expect("open chain");
let missing: Vec<String> = refs
.iter()
@@ -195,11 +188,7 @@ fn every_shipped_texture_path_resolves_in_the_client_archives() {
/// keys would rot into agreeing with itself).
#[test]
fn every_shipped_text_attribute_answers_against_the_real_global_strings() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
@@ -209,11 +209,7 @@ fn a_single_rank_line_paints_gray_with_no_rank_text() {
/// without client data.
#[test]
fn a_real_hunters_block_lists_exactly_what_the_reference_client_lists() {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_skill_line_catalog(&mut chain).expect("skill lines");
+1 -5
View File
@@ -263,11 +263,7 @@ mod tests {
/// rests on, and the one a synthetic catalog could not catch. Skips without client data.
#[test]
fn zone_names_resolve_against_real_area_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let areas = benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable.dbc");
+2 -10
View File
@@ -431,11 +431,7 @@ mod tests {
/// data (the `taxi_nodes.rs`/`taxi_path.rs` test style).
#[test]
fn real_taxi_projection_matches_geography() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let nodes = load_taxi_nodes(&mut chain).expect("load TaxiNodes");
let continents = load_world_map_continent_catalog(&mut chain).expect("load WMC");
@@ -491,11 +487,7 @@ mod tests {
/// data.
#[test]
fn build_nodes_classifies_a_real_known_hop() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let cat = TaxiCatalogs {
nodes: load_taxi_nodes(&mut chain).expect("load TaxiNodes"),
+3 -15
View File
@@ -60,11 +60,7 @@ fn empty_player() -> ObjectStore {
/// engine's byte formulas, and the view's verified cell shapes. Skips without client data.
#[test]
fn fireball_view_on_real_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
@@ -130,11 +126,7 @@ fn fireball_view_on_real_data() {
/// the mana-keyed Instant fork. Skips without client data.
#[test]
fn cost_and_cast_cells_on_real_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
@@ -221,11 +213,7 @@ fn cost_and_cast_cells_on_real_data() {
/// REAL 5875 data. Skips without client data.
#[test]
fn the_pinned_c6_lines_on_real_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
+4 -20
View File
@@ -244,11 +244,7 @@ fn trainer_icon_is_nil_until_the_product_template_lands_and_asks_once() {
/// serve 2756's own icon. Skips without client data.
#[test]
fn trainer_icon_on_real_data_reaches_for_the_crafted_item() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell");
@@ -300,11 +296,7 @@ fn trainer_icon_on_real_data_reaches_for_the_crafted_item() {
/// tree. Skips without client data.
#[test]
fn resolve_hops_the_learn_wrapper_to_the_taught_ability() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell");
let skills = benilla_formats::load_skill_line_catalog(&mut chain).expect("load skill lines");
@@ -337,11 +329,7 @@ fn resolve_hops_the_learn_wrapper_to_the_taught_ability() {
/// the director watches change. Skips without client data.
#[test]
fn display_name_is_the_wire_spell_not_the_taught_one() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell");
let skills = benilla_formats::load_skill_line_catalog(&mut chain).expect("load skill lines");
@@ -478,11 +466,7 @@ fn ability_req_met_tracks_known_spells_not_the_service_category() {
/// (Rank 1)", met iff the player knows 78. Skips without client data.
#[test]
fn ability_req_shows_the_required_rank_on_real_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell");
+6
View File
@@ -356,6 +356,12 @@ fn dev_map_jump(
data: Option<Res<WorldMapUiData>>,
net: Res<crate::net::NetCommands>,
) {
// A dev affordance living in a gameplay module — it names no dev root, so nothing about it
// fails to compile in a player build, and it shipped in 1174's (decision 1179). Alt-click is
// free-fly's closest sibling: it moves the player's body across the continent.
if !crate::run_mode::dev_affordances() {
return;
}
let alt = keys.any_pressed([KeyCode::AltLeft, KeyCode::AltRight]);
if !alt || !buttons.just_pressed(MouseButton::Left) {
return;
+9 -1
View File
@@ -5,10 +5,18 @@ edition.workspace = true
license.workspace = true
description = "Bevy AssetSource + AssetLoaders over the WoW 1.12.1 MPQ patch chain"
[features]
# Forwarded dev/player seam (0026 · 1173/1174 · 1175). Default-on. `benilla-formats` must be taken
# with `default-features = false` and its `dev` re-exported here: it is default-on there too, so
# without this a `--no-default-features` player build gets the feature back through unification and
# the binary carries the build machine's source-tree path after all.
default = ["dev"]
dev = ["benilla-formats/dev"]
[dependencies]
anyhow.workspace = true
bevy.workspace = true
benilla-formats = { path = "../benilla-formats" }
benilla-formats = { path = "../benilla-formats", default-features = false }
# Nearest-neighbour resize for off-Elwynn layer BLPs authored below LAYER_TEX_SIZE (texel
# replication in gamma byte space — no averaging, so the C2 mid-tone darkening can't creep in).
image = { version = "0.25", default-features = false }
@@ -36,7 +36,7 @@ fn main() -> anyhow::Result<()> {
let z: f32 = args.next().ok_or_else(usage)?.parse()?;
let filter = args.next().unwrap_or_default().to_lowercase();
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
let tiles = benilla_formats::MapTiles::load(&mut chain, &map)?;
let pin = wow_to_bevy([x, y, z]);
+7 -40
View File
@@ -247,11 +247,6 @@ mod tests {
use bevy::camera::primitives::MeshAabb;
use bevy::tasks::block_on;
/// crates/benilla-assets -> repo root -> WoW/Data (gitignored; test skips when absent).
fn vanilla_data_dir() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
}
/// The sampler-mode URL round-trips, and repeat/repeat stays byte-identical to the old bare
/// path — so the common case keeps ONE upload and no pre-existing URL changed (decision 0763).
#[test]
@@ -284,11 +279,7 @@ mod tests {
#[test]
fn mpq_reader_loads_real_client_bytes_through_the_assetreader() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let reader = MpqAssetReader::open(&data).expect("open mpq reader");
// A real file resolves and drains to its bytes through the Bevy AssetReader/Reader traits.
@@ -313,11 +304,7 @@ mod tests {
#[test]
fn loads_a_blp_image_through_the_full_mpq_pipeline() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
// Full pipeline, headless: mpq:// source → AssetServer → BlpImageLoader → Handle<Image>.
let mut app = App::new();
app.add_plugins(MinimalPlugins);
@@ -363,11 +350,7 @@ mod tests {
#[test]
fn minimap_tile_settings_reach_the_async_loader() {
use bevy::render::render_resource::TextureFormat;
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut app = App::new();
app.add_plugins(MinimalPlugins);
register_mpq_source(&mut app, &data).expect("register mpq source");
@@ -409,11 +392,7 @@ mod tests {
/// small groups 1×1. Grounds the (RE-inferred) footprint→grid bake the interior renderer rests on.
#[test]
fn ironforge_group_grid_matches_trs() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: no client data");
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut app = App::new();
app.add_plugins(MinimalPlugins);
register_mpq_source(&mut app, &data).expect("mpq");
@@ -457,11 +436,7 @@ mod tests {
#[test]
fn loads_an_m2_model_through_the_pipeline() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut app = App::new();
app.add_plugins(MinimalPlugins);
register_mpq_source(&mut app, &data).expect("register mpq source");
@@ -521,11 +496,7 @@ mod tests {
#[test]
fn loads_a_wmo_model_through_the_pipeline() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut app = App::new();
app.add_plugins(MinimalPlugins);
register_mpq_source(&mut app, &data).expect("register mpq source");
@@ -569,11 +540,7 @@ mod tests {
#[test]
fn loads_an_adt_terrain_tile_through_the_pipeline() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
// Find an existing Elwynn-area Azeroth tile (don't hardcode exact coords).
let reader = benilla_formats::Chain::open(&data).expect("open chain");
let mut url = None;
+2 -10
View File
@@ -932,11 +932,7 @@ mod tests {
/// palette. Skips when the client isn't installed (the repo ships no assets).
#[test]
fn a_welded_billboard_bone_still_reaches_the_palette_with_its_arm() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let path = "Item\\ObjectComponents\\Shoulder\\LShoulder_Plate_PVPAlliance_A_01.m2";
let bytes = chain.read_file(path).expect("read the pauldron");
@@ -976,11 +972,7 @@ mod tests {
/// seat whose ancestors don't rotate.
#[test]
fn the_riding_horse_seat_bone_carries_the_root_basis_arm() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let bytes = chain
.read_file("Creature\\RidingHorse\\RidingHorse.m2")
+1 -5
View File
@@ -885,11 +885,7 @@ mod doodad_base_tests {
/// calls, so it fails the moment the lane law regresses (decision 0969).
#[test]
fn booty_bays_entrance_arch_is_sky_lit_not_a_black_silhouette() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let data = benilla_formats::wow_data_or_skip!();
let stem = "World\\wmo\\Azeroth\\Buildings\\Stranglethorn_BootyBay\\BootyBay";
let chain = benilla_formats::Chain::open(&data).expect("open vanilla patch chain");
let bytes = chain
+10
View File
@@ -5,6 +5,16 @@ edition.workspace = true
license.workspace = true
description = "WoW 1.12.1 asset-format adapters (MPQ/BLP/DBC/ADT/WDT) and the benilla-extract CLI"
[features]
# The dev/player seam (0026, built by 1173/1174). Default-on, so every normal build keeps the
# instruments; `--no-default-features` is the player build. Here it gates exactly one thing:
# `install::candidates`' project-folder probe, which bakes THIS source tree's absolute path
# (decision 1175). EVERY dependent must take this crate with `default-features = false` and
# re-export `dev`, or feature unification puts `/Users/…` back into a player binary —
# `scripts/gates.sh`'s `--no-default-features` line is what catches getting that wrong.
default = ["dev"]
dev = []
[dependencies]
anyhow.workspace = true
clap.workspace = true
@@ -25,7 +25,7 @@ fn main() -> anyhow::Result<()> {
if args.is_empty() {
anyhow::bail!("usage: area_trigger_at <id|map:N>...");
}
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
let cat = benilla_formats::load_area_trigger_catalog(&mut chain)?;
println!("AreaTrigger.dbc — {} rows\n", cat.len());
@@ -14,7 +14,7 @@ fn main() -> anyhow::Result<()> {
.next()
.ok_or_else(|| anyhow::anyhow!("usage: batch_alpha <m2 path> [sequence slot]"))?;
let slot: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
let bytes = chain.read_file(&virt)?;
let subs = benilla_formats::parse_m2_render_submeshes(&bytes, "", &[])?;
+1 -1
View File
@@ -15,7 +15,7 @@ fn main() -> anyhow::Result<()> {
let virt = std::env::args()
.nth(1)
.ok_or_else(|| anyhow::anyhow!("usage: batch_lit <m2 path>"))?;
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
let bytes = chain.read_file(&virt)?;
let subs = benilla_formats::parse_m2_render_submeshes(&bytes, "", &[])?;
+1 -1
View File
@@ -1,7 +1,7 @@
//! Throwaway: dump a DBC's header (record count / field count / record size) and the first rows'
//! raw dwords, so a claimed column offset can be checked against the real shipped file.
fn main() -> anyhow::Result<()> {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
for name in std::env::args().skip(1) {
let path = format!("DBFilesClient\\{name}.dbc");
@@ -12,7 +12,7 @@ fn main() -> anyhow::Result<()> {
let virt = std::env::args()
.nth(1)
.ok_or_else(|| anyhow::anyhow!("usage: dump_emitters <m2 path>"))?;
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
let bytes = chain.read_file(&virt)?;
for (i, e) in benilla_formats::parse_m2_particle_emitters(&bytes)?
+1 -2
View File
@@ -4,13 +4,12 @@
//! data — pipe it to the scratchpad, never into the repo.
use std::io::Write;
use std::path::PathBuf;
fn main() -> anyhow::Result<()> {
let virt = std::env::args()
.nth(1)
.ok_or_else(|| anyhow::anyhow!("usage: dump_file <virtual\\mpq\\path>"))?;
let data = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
let bytes = chain.read_file(&virt)?;
std::io::stdout().write_all(&bytes)?;
@@ -21,7 +21,7 @@ fn main() -> anyhow::Result<()> {
let arg = std::env::args()
.nth(1)
.ok_or_else(|| anyhow::anyhow!("usage: dump_model_anim <m2 path | substring>"))?;
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
// An exact path dumps in full; anything else is a substring sweep that prints only the models
+1 -1
View File
@@ -13,7 +13,7 @@
fn main() -> anyhow::Result<()> {
let virt = std::env::args().nth(1).unwrap();
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
let bytes = chain.read_file(&virt)?;
let dir = virt.rsplit_once('\\').map_or("", |(d, _)| d);
@@ -179,7 +179,7 @@ fn main() -> anyhow::Result<()> {
.next()
.ok_or_else(|| anyhow::anyhow!("usage: eye_burial_anim <m2 path> [seq slot]"))?;
let slot: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
let bytes = chain.read_file(&virt)?;
@@ -142,7 +142,7 @@ fn main() -> anyhow::Result<()> {
let virt = std::env::args()
.nth(1)
.ok_or_else(|| anyhow::anyhow!("usage: eye_quad_pass <m2 path>"))?;
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
let bytes = chain.read_file(&virt)?;
+1 -1
View File
@@ -9,7 +9,7 @@ fn main() -> anyhow::Result<()> {
let virt = std::env::args()
.nth(1)
.ok_or_else(|| anyhow::anyhow!("usage: gseqdump <m2 path>"))?;
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
let bytes = chain.read_file(&virt)?;
for g in benilla_formats::parse_m2_global_sequence_bones(&bytes) {

Some files were not shown because too many files have changed in this diff Show More