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) endif 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) endif 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) endif 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) endif 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) endif 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) endif 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