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:
Generated
+20
-1
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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:
|
||||
//!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
+40
-11
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
+30
-6
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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?)");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "", &[])?;
|
||||
|
||||
@@ -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,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)?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user