From 7b4bd8af4ac89f854aa16c398342ddc3ab1bc7e5 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 11 Aug 2026 18:00:02 +0700 Subject: [PATCH] quest timers, honest interiors, and snow rebuilt from the bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - timed quests get their countdown — a Quest Timers box under the minimap and a Time Remaining line in the quest log, kept against the server's own clock - interiors go honest: footsteps stop crunching the terrain's snow through a building's floor, and tavern voices are authored dry, so the fake cave echo is gone - snow is the reference's again — point-sprite flakes at the era's pixel size, and a spawn slab that leans into your run so a runner no longer outruns the field - Stormwind stops drawing Elwynn through its walls, a looted quest crate's lid settles, cold zones puff visible breath, and the collect-quest toast fires only on a real advance Co-Authored-By: Claude Fable 5 --- Cargo.toml | 16 + .../benilla-app/assets/ui/CharacterFrame.xml | 41 +- .../benilla-app/assets/ui/QuestLogFrame.xml | 95 +- .../benilla-app/assets/ui/QuestTimerFrame.xml | 255 +++ crates/benilla-app/assets/ui/UIParent.xml | 81 +- crates/benilla-app/assets/ui/UnitFrames.xml | 127 +- crates/benilla-app/src/bgwin.rs | 50 + crates/benilla-app/src/blob_shadow.rs | 1 + crates/benilla-app/src/capture/live_shot.rs | 2 +- crates/benilla-app/src/capture/mod.rs | 2 +- crates/benilla-app/src/capture/probes.rs | 1510 ----------------- crates/benilla-app/src/capture/probes/act.rs | 325 ++++ .../src/capture/probes/live_fps.rs | 583 +++++++ crates/benilla-app/src/capture/probes/mod.rs | 137 ++ .../src/capture/probes/particle_census.rs | 210 +++ crates/benilla-app/src/capture/probes/run.rs | 233 +++ .../src/capture/probes/world_census.rs | 202 +++ crates/benilla-app/src/creature_anim.rs | 15 + .../benilla-app/src/creature_anim/breath.rs | 308 ++++ crates/benilla-app/src/cvars.rs | 14 +- crates/benilla-app/src/entities.rs | 7 + crates/benilla-app/src/entities/chain_beam.rs | 1 + crates/benilla-app/src/footprints.rs | 35 +- crates/benilla-app/src/go_anim.rs | 385 ++++- crates/benilla-app/src/ground_fx.rs | 1 + crates/benilla-app/src/interact/inspect.rs | 43 +- crates/benilla-app/src/lib.rs | 15 +- crates/benilla-app/src/minimap/interior.rs | 2 + crates/benilla-app/src/net.rs | 94 +- crates/benilla-app/src/net/apply.rs | 13 +- crates/benilla-app/src/net/apply/session.rs | 23 +- crates/benilla-app/src/net/io.rs | 1 + crates/benilla-app/src/particles/buffer.rs | 31 +- crates/benilla-app/src/particles/render.rs | 10 + crates/benilla-app/src/particles/sim.rs | 2 + crates/benilla-app/src/perf.rs | 61 + crates/benilla-app/src/pipe_warm/mod.rs | 1 + crates/benilla-app/src/player/state.rs | 23 + crates/benilla-app/src/ribbons.rs | 1 + crates/benilla-app/src/sound/footsteps.rs | 31 +- crates/benilla-app/src/sound/kit.rs | 19 +- crates/benilla-app/src/sound/mixer.rs | 24 +- crates/benilla-app/src/sound/mod.rs | 23 + crates/benilla-app/src/sound/reverb.rs | 24 +- crates/benilla-app/src/surface.rs | 82 + crates/benilla-app/src/target/reticle.rs | 1 + crates/benilla-app/src/target/ring.rs | 1 + crates/benilla-app/src/terrain_stream.rs | 4 +- .../benilla-app/src/terrain_stream/queries.rs | 19 + crates/benilla-app/src/ui_action/errors.rs | 5 +- crates/benilla-app/src/ui_action/feed.rs | 5 +- crates/benilla-app/src/ui_action/state.rs | 5 +- crates/benilla-app/src/ui_action/usable.rs | 5 +- crates/benilla-app/src/ui_craft.rs | 6 +- crates/benilla-app/src/ui_items/mod.rs | 476 ++++-- crates/benilla-app/src/ui_quest_log.rs | 207 ++- .../src/ui_script/framexml_diff.rs | 152 ++ .../src/ui_script/friends_tests.rs | 112 +- crates/benilla-app/src/ui_script/manifest.rs | 5 + crates/benilla-app/src/ui_script/mod.rs | 7 + .../src/ui_script/quest_timer_tests.rs | 192 +++ .../src/ui_script/questlog_tests.rs | 4 + .../src/ui_script/unit_frame_tests.rs | 151 +- crates/benilla-app/src/ui_tooltip/mod.rs | 17 +- crates/benilla-app/src/ui_tradeskill.rs | 6 +- crates/benilla-app/src/water_fx/mod.rs | 1 + crates/benilla-app/src/weather/precip/mist.rs | 65 +- crates/benilla-app/src/weather/precip/mod.rs | 234 ++- crates/benilla-app/src/weather/precip/pool.rs | 365 +++- .../benilla-app/src/weather/precip/render.rs | 306 +++- crates/benilla-app/src/weather/precip/wind.rs | 168 +- .../benilla-app/src/wmo_portal/audit/mod.rs | 1 + crates/benilla-app/src/wmo_portal/interior.rs | 97 +- crates/benilla-app/src/wmo_portal/mod.rs | 230 ++- crates/benilla-assets/src/blp.rs | 57 +- crates/benilla-assets/src/wmo.rs | 5 + .../benilla-formats/examples/surface_here.rs | 148 ++ crates/benilla-formats/src/area_table.rs | 82 + .../src/bin/benilla-extract/scan/sequence.rs | 53 +- crates/benilla-formats/src/creatures.rs | 65 + crates/benilla-formats/src/footsteps.rs | 36 +- .../benilla-formats/src/models/wmo/group.rs | 12 + crates/benilla-formats/src/models/wmo/root.rs | 14 + crates/benilla-formats/src/sound_entries.rs | 53 +- crates/benilla-formats/tests/chain.rs | 98 ++ .../benilla-formats/tests/m2_cold_breath.rs | 125 ++ .../benilla-formats/tests/m2_go_crate_lid.rs | 130 ++ .../src/bin/benilla-world/main.rs | 15 +- .../src/bin/benilla-world/probes/mod.rs | 2 + .../bin/benilla-world/probes/questtimer.rs | 191 +++ crates/benilla-protocol/src/events.rs | 6 + crates/benilla-protocol/src/events/decode.rs | 3 + .../benilla-protocol/src/messages/client.rs | 9 + crates/benilla-protocol/src/messages/mod.rs | 2 +- .../benilla-protocol/src/messages/opcode.rs | 12 + .../benilla-protocol/src/messages/packet.rs | 9 + crates/benilla-protocol/src/messages/parse.rs | 9 + crates/benilla-protocol/src/world/session.rs | 8 + .../src/world/writer/lifecycle.rs | 12 + .../benilla-protocol/tests/simple_packets.rs | 39 + crates/benilla-ui/src/script/model.rs | 10 + crates/benilla-ui/src/script/quest_log.rs | 265 ++- crates/benilla-wmo/src/lib.rs | 12 + 103 files changed, 7352 insertions(+), 2136 deletions(-) create mode 100644 crates/benilla-app/assets/ui/QuestTimerFrame.xml delete mode 100644 crates/benilla-app/src/capture/probes.rs create mode 100644 crates/benilla-app/src/capture/probes/act.rs create mode 100644 crates/benilla-app/src/capture/probes/live_fps.rs create mode 100644 crates/benilla-app/src/capture/probes/mod.rs create mode 100644 crates/benilla-app/src/capture/probes/particle_census.rs create mode 100644 crates/benilla-app/src/capture/probes/run.rs create mode 100644 crates/benilla-app/src/capture/probes/world_census.rs create mode 100644 crates/benilla-app/src/creature_anim/breath.rs create mode 100644 crates/benilla-app/src/surface.rs create mode 100644 crates/benilla-app/src/ui_script/framexml_diff.rs create mode 100644 crates/benilla-app/src/ui_script/quest_timer_tests.rs create mode 100644 crates/benilla-formats/examples/surface_here.rs create mode 100644 crates/benilla-formats/tests/m2_cold_breath.rs create mode 100644 crates/benilla-formats/tests/m2_go_crate_lid.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/questtimer.rs diff --git a/Cargo.toml b/Cargo.toml index 79498609..29fd776b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,3 +48,19 @@ opt-level = 1 inherits = "release" lto = "fat" codegen-units = 1 + +# The director's play loop (decision 1157): `release` plus incremental compilation. `benilla-app` is one crate of +# ~243k lines, and release is non-incremental by cargo's default, so a one-line edit re-optimizes +# all of it — measured 2m47s (43s frontend, ~121s LLVM at opt-level 3) against a 1.8s stamp rebuild +# and a 2.5s no-op. Incremental caches both halves per codegen unit, so an edit pays for what it +# touched. Cargo applies `incremental` to workspace members ONLY (verified: `target/debug/incremental` +# holds our 16 crates and zero registry deps), so the ~760 dependencies stay non-incremental and +# sccache-cacheable — 0192's wrapper and the cold-slot build are untouched. +# +# Why a separate profile rather than flipping `release`: incremental raises the codegen-unit count +# and drops cross-unit inlining, which is a real codegen difference. The FPS campaign grades on +# default `release` (0736) and same-day A/B is the law, so the graded profile must not quietly +# change under it. Run it as `cargo play` (the alias in `.cargo/config.toml`). +[profile.play] +inherits = "release" +incremental = true diff --git a/crates/benilla-app/assets/ui/CharacterFrame.xml b/crates/benilla-app/assets/ui/CharacterFrame.xml index 22d45eda..e4ba3cbe 100644 --- a/crates/benilla-app/assets/ui/CharacterFrame.xml +++ b/crates/benilla-app/assets/ui/CharacterFrame.xml @@ -325,23 +325,43 @@ -- the full row, not the currently-visible one, exactly as the ref's SetNumTabs(5) is. PanelTemplates_SetNumTabs(frame, 3) PanelTemplates_SetTab(frame, 1) + -- The reference LABELS its numerals here (CharacterFrame_OnLoad l.55-57: "Health" on + -- the player's health bar, "XP" on the XP bar, with the power bar's following the + -- resource in UnitFrames.xml). Shipped in 1146 and cut the same day on the director's + -- call — bare numbers read better on our bars (decision 1147). The mechanism is + -- untouched and live: SetTextStatusBarTextPrefix works, and restoring any one of the + -- three is the one line the reference has. If it is ever restored, note the ordering + -- it depends on — a prefix only sticks on a bar that already has its TextString, and + -- this file's seat works only because the manifest runs UnitFrames (52) and ActionBar + -- (76) well ahead of it (149), exactly as FrameXML runs PlayerFrame (44) and + -- MainMenuBar (34) ahead of CharacterFrame (53). end - -- ref CharacterFrame_OnShow (l.82-93): Show*TextStatusBarText/ShowWatchedReputationBarText - -- OMITTED — no unit-frame bar-text-mode toggle or reputation-bar-text mechanism exists in - -- this engine. UpdateMicroButtons is LIVE as of the micro-menu slice (MicroMenu.xml); it was - -- omitted only for want of a micro bar. UnitPVPName -> plain UnitName (this file's header - -- comment: PVP rank is a named deferral). + -- ref CharacterFrame_OnShow (l.82-93). The Show*TextStatusBarText calls are LIVE as of + -- decision 1146 — the note that used to sit here ("no unit-frame bar-text-mode toggle + -- exists in this engine") was true when it was written and stopped being true when the + -- numerals landed (1143). Opening the character sheet reveals your own and your pet's + -- numbers for as long as it is open, whatever Status Bar Text says, through the same + -- lockShow refcount the hover uses — so a hover inside an open sheet still balances. + -- ShowWatchedReputationBarText stays OMITTED: no reputation watch bar exists. + -- UnitPVPName -> plain UnitName (this file's header comment: PVP rank is a named deferral). function BenillaCharacterFrame_OnShow() if UpdateMicroButtons then UpdateMicroButtons() end PlaySound("igCharacterInfoOpen") SetPortraitTexture(getglobal("BenillaCharacterFramePortrait"), "player") getglobal("BenillaCharacterNameText"):SetText(UnitName("player")) + if ShowTextStatusBarText then -- engine tests load file subsets + ShowTextStatusBarText(BenillaPlayerFrameHealthBar) + ShowTextStatusBarText(BenillaPlayerFramePowerBar) + ShowTextStatusBarText(BenillaExpBar) + ShowTextStatusBarText(BenillaPetFrameHealthBar) + ShowTextStatusBarText(BenillaPetFrameManaBar) + end end -- ref CharacterFrame_OnHide (l.95-104): everything past the PlaySound is the OnShow list's - -- Hide-twin — UpdateMicroButtons live (MicroMenu.xml), the bar-text pair still OMITTED for - -- the same reason as above. The subframe hide-loop is OUR OWN addition + -- Hide-twin — UpdateMicroButtons live (MicroMenu.xml), the bar-text calls live too (1146), + -- the reputation one still omitted for the same reason as above. The subframe hide-loop is OUR OWN addition -- (decision 0437 phase 4): BenillaPaperDollFrame is a true engine child of this frame (its -- own header comment) so it hides for free on the parent's Hide-cascade, but -- BenillaSkillFrame is a SEPARATE top-level file — this engine has no cross-file `parent=` @@ -354,6 +374,13 @@ function BenillaCharacterFrame_OnHide() if UpdateMicroButtons then UpdateMicroButtons() end PlaySound("igCharacterInfoClose") + if HideTextStatusBarText then -- engine tests load file subsets + HideTextStatusBarText(BenillaPlayerFrameHealthBar) + HideTextStatusBarText(BenillaPlayerFramePowerBar) + HideTextStatusBarText(BenillaExpBar) + HideTextStatusBarText(BenillaPetFrameHealthBar) + HideTextStatusBarText(BenillaPetFrameManaBar) + end for _, name in ipairs(BENILLA_CHARACTERFRAME_SUBFRAMES) do local subFrame = getglobal(name) if subFrame then subFrame:Hide() end diff --git a/crates/benilla-app/assets/ui/QuestLogFrame.xml b/crates/benilla-app/assets/ui/QuestLogFrame.xml index 70651da6..3754e6d4 100644 --- a/crates/benilla-app/assets/ui/QuestLogFrame.xml +++ b/crates/benilla-app/assets/ui/QuestLogFrame.xml @@ -84,6 +84,12 @@ -- one rolling pool across every watch, dynamically re-chained each paint — the ref's own -- model (the old fixed 5×(1+4) per-slot grid predated region Show/Hide/SetPoint). BENILLA_QUESTWATCH_LINES = 30 + -- The timed-quest countdown row (decision 1150). UPDATE_DELAY is the ref's own + -- (QuestLogFrame.lua:5) — the throttle on the "Time Remaining:" repaint, not a benilla + -- dial. TIME_REMAINING is the shipped enUS GlobalString (GlobalStrings.lua:4248), inlined + -- like every other string in these files (we load no GlobalStrings.lua). + BENILLA_QUESTLOG_UPDATE_DELAY = 0.1 + BENILLA_TIME_REMAINING = "Time Remaining:" -- The detail pane's ScrollFrame child-resize sum (`BenillaQuestLogDetail_ResizeChild`'s own -- comment) — fixed offsets/row heights transcribed from this chain's own FontString/SetPoint @@ -454,6 +460,14 @@ else objSpan = BENILLA_QUESTLOG_DETAIL_OBJ_GAP end + -- The countdown row, when the selection has one (decision 1150): one more -10 hop and + -- one more fixed 12px row ahead of the objectives, whether or not any objective follows + -- it — both branches above start at the same ObjectivesText→next -10, so the delta is + -- the same either way. Read off the ref's own hasTimer flag rather than a second + -- parameter, so the OnUpdate re-check below re-sums it without threading state. + if BenillaQuestLogHasTimer then + objSpan = objSpan + BENILLA_QUESTLOG_DETAIL_OBJ_GAP + BENILLA_QUESTLOG_DETAIL_OBJ_ROW + end local height = BENILLA_QUESTLOG_DETAIL_TOP + BenillaQuestLogDetail_TextHeight(title) @@ -486,6 +500,24 @@ BenillaQuestLogDetail_ResizeChild(detailObjAnchorIndex, detailRewardHeight) end + -- ref QuestLog_OnUpdate (QuestLogFrame.lua:96-105), transcribed 1:1 including its + -- UPDATE_DELAY throttle: the countdown is repainted ten times a second, not every frame — + -- SecondsToTime's output only changes once a second, so anything faster is a re-measure of + -- an identical string. The flag/accumulator pair is the ref's own (its two fields on + -- QuestLogFrame; ours are file globals because our detail state already lives that way). + function BenillaQuestLog_OnUpdate(elapsed) + if not BenillaQuestLogHasTimer then + return + end + BenillaQuestLogTimePassed = (BenillaQuestLogTimePassed or 0) + (elapsed or 0) + if BenillaQuestLogTimePassed > BENILLA_QUESTLOG_UPDATE_DELAY then + local left = GetQuestLogTimeLeft() + getglobal("BenillaQuestLogTimerText"):SetText( + BENILLA_TIME_REMAINING .. " " .. SecondsToTime(left or 0)) + BenillaQuestLogTimePassed = 0 + end + end + -- Steps the detail ScrollFrame by `delta` px — the wheel handler and both scrollbar arrow -- buttons share this one path; SetVerticalScroll's own clamp (benilla-ui's `scrollframe.rs`) -- does the range-bounding, this function just supplies the signed step. @@ -529,6 +561,11 @@ function BenillaQuestLogDetail_Clear() getglobal("BenillaQuestLogQuestTitle"):SetText("") getglobal("BenillaQuestLogObjectivesText"):SetText("") + -- The ref clears hasTimer on the empty-log path too (QuestLog_Update, :108) — without + -- it the per-tick repaint keeps writing a countdown into a pane with no selection, and + -- the child height keeps paying for a row that isn't there. + BenillaQuestLogHasTimer = nil + getglobal("BenillaQuestLogTimerText"):SetText("") getglobal("BenillaQuestLogDescriptionTitle"):SetText("") getglobal("BenillaQuestLogDescription"):SetText("") for i = 1, BENILLA_QUESTLOG_MAX_OBJECTIVES do @@ -758,6 +795,26 @@ -- paragraph (not the leaderboard lines) is the second. local description, objectivesText = GetQuestLogQuestText() getglobal("BenillaQuestLogObjectivesText"):SetText(objectivesText or "") + + -- pin §3, QuestLog_UpdateQuestDetails (QuestLogFrame.lua:364-374) verbatim, minus the + -- Show/Hide our region model has no verb for: a timed quest gets the "Time Remaining:" + -- row and pushes Objective1 below it; an untimed one blanks the row (our stand-in for + -- the ref's :Hide()) and anchors Objective1 straight onto ObjectivesText, so the row + -- costs an untimed quest no space at all. `hasTimer` is the ref's own flag and drives + -- both the per-tick repaint (BenillaQuestLog_OnUpdate) and the child-height sum. + local questTimer = GetQuestLogTimeLeft() + local timerText = getglobal("BenillaQuestLogTimerText") + local objective1 = getglobal("BenillaQuestLogObjective1") + if questTimer then + BenillaQuestLogHasTimer = 1 + BenillaQuestLogTimePassed = 0 + timerText:SetText(BENILLA_TIME_REMAINING .. " " .. SecondsToTime(questTimer)) + objective1:SetPoint("TOPLEFT", timerText, "BOTTOMLEFT", 0, -10) + else + BenillaQuestLogHasTimer = nil + timerText:SetText("") + objective1:SetPoint("TOPLEFT", getglobal("BenillaQuestLogObjectivesText"), "BOTTOMLEFT", 0, -10) + end getglobal("BenillaQuestLogDescription"):SetText(description or "") -- Lua-managed header (ref text=QUEST_DESCRIPTION is static since the ref always has a real -- selection whenever this pane is even visible; our flat window keeps the same FontString @@ -791,13 +848,15 @@ -- Objective10 — a quest with fewer than 10 objectives would otherwise drag this header -- down by however much dead space the unused tail slots add (this window's own comment -- on the FontString). No RequiredMoney branch here (this file's documented v1 gap: no - -- widget backs GetQuestLogRequiredMoney yet); no timer branch (no timed-quest countdown, - -- same v1 gap as QuestLogObjective1's own fixed anchor) — those two ref branches collapse - -- to the ref's own final "else" (anchor to ObjectivesText) whenever they don't apply. + -- widget backs GetQuestLogRequiredMoney yet) — that ref branch collapses into the rest. + -- The objective-less TIMED branch (ref :432-437) is transcribed: a timed quest with no + -- leaderboard lines hangs the header off the countdown row, not off ObjectivesText. local objAnchorIndex = math.min(numObjectives, BENILLA_QUESTLOG_MAX_OBJECTIVES) local descTitle = getglobal("BenillaQuestLogDescriptionTitle") if objAnchorIndex > 0 then descTitle:SetPoint("TOPLEFT", getglobal("BenillaQuestLogObjective" .. objAnchorIndex), "BOTTOMLEFT", 0, -10) + elseif questTimer then + descTitle:SetPoint("TOPLEFT", getglobal("BenillaQuestLogTimerText"), "BOTTOMLEFT", 0, -10) else descTitle:SetPoint("TOPLEFT", getglobal("BenillaQuestLogObjectivesText"), "BOTTOMLEFT", 0, -10) end @@ -1480,15 +1539,28 @@ + + + + + + BenillaQuestLog_OnUpdate(arg1) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + QuestTimerFrame_OnLoad(self) + QuestTimerFrame_OnUpdate() + QuestTimerFrame_OnEvent() + + UIParent_ManageFramePositions() + UIParent_ManageFramePositions() + + + diff --git a/crates/benilla-app/assets/ui/UIParent.xml b/crates/benilla-app/assets/ui/UIParent.xml index c7f5bc22..a3f07462 100644 --- a/crates/benilla-app/assets/ui/UIParent.xml +++ b/crates/benilla-app/assets/ui/UIParent.xml @@ -75,6 +75,67 @@ -- read by every `Model_OnUpdate` transcription (the character window's + inspect's). ROTATIONS_PER_SECOND = .5 + -- ref SecondsToTime (UIParent.lua:1004-1031) and its GlobalStrings, in the ref's own home + -- file — two consumers now (the quest-timer window and the quest log's "Time Remaining:" + -- row, decision 1150), so it lives here rather than being redefined per file the way + -- BuffFrame.xml had to for SecondsToTimeAbbrev when it was the only caller. + -- + -- Departures, both named: + -- - `GetText(TOKEN, nil, n)` is the ref's plural-aware GlobalString lookup, a C binding we + -- don't ship. Its enUS rule is the singular token for exactly 1 and the `_P1` twin + -- otherwise — the same rule already codified in `tooltip_item/render.rs` and MailFrame.xml + -- — so it collapses to the local `plural()` below and the four shipped string pairs + -- (GlobalStrings.lua:895-896, 2296-2297, 2693-2694, 3404-3405). + -- - Lua 5.1 dialect: `math.mod`/`math.floor` for the 5.0 bare `mod`/`floor`. + -- + -- Everything else is verbatim, INCLUDING the two behaviours that look like bugs and are + -- not: the thresholds are strict `>` (86400 s reads as hours, not "1 Day"), and `count < 2` + -- stops after two units, so a 3-day quest reads "3 Days 4 Hrs" and never mentions minutes. + DAYS_ABBR = DAYS_ABBR or "Day" + DAYS_ABBR_P1 = DAYS_ABBR_P1 or "Days" + HOURS_ABBR = HOURS_ABBR or "Hr" + HOURS_ABBR_P1 = HOURS_ABBR_P1 or "Hrs" + MINUTES_ABBR = MINUTES_ABBR or "Min" + MINUTES_ABBR_P1 = MINUTES_ABBR_P1 or "Mins" + SECONDS_ABBR = SECONDS_ABBR or "Sec" + SECONDS_ABBR_P1 = SECONDS_ABBR_P1 or "Secs" + + if not SecondsToTime then + local function plural(singular, pluralForm, n) + if n == 1 then return singular end + return pluralForm + end + + function SecondsToTime(seconds, noSeconds) + local time = "" + local count = 0 + local tempTime + seconds = math.floor(seconds) + if seconds > 86400 then + tempTime = math.floor(seconds / 86400) + time = tempTime .. " " .. plural(DAYS_ABBR, DAYS_ABBR_P1, tempTime) .. " " + seconds = math.mod(seconds, 86400) + count = count + 1 + end + if seconds > 3600 then + tempTime = math.floor(seconds / 3600) + time = time .. tempTime .. " " .. plural(HOURS_ABBR, HOURS_ABBR_P1, tempTime) .. " " + seconds = math.mod(seconds, 3600) + count = count + 1 + end + if count < 2 and seconds >= 60 then + tempTime = math.floor(seconds / 60) + time = time .. tempTime .. " " .. plural(MINUTES_ABBR, MINUTES_ABBR_P1, tempTime) .. " " + seconds = math.mod(seconds, 60) + count = count + 1 + end + if count < 2 and seconds > 0 and not noSeconds then + time = time .. seconds .. " " .. plural(SECONDS_ABBR, SECONDS_ABBR_P1, seconds) .. " " + end + return time + end + end + UIPARENT_MANAGED_FRAME_POSITIONS = {} -- Frames UIPARENT_MANAGED_FRAME_POSITIONS["MultiBarBottomLeft"] = {baseY = 17, reputation = 9, maxLevel = -5, anchorTo = "ActionButton1", point = "BOTTOMLEFT", rpoint = "TOPLEFT"} @@ -247,14 +308,24 @@ -- The under-minimap right stack (ref l.1740-1770): anchorY walks down capture bars → -- quest timer → durability → quest watch, each shown frame pushing the next one down - -- by its own height. benilla ships the durability + quest-watch arms (below); the two - -- feeders above them (capture bars, quest timer) don't exist yet, so anchorY starts at - -- the ref's initial 0. The reference never leaves DurabilityFrame on its static XML - -- anchor (whose +40 hangs the side glyphs off-screen — director-caught): every pass - -- seats it at -CONTAINER_OFFSET_X, minus 20 more when a showSeparate glyph + -- by its own height. benilla ships the quest-timer, durability and quest-watch arms + -- (below); the capture bars above them don't exist yet, so anchorY starts at the ref's + -- initial 0. The reference never leaves DurabilityFrame on its static XML anchor + -- (whose +40 hangs the side glyphs off-screen — director-caught): every pass seats it + -- at -CONTAINER_OFFSET_X, minus 20 more when a showSeparate glyph -- (shield/off-weapon/ranged) extends the art to the right — then drops the quest -- tracker to the running anchorY so the two never collide at the same corner. local anchorY = 0 + -- ref l.1753-1757 verbatim (decision 1150). Seated whether shown or not — the ref + -- SetPoints unconditionally and only the height accrual is gated — so the frame is + -- already in place the frame it appears. Nil-guarded like every row here: the + -- durability-only test harness ships no quest-timer frame. + if ( QuestTimerFrame ) then + QuestTimerFrame:SetPoint("TOPRIGHT", "MinimapCluster", "BOTTOMRIGHT", -CONTAINER_OFFSET_X, anchorY) + if ( QuestTimerFrame:IsShown() ) then + anchorY = anchorY - QuestTimerFrame:GetHeight() + end + end if ( DurabilityFrame ) then local durabilityOffset = 0 if ( DurabilityShield:IsShown() or DurabilityOffWeapon:IsShown() or DurabilityRanged:IsShown() ) then diff --git a/crates/benilla-app/assets/ui/UnitFrames.xml b/crates/benilla-app/assets/ui/UnitFrames.xml index d9beec4c..837d95db 100644 --- a/crates/benilla-app/assets/ui/UnitFrames.xml +++ b/crates/benilla-app/assets/ui/UnitFrames.xml @@ -162,6 +162,15 @@ frame.powerBar.textLockable = 1 end end + -- The hover seam (1146): who this bar belongs to, and what its click means. + local click = BenillaTargetFrame_OnClick + if unit == "player" then + click = BenillaPlayerFrame_OnClick + end + frame.healthBar.owner = frame + frame.powerBar.owner = frame + frame.healthBar.ownerClick = click + frame.powerBar.ownerClick = click frame.nameText = getglobal(tf .. "NameText") frame.levelText = getglobal(tf .. "LevelText") frame.deadText = getglobal(tf .. "DeadText") @@ -268,6 +277,41 @@ -- over a UI element in 1.12, and it is authored here in Lua rather than in the engine -- (wow-re item-target-cursor-and-dropitemonunit.md 4.3). Bag and paper-doll slots have no -- such branch, which is why they show the ordinary pointer. + -- A BAR's own hover (decision 1146; ref TextStatusBar's OnEnter/OnLeave on a mouse-enabled + -- bar). Two jobs, and the second is the one a naive transcription drops: reveal the numbers + -- while the pointer is on the bar, then hand the hover ON to the frame underneath, because + -- a mouse-enabled child swallows the parent's OnEnter — and with it the unit tooltip AND + -- the cast-cursor split that only exists over unit frames. Same for the click: the ref + -- routes its bars' OnMouseUp straight back to the frame's own handler (PlayerFrame.xml + -- l.304-306), which is what keeps a bar click targeting. + -- + -- `owner`/`ownerClick` are stamped at load rather than read from GetParent(), because the + -- pet's bars are siblings of its TextureFrame, not children of the frame they belong to. + function BenillaUnitFrameBar_OnEnter(bar) + if TextStatusBar_UpdateTextString then + TextStatusBar_UpdateTextString(bar) + ShowTextStatusBarText(bar) + end + if bar.owner and BenillaUnitFrame_OnEnter and bar.owner.unit then + BenillaUnitFrame_OnEnter(bar.owner) + end + end + + function BenillaUnitFrameBar_OnLeave(bar) + if HideTextStatusBarText then + HideTextStatusBarText(bar) + end + if bar.owner and BenillaUnitFrame_OnLeave and bar.owner.unit then + BenillaUnitFrame_OnLeave(bar.owner) + end + end + + function BenillaUnitFrameBar_OnMouseUp(bar, button) + if bar.ownerClick then + bar.ownerClick(button) + end + end + function BenillaUnitFrame_OnEnter(frame) local unit = frame.unit if SpellIsTargeting() then @@ -533,6 +577,12 @@ local _, token = UnitPowerType(unit) local c = BENILLA_POWER_COLORS[token] or BENILLA_POWER_COLORS.MANA frame.powerBar:SetStatusBarColor(c.r, c.g, c.b) + -- NO label. 1.12 names the resource here (ref UnitFrame_UpdateManaType l.129 → + -- "Rage 0 / 100" off ManaBarColor's own prefix field, and CharacterFrame_OnLoad + -- labels health and XP the same way). Shipped in 1146, cut on the director's call + -- the same day: bare numbers read better on these bars (decision 1147). The + -- mechanism stays — SetTextStatusBarTextPrefix is live and one call brings any + -- label back — only the calls are gone. frame.powerBar:SetMinMaxValues(0, maxPower) frame.powerBar:SetValue(UnitPower(unit)) else @@ -928,7 +978,7 @@ - + @@ -938,10 +988,13 @@ if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end + BenillaUnitFrameBar_OnEnter(self) + BenillaUnitFrameBar_OnLeave(self) + BenillaUnitFrameBar_OnMouseUp(self, arg1) - + @@ -951,6 +1004,9 @@ if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end + BenillaUnitFrameBar_OnEnter(self) + BenillaUnitFrameBar_OnLeave(self) + BenillaUnitFrameBar_OnMouseUp(self, arg1) - + @@ -1226,10 +1282,13 @@ if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end + BenillaUnitFrameBar_OnEnter(self) + BenillaUnitFrameBar_OnLeave(self) + BenillaUnitFrameBar_OnMouseUp(self, arg1) - + @@ -1239,6 +1298,9 @@ if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end + BenillaUnitFrameBar_OnEnter(self) + BenillaUnitFrameBar_OnLeave(self) + BenillaUnitFrameBar_OnMouseUp(self, arg1) + + + + + + + + + + @@ -1455,6 +1534,10 @@ frame.healthBar.textLockable = 1 frame.manaBar.textLockable = 1 end + -- The pet's bars hover for their numbers, but hand nothing on: the pet frame has no + -- OnEnter of its own, and its click is its own handler (1146). + frame.healthBar.ownerClick = BenillaPetFrame_OnClick + frame.manaBar.ownerClick = BenillaPetFrame_OnClick frame.attackModeCounter = 0 frame.attackModeSign = -1 frame:RegisterEvent("PLAYER_ENTERING_WORLD") @@ -1716,7 +1799,7 @@ - + @@ -1724,9 +1807,12 @@ if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end + BenillaUnitFrameBar_OnEnter(self) + BenillaUnitFrameBar_OnLeave(self) + BenillaUnitFrameBar_OnMouseUp(self, arg1) - + @@ -1734,6 +1820,9 @@ if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end + BenillaUnitFrameBar_OnEnter(self) + BenillaUnitFrameBar_OnLeave(self) + BenillaUnitFrameBar_OnMouseUp(self, arg1) @@ -1769,20 +1858,24 @@ - + - + - + diff --git a/crates/benilla-app/src/bgwin.rs b/crates/benilla-app/src/bgwin.rs index 25f4993f..9938356b 100644 --- a/crates/benilla-app/src/bgwin.rs +++ b/crates/benilla-app/src/bgwin.rs @@ -83,6 +83,56 @@ pub fn background_run() -> bool { }) } +/// Background-run envs whose run produces **no image** — nothing reads the framebuffer, so the +/// window is pure plumbing. Prefix-matched, and the rule in [`no_pixel_run`] is **all-of**: a run +/// counts as no-pixel only when *every* background env it sets is in this list, so a +/// `WOW_PROBE_CHAT=… WOW_LIVE_SHOT=…` pairing (drive there, then shoot) keeps its full-size window. +/// +/// Erring here is cheap in one direction only: a missing entry just means a big window, which is +/// the old behaviour. A *wrong* entry shrinks a window someone is photographing — so a new +/// `WOW_PROBE…`-named env that captures pixels has to be excluded by name here, since the bare +/// `WOW_PROBE` prefix would otherwise sweep it in. +const NO_PIXEL_ENV_PREFIXES: &[&str] = &[ + "WOW_FPS_", + "WOW_LIVE_FPS", + "WOW_PARTICLE_CENSUS", + "WOW_PROBE", + "WOW_RIG", +]; + +/// Does this run read no pixels — i.e. may its window be small and parked out of the way? +/// +/// The point is the director's screen. A probe window is asserted `AlwaysOnTop` for its whole life +/// ([`crate::capture::ProbeFocusPlugin`], decision 0906 — an occluded macOS window throttles to +/// ~1 fps and a wall-clock probe schedule then fires the wrong steps), which silently defeats +/// everything this module does to keep instrumented runs behind the director's work: the level is +/// re-asserted every frame, over the `AlwaysOnBottom` birth cage AND over the `Normal` this module +/// hands back at release. That assertion is correct and stays. What was wrong is pairing it with +/// the **full 1600×900 default**, so every agent probe planted a screen-filling window over +/// whatever they were doing — six of them in one session, which is how it got reported +/// (decision 1148). Un-occludable and unobtrusive were never in conflict: a small window in a +/// corner is both. +/// +/// `WOW_BG=1` (forced background, no run-driving env) is deliberately NOT no-pixel — that is the +/// hand-driven case, and shrinking a window the director asked for is the opposite of the point. +pub fn no_pixel_run() -> bool { + if !background_run() { + return false; + } + let mut saw_one = false; + for (name, _) in std::env::vars_os() { + let Some(name) = name.to_str() else { continue }; + if !BG_ENV_PREFIXES.iter().any(|p| name.starts_with(p)) { + continue; + } + if !NO_PIXEL_ENV_PREFIXES.iter().any(|p| name.starts_with(p)) { + return false; + } + saw_one = true; + } + saw_one +} + /// On a [`background_run`], undo winit's launch-time app activation and window ordering (macOS; /// no-op elsewhere) for the length of the launch window, then get out of the way. The /// `focused: false` half lives where the window is built, in `main`. diff --git a/crates/benilla-app/src/blob_shadow.rs b/crates/benilla-app/src/blob_shadow.rs index 1d9f36df..91ad43c5 100644 --- a/crates/benilla-app/src/blob_shadow.rs +++ b/crates/benilla-app/src/blob_shadow.rs @@ -493,6 +493,7 @@ fn push_shadows( anchor: key.feet, bias: SHADOW_SORT_BIAS, raster_bias: SHADOW_RASTER_BIAS, + cam_relative: false, main_entity: entity, light: None, }, diff --git a/crates/benilla-app/src/capture/live_shot.rs b/crates/benilla-app/src/capture/live_shot.rs index dcd2bdde..876bf33a 100644 --- a/crates/benilla-app/src/capture/live_shot.rs +++ b/crates/benilla-app/src/capture/live_shot.rs @@ -2,7 +2,7 @@ //! with the two validity gates that make its output *evidence*: the death gate (a dead/ghost //! avatar renders the whole world through the death filter) and the subject gate //! (`WOW_SHOT_REQUIRE` — a frame that cannot contain the reported subject is not a measurement of -//! it). Split from `probes.rs` when the gates outgrew it; the sibling probe one-shots stay there. +//! it). Split from `probes` when the gates outgrew it; the sibling probe one-shots stay there. use bevy::prelude::*; use bevy::render::view::screenshot::{save_to_disk, Screenshot}; diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 233489a7..c938f3b5 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -401,7 +401,7 @@ fn resize_request() -> Option<(u32, u32)> { Some((w.parse().ok()?, h.parse().ok()?)) } -/// The present mode a perf probe uncaps to (also the live probe's — see `probes.rs`). +/// The present mode a perf probe uncaps to (also the live probe's — see `probes/live_fps.rs`). /// /// `AutoNoVsync`, measured, not assumed: explicit `Immediate` on macOS/Metal is a trap — A/B'd /// 2026-07-27 (overlook-noon, release, twice each), it *rails* near 16.6 ms AND takes 1.0–1.5 s diff --git a/crates/benilla-app/src/capture/probes.rs b/crates/benilla-app/src/capture/probes.rs deleted file mode 100644 index 3faca803..00000000 --- a/crates/benilla-app/src/capture/probes.rs +++ /dev/null @@ -1,1510 +0,0 @@ -//! The LIVE-run probe instruments — every plugin here rides a NORMAL connected session (unlike -//! the parent's server-less [`super::CapturePlugin`] harness): scripted chat sends -//! ([`ProbeChatPlugin`]), synthetic key taps ([`ProbeKeyPlugin`]), a Lua chunk in the live UI VM -//! ([`ProbeLuaPlugin`]), the bounded-lifetime self-exit ([`ProbeExitPlugin`]), and the live -//! frame-time sampler ([`LiveFpsPlugin`]). The live screenshot and its validity gates live in the -//! sibling [`super::live_shot`]. Each is env-gated and registered by `main`; compose them for -//! unattended "park, act, observe" probes. - -use bevy::ecs::system::SystemParam; -use bevy::prelude::*; - -use super::PROBE_WARMUP_FRAMES; - -/// The clock **every probe schedule reads** — real time, never the virtual clock (decision 0789). -/// -/// A probe knob is a wall-clock instruction: "send this at 20 s", "sample 300 frames from 25 s", -/// "resize at 12 s", "exit at 480 s". `Time` cannot honour one — it clamps every frame delta -/// to `max_delta` (250 ms, `bevy_time`'s default), so on any leg that hitches (a streaming burst, an -/// occluded window, a loaded machine) it falls behind real time and drags the whole schedule with it. -/// The knob then means something the operator never asked for, *silently*. -/// -/// **Third time this clock has cost us a measurement.** 0615 moved the relayed-move replay off it -/// (and cites the UI script clock as the same lesson before that); then B131's first causal leg was -/// destroyed by it here — the probe-chat hops drifted 40 s → 75 s apart, so windows labelled -/// "parked, ticks off" in fact contained a teleport and live ticks, and an eight-minute leg had to be -/// thrown away (decision 0785's discarded run). A named alias is what makes the next probe get it -/// right without knowing the story: type this, and the mistake is unavailable. -/// -/// The one deliberate virtual clock in the harness is the fixture **age** in -/// [`super::drive_capture`] — an age on the clock the effect animates on (and which the capture -/// freezes at save time), which is not a schedule at all. -pub(crate) type ProbeClock<'w> = Res<'w, Time>; - -/// The PROBE CHAT one-shot (`WOW_PROBE_CHAT="[;…]"`, delay via `WOW_PROBE_CHAT_AT` -/// seconds, default 8): send each `;`-separated line as Say once we are in-world — the "park the -/// probe character anywhere" instrument. The probe account (gmlevel 6) makes `.go xyz …`, `.gm on`, -/// `.additem` etc. work headlessly, which a direct `characters` DB edit does NOT (the live world -/// server's logout save overwrites it, and the row is only re-read at login). Pair with -/// [`LiveShotPlugin`] at a later `WOW_LIVE_SHOT_AT` so the destination has streamed in. -/// `WOW_PROBE_CHAT_EVERY=` spaces the lines apart instead of sending them in one burst — -/// the "do X, wait, then do Y" probe (a mount-then-dismount transition, a buff-then-cancel): -/// two field flips inside one drain merge to a no-op, so time-separated sends are what actually -/// exercise a transition (decision 0441's teardown verification). -pub(crate) struct ProbeChatPlugin; - -impl Plugin for ProbeChatPlugin { - fn build(&self, app: &mut App) { - let lines = std::env::var("WOW_PROBE_CHAT").unwrap_or_default(); - let at = std::env::var("WOW_PROBE_CHAT_AT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(8.0); - let every = std::env::var("WOW_PROBE_CHAT_EVERY") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(0.0); - app.insert_resource(ProbeChat { - lines, - at, - every, - sent: 0, - }) - .add_systems(Update, fire_probe_chat); - } -} - -/// [`ProbeChatPlugin`] state: the `;`-separated lines, the first-fire time, the per-line spacing -/// (`0` = one burst), and how many lines have gone out. -#[derive(Resource)] -struct ProbeChat { - lines: String, - at: f32, - every: f32, - sent: usize, -} - -/// The PROBE KEY one-shots (`WOW_PROBE_KEY="@[:][;…]"`): synthesize a key press -/// at each given time once in-world, released `` seconds later ([`PROBE_KEY_TAP_SECS`] when -/// omitted — the tap this instrument shipped with). The "press space headlessly" instrument for -/// input-gated behavior (the mounted flourish, a jump, the X/Z toggles), which neither a chat -/// command nor a Lua chunk can reach (1.12 has no jump Lua API; the gate lives in the -/// controller's key read). -/// -/// The optional hold is what makes *sustained* locomotion reachable headlessly: a 0.25 s W tap -/// travels ~1.2 yd, far too little to cross a liquid surface's own slope, so a swim defect that -/// only appears while moving over water could not be reproduced without asking the director to -/// drive (decision 0644 — the gap `WOW_PROBE_LOOK` closed for mouse-turns, on the key side). -/// -/// Runs in `PreUpdate` -/// after winit's input processing ([`bevy::input::InputSystems`]) so the synthetic -/// `just_pressed` is visible to every `Update` reader that same frame — a press from inside -/// `Update` would be cleared at the next frame's input pass before an earlier-ordered -/// controller ever saw it. -pub(crate) struct ProbeKeyPlugin; - -/// Keep a probe run's window **un-occludable** — the one defence against macOS's ~1 fps -/// throttle for a fully covered window (decisions 0713/0777, method.md's `caffeinate` note). -/// -/// It used to live inside the FPS probe alone, which reads as "a frame-rate concern". It is not: -/// **every** scripted probe schedule is wall-clock ([`ProbeClock`]), so a throttled run doesn't -/// just measure slowly, it *executes the wrong script* — this session's mounted-jump run fired -/// `W@16` and `Space@19` in the SAME frame at ~1 fps, i.e. it jumped from a standstill instead of -/// mid-run, and the leg had to be re-read to notice (decision 0906). Any probe env arms it, so a -/// key/chat/Lua probe defends itself exactly like the FPS one. Write-gated: re-marking `Window` -/// every frame would re-apply its whole state through winit. -pub(crate) struct ProbeFocusPlugin; - -impl Plugin for ProbeFocusPlugin { - fn build(&self, app: &mut App) { - app.add_systems(Update, keep_probe_window_on_top); - } -} - -fn keep_probe_window_on_top(mut windows: Query<&mut Window, With>) { - if let Ok(mut w) = windows.single_mut() { - if w.window_level != bevy::window::WindowLevel::AlwaysOnTop { - w.window_level = bevy::window::WindowLevel::AlwaysOnTop; - } - } -} - -impl Plugin for ProbeKeyPlugin { - fn build(&self, app: &mut App) { - let spec = std::env::var("WOW_PROBE_KEY").unwrap_or_default(); - let taps = spec - .split(';') - .map(str::trim) - .filter(|s| !s.is_empty()) - .filter_map(|s| { - let (key, rest) = s.split_once('@')?; - let (at, hold) = match rest.split_once(':') { - Some((at, hold)) => (at, hold.trim().parse::().ok()?), - None => (rest, PROBE_KEY_TAP_SECS), - }; - match (probe_key_by_name(key.trim()), at.trim().parse::()) { - (Some(key), Ok(at)) => Some(ProbeKeyTap { - key, - at, - hold, - pressed: false, - released: false, - }), - _ => { - warn!("probe-key: unparseable tap {s:?} (want e.g. Space@14 or W@20:6) — skipped"); - None - } - } - }) - .collect(); - app.insert_resource(ProbeKeys { taps }).add_systems( - bevy::app::PreUpdate, - fire_probe_key.after(bevy::input::InputSystems), - ); - } -} - -/// How long a probe press stays held when the spec gives no `:`. Long enough that a -/// `pressed`-reader (a held-key gate) sees it across several frames; short enough to stay a tap. -const PROBE_KEY_TAP_SECS: f32 = 0.25; - -/// The key names [`ProbeKeyPlugin`] accepts — the controller-read set; extend as probes need. -fn probe_key_by_name(name: &str) -> Option { - Some(match name { - "Space" => KeyCode::Space, - "W" => KeyCode::KeyW, - "A" => KeyCode::KeyA, - "S" => KeyCode::KeyS, - "D" => KeyCode::KeyD, - "Q" => KeyCode::KeyQ, - "E" => KeyCode::KeyE, - "X" => KeyCode::KeyX, - "Z" => KeyCode::KeyZ, - "Tab" => KeyCode::Tab, - // The detached free-fly toggle (`player.rs`) and its Ctrl speed boost (`camera.rs`, ×5). - // Added because the harness could not reach the leg that broke decision 0793: the director's - // first real run was a boosted free-fly, whose camera crosses the art radius in ~5 s, and - // reproducing it needed a held `Ctrl` + `W` behind an `F`. - // - // The toggle is the dev chord + `F` since decision 1043 (it was a bare `F`), so a probe that - // wants free-fly holds `Ctrl` and `Shift` across the `F` tap — hence `Shift` here. `Ctrl` - // stays held afterwards for the ×5 boost, which is unchanged. - "F" => KeyCode::KeyF, - "Ctrl" => KeyCode::ControlLeft, - "Shift" => KeyCode::ShiftLeft, - // The text-editing keys. Added for decision 1077's hyperlink-atomicity law, whose whole - // observable — "one BACKSPACE removes a whole item link" — is a keypress no chat command - // and no Lua chunk can reach (`EditBox` has no Lua deletion API; the law lives behind the - // host's chord table). Without these a caret/deletion defect could only be reproduced by - // asking the director to type, which is the asymmetry probes exist to remove. - "Backspace" => KeyCode::Backspace, - "Delete" => KeyCode::Delete, - "Left" => KeyCode::ArrowLeft, - "Right" => KeyCode::ArrowRight, - "Home" => KeyCode::Home, - "End" => KeyCode::End, - _ => return None, - }) -} - -/// [`ProbeKeyPlugin`] state: one entry per scheduled tap. -#[derive(Resource)] -struct ProbeKeys { - taps: Vec, -} - -struct ProbeKeyTap { - key: KeyCode, - at: f32, - /// Seconds the key stays down — the spec's `:`, else [`PROBE_KEY_TAP_SECS`]. - hold: f32, - pressed: bool, - released: bool, -} - -/// Press each due tap (in-world gated, like the chat probe) and release it after its hold window. -/// -/// Both input currencies, deliberately (0997): `ButtonInput` for every held-state reader (and -/// the binding dispatch's stuck-latch sweep, which treats "latched but not pressed" as a missed -/// release), plus the raw [`KeyboardInput`] message the binding dispatch's press/release edges -/// actually consume — a state-only synthetic press was invisible to the chord latcher, which -/// would have silently killed this instrument the day the dispatch landed. -fn fire_probe_key( - mut probe: ResMut, - time: ProbeClock, - self_player: Query<(), With>, - mut keys: ResMut>, - mut events: MessageWriter, -) { - if probe.taps.is_empty() || self_player.is_empty() { - return; - } - let now = time.elapsed_secs(); - let mut synth = |key: KeyCode, state: bevy::input::ButtonState| { - events.write(bevy::input::keyboard::KeyboardInput { - key_code: key, - logical_key: bevy::input::keyboard::Key::Unidentified( - bevy::input::keyboard::NativeKey::Unidentified, - ), - state, - text: None, - repeat: false, - window: Entity::PLACEHOLDER, - }); - }; - for tap in &mut probe.taps { - if !tap.pressed && now >= tap.at { - info!( - "probe-key: {:?} down ({now:.1}s, hold {:.2}s)", - tap.key, tap.hold - ); - keys.press(tap.key); - synth(tap.key, bevy::input::ButtonState::Pressed); - tap.pressed = true; - } else if tap.pressed && !tap.released && now >= tap.at + tap.hold { - keys.release(tap.key); - synth(tap.key, bevy::input::ButtonState::Released); - tap.released = true; - } - } -} - -/// The PROBE LUA one-shot (`WOW_PROBE_LUA=""`, delay via `WOW_PROBE_LUA_AT` seconds, -/// default 10): run one Lua chunk in the live UI VM once we are in-world — the "press the button -/// headlessly" instrument. The chunk drives the REAL FrameXML API surface (`CastSpell`, -/// `UseAction`, `TargetUnit`, …), so whatever it triggers takes the exact app path a click -/// takes — a headless wire probe can measure the server, but only the live VM exercises the -/// button feed and the widget clock. -pub(crate) struct ProbeLuaPlugin; - -impl Plugin for ProbeLuaPlugin { - fn build(&self, app: &mut App) { - let chunk = std::env::var("WOW_PROBE_LUA").unwrap_or_default(); - let at = std::env::var("WOW_PROBE_LUA_AT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(10.0); - app.insert_resource(ProbeLua { - chunk, - at, - fired: false, - }) - .add_systems(Update, fire_probe_lua); - } -} - -/// The probe self-termination as its own plugin, registered whenever `WOW_PROBE_EXIT_AT` is set -/// — it used to ride inside [`ProbeLuaPlugin`], so a chat/key-only probe's exit knob silently -/// did nothing (the 0441 flourish probe hung past its window on exactly that). -pub(crate) struct ProbeExitPlugin; - -impl Plugin for ProbeExitPlugin { - fn build(&self, app: &mut App) { - app.insert_resource(ProbeExit { - at: std::env::var("WOW_PROBE_EXIT_AT") - .ok() - .and_then(|v| v.parse().ok()), - fired: false, - }) - .add_systems(Update, fire_probe_exit); - } -} - -/// [`ProbeLuaPlugin`] state: the chunk, the fire time, and the once-latch. -#[derive(Resource)] -struct ProbeLua { - chunk: String, - at: f32, - fired: bool, -} - -/// The probe run's clean self-termination (`WOW_PROBE_EXIT_AT=`, off when unset): exit the -/// app after N wall seconds, so a scripted live probe (`WOW_PROBE_LUA`/`WOW_PROBE_CHAT`) is one -/// foreground command with a bounded lifetime — no external kill, no orphaned window (0437's -/// probe rounds prompted it; generic to every future live probe). -#[derive(Resource)] -struct ProbeExit { - at: Option, - fired: bool, -} - -fn fire_probe_exit( - mut probe: ResMut, - time: ProbeClock, - mut exit: MessageWriter, -) { - let Some(at) = probe.at else { return }; - if !probe.fired && time.elapsed_secs() >= at { - info!("probe-exit: {at}s elapsed — exiting"); - probe.fired = true; - exit.write(AppExit::Success); - // The hard backstop rides its own OS thread: the polite AppExit stops the Update - // schedule, so an in-schedule backstop can never fire — exactly the hang it existed - // for (a winit/net-thread teardown hang leaves a zombie client holding the account; - // the 0451 probe reproduced it). A probe run has nothing to lose. - std::thread::spawn(|| { - std::thread::sleep(std::time::Duration::from_secs(5)); - warn!("probe-exit: still alive 5s after AppExit — hard exit"); - std::process::exit(0); - }); - } -} - -/// The window-resize probe (`WOW_PROBE_RESIZE=":x"`, logical units): resize the -/// primary window mid-run — the headless stand-in for a mac fullscreen toggle or a window drag, -/// so resize-reactive layout (the glue screens' rescale rebuild) is verifiable in one scripted -/// run: open, resize at `t`, shoot after (`WOW_LOGIN_SHOT_OUT` fires at 8 s). -pub(crate) struct ProbeResizePlugin; - -impl Plugin for ProbeResizePlugin { - fn build(&self, app: &mut App) { - let spec = std::env::var("WOW_PROBE_RESIZE").unwrap_or_default(); - let parsed = spec.split_once(':').and_then(|(t, wh)| { - let (w, h) = wh.split_once('x')?; - Some((t.parse().ok()?, w.parse().ok()?, h.parse().ok()?)) - }); - match parsed { - Some((at, w, h)) => { - app.insert_resource(ProbeResize { - at, - size: Vec2::new(w, h), - fired: false, - }) - .add_systems(Update, fire_probe_resize); - } - None => warn!("WOW_PROBE_RESIZE: expected \":x\", got {spec:?}"), - } - } -} - -/// [`ProbeResizePlugin`] state: the fire time, the target logical size, and the once-latch. -#[derive(Resource)] -struct ProbeResize { - at: f32, - size: Vec2, - fired: bool, -} - -fn fire_probe_resize( - mut probe: ResMut, - time: ProbeClock, - mut windows: Query<&mut Window, With>, -) { - if probe.fired || time.elapsed_secs() < probe.at { - return; - } - probe.fired = true; - if let Ok(mut w) = windows.single_mut() { - w.resolution.set(probe.size.x, probe.size.y); - info!( - "probe-resize: window -> {}x{} logical", - probe.size.x, probe.size.y - ); - } -} - -/// Run the probe chunk once the delay has elapsed AND the session is in-world. -fn fire_probe_lua( - mut probe: ResMut, - time: ProbeClock, - script: Option>, - self_player: Query<(), With>, -) { - if probe.fired || probe.chunk.is_empty() || time.elapsed_secs() < probe.at { - return; - } - if self_player.is_empty() { - return; // not in-world yet — keep waiting past the delay - } - let Some(script) = script else { - return; - }; - probe.fired = true; - // `ProbeLog(text)` — the chunk's data channel OUT of the VM (greppable `probe-log:` lines); - // until now a probe could only report through screenshots or by erroring. Installed only - // when a probe chunk actually fires — never part of the shipping API surface. - let install = script.lua().create_function(|_, text: String| { - info!("probe-log: {text}"); - Ok(()) - }); - match install { - Ok(f) => { - if let Err(e) = script.lua().globals().set("ProbeLog", f) { - error!("probe-lua: installing ProbeLog: {e}"); - } - } - Err(e) => error!("probe-lua: creating ProbeLog: {e}"), - } - info!("probe-lua: running {:?}", probe.chunk); - if let Err(e) = script.run(&probe.chunk) { - error!("probe-lua: {e}"); - } -} - -/// Submit the probe lines once the delay has elapsed AND the session is in-world (the self player -/// exists) — a `.go` sent before world-enter would be dropped server-side. -/// -/// Lines go in through the **chat EditBox seam**, not straight to the wire: a probe line is "what -/// the director would type", so a client-side slash command (`/duel`, `/reaction`) is parsed by -/// the same drain that serves the real chat box, while plain text and `.gm`/`.go` server commands -/// still leave as Say exactly as before. Sending them as Say instead — the original shape — meant -/// every client-side command silently went out as public chat and did nothing (decision 0637). -fn fire_probe_chat( - mut probe: ResMut, - time: ProbeClock, - script: Option>, - self_player: Query<(), With>, -) { - if probe.lines.is_empty() { - return; - } - if self_player.is_empty() { - return; // not in-world yet — keep waiting past the delay - } - let Some(mut script) = script else { - return; - }; - // With no spacing every line goes in the first eligible frame (the original burst); with - // `every`, line N waits until `at + N·every` — the "do X, wait, then do Y" cadence. - loop { - let Some(line) = probe - .lines - .split(';') - .map(str::trim) - .filter(|l| !l.is_empty()) - .nth(probe.sent) - else { - return; // all sent - }; - let due = probe.at + probe.every * probe.sent as f32; - if time.elapsed_secs() < due { - return; - } - info!("probe-chat: sending {line:?}"); - script.push_chat_input(line.to_string()); - probe.sent += 1; - } -} - -/// The LIVE FPS probe (`WOW_LIVE_FPS=`, delay via `WOW_LIVE_FPS_AT` seconds, default 25; -/// `WOW_LIVE_FPS_MOVE=1` holds W through warmup + sampling, so the probe measures RUNNING through -/// the scene — streaming, spawns, re-classification — not a parked camera; the 0366 hunt's -/// "running around SW" gap): -/// the [`super::CapturePlugin`] probe's numbers on a NORMAL connected run — streamed units, net -/// apply, quest markers, everything the server-less harness deliberately excludes. Built for the -/// 0362 residual: the serverless stormwind probe pinned 60 while the director's live session read -/// 20, so the gap IS the live world — this instrument measures it. Waits for in-world + the delay -/// (park the character first with [`ProbeChatPlugin`]), uncaps vsync, warms -/// [`PROBE_WARMUP_FRAMES`], samples, prints the same machine-greppable `FPS_PROBE` line -/// (scenario=`live`), and exits. -pub(crate) struct LiveFpsPlugin; - -impl Plugin for LiveFpsPlugin { - fn build(&self, app: &mut App) { - let frames = std::env::var("WOW_LIVE_FPS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(300); - let at = std::env::var("WOW_LIVE_FPS_AT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(25.0); - app.insert_resource(LiveFps { - frames, - at, - run: std::env::var("WOW_LIVE_FPS_MOVE").as_deref() == Ok("1"), - phase: LiveFpsPhase::Waiting, - samples: Vec::new(), - cpu_at_start: None, - occluded_now: false, - occluded_frames: 0, - }) - .init_resource::() - .add_systems(Update, drive_live_fps) - .add_systems( - First, - ( - ( - churn_counter::("image"), - churn_counter::("mesh"), - churn_counter::("std"), - churn_counter::("terrain"), - churn_counter::("model"), - churn_counter::("wdl"), - churn_counter::("liquid"), - ), - ( - churn_counter::("sky"), - churn_counter::("celestial"), - churn_counter::("star"), - churn_counter::("cloud"), - churn_counter::("uiquad"), - ), - ), - ); - } -} - -#[derive(Clone, Copy, PartialEq)] -enum LiveFpsPhase { - Waiting, - Warmup(u32), - Sampling, - Done, -} - -/// [`LiveFpsPlugin`] state. -#[derive(Resource)] -struct LiveFps { - frames: usize, - at: f32, - /// Hold W while measuring (`WOW_LIVE_FPS_MOVE=1`) — the moving-workload probe. - run: bool, - phase: LiveFpsPhase, - samples: Vec, - /// Process CPU seconds at the first sampled frame ([`crate::perf::process_cpu_secs`]) — the - /// baseline for the window's `cpu_ms`/`cpu_pct`. - cpu_at_start: Option, - /// The window's occlusion state, maintained from `WindowOccluded` transitions. macOS - /// throttles a FULLY covered window to ~1 fps (any covering window, not just the lock - /// screen — the director's correction to 0729/0730's lock-screen reading): a probe launched - /// detached spawns unfocused and can land completely behind other windows, and its leg then - /// measures the throttle, not the client. - occluded_now: bool, - /// Sampled frames taken while occluded — `occluded_frames=` on the probe line, so a - /// throttled leg names itself instead of being inferred from a ~1 s hitch signature. - occluded_frames: usize, -} - -/// Asset residency — the leak meter (the #bugs teleport leak: caches hold strong handles, so maps -/// visited earlier keep their materials/meshes/images resident, and every uv/tint registry survivor -/// is re-uploaded per frame). A tour probe reading the same counts as a fresh control is what -/// "torn down" means, machine-checked. One struct because `drive_live_fps` is at Bevy's -/// system-param arity limit. -#[derive(bevy::ecs::system::SystemParam)] -struct ResidencyMeter<'w> { - mats: Res<'w, Assets>, - meshes: Res<'w, Assets>, - images: Res<'w, Assets>, - uv_reg: Res<'w, crate::doodad_anim::UvAnimMaterials>, - tint_reg: Res<'w, crate::doodad_anim::TintAnimMaterials>, - models: Res<'w, Assets>, - server: Res<'w, AssetServer>, - churn: ResMut<'w, ChurnCensus>, -} - -/// The asset-churn census (the probe's ratchet meter): `AssetEvent::Modified` counts per asset -/// type across the sample window, printed as one `MAT_CHURN` line beside `FPS_PROBE`. A modified -/// material re-creates its uniform buffers + bind group that frame (the Metal non-bindless path), -/// and a modified image/mesh re-uploads — the teleport leak's CPU engine was exactly a per-frame -/// ratchet of this shape, so the floor hunt names them instead of guessing suspects one at a -/// time. Counters register only under `WOW_LIVE_FPS` ([`LiveFpsPlugin`]) — a normal run carries -/// none of this. -#[derive(Resource, Default)] -struct ChurnCensus(std::collections::BTreeMap<&'static str, usize>); - -/// One census counter for asset type `A`, folding this frame's `Modified` events in under -/// `label` (a short stable name — the `type_name` of an `ExtendedMaterial` alias is unreadable). -fn churn_counter( - label: &'static str, -) -> impl FnMut(MessageReader>, ResMut) { - move |mut reader, mut census| { - let n = reader - .read() - .filter(|e| matches!(e, bevy::asset::AssetEvent::Modified { .. })) - .count(); - if n > 0 { - *census.0.entry(label).or_default() += n; - } - } -} - -impl ResidencyMeter<'_> { - /// `WOW_ASSET_DUMP=1`: one `ASSET_DUMP` line per resident image/mesh at sample time, path - /// via the asset server (runtime-built assets have no path and print as `` counts). - /// Diffing a tour probe's dump against a fresh control's names exactly which files a - /// teardown left behind — the leak meter's magnifying glass. - fn dump(&self) { - let mut lines: Vec = Vec::new(); - let mut unpathed = [0usize; 3]; - for (kind, ids) in [ - ( - "image", - self.images.ids().map(|i| i.untyped()).collect::>(), - ), - ( - "mesh", - self.meshes.ids().map(|i| i.untyped()).collect::>(), - ), - ( - "model", - self.models.ids().map(|i| i.untyped()).collect::>(), - ), - ] { - let slot = match kind { - "image" => 0, - "mesh" => 1, - _ => 2, - }; - for id in ids { - match self.server.get_path(id) { - Some(p) => lines.push(format!("ASSET_DUMP {kind} {p}")), - None => unpathed[slot] += 1, - } - } - } - lines.sort(); - for l in &lines { - println!("{l}"); - } - println!( - "ASSET_DUMP images={} meshes={} models={} materials={}", - unpathed[0], - unpathed[1], - unpathed[2], - self.mats.len() - ); - } -} - -/// Where — and in what scene state — the sample was taken. Bundled because [`drive_live_fps`] sits -/// at Bevy's 16-param ceiling, and because these four are read together or not at all. -/// -/// The `room`/`windows` half is the exterior-scene gate's two terms (decision 0774), the same pair -/// the debug panel's World section shows. Without them a `drawn=` reading taken indoors cannot be -/// read at all: a big number means either "we claimed a room and the cull let everything through" or -/// "we never claimed a room, so nothing was gated" — opposite bugs with identical numbers, and the -/// difference cost a measurement (0780). -#[derive(SystemParam)] -struct SamplePin<'w> { - /// The map the sample landed on (0705's prove-the-run law): a probe number is evidence only once - /// the body is known to be at the pin, and `WOW_PROBE_CHAT`'s `.go` can silently fail (a bad map - /// id, a refused command) leaving the run measuring the login spot. - map: Option>, - body: Option>, - room: Option>, - windows: Option>, - /// What the cull DID, not just what it was told — see [`crate::exterior_cull::ExteriorCullVerdict`]. - verdict: Option>, -} - -/// Wait for in-world + the delay, uncap, warm, sample, print, exit — the live twin of the -/// harness probe's `Phase::ProbeWarmup`/`Probing` arms. -#[allow(clippy::too_many_arguments)] -fn drive_live_fps( - mut probe: ResMut, - time: Res>, - self_player: Query<(), With>, - mut windows: Query<&mut Window, With>, - particles: Query<&crate::particles::ParticleEmitter>, - // Every spawned model submesh, with the two facts that make a visible one accountable: which - // subsystem it belongs to, and whether it is gated by the exterior-scene cull. See - // [`VisCensus`] for why "visible" alone was not enough to diagnose anything. - parts: Query, - streamed: Query<(), With>, - // The animation-LOD gate's effect, machine-readable per probe (decision 0448): how many - // streamed rigs sat parked at sample end. - parked: Query<(), With>, - entities: Query<()>, - pin: SamplePin, - // The owned skin-palette occupancy (decision 0720) — `rigs=live/peak bones=live/peak` on the - // probe line proves the palette lane is actually populated (an all-zero table renders - // origin-collapsed rigs, which no other probe number would catch). - palettes: Option>, - mut residency: ResidencyMeter, - mut keys: ResMut>, - mut key_events: MessageWriter, - mut exit: MessageWriter, - mut occlusions: MessageReader, -) { - // Drain every frame so the state is current whichever phase we're in — the window can be - // occluded before sampling ever starts (a detached launch spawns behind whatever is open). - for o in occlusions.read() { - probe.occluded_now = o.occluded; - } - match probe.phase { - LiveFpsPhase::Done => {} - LiveFpsPhase::Waiting => { - // (The un-occludable window that keeps the SETTLE phase from streaming the world at - // ~1 fps is [`ProbeFocusPlugin`]'s now — every probe needs it, not just this one.) - if time.elapsed_secs() < probe.at || self_player.is_empty() { - return; - } - if let Ok(mut w) = windows.single_mut() { - w.present_mode = super::probe_uncap_mode(); - } - info!( - "live-fps: in-world + settled; vsync off, warming {PROBE_WARMUP_FRAMES} frames{}", - if probe.run { ", holding W" } else { "" } - ); - if probe.run { - // A synthetic held key: `ButtonInput` persists a press until its release, and the - // winit feed only releases keys it saw go down, so this holds across frames. The - // raw KeyboardInput message rides along for the binding dispatch's press edge - // (0997 — MOVEFORWARD latches off the event, holds off the state). - keys.press(KeyCode::KeyW); - key_events.write(bevy::input::keyboard::KeyboardInput { - key_code: KeyCode::KeyW, - logical_key: bevy::input::keyboard::Key::Unidentified( - bevy::input::keyboard::NativeKey::Unidentified, - ), - state: bevy::input::ButtonState::Pressed, - text: None, - repeat: false, - window: Entity::PLACEHOLDER, - }); - } - probe.phase = LiveFpsPhase::Warmup(0); - } - LiveFpsPhase::Warmup(n) => { - probe.phase = if n + 1 >= PROBE_WARMUP_FRAMES { - LiveFpsPhase::Sampling - } else { - LiveFpsPhase::Warmup(n + 1) - }; - } - LiveFpsPhase::Sampling => { - if probe.samples.is_empty() { - probe.cpu_at_start = crate::perf::process_cpu_secs(); - // The churn census restarts with the window — warmup noise (streaming, shader - // warms) would otherwise read as steady-state ratchets. - residency.churn.0.clear(); - probe.occluded_frames = 0; - } - if probe.occluded_now { - probe.occluded_frames += 1; - } - let ms = time.delta_secs() * 1000.0; - probe.samples.push(ms); - if probe.samples.len() < probe.frames { - return; - } - let mut v = probe.samples.clone(); - v.sort_by(f32::total_cmp); - let at = |q: f32| v[(((v.len() - 1) as f32) * q).round() as usize]; - let mean = v.iter().sum::() / v.len() as f32; - let (emitters, active, live) = particles - .iter() - .fold((0usize, 0usize, 0usize), |(e, a, l), p| { - (e + 1, a + usize::from(p.live() > 0), l + p.live()) - }); - let mut census = VisCensus { - own_instance: pin.room.as_ref().and_then(|r| r.0).map(|c| c.room.instance), - ..Default::default() - }; - let (submeshes, drawn) = parts.iter().fold((0usize, 0usize), |(n, d), row| { - census.add(row); - (n + 1, d + usize::from(row.0.get())) - }); - let px = windows - .single() - .map(|w| (w.physical_width(), w.physical_height())) - .unwrap_or((0, 0)); - // The present mode actually measured under — an uncap that silently rails (0362) is - // only diagnosable if the line says what was asked for. - let present = windows - .single() - .map(|w| format!(" present={:?}", w.present_mode)) - .unwrap_or_default(); - // CPU cost per frame across every thread — the load-robust half of the measurement - // (`perf::process_cpu_secs`), and directly comparable with a reporter's CPU %. - let cpu = match (probe.cpu_at_start, crate::perf::process_cpu_secs()) { - (Some(t0), Some(t1)) => { - let per_frame_ms = (t1 - t0) * 1000.0 / v.len() as f64; - format!( - " cpu_ms={per_frame_ms:.2} cpu_pct={:.0}", - per_frame_ms / mean as f64 * 100.0 - ) - } - _ => String::new(), - }; - // The pin the number belongs to, in the `.go xyz` order, so a probe line can be - // matched against the report's coordinates without a second instrument — followed by - // the exterior-scene gate's state, which is what makes `drawn=` legible indoors. - let at_pin = match (pin.map.as_ref(), pin.body.as_ref().filter(|b| b.active)) { - (Some(m), Some(b)) => { - let [x, y, z] = benilla_assets::coords::bevy_to_wow(b.pos); - format!(" map={} pos={x:.1},{y:.1},{z:.1}", m.0) - } - _ => String::new(), - }; - let gate = { - let room = match pin.room.as_ref().and_then(|r| r.0) { - Some(claim) => format!("g{:02}", claim.room.group), - None => "none".to_string(), - }; - match pin.windows.as_deref() { - Some(crate::wmo_portal::ExteriorWindows::Windows(rects)) => { - format!(" room={room} windows={}", rects.len()) - } - Some(crate::wmo_portal::ExteriorWindows::Unrestricted) => { - format!(" room={room} windows=unrestricted") - } - None => String::new(), - } - }; - let culled = match pin.verdict.as_deref() { - Some(v) => format!( - " cull_windows={} cull_frusta={} cull_tested={} cull_hidden={} cull_unbounded={}", - v.windows - .map_or("unrestricted".to_string(), |n| n.to_string()), - v.frusta, - v.tested, - v.hidden, - v.unbounded - ), - None => String::new(), - }; - let rigs = palettes - .map(|p| { - let (s, b, ps, pb) = p.occupancy(); - format!( - " rigs={s}/{ps} rig_bones={b}/{pb} rig_computed={}", - p.computed_rigs() - ) - }) - .unwrap_or_default(); - let residency_line = format!( - " mats={} meshes={} images={} uv={} tint={}", - residency.mats.len(), - residency.meshes.len(), - residency.images.len(), - residency.uv_reg.0.len(), - residency.tint_reg.0.len(), - ); - println!( - "FPS_PROBE scenario=live 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} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{present} occluded_frames={}{at_pin}{gate}{culled}", - v.len(), - at(0.50), - at(0.95), - at(0.99), - v[v.len() - 1], - 1000.0 / mean, - streamed.iter().len(), - parked.iter().len(), - entities.iter().len(), - px.0, - px.1, - probe.occluded_frames, - ); - census.print(); - // The window's Modified-event totals per asset type — a type at ~1×/frame here is a - // per-frame re-upload ratchet (see [`ChurnCensus`]); absent means quiet. - if !residency.churn.0.is_empty() { - let churn = residency - .churn - .0 - .iter() - .map(|(k, n)| format!("{k}={n}")) - .collect::>() - .join(" "); - println!("MAT_CHURN frames={} {churn}", v.len()); - } - if std::env::var_os("WOW_ASSET_DUMP").is_some() { - residency.dump(); - } - if probe.run { - keys.release(KeyCode::KeyW); - } - probe.phase = LiveFpsPhase::Done; - exit.write(AppExit::Success); - } - } -} - -/// **What is on the screen, and who is accountable for it** — the `VIS_CENSUS` line beside -/// `FPS_PROBE`, plus a per-model breakdown under `WOW_VIS_DUMP=1`. -/// -/// `drawn=` alone cannot answer "why can I still see that from in here?". It is one number over -/// every model submesh, and the interesting split is not visible-vs-not: it is which *subsystem* the -/// visible thing belongs to and whether anything is gating it at all. A tree that draws through a -/// wall is a completely different defect depending on whether it carries -/// [`crate::exterior_cull::ExteriorScene`] (tagged but admitted — the cull or the bound is wrong) or -/// does not (nothing is gating it — the wrong lane spawned it). Naming which took a screenshot, an -/// asset dig and a wrong guess; this line answers it in one run. -/// -/// `WOW_VIS_DUMP=1` then names the models: one `VIS_DUMP` line per distinct visible label, ungated -/// first, most-drawn first — which is the "so WHICH trees are they?" question. -#[derive(Default)] -struct VisCensus { - /// Per [`ModelKind`] index: `(visible, visible-and-gated)`. - kinds: [(usize, usize); 4], - /// `label -> (visible count, gated)`, for the dump. - labels: std::collections::HashMap<(String, bool), usize>, - /// Of every `ExteriorScene`-tagged submesh: how many the cull actually wrote `Hidden` on, and - /// how many carry **no `Aabb`** — the cull's fail-open arm, which admits them unconditionally. - /// A tagged-but-drawn object is one of those two, and they are opposite bugs. - gated_total: usize, - gated_hidden: usize, - gated_no_aabb: usize, - /// Tagged, bounded, NOT exempt, and yet NOT `Hidden` — the escapees, by `(label, is a - /// billboard card)`. **Exempt** means the piece belongs to the placement the camera is standing - /// in, which is not exterior scene to itself (decision 0784) and is *supposed* to draw; without - /// that subtraction this list is all room-you-are-in furniture and says nothing. - escaped: std::collections::HashMap<(String, bool), usize>, - /// The placement the camera is inside, for that subtraction. - own_instance: Option, - /// How many tagged pieces were exempt — the number that explains the `tagged` vs `hidden` gap. - exempt: usize, -} - -/// What [`VisCensus`] reads off every model submesh — the query shape and its fetched row. -type CensusData = ( - &'static ViewVisibility, - &'static crate::debug_panel::ModelPart, - Has, - Option<&'static crate::interact::WorldObject>, - &'static Visibility, - Option<&'static bevy::camera::primitives::Aabb>, - Has, - Option<&'static crate::wmo_portal::WmoGroupVis>, -); - -type CensusRow<'a> = ( - &'a ViewVisibility, - &'a crate::debug_panel::ModelPart, - bool, - Option<&'a crate::interact::WorldObject>, - &'a Visibility, - Option<&'a bevy::camera::primitives::Aabb>, - bool, - Option<&'a crate::wmo_portal::WmoGroupVis>, -); - -impl VisCensus { - fn add(&mut self, (vis, part, gated, object, want, aabb, card, group): CensusRow) { - if gated { - let exempt = group.is_some_and(|g| Some(g.instance) == self.own_instance); - self.gated_total += 1; - self.gated_hidden += usize::from(*want == Visibility::Hidden); - self.gated_no_aabb += usize::from(aabb.is_none()); - self.exempt += usize::from(exempt); - if *want != Visibility::Hidden && aabb.is_some() && !exempt { - let label = object.map_or("", |o| o.label.as_str()); - *self.escaped.entry((label.to_string(), card)).or_default() += 1; - } - } - if !vis.get() { - return; - } - let slot = &mut self.kinds[kind_index(part.kind)]; - slot.0 += 1; - slot.1 += usize::from(gated); - if let Some(o) = object { - *self.labels.entry((o.label.clone(), gated)).or_default() += 1; - } - } - - fn print(&self) { - let line = ["doodad", "wmo", "creature", "gameobject"] - .iter() - .zip(&self.kinds) - .map(|(name, (vis, gated))| format!("{name}={vis}/gated{gated}")) - .collect::>() - .join(" "); - println!( - "VIS_CENSUS visible-submeshes {line} | tagged={} hidden={} exempt={} no_aabb={}", - self.gated_total, self.gated_hidden, self.exempt, self.gated_no_aabb - ); - // The escapees always print: a tagged, bounded object the cull left un-hidden is a defect - // by construction, and burying it behind a flag is how it stays unnoticed. - let mut escapees: Vec<_> = self.escaped.iter().collect(); - escapees.sort_by(|a, b| b.1.cmp(a.1)); - for ((label, card), n) in escapees { - let c = if *card { " BILLBOARD-CARD" } else { "" }; - println!("VIS_ESCAPED {n}{c} {label}"); - } - if std::env::var_os("WOW_VIS_DUMP").is_none() { - return; - } - let mut rows: Vec<_> = self.labels.iter().collect(); - // Ungated first (the leak candidates), then most-drawn first. - rows.sort_by(|a, b| a.0 .1.cmp(&b.0 .1).then(b.1.cmp(a.1))); - for ((label, gated), n) in rows { - let g = if *gated { "gated" } else { "UNGATED" }; - println!("VIS_DUMP {g} {n} {label}"); - } - } -} - -/// [`ModelKind`] has a private `index`; the census needs its own (and pins the column order). -fn kind_index(kind: crate::debug_panel::ModelKind) -> usize { - match kind { - crate::debug_panel::ModelKind::Doodad => 0, - crate::debug_panel::ModelKind::Wmo => 1, - crate::debug_panel::ModelKind::Creature => 2, - crate::debug_panel::ModelKind::GameObject => 3, - } -} - -/// The particle census (`WOW_PARTICLE_CENSUS=`): once, `t` seconds in, print one line per -/// live emitter (blend, file flags, sampled rate keys, texture, live count) plus a machine- -/// readable total — the like-for-like number to put beside a reference-trace quad count (the -/// login whirlpool investigation: the real client draws 793 particle quads across 23 draws in -/// one `UI_MainMenu` frame). Works at any state — the glue screens included, unlike the -/// in-world-gated FPS probe. -/// -/// It also measures **draw distance** (decision 0678): each emitter's planar depth along -/// camera-forward — the coordinate the far-clip wall uses — and the draw-set gate's verdict, with -/// `drawn_beyond_wall` on the summary line. That is the numeric form of "effects render at -/// unlimited distance" (bug B39): emitters still ticking and drawing past the wall that has already -/// discarded the terrain beneath them. **It must read 0**; a non-zero value is the bug, live. -/// -/// **`WOW_PARTICLE_CENSUS=+` fires that long after the world is first SHOWN** (the loading -/// screen dropping) instead of after app start. Everything that rides the appear ramp — decision -/// 0827/0833's `alpha` column above all — lives in a 2-second window whose start moves by *seconds* -/// between a warm and a cold load, and a wall-clock timer either lands in it or does not: three -/// runs in a row missed it while the question was "does a weapon glow ramp with its wearer?". A -/// probe should not be a dice roll, and the ramp's own trigger is the thing to time from. -pub(crate) struct ParticleCensusPlugin; - -impl Plugin for ParticleCensusPlugin { - fn build(&self, app: &mut App) { - let spec = std::env::var("WOW_PARTICLE_CENSUS").unwrap_or_default(); - let after_shown = spec.starts_with('+'); - let at = spec - .trim_start_matches('+') - .parse() - .unwrap_or(if after_shown { 1.0 } else { 10.0 }); - app.insert_resource(ParticleCensus { - at, - after_shown, - fired: false, - }) - .add_systems(Update, fire_particle_census); - } -} - -/// [`ParticleCensusPlugin`] state: the fire time and the once-latch. -#[derive(Resource)] -struct ParticleCensus { - at: f32, - /// `at` is measured from the world being shown, not from app start — and is rewritten into an - /// absolute time the first frame the loading screen stops covering. - after_shown: bool, - fired: bool, -} - -fn fire_particle_census( - mut probe: ResMut, - screen: Res, - time: ProbeClock, - view: Res, - cam: Query<&GlobalTransform, With>, - emitters: Query<( - &crate::particles::ParticleEmitter, - Option<&crate::particles::EmitterFade>, - Option<&bevy::camera::visibility::RenderLayers>, - )>, -) { - // Latch the shown-relative deadline the first frame the screen drops (and only then: while it - // still covers there is no ramp to be relative to). - if probe.after_shown { - if screen.covering() { - return; - } - probe.at += time.elapsed_secs(); - probe.after_shown = false; - } - if probe.fired || time.elapsed_secs() < probe.at { - return; - } - probe.fired = true; - let mut total = 0usize; - let mut n = 0usize; - // The B39 columns (decision 0678). Per emitter: its planar depth along camera-forward (the - // coordinate the far-clip wall is measured in) and the draw-set gate's live verdict. - // - // **`drawn_beyond_wall` is the number that names the bug.** It counts emitters the gate is - // still ticking and drawing at a depth where the detailed world — the terrain under them - // included — has already been discarded by the wall. Before 0678 it was routinely non-zero, - // because `doodad_fade_alpha` admits any owner over `NEVER_FADE_RADIUS` at *every* distance - // and nothing else bounded depth; that is precisely "all effects render at unlimited - // distance", and precisely the reporter's "the terrain is not even rendered that far". - // It must now be **0**: past the wall the gate hides the emitter and freezes its pool. - // - // `beyond_wall` (verdict ignored) stays as the denominator — emitters *exist* out there and - // should, they are simply frozen. A fix that despawned them would be the wrong fix. - // - // **Booth-layered emitters are excluded from the distance accounting** — the same layer filter - // `simulate_particles` uses to pick a booth's camera. The portrait/glue scenes are parked - // thousands of yards from the world and drawn by their OWN camera, so the world camera's wall - // says nothing about them; counting them read as 28 phantom "effects past the wall" (all of - // them Karazahn braziers and night-elf glows at ~7080 yd) on a build where the world was - // already clean. Measuring the right subject is the instrument's job, not the reader's. - let cam_tf = cam.iter().next(); - let mut beyond_wall = 0usize; - let mut drawn_beyond_wall = 0usize; - let mut drawn_beyond_wall_live = 0usize; - let mut booth = 0usize; - let mut max_drawn_depth = f32::NEG_INFINITY; - for (e, fade, layers) in &emitters { - let world_layer = - layers.is_none_or(|l| l.intersects(&bevy::camera::visibility::RenderLayers::default())); - // Depth to the OWNER's fade sphere where there is one (the gate's own subject), else the - // emitter's live anchor — so the number always names what the gate actually tests. - let depth = cam_tf.map(|t| { - let center = fade.map_or_else(|| e.anchor_world(), |f| f.center); - let radius = fade.map_or(0.0, |f| f.radius); - (center - t.translation()).dot(Vec3::from(t.forward())) - radius - }); - let drawn = e.drawn(); - if !world_layer { - booth += 1; - } - if let Some(d) = depth.filter(|_| world_layer) { - if drawn { - max_drawn_depth = max_drawn_depth.max(d); - } - if d > view.farclip { - beyond_wall += 1; - if drawn { - drawn_beyond_wall += 1; - drawn_beyond_wall_live += e.live(); - } - } - } - let dist = depth - .map(|d| { - let lane = if world_layer { "world" } else { "booth" }; - let c = fade.map_or_else(|| e.anchor_world(), |f| f.center); - format!( - " depth={d:.1} drawn={drawn} lane={lane} gated={} at=({:.0},{:.0},{:.0})", - fade.is_some(), - c.x, - c.y, - c.z - ) - }) - .unwrap_or_default(); - let d = e.def(); - // The rate summary: the constant (the common shape), else each slot's key count — the - // full per-sequence choreography lives in `benilla-extract m2anim`, not a census line. - let rate_keys: Vec = match d.timing.constant_rate() { - Some(r) => vec![format!("{r:.1}")], - None => d - .timing - .slot_views() - .iter() - .enumerate() - .map(|(s, (_, r, _))| format!("s{s}:{}k", r.map_or(0, <[(f32, f32)]>::len))) - .collect(), - }; - // The orientation fingerprint (world plane normal + thickness/radius) is the numeric - // "which way does this cloud face" — the flat-vs-standing question a screenshot can - // only suggest (the InstancePortal swirl-plane investigation). - let plane = e - .cloud_fingerprint() - .map(|(c, nrm, thick, radius)| { - format!( - " ctr=({:.1},{:.1},{:.1}) normal=({:+.2},{:+.2},{:+.2}) thick={thick:.2} radius={radius:.2}", - c.x, c.y, c.z, nrm.x, nrm.y, nrm.z - ) - }) - .unwrap_or_default(); - println!( - "PARTICLE_CENSUS_EMITTER blend={:?} flags={:#06x} rate=[{}] life={:.2} tex={} live={} alpha={:.2}{dist}{plane}", - d.blend, - d.flags, - rate_keys.join(","), - d.params.sample(None, 0.0, 0.0).lifespan, - d.texture.as_deref().unwrap_or("-"), - e.live(), - // The frame's composed MODEL alpha (decision 0827/0833) — the number that answers - // "this cloud is drawing, why can't I see it / why is it full strength?". An effect on - // a unit that has not appeared yet reads ~0; one with no model above it reads 1. - e.render_alpha(), - ); - total += e.live(); - n += 1; - } - let max_drawn_depth = if max_drawn_depth.is_finite() { - max_drawn_depth - } else { - 0.0 - }; - // The camera pose goes on the line so a census is self-describing: every distance number here - // is measured from it, and a probe whose `.go` silently failed otherwise reports crisp numbers - // about the wrong place. - let where_ = cam_tf - .map(|t| { - let p = t.translation(); - format!(" cam=({:.1},{:.1},{:.1})", p.x, p.y, p.z) - }) - .unwrap_or_else(|| " cam=none".into()); - println!( - "PARTICLE_CENSUS emitters={n} booth={booth} live_total={total} farclip={:.0} \ - beyond_wall={beyond_wall} drawn_beyond_wall={drawn_beyond_wall} \ - drawn_beyond_wall_live={drawn_beyond_wall_live} max_drawn_depth={max_drawn_depth:.1}{where_}", - view.farclip, - ); -} - -/// The bevy_ui node census (`WOW_NODE_PROBE=`): once, `t` seconds in, print one line per -/// live `ComputedNode` entity — resolved rect (logical px, y-down), visibility, and the entity's -/// full component list — the "who owns this rectangle" instrument for UI drawn OUTSIDE the -/// FrameXML quad pass (the glue widgets, loading screen, overlays), which `WOW_UI_PROBE`'s quad -/// dump can't see. Born hunting a phantom gold-bordered box over the mail window's send tab. -pub(crate) struct NodeProbePlugin; - -impl Plugin for NodeProbePlugin { - fn build(&self, app: &mut App) { - let at = std::env::var("WOW_NODE_PROBE") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(10.0); - app.insert_resource(NodeProbe { at, fired: false }) - .add_systems(Update, fire_node_probe); - } -} - -/// [`NodeProbePlugin`] state: the fire time and the once-latch. -#[derive(Resource)] -struct NodeProbe { - at: f32, - fired: bool, -} - -fn fire_node_probe(world: &mut World) { - { - let time = world.resource::