From 0535fe9097defcfc5def711ee29ef6ca36953e77 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 4 Sep 2026 20:48:52 +0700 Subject: [PATCH 01/78] a body lands with a thud, and twelve lists scroll again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - a dying body lands with a sound: the `$DTH` marker makes three calls in the reference (`0x624a94`) and benilla had built only the middle one, so the screen shook and nothing landed; the thud is a two-axis join — size class against terrain sound, through `DeathThudLookups.dbc` to a land or water kit — behind exactly three gates: more than 2.0 yd of liquid over the feet is complete silence, a size class outside 0..=4, and no terrain, no row or a kit of 0; it plays on bus 0, uncapped, so a battlefield's worth of bodies all land — the shake is the rare thing (49 of 430 models name a preset), the thud the universal one (329 key a `$DTH`, players included); the size axis had sat in our `CreatureDisplayInfo` schema labelled a string column 5875 does not have, and `StringLookups` stays unparsed on purpose — 9 rows benilla already resolves (1866) - twelve faux-scroll lists showed their bar, moved their thumb and never moved their list: the reference's kit writes `frame.offset` from one handler the OWNER declares, and ours were wired to a `frame.updateFunc` field the reference does not have — Skill, Craft, TradeSkill, ScriptLog, the auction and the social panes are rewired, and a gate fails any faux frame without a handler of its own, because a list only scrolls when something drives it (1868); and the macro window's "Change Name/Icon" raised on a nil offset: `MacroPopupScrollFrame` inherits a template the manifest never loaded, and a missing template is a warning, so the frame was built BARE — no scroll bar, no scripts, no `offset = 0` — and the reference's offset read has no `or 0` cushion; the line is in the manifest, and a static gate fails any `inherits=` the manifest declares nowhere (1862) - the character geoset selector's missing eighth branch is the tabard designer: B6 of `0x477520` is gated on "the guild registrar's tabard designer is open", not an ItemDisplayInfo column, and wears the tabard flap with an EMPTY tabard slot; dormant until `TabardFrame` migrates, but reading the whole function turned up three branches wrong since their first pass: B4-short never disabled [501,599], so the bare foot — a real submesh on 11 of the 18 character models — rendered inside every boot; B3 asked "is a chest equipped?" where the reference asks whether anything but the shirt paints the ArmLower tile, wrong more often than right (533 of 637 chest displays leave it empty, 566 of 570 wrist displays fill it); and B7 had no tabard guard, an ungated doublet arm, and a leg arm read off chest-or-legs where the reference reads the chest's bit alone (1864) - a GameObject's display sounds have one trigger and two lanes: `GameObjectDisplayInfo.Sound[0..9]` has one reader (`0x5f4010`), reached only from the anim-event dispatcher, so benilla's state-transition arm — 19 Open and 4 Close rows the reference leaves silent, 80 Open and 38 Close doubled against the clip's own `$GO1`/`$GO3` — is deleted; and the kit's `0x200` flag is a LANE SELECT, not a loop flag: set means the ambient pool, which every campfire, brazier, torch and fountain names (37 live slots), and which had been starting untracked forever-channels (1867); and `WOW_PROBE_SERVICE=1` reads the NPC-service ladder back off a live server — four real NPCs, reporting the arm taken, the opcode sent and the window opened — with every hop inside the reference's own service reach, after a first run parked ~29 yd from Deputy Willem and blamed the client for a dropped packet (1865) Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/assets/ui/AuctionFrame.xml | 38 +- crates/benilla-app/assets/ui/CraftFrame.xml | 16 +- crates/benilla-app/assets/ui/FriendsFrame.xml | 36 +- .../benilla-app/assets/ui/QuestLogFrame.xml | 2 +- .../benilla-app/assets/ui/ScriptLogFrame.xml | 10 +- crates/benilla-app/assets/ui/SkillFrame.xml | 23 +- .../benilla-app/assets/ui/TradeSkillFrame.xml | 17 +- crates/benilla-app/assets/ui/benilla.toc | 11 + crates/benilla-app/src/capture/mod.rs | 2 + .../benilla-app/src/capture/probe_service.rs | 544 ++++++++++++++++++ crates/benilla-app/src/dev.rs | 8 + crates/benilla-app/src/entities.rs | 7 + .../src/entities/attach/char_skin.rs | 18 +- crates/benilla-app/src/entities/attach/mod.rs | 2 +- crates/benilla-app/src/go_anim.rs | 158 ++++- crates/benilla-app/src/sound/anim_events.rs | 59 +- crates/benilla-app/src/sound/death_thud.rs | 259 +++++++++ .../sound/{doodad_pool.rs => emitter_pool.rs} | 237 +++++--- crates/benilla-app/src/sound/gameobject.rs | 184 +++--- crates/benilla-app/src/sound/kit.rs | 2 +- crates/benilla-app/src/sound/mod.rs | 6 +- crates/benilla-app/src/target/click.rs | 4 +- crates/benilla-app/src/target/cursor_mode.rs | 2 +- .../benilla-app/src/ui_script/macro_tests.rs | 34 ++ .../benilla-app/src/ui_script/reference_ui.rs | 224 ++++++++ .../src/ui_script/shipped_xml_tests.rs | 57 ++ .../src/bin/benilla-extract/charatlas.rs | 12 +- .../src/bin/benilla-extract/main.rs | 24 +- .../src/bin/benilla-extract/scan/mod.rs | 4 +- .../src/bin/benilla-extract/scan/sequence.rs | 225 +++++++- .../src/bin/benilla-extract/thudcensus.rs | 206 +++++++ .../benilla-formats/src/characters/geosets.rs | 238 +++++++- crates/benilla-formats/src/characters/mod.rs | 4 +- .../src/characters/sections.rs | 23 + crates/benilla-formats/src/creatures.rs | 108 +++- crates/benilla-formats/src/death_thud.rs | 211 +++++++ crates/benilla-formats/src/lib.rs | 8 +- 37 files changed, 2759 insertions(+), 264 deletions(-) create mode 100644 crates/benilla-app/src/capture/probe_service.rs create mode 100644 crates/benilla-app/src/sound/death_thud.rs rename crates/benilla-app/src/sound/{doodad_pool.rs => emitter_pool.rs} (74%) create mode 100644 crates/benilla-formats/src/bin/benilla-extract/thudcensus.rs create mode 100644 crates/benilla-formats/src/death_thud.rs diff --git a/crates/benilla-app/assets/ui/AuctionFrame.xml b/crates/benilla-app/assets/ui/AuctionFrame.xml index 48960683..066b3f1a 100644 --- a/crates/benilla-app/assets/ui/AuctionFrame.xml +++ b/crates/benilla-app/assets/ui/AuctionFrame.xml @@ -231,10 +231,6 @@ BuyoutPrice.onValueChanged = AuctionsFrameAuctions_ValidateAuction StartPriceGold:SetMaxLetters(6) -- Each faux list repaints through its own tab's Update. - BrowseScrollFrame.updateFunc = AuctionFrameBrowse_Update - BrowseFilterScrollFrame.updateFunc = AuctionFrameFilters_Update - BidScrollFrame.updateFunc = AuctionFrameBid_Update - AuctionsScrollFrame.updateFunc = AuctionFrameAuctions_Update end function AuctionFrame_OnShow() @@ -783,6 +779,7 @@ end end FauxScrollFrame_Update(BrowseFilterScrollFrame, total, BENILLA_FILTER_ROWS, BENILLA_FILTER_ROW_HEIGHT) + BenillaScrollTrough_Follow("BrowseFilterScrollFrame") end -- Clicking a filter row NEVER queries — it only moves the selection, which the next Search @@ -940,6 +937,7 @@ local rows = auctionPagerUpdate("Browse", AuctionFrameBrowse.page, numBatch, total, lastSlotEmpty) FauxScrollFrame_Update(BrowseScrollFrame, rows, BENILLA_BROWSE_ROWS, BENILLA_AUCTION_ROW_HEIGHT) + BenillaScrollTrough_Follow("BrowseScrollFrame") end -- The two Browse turners re-SEARCH rather than scroll: the next 50 rows live on the server @@ -2056,7 +2054,13 @@ - + + + FauxScrollFrame_OnVerticalScroll(BENILLA_FILTER_ROW_HEIGHT, AuctionFrameFilters_Update) + + - + - + @@ -2474,7 +2329,7 @@ AuctionsRadioButton_OnClick(3) - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/crates/benilla-app/assets/ui/MailFrame.xml b/crates/benilla-app/assets/ui/MailFrame.xml index 216097e1..04c766b6 100644 --- a/crates/benilla-app/assets/ui/MailFrame.xml +++ b/crates/benilla-app/assets/ui/MailFrame.xml @@ -48,8 +48,10 @@ MAX_COD_AMOUNT = 10000 -- MAX_COD_AMOUNT (ref l.6) STATIONERY_PATH = "Interface\\Stationery\\" -- STATIONERY_PATH (ref l.5) - -- The money entry uses the shared BenillaMoneyInput_* kit (MerchantFrame.xml, loaded first) — - -- the send tab's three numeric edit boxes are named SendMailMoneyGold/Silver/Copper. + -- The money entry is the reference's own `MoneyInputFrame_*` off the chain (1882 retired + -- our BenillaMoneyInput_* duplicate) — the send tab's three numeric edit boxes are named + -- SendMailMoneyGold/Silver/Copper, which is what both kits read and why the swap was a + -- rename plus an `inherits=`. -- ── Load / event dispatch (ref MailFrame_Onload l.8-24 / MailFrame_OnEvent l.26-62) ────── function BenillaMailFrame_OnLoad(frame) @@ -538,7 +540,7 @@ -- ── Send tab (ref SendMail* l.484-611 / l.936-960) ────────────────────────────────────── function SendMailMailButton_OnClick() - local copper = BenillaMoneyInput_GetCopper("SendMailMoney") + local copper = MoneyInputFrame_GetCopper(SendMailMoney) if SendMailSendMoneyButton:GetChecked() then SetSendMailCOD(0) if copper > 0 then @@ -593,7 +595,7 @@ SendMailNameEditBox:SetText("") SendMailSubjectEditBox:SetText("") SendMailBodyEditBox:SetText("") - BenillaMoneyInput_ResetMoney("SendMailMoney") + MoneyInputFrame_ResetMoney(SendMailMoney) SendMailRadioButton_OnClick(1) SendMailFrame_Update() end @@ -607,7 +609,7 @@ if string.len(SendMailSubjectEditBox:GetText()) > 0 then checks = checks + 1 end if SendMailCODButton:GetChecked() then checksRequired = 3 - if BenillaMoneyInput_GetCopper("SendMailMoney") > MAX_COD_AMOUNT * 10000 then + if MoneyInputFrame_GetCopper(SendMailMoney) > MAX_COD_AMOUNT * 10000 then SendMailErrorText:Show() SendMailErrorCoin:Show() else @@ -684,7 +686,7 @@ button1 = "Accept", button2 = "Cancel", OnAccept = function() - if SetSendMailMoney(BenillaMoneyInput_GetCopper("SendMailMoney")) then + if SetSendMailMoney(MoneyInputFrame_GetCopper(SendMailMoney)) then SendMailFrame_SendMail() end end, @@ -1114,136 +1116,18 @@ - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - getglobal(this:GetParent():GetName().."Silver"):SetFocus() - getglobal(this:GetParent():GetName().."Silver"):SetFocus() - this:ClearFocus() - SendMailFrame_CanSend() - this:HighlightText(0, 0) - this:HighlightText() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - getglobal(this:GetParent():GetName().."Copper"):SetFocus() - getglobal(this:GetParent():GetName().."Copper"):SetFocus() - this:ClearFocus() - SendMailFrame_CanSend() - this:HighlightText(0, 0) - this:HighlightText() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - this:ClearFocus() - SendMailFrame_CanSend() - this:HighlightText(0, 0) - this:HighlightText() - - - - + + MoneyInputFrame_SetOnvalueChangedFunc(this, SendMailFrame_CanSend) + diff --git a/crates/benilla-app/assets/ui/MoneyFrame.xml b/crates/benilla-app/assets/ui/MoneyFrame.xml index 3708b358..40d36788 100644 --- a/crates/benilla-app/assets/ui/MoneyFrame.xml +++ b/crates/benilla-app/assets/ui/MoneyFrame.xml @@ -164,56 +164,6 @@ end end - -- ── The editable money-input kit (ref MoneyInputFrame.lua) ──────────────────────────────── - -- A naming three numeric EditBoxes Gold/Silver/Copper — the shared money entry - -- both the send-mail and trade windows inline (decision 0592 P2 builds it here, the money home, - -- so both reuse it). GetCopper folds the three digits into a copper total; SetCopper splits a - -- copper amount back across the boxes. SetCopper is a *programmatic* fill, so it must not re-fire - -- a box's OnTextChanged value-changed callback (the loop the reference's `expectChanges` counter - -- guards, MoneyInputFrame.lua l.44-73). Because benilla fires OnTextChanged synchronously inside - -- SetText, a bracket flag is exact and simpler than the counter: a window's box calls - -- BenillaMoneyInput_Consume(prefix) at the top of its OnTextChanged — false (swallow) while a - -- SetCopper is in flight, true for a genuine keystroke. - BENILLA_MONEYINPUT_SUPPRESS = {} - - function BenillaMoneyInput_ResetMoney(prefix) - getglobal(prefix .. "Gold"):SetText("") - getglobal(prefix .. "Silver"):SetText("") - getglobal(prefix .. "Copper"):SetText("") - end - - function BenillaMoneyInput_GetCopper(prefix) - local function n(box) return tonumber(getglobal(prefix .. box):GetText()) or 0 end - return n("Gold") * 10000 + n("Silver") * 100 + n("Copper") - end - - function BenillaMoneyInput_ClearFocus(prefix) - getglobal(prefix .. "Gold"):ClearFocus() - getglobal(prefix .. "Silver"):ClearFocus() - getglobal(prefix .. "Copper"):ClearFocus() - end - - function BenillaMoneyInput_SetCopper(prefix, money) - money = money or 0 - local parts = { - Gold = math.floor(money / 10000), - Silver = math.floor((money % 10000) / 100), - Copper = money % 100, - } - BENILLA_MONEYINPUT_SUPPRESS[prefix] = true - for box, val in pairs(parts) do - local eb = getglobal(prefix .. box) - if (tonumber(eb:GetText()) or 0) ~= val then - eb:SetText(val == 0 and "" or tostring(val)) - end - end - BENILLA_MONEYINPUT_SUPPRESS[prefix] = false - end - - function BenillaMoneyInput_Consume(prefix) - return not BENILLA_MONEYINPUT_SUPPRESS[prefix] - end - -- ── What a coin is worth ───────────────────────────────────────────────────────────────── -- Published globals: an addon that formats a price does the same split we do below, and -- reads these rather than spelling 100 twice. @@ -394,7 +344,10 @@ function MoneyFrame_SetType(type) local info = MoneyTypeInfo[type] if not info then - -- ref l.145 diagnoses this with `message()`, which we do not ship (see the header). + -- ref l.145 diagnoses this with `message()`, which we DO have: it comes off the + -- chain in `Interface\FrameXML\BasicControls.xml` (1881 migrated that file; before + -- it, our own copy carried the same function). The `if` is therefore belt-and-braces + -- for a harness that loads this file without BasicControls, not a missing verb. if message then message("Invalid money type: " .. type) end diff --git a/crates/benilla-app/assets/ui/RaidFrame.xml b/crates/benilla-app/assets/ui/RaidFrame.xml deleted file mode 100644 index ff5a2adb..00000000 --- a/crates/benilla-app/assets/ui/RaidFrame.xml +++ /dev/null @@ -1,1118 +0,0 @@ - - - - - - - - - - - this:SetFrameLevel(this:GetFrameLevel() + 2) - getglobal(this:GetName() .. "Class"):SetFrameLevel(this:GetFrameLevel() + 2) - this:RegisterForDrag("LeftButton") - this:RegisterForClicks("LeftButtonUp", "RightButtonUp") - - RaidGroupButton_OnClick(arg1) - RaidGroupButton_OnDragStart() - RaidGroupButton_OnDragStop() - RaidGroupButton_OnEnter() - GameTooltip:Hide() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - getglobal(this:GetName() .. "Label"):SetText(GROUP .. " " .. this:GetID()) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - this:RegisterEvent("RAID_ROSTER_UPDATE") - - -- The raid ending ends the question with it. - if event == "RAID_ROSTER_UPDATE" and GetNumRaidMembers() == 0 then - ReadyCheckFrame.timer = nil - ReadyCheckFrame:Hide() - end - - ReadyCheck_OnUpdate(arg1) - - - diff --git a/crates/benilla-app/assets/ui/ReputationFrame.xml b/crates/benilla-app/assets/ui/ReputationFrame.xml deleted file mode 100644 index 2d5196b7..00000000 --- a/crates/benilla-app/assets/ui/ReputationFrame.xml +++ /dev/null @@ -1,922 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - GameTooltip:SetOwner(this, "ANCHOR_RIGHT") - GameTooltip:SetText(REPUTATION_STATUS_AT_WAR, nil, nil, nil, nil, 1) - - GameTooltip:Hide() - - - - - - - if this.tooltip then - getglobal(this:GetName() .. "FactionStanding"):SetText(this.tooltip) - end - BenillaReputationBar_SetGlow(this:GetName(), 1) - - - getglobal(this:GetName() .. "FactionStanding"):SetText(this.standingText) - BenillaReputationBar_SetGlow(this:GetName(), GetSelectedFaction() == this.id) - - ReputationBar_OnClick() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - FauxScrollFrame_OnVerticalScroll(REPUTATIONFRAME_FACTIONHEIGHT, ReputationFrame_Update) - - - - - - - - - - - GameTooltip_AddNewbieTip(FACTION, 1.0, 1.0, 1.0, REPUTATION_FACTION_DESCRIPTION, 1) - GameTooltip:Hide() - - - - - - - - - GameTooltip_AddNewbieTip(STANDING, 1.0, 1.0, 1.0, REPUTATION_STANDING_DESCRIPTION, 1) - GameTooltip:Hide() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - getglobal(this:GetName() .. "Text"):SetText(AT_WAR) - getglobal(this:GetName() .. "Text"):SetVertexColor(RED_FONT_COLOR.r, RED_FONT_COLOR.g, RED_FONT_COLOR.b) - - BenillaReputationAtWarBox_OnClick(self) - - GameTooltip:SetOwner(this, "ANCHOR_RIGHT") - GameTooltip:SetText(REPUTATION_AT_WAR_DESCRIPTION, nil, nil, nil, nil, 1) - - GameTooltip:Hide() - - - - - - - - - - - - - - - - - - - - - getglobal(this:GetName() .. "Text"):SetText(MOVE_TO_INACTIVE) - BenillaReputationInactiveBox_OnClick(self) - - - - - - - - - - - - getglobal(this:GetName() .. "Text"):SetText(SHOW_FACTION_ON_MAINSCREEN) - BenillaReputationMainScreenBox_OnClick(self) - - - - - - - ReputationFrame_OnLoad() - ReputationFrame_OnShow() - ReputationFrame_OnEvent(event) - BenillaReputationFrame_OnMouseWheel(arg1) - - - diff --git a/crates/benilla-app/assets/ui/TradeFrame.xml b/crates/benilla-app/assets/ui/TradeFrame.xml index e99083e5..3c31b707 100644 --- a/crates/benilla-app/assets/ui/TradeFrame.xml +++ b/crates/benilla-app/assets/ui/TradeFrame.xml @@ -63,7 +63,7 @@ TradeFrameTradeButton:Enable() TradeFrame_Update() elseif event == "PLAYER_TRADE_MONEY" then - BenillaMoneyInput_SetCopper("TradePlayerInputMoney", GetPlayerTradeMoney()) + MoneyInputFrame_SetCopper(TradePlayerInputMoney, GetPlayerTradeMoney()) elseif event == "TRADE_CLOSED" then HideUIPanel(frame) elseif event == "TRADE_PLAYER_ITEM_CHANGED" then @@ -82,17 +82,17 @@ function TradeFrame_OnHide() -- The client-side close verb (ref TradeFrame_OnHide l.144) → CMSG_CANCEL_TRADE + a local -- clear (the app's drain); clear our money entry so a later trade opens fresh (ref l.146). - BenillaMoneyInput_ResetMoney("TradePlayerInputMoney") + MoneyInputFrame_ResetMoney(TradePlayerInputMoney) CloseTrade() end -- A keystroke in our money input (ref TradeFrame_UpdateMoney l.162): read the copper total, -- clamp to what we actually carry, and offer it → SetTradeMoney → CMSG_SET_TRADE_GOLD. function TradeFrame_UpdateMoney() - local copper = BenillaMoneyInput_GetCopper("TradePlayerInputMoney") + local copper = MoneyInputFrame_GetCopper(TradePlayerInputMoney) if GetMoney and copper > GetMoney() then copper = GetPlayerTradeMoney() - BenillaMoneyInput_SetCopper("TradePlayerInputMoney", copper) + MoneyInputFrame_SetCopper(TradePlayerInputMoney, copper) end SetTradeMoney(copper) end @@ -468,77 +468,28 @@ HideUIPanel(TradeFrame) - - + + - - - - - - - - - - - - - - getglobal(this:GetParent():GetName().."Silver"):SetFocus() - getglobal(this:GetParent():GetName().."Silver"):SetFocus() - this:ClearFocus() - if ( BenillaMoneyInput_Consume("TradePlayerInputMoney") ) then TradeFrame_UpdateMoney() end - this:HighlightText(0, 0) - this:HighlightText() - - - - - - - - - - - - - - - - getglobal(this:GetParent():GetName().."Copper"):SetFocus() - getglobal(this:GetParent():GetName().."Copper"):SetFocus() - this:ClearFocus() - if ( BenillaMoneyInput_Consume("TradePlayerInputMoney") ) then TradeFrame_UpdateMoney() end - this:HighlightText(0, 0) - this:HighlightText() - - - - - - - - - - - - - - - - this:ClearFocus() - if ( BenillaMoneyInput_Consume("TradePlayerInputMoney") ) then TradeFrame_UpdateMoney() end - this:HighlightText(0, 0) - this:HighlightText() - - - - + + + MoneyInputFrame_SetMode(this, "compact") + MoneyInputFrame_SetOnvalueChangedFunc(this, TradeFrame_UpdateMoney) + + diff --git a/crates/benilla-app/assets/ui/UIParent.xml b/crates/benilla-app/assets/ui/UIParent.xml index 11789567..572a53de 100644 --- a/crates/benilla-app/assets/ui/UIParent.xml +++ b/crates/benilla-app/assets/ui/UIParent.xml @@ -215,6 +215,41 @@ -- statements on the same event). `MultiActionBar_Update` is existence-guarded because the -- ~25 per-window harnesses that load this file load no bars; the binding itself is engine -- level and always installed. Decision 1500. + -- ── UIParentLoadAddOn (ref UIParent.lua l.638-647, and now our slot for it) ────── + -- It lived in our own BasicControls.xml until 1881 migrated that file to the chain; + -- the reference never put it there, so the move is back to where it belongs. + -- The on-demand loader every "open the auction house / tradeskill / talent panel" path + -- goes through, and the one 5 corpus addons name — FuBar_MCPFu, Fubar_RareTrackerFu, MCP, + -- MikScrollingBattleText, SpecialTalent: five separate addons, not one library replicated + -- (1207/1240). + -- + -- Every piece was already here and only the join was missing: `LoadAddOn` answers the + -- reference's own `(1, nil)` / `(nil, reason)` pair, `message` and `TEXT` are directly + -- above, and the `ADDON_*` reason words come from the real `GlobalStrings.lua` we RUN off + -- the patch chain rather than copy (1234). + -- + -- **`FailedAddOnLoad` is the reference's own latch, and it is not decoration.** Without it + -- an addon that retries the load from a handler raises a modal on every attempt; with it + -- the failure shows once per addon name per session. It is exactly the detail this would + -- have lacked had it been reconstructed from the name instead of read. + -- + -- Every reason our `LoadAddOn` can answer resolves to a string: MISSING, DISABLED, + -- NOT_DEMAND_LOADED, DEP_MISSING and DEP_DISABLED all have an `ADDON_..reason` word + -- (GlobalStrings l.16-31), the same reason set wow-re's `ui.md` l.2396 carves off + -- `0x51e780`. So the `getglobal` below cannot hand `format` a nil. + FailedAddOnLoad = {}; + + function UIParentLoadAddOn(name) + local loaded, reason = LoadAddOn(name) + if not loaded then + if not FailedAddOnLoad[name] then + message(format(TEXT(ADDON_LOAD_FAILED), name, TEXT(getglobal("ADDON_" .. reason)))) + FailedAddOnLoad[name] = true + end + end + return loaded + end + -- ref UIParent.lua:223, whose slot this is — immediately above `UIParent_OnEvent`, the -- same neighbour it has there. It moved here from our own InspectFrame.xml when 1832 -- retired that file: the name belongs to UIParent in the reference, and the window it @@ -263,6 +298,20 @@ end end + -- ref UIParent.lua's own `RaidFrame_LoadUI` (l.182-184), verbatim — the fourth squatter, + -- after `InspectUnit` (1832), `ToggleTalentFrame` (1833) and `ShowMacroFrame` (1848). + -- + -- **Unlike `MacroFrame_LoadUI`, this one cannot be left out.** That one was omitted because + -- nothing on the chain calls it; the reference's own `RaidFrame.lua` calls THIS on + -- `RAID_ROSTER_UPDATE` and again at `PLAYER_LOGIN` with a raid up (l.34, l.39), so its + -- absence is a raise every time the roster moves. It is a FrameXML function, not an engine + -- verb, and this file is our stand-in for `UIParent.lua` — the same relationship the three + -- above have. `UIParentLoadAddOn` is just above, and it no-ops on an addon that is + -- already loaded, which ours is: benilla.toc takes `Blizzard_RaidUI` eagerly (1874). + function RaidFrame_LoadUI() + UIParentLoadAddOn("Blizzard_RaidUI"); + end + function UIParent_OnEvent(event) -- The reference's own first arm (UIParent.lua l.230-235). `LocalizeFrames` comes from -- the sourced `Interface\FrameXML\Localization.xml` (1751 window 5) and is the file diff --git a/crates/benilla-app/assets/ui/UiPanels.xml b/crates/benilla-app/assets/ui/UiPanels.xml index 891017cb..aa92c6c3 100644 --- a/crates/benilla-app/assets/ui/UiPanels.xml +++ b/crates/benilla-app/assets/ui/UiPanels.xml @@ -1583,6 +1583,57 @@ end end + -- ── Two dialogs rehomed from our retired RaidFrame.xml (1874) ────────────────────────── + -- Their reference home is `StaticPopup.lua`, which we do not load — this file is our + -- stand-in for that engine, so this is where they belong. They came here because the raid + -- pane went to the chain and the reference's own `RaidFrame.lua` does not declare them: + -- deleting our file took `CONFIRM_RESET_INSTANCES` with it, and `UnitPopup.xml`'s "Reset + -- all instances" row calls `StaticPopup_Show` on exactly that name. + -- ── Add Member's dialog (ref StaticPopup.lua l.871-895) ───────────────────────────────── + StaticPopupDialogs["ADD_RAIDMEMBER"] = { + text = ADD_RAIDMEMBER_LABEL, + button1 = "Accept", + button2 = "Cancel", + hasEditBox = 1, + maxLetters = 12, + timeout = 0, + hideOnEscape = 1, + OnAccept = function() + InviteByName(getglobal("StaticPopup1EditBox"):GetText()) + end, + OnShow = function() + getglobal("StaticPopup1EditBox"):SetFocus() + end, + EditBoxOnEnterPressed = function() + InviteByName(getglobal("StaticPopup1EditBox"):GetText()) + StaticPopup_Hide("ADD_RAIDMEMBER") + end, + } + + -- ── "Reset all instances" (ref StaticPopup.lua l.23-33; decision 1748) ────────────────── + -- Raised by UnitPopup's SELF menu, and registered HERE because this file is the instance + -- house — the same rule that keeps the pet dialogs in PetActionBar.xml. `ResetInstances()` + -- is the only call in the client that sends `CMSG_RESET_INSTANCES`; everything else in the + -- lockout family is the server talking. + -- + -- The answer is per instance rather than one verdict, and it arrives as CHAT_MSG_SYSTEM + -- lines the engine composes: an `SMSG_INSTANCE_RESET` per dungeon that reset, and — on + -- the group path only — an `SMSG_INSTANCE_RESET_FAILED` per dungeon that would not. + -- Nothing back at all is the ordinary answer for a character with nothing resettable. So + -- there is nothing to show here and no result to wait for. + StaticPopupDialogs["CONFIRM_RESET_INSTANCES"] = { + text = CONFIRM_RESET_INSTANCES, + button1 = YES, + button2 = NO, + timeout = 0, + whileDead = 1, + hideOnEscape = 1, + OnAccept = function() + ResetInstances() + end, + } + + StaticPopupDialogs["PARTY_INVITE"] = { text = INVITATION, button1 = ACCEPT, @@ -2042,6 +2093,110 @@ return nil end + -- ── The FLASH kit (ref UIParent.lua l.1166, l.1231-1333), transcribed ──────────────── + -- Its home is `UIParent.lua`, which we do not load; this file is our stand-in for that + -- engine's half, and the FADE kit it drives through already lives here for the same reason. + -- Every field it sets on the frame — `fadeInTime`, `fadeOutTime`, `flashDuration`, + -- `showWhenDone`, `flashTimer`, `flashMode`, `flashInHoldTime`, `flashOutHoldTime` — is the + -- reference's own, and it reaches the fade kit through `fadeHoldTime`/`finishedFunc`, both + -- of which `UIFrameFade` above already honours. + -- + -- Built because two stock windows call it and nothing answered: `SpellBookFrame` and + -- `HelpFrame` (1879). It is FrameXML, not an engine verb, so this is a transcription and + -- not the stub 1203 forbids. + FLASHFRAMES = {} + + function UIFrameFlashRemoveFrame(frame) + local index = 1 + while FLASHFRAMES[index] do + if FLASHFRAMES[index] == frame then + table.remove(FLASHFRAMES, index) + else + index = index + 1 + end + end + end + + function UIFrameFlash(frame, fadeInTime, fadeOutTime, flashDuration, showWhenDone, flashInHoldTime, flashOutHoldTime) + if not frame then return end + -- Already flashing: the reference returns rather than re-arming (l.1234-1240). + local index = 1 + while FLASHFRAMES[index] do + if FLASHFRAMES[index] == frame then return end + index = index + 1 + end + frame.fadeInTime = fadeInTime + frame.fadeOutTime = fadeOutTime + frame.flashDuration = flashDuration + frame.showWhenDone = showWhenDone + frame.flashTimer = 0 + frame.flashMode = "IN" + frame.flashInHoldTime = flashInHoldTime + frame.flashOutHoldTime = flashOutHoldTime + table.insert(FLASHFRAMES, frame) + BenillaFlashDriver:Show() + end + + function UIFrameFlashSwitch(frameName, mode) + local frame = getglobal(frameName) + if frame then frame.flashMode = mode end + end + + function UIFrameIsFlashing(frame) + local index = 1 + while FLASHFRAMES[index] do + if FLASHFRAMES[index] == frame then return 1 end + index = index + 1 + end + return nil + end + + function UIFrameFlashStop(frame) + frame.flashDuration = 0 + frame:Hide() + end + + -- `flashDuration == -1` means flash forever, which is why the timer check carries that + -- guard rather than a plain `>` (ref l.1272). + function UIFrameFlashUpdate(elapsed) + local index = 1 + while FLASHFRAMES[index] do + local frame = FLASHFRAMES[index] + frame.flashTimer = (frame.flashTimer or 0) + elapsed + if frame.flashTimer > frame.flashDuration and frame.flashDuration ~= -1 then + UIFrameFadeRemoveFrame(frame) + UIFrameFlashRemoveFrame(frame) + frame:SetAlpha(1.0) + frame.flashTimer = nil + if frame.showWhenDone then frame:Show() else frame:Hide() end + else + -- A flashMode is only set once the previous fade finished (ref l.1283). + if frame.flashMode then + local fadeInfo = {} + if frame.flashMode == "IN" then + fadeInfo.timeToFade = frame.fadeInTime + fadeInfo.mode = "IN" + fadeInfo.finishedFunc = UIFrameFlashSwitch + fadeInfo.finishedArg1 = frame:GetName() + fadeInfo.finishedArg2 = "OUT" + fadeInfo.fadeHoldTime = frame.flashOutHoldTime + UIFrameFade(frame, fadeInfo) + elseif frame.flashMode == "OUT" then + fadeInfo.timeToFade = frame.fadeOutTime + fadeInfo.mode = "OUT" + fadeInfo.finishedFunc = UIFrameFlashSwitch + fadeInfo.finishedArg1 = frame:GetName() + fadeInfo.finishedArg2 = "IN" + fadeInfo.fadeHoldTime = frame.flashInHoldTime + UIFrameFade(frame, fadeInfo) + end + frame.flashMode = nil + end + index = index + 1 + end + end + end + function UIFrameFadeRemoveFrame(frame) local index = FadeIndexOf(frame) while index do @@ -2319,6 +2474,16 @@ It runs only while FADEFRAMES has entries: the engine dispatches OnUpdate to visible frames only (tick.rs), so hiding itself on an empty list stops the per-frame walk cold, and UIFrameFade — the single inserter — shows it again (decision 1396's class). --> + + + + + if not FLASHFRAMES[1] then this:Hide() return end UIFrameFlashUpdate(arg1) + + diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index b47de93a..b7edb5fc 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -96,7 +96,7 @@ Interface\FrameXML\LocaleProperties.lua # ScriptErrors dialog, and the DialogBoxFrame + DialogButton*Texture kit it is built from. # Straight after Fonts.xml because those are the only things it needs (GameFontRedLarge, # DialogButtonNormalText/HighlightText) and everything above it in FrameXML.toc is engine. -BasicControls.xml +Interface\FrameXML\BasicControls.xml # **UIParent itself, and it must come before every frame that declares `parent="UIParent"`** — # a parent name is resolved at LOAD, and a name that does not exist yet does not error: the loader # warns and silently falls back to the enclosing frame (`loader/mod.rs`, "names no frame"). With @@ -678,7 +678,19 @@ ContainerFrameAdapters.xml # file's own comment carries the justification — so `FACTION_BAR_COLORS` and # `ReputationWatchBar_Update` are already defined when this page paints a row or ticks the # "Show as Experience Bar" box. -ReputationFrame.xml +# ══ THE REPUTATION PANE — the reference's own, off the player's chain (1751, scoped by 1844) ══ +# One line replaces one of ours: 47 of our 48 declared names were already the reference's, and the +# one that was not (`ReputationListScrollFrameScrollBarTrough`) is our own scroll kit's. Nothing +# gated it — the stock file inherits its own two row templates plus `UIPanelCloseButton`, fonts and +# `OptionsCheckButtonTemplate`, and that last came off the chain with 1841. +# +# THE CO-EDIT was compulsory. The reference declares the reputation WATCH BAR in this same file; +# ours lived in `ActionBar.xml` behind a note defending the placement. Frame globals publish +# NON-overwritingly and ActionBar.xml is manifest line 340 against this file's — so ours would have +# kept the name and the chain's bar would have been built and unreachable, which is exactly what +# `GroupLootFrame` did for months (1838). The bar, its update, its two text verbs, +# `MAX_PLAYER_LEVEL` and `FACTION_BAR_COLORS` all went home with it (1875). +Interface\FrameXML\ReputationFrame.xml # The Skills tab (decision 0437 phase 4): a CharacterFrame child page, but a separate # top-level file — it declares `parent="CharacterFrame"`, which really attaches (1734), and # is positioned by name via ``, the geometry it ships @@ -767,10 +779,21 @@ Interface\AddOns\Blizzard_TalentUI\Blizzard_TalentUI.xml # GameTooltip (the newbie tips) and UIDropDownMenu (the who list's variable column) — # all loaded above. FriendsFrame.xml +# ══ THE RAID PANE — the reference's own, off the player's chain (1751, scoped by 1839) ══ # The social window's fourth tab, in its own file because RaidFrame is a child of FriendsFrame and # a child cannot be declared before its parent — the reference splits it the same way and for the # same reason (its FrameXML.toc has RaidFrame.xml on the very next line). Decision 1549. -RaidFrame.xml +# +# TWO entries replace one of ours: the pane itself, and the LoadOnDemand half that carries the +# group frames and the tear-off pullouts. 1839 measured the swap — 83 of our 84 frame names were +# already the reference's, 20 of our 22 Lua functions were, and NOTHING needed building. The one +# name that was not is `BenillaRaidDropDown`, and the reference has no raid drop-down at all: it +# reuses `FriendsDropDown`, which FriendsFrame.xml declares four manifest lines above. +# +# GAINED: `RaidPulloutFrameTemplate`, `RaidPulloutButtonTemplate`, `RaidBuffButtonTemplate` and the +# fifteen `RaidPullout*` verbs — the tear-off group pullouts we never transcribed. +Interface\FrameXML\RaidFrame.xml +Interface\AddOns\Blizzard_RaidUI\Blizzard_RaidUI.xml # ══ THE GOSSIP WINDOW — the reference's own, off the player's chain (1751) ══════════════════ # No engine verb, no widget method, no shadowed name — all three instruments clean. It was # deferred once on `GossipResize` reading `GetTextHeight()` in the same repaint that sets the @@ -877,7 +900,7 @@ Interface\FrameXML\PetStable.xml # windows grouped" cluster beside Trainer/Bank/Taxi, and loaded AFTER MoneyFrame (its eight # money displays are SmallMoneyFrameTemplate, and it registers the AUCTION_DEPOSIT money type # into that file's MoneyTypeInfo), after MerchantFrame (grouping only now — that file's global -# BenillaMoneyInput_* helpers for its four money inputs), after ScrollTemplates (four faux lists), after +# chain's MoneyInputFrame_* for its four money inputs, 1882), after ScrollTemplates (four faux lists), after # UIPanelTemplates (UIPanelButtonTemplate / UIPanelCloseButton / InputBoxTemplate / # UICheckButtonTemplate), after UIDropDownMenu (the rarity capsule) and after UiPanels (the # panel manager's `doublewide` seat, `CharacterFrameTabButtonTemplate`, the PanelTemplates diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index bcc0eae1..ce31791d 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -98,6 +98,7 @@ mod probe_chest; mod probe_clam; mod probe_crossing; mod probe_gm_ticket; +mod probe_goquest; mod probe_guard_poi; mod probe_mail; mod probe_melee; @@ -124,6 +125,7 @@ pub(crate) use probe_chest::ProbeChestPlugin; pub(crate) use probe_clam::ProbeClamPlugin; pub(crate) use probe_crossing::ProbeCrossingPlugin; pub(crate) use probe_gm_ticket::ProbeGmTicketPlugin; +pub(crate) use probe_goquest::ProbeGoQuestPlugin; pub(crate) use probe_guard_poi::ProbeGuardPoiPlugin; pub(crate) use probe_mail::ProbeMailPlugin; pub(crate) use probe_melee::ProbeMeleePlugin; diff --git a/crates/benilla-app/src/capture/probe_goquest.rs b/crates/benilla-app/src/capture/probe_goquest.rs new file mode 100644 index 00000000..f25e4796 --- /dev/null +++ b/crates/benilla-app/src/capture/probe_goquest.rs @@ -0,0 +1,434 @@ +//! The GameObject-questgiver live probe (`WOW_PROBE_GOQUEST=1`) — the instrument for *"wanted +//! posters and quest objects are never status-queried"*, and for the answer that question turned +//! out to have (decision 1872). +//! +//! A quest-giving **GameObject** — a wanted poster, a half-eaten body, a suspicious barrel — is a +//! questgiver on the wire exactly as a creature is, and the reference client asks about it: the +//! sweep callback tests typemask bit 5 and sends `CMSG_QUESTGIVER_STATUS_QUERY` for any GameObject +//! carrying `GAMEOBJECT_FLAGS` bit 2 whose reaction toward us is `> 1`. benilla asked about no +//! GameObject at all, which is a real wire gap. +//! +//! But **the answer is refused, and nothing is ever drawn**: the reference's handler `0x5dc9f0` +//! resolves the GUID with typemask 8, a GameObject's `0x21` misses bit 3, and the packet dies at +//! `0x5dca2f`. And the real 1.12 service never sent one anyway — zero GameObject GUIDs across the +//! 1292 in the genuine sniff corpus. **vmangos does send one** (`TYPEMASK_CREATURE_OR_GAMEOBJECT`, +//! `QuestHandler.cpp:41`), so on our server the refusal is load-bearing rather than theoretical: +//! without it a wanted poster would wear a gold `!` the reference client leaves bare. +//! +//! That makes this probe's job the three-way distinction a unit test cannot make at the real +//! object: **the query went out**, **the answer came back**, and **we refused it** — where "never +//! asked" and "asked and correctly refused" both read as an empty status map. The refusal readout +//! ([`QuestGiver::refused`]) is what separates them, and an arriving status can only exist if our +//! query did. +//! +//! Four readings, in order, and the last two are the controls that make it a regression test: +//! +//! 1. **LOW** — at level [`LOW_LEVEL`], below the quest's own `MinLevel`: the server must answer +//! `1` (`UNAVAILABLE`, the grey `!` a *creature* would wear) and we must refuse it. +//! 2. **HIGH** — after a ding past `MinLevel`: the answer must change to `5` (`AVAILABLE`). A probe +//! that only ever saw one value could not tell a live query from a single stale packet. +//! 3. **NO STATUS** — `QuestGiver::status()` for the poster stays `None` throughout, sampled every +//! frame rather than at the windows. The marker layer and the minimap dot both draw from exactly +//! that map, so an empty entry *is* "no `!`, no dot". +//! 4. **THE UNIT CONTROL** — a creature questgiver in the same scene still gets and keeps its own +//! status. This is the half that must not change, and it is what would catch a refusal written +//! one bit too wide. +//! +//! ## The run recipe +//! +//! ```text +//! WOW_NOSOUND=1 WOW_USER=probe4 WOW_PASS=pprobe4 WOW_CHAR=Probefour \ +//! WOW_PROBE_GOQUEST=1 cargo run -q -p benilla +//! ``` +//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe`, `method.md` +//! "The local vmangos server"). `WOW_PROBE_GOQUEST=,,[,]` aims it elsewhere; the +//! default is the **Goldshire wanted poster** — `gameobject.guid` 26843, template 68 +//! `Wanted Poster`, `type` 2 `GAMEOBJECT_TYPE_QUESTGIVER`, `flags` 4 `INTERACT_COND`, offering +//! quest 176 `Wanted: "Hogger"` (`MinLevel` 5, `QuestLevel` 11). It is a single **unpooled** spawn +//! with a 900 s respawn, so it is there every run — unlike the chest probe's first target, which +//! was a spawn-pool member and read "missing" most of the time (decision 1471's trap). Goldshire +//! also supplies the unit control (Marshal Dughan and the innkeeper are both questgiver-flagged), +//! which is why both halves can be read at one spot. +//! +//! Each window forces its own sweep, because that is the *only* way a GameObject GUID reaches the +//! wire: it sets the level (the reference's own `UNIT_FIELD_LEVEL` watch, and what makes the answer +//! change) and then bumps the packet epoch, so the window sweeps even if the character already +//! happened to be standing at the level the window wants. The starting level is restored on exit. +//! +//! **One trap the scan line will show you:** every probe character is permanently in GM mode +//! (`characters.extra_flags` bit `0x1`), and vmangos ORs `target->IsGameMaster()` into a +//! GameObject's `IsActivateToQuest` (`Object.cpp`) — so `GAMEOBJECT_DYN_FLAGS` reads `0x1` on +//! every quest object regardless of whether the character could actually take the quest. The +//! *dialog status* is computed without any GM term, so the windows below are unaffected; but do +//! not read the dyn-flag as "this quest is available to me", and do not gate a query on it. +//! +//! Grep `PROBE_GOQUEST:` for the verdict; the probe self-exits when it lands. + +use bevy::prelude::*; + +use benilla_protocol::EntityKind; + +use super::probes::ProbeClock; +use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer}; +use crate::player::Player; +use crate::ui_quest::QuestGiver; + +/// The probe's default object: the `Wanted Poster` outside the Goldshire inn (`gameobject` 26843, +/// template 68, quest 176). `[x, y, z, map]`. +const POSTER_AT: [f32; 4] = [-9668.23, 683.39, 36.33, 0.0]; +/// `GAMEOBJECT_TYPE_QUESTGIVER` — the strategy type a wanted poster carries. +const GO_TYPE_QUESTGIVER: i32 = 2; +/// `GAMEOBJECT_FLAGS` bit 2 — the reference's whole GameObject query gate (`0x5eb0f2 shr eax,0x2`). +const GO_FLAG_INTERACT_COND: u32 = 0x4; +/// `UNIT_NPC_FLAGS` questgiver bit — how the unit control names itself. +const NPC_FLAG_QUESTGIVER: u32 = 0x2; +/// The two levels the two windows are read at, chosen against quest 176's own `MinLevel` 5 and +/// `QuestLevel` 11: below it the server answers `UNAVAILABLE`, above it `AVAILABLE` (and not +/// `CHAT`, which would need `level > QuestLevel + Quests.LowLevelHideDiff`). +const LOW_LEVEL: u32 = 2; +const HIGH_LEVEL: u32 = 8; +/// `DialogStatus` ids the verdict is written in (wow-re `questgiver-marker.md` §Q1's own map). +const STATUS_UNAVAILABLE: u32 = 1; +const STATUS_AVAILABLE: u32 = 5; + +const SETTLE_SECS: f64 = 5.0; +const SCAN_TIMEOUT_SECS: f64 = 25.0; +/// How long a window waits for the refused answer. The query goes out on the sweep the window +/// itself forces; nothing arriving in this long means it was never sent. +const STATUS_TIMEOUT_SECS: f64 = 15.0; +/// How long a `.levelup` is given to come back down the wire as a `UNIT_FIELD_LEVEL` change. +const LEVEL_TIMEOUT_SECS: f64 = 15.0; +/// Scan radius around the landing spot, in yards. +const SCAN_RANGE: f32 = 40.0; + +pub(crate) struct ProbeGoQuestPlugin; + +impl Plugin for ProbeGoQuestPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Update, goquest_probe); + } +} + +#[derive(Resource, Default)] +struct GoQuestProbe { + phase: Phase, + /// The poster's guid, once the scan finds it. + poster: Option, + /// The unit control's guid — a questgiver-flagged creature in the same scene. + control: Option, + /// The level the character was at when the probe started — restored before it exits. + start_level: Option, + /// The refused status read in each window. + low: Option, + high: Option, + /// Whether the poster ever acquired a stored status (it must not) and the control's own stored + /// status (it must have one). + poster_ever_stored: bool, + control_stored: Option, + fails: u32, + exited: bool, +} + +#[derive(Default, Clone, Copy, PartialEq)] +enum Phase { + #[default] + Wait, + /// `.go` issued; letting the world stream the poster in. + Settling { + sent_at: f64, + }, + /// Levelling into a window's level, then sweeping. + Level { + to: u32, + since: f64, + }, + /// Waiting for the refused answer at the window's level. + Read { + level: u32, + since: f64, + }, + /// Levelling back to where the character started. + Restore { + since: f64, + }, + Done, +} + +/// Where the probe is aimed: `WOW_PROBE_GOQUEST=,,[,]`, else [`POSTER_AT`]. Anything +/// unparseable falls back to the default rather than failing the run — the common value is `1`. +fn target() -> [f32; 4] { + let Ok(raw) = std::env::var("WOW_PROBE_GOQUEST") else { + return POSTER_AT; + }; + let parts: Vec = raw + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + match parts.len() { + 3 => [parts[0], parts[1], parts[2], POSTER_AT[3]], + 4 => [parts[0], parts[1], parts[2], parts[3]], + _ => POSTER_AT, + } +} + +/// `.levelup ` on the probe's own character. Nothing is ever selected by this probe, so the +/// command's creature branch (`GetSelectedCreature`) can't fire. +fn level_to(net: &NetCommands, from: u32, to: u32) { + let delta = i64::from(to) - i64::from(from); + if delta == 0 { + return; + } + let _ = net.0.send(ClientCommand::Chat { + kind: ChatKind::Say, + target: None, + text: format!(".levelup {delta}"), + }); +} + +/// One window's verdict line. +fn report(label: &str, expect: u32, seen: Option) -> u32 { + match seen { + Some(s) if s == expect => { + info!("PROBE_GOQUEST: {label:<4} PASS — the server answered {s}, and we refused it"); + 0 + } + Some(s) => { + error!("PROBE_GOQUEST: {label:<4} FAIL — refused status {s}, expected {expect}"); + 1 + } + None => { + error!( + "PROBE_GOQUEST: {label:<4} FAIL — no status ever arrived for the object (expected \ + {expect}). vmangos answers every status query it can resolve, so nothing arriving \ + means the query was never SENT — the sweep's GameObject leg is not firing" + ); + 1 + } + } +} + +#[allow(clippy::too_many_arguments)] +fn goquest_probe( + time: ProbeClock, + mut probe: ResMut, + self_store: Query<&ObjectStore, With>, + player: Res, + objects: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without>, + mut quest: ResMut, + net: Res, + mut exit: MessageWriter, +) { + let Ok(me) = self_store.single() else { + return; // not in-world yet + }; + let Some(level) = me.0.unit_level() else { + return; // our own descriptor hasn't landed + }; + let now = time.elapsed_secs_f64(); + // Sampled every frame, not just at the windows: a status that flashed on for one frame and was + // pruned would still have been a rendered `!`, and the claim is that none is ever stored. + if probe.poster.is_some_and(|p| quest.status(p).is_some()) { + probe.poster_ever_stored = true; + } + if let Some(s) = probe.control.and_then(|c| quest.status(c)) { + probe.control_stored = Some(s); + } + + match probe.phase { + Phase::Wait => { + let [x, y, z, map] = target(); + probe.start_level = Some(level); + info!( + "PROBE_GOQUEST: heading to the quest object ({x} {y} {z} map {map}) — \ + GameObject type {GO_TYPE_QUESTGIVER}, starting level {level}" + ); + let _ = net.0.send(ClientCommand::Chat { + kind: ChatKind::Say, + target: None, + text: format!(".go xyz {x} {y} {z} {map}"), + }); + probe.phase = Phase::Settling { sent_at: now }; + } + Phase::Settling { sent_at } => { + if now - sent_at < SETTLE_SECS { + return; + } + let here = player.pos; + let in_range = |tf: &Transform| tf.translation.distance(here) < SCAN_RANGE; + let poster = objects.iter().find(|(_, net_e, store, tf)| { + net_e.kind == EntityKind::GameObject + && store.0.gameobject_type_id() == GO_TYPE_QUESTGIVER + && in_range(tf) + }); + let control = objects.iter().find(|(_, net_e, store, tf)| { + net_e.kind == EntityKind::Unit + && store.0.unit_npc_flags() & NPC_FLAG_QUESTGIVER != 0 + && in_range(tf) + }); + let Some((guid, _, store, _)) = poster else { + if now - sent_at > SCAN_TIMEOUT_SECS { + error!( + "PROBE_GOQUEST: FAIL — no type-{GO_TYPE_QUESTGIVER} GameObject within \ + {SCAN_RANGE} yd in {SCAN_TIMEOUT_SECS}s. This is NOT a passing run" + ); + probe.fails += 1; + probe.phase = Phase::Done; + } + return; + }; + let flags = store.0.gameobject_flags(); + info!( + "PROBE_GOQUEST: quest object {:#x} in range — GAMEOBJECT_FLAGS {flags:#x} \ + (INTERACT_COND {}), GAMEOBJECT_DYN_FLAGS {:#x}", + guid.0, + flags & GO_FLAG_INTERACT_COND != 0, + store.0.gameobject_dynamic_flags(), + ); + if flags & GO_FLAG_INTERACT_COND == 0 { + error!( + "PROBE_GOQUEST: FAIL — this object does not carry GAMEOBJECT_FLAGS bit 2, so \ + the reference would never query it either. Aim the probe at a quest object" + ); + probe.fails += 1; + probe.phase = Phase::Done; + return; + } + probe.poster = Some(guid.0); + match control { + Some((g, ..)) => { + info!( + "PROBE_GOQUEST: unit control — questgiver creature {:#x}", + g.0 + ); + probe.control = Some(g.0); + } + None => warn!( + "PROBE_GOQUEST: no questgiver-flagged creature within {SCAN_RANGE} yd — the \ + unit control cannot be read at this spot" + ), + } + probe.phase = Phase::Level { + to: LOW_LEVEL, + since: now, + }; + level_to(&net, level, LOW_LEVEL); + } + Phase::Level { to, since } => { + if level == to { + // Force the sweep this window depends on. A GameObject GUID reaches the wire ONLY + // from a sweep (wow-re §W14.8), and the level change above is one — but it is a + // no-op when the character already stood at this level, so the epoch bump makes + // the window's sweep unconditional. + quest.bump_reask(); + info!("PROBE_GOQUEST: at level {to}, swept — reading the window"); + probe.phase = Phase::Read { + level: to, + since: now, + }; + } else if now - since > LEVEL_TIMEOUT_SECS { + error!("PROBE_GOQUEST: FAIL — still level {level} after a `.levelup` to {to}"); + probe.fails += 1; + probe.phase = Phase::Done; + } + } + Phase::Read { level: at, since } => { + let (refused, count) = quest.refused(); + let mine = refused + .filter(|(g, _)| Some(*g) == probe.poster) + .map(|(_, s)| s); + // The HIGH window must see a *different* answer than LOW, or one stale refusal would + // satisfy both. + let settled = if at == LOW_LEVEL { + mine + } else { + mine.filter(|s| Some(*s) != probe.low) + }; + if settled.is_none() && now - since <= STATUS_TIMEOUT_SECS { + return; + } + if at == LOW_LEVEL { + probe.low = settled; + info!( + "PROBE_GOQUEST: LOW refused {settled:?} (refusals so far: {count}) — dinging \ + to level {HIGH_LEVEL}" + ); + probe.phase = Phase::Level { + to: HIGH_LEVEL, + since: now, + }; + level_to(&net, level, HIGH_LEVEL); + } else { + probe.high = settled; + let back = probe.start_level.unwrap_or(level); + info!( + "PROBE_GOQUEST: HIGH refused {settled:?} (refusals so far: {count}) — \ + restoring level {back}" + ); + level_to(&net, level, back); + probe.phase = Phase::Restore { since: now }; + } + } + Phase::Restore { since } => { + let back = probe.start_level.unwrap_or(level); + if level == back { + probe.phase = Phase::Done; + } else if now - since > LEVEL_TIMEOUT_SECS { + warn!("PROBE_GOQUEST: left the character at level {level}, not {back}"); + probe.phase = Phase::Done; + } + } + Phase::Done => { + if probe.exited { + return; + } + probe.exited = true; + let mut fails = probe.fails; + fails += report("LOW", STATUS_UNAVAILABLE, probe.low); + fails += report("HIGH", STATUS_AVAILABLE, probe.high); + if probe.poster_ever_stored { + error!( + "PROBE_GOQUEST: STORE FAIL — a status was stored for the GameObject. The \ + marker layer and the minimap dot both read that map, so this is a `!` the \ + reference client does not draw" + ); + fails += 1; + } else { + info!( + "PROBE_GOQUEST: STORE PASS — no status ever stored for the GameObject, so no \ + marker and no minimap dot" + ); + } + match (probe.control, probe.control_stored) { + (Some(g), Some(s)) => info!( + "PROBE_GOQUEST: CTRL PASS — the questgiver creature {g:#x} still has its own \ + status ({s})" + ), + (Some(g), None) => { + error!( + "PROBE_GOQUEST: CTRL FAIL — the questgiver creature {g:#x} never got a \ + status. The refusal is one bit too wide and units lost theirs" + ); + fails += 1; + } + (None, _) => warn!("PROBE_GOQUEST: CTRL SKIPPED — no creature questgiver in range"), + } + info!( + "PROBE_GOQUEST: DONE object={:#x} low={:?} high={:?} refusals={} fail={fails}", + probe.poster.unwrap_or_default(), + probe.low, + probe.high, + quest.refused().1, + ); + // The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit + // plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie + // client holding the probe account. + exit.write(AppExit::Success); + std::thread::spawn(|| { + std::thread::sleep(std::time::Duration::from_secs(5)); + warn!("PROBE_GOQUEST: still alive 5s after AppExit — hard exit"); + std::process::exit(0); + }); + } + } +} diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs index 57efe851..1902f7e2 100644 --- a/crates/benilla-app/src/dev.rs +++ b/crates/benilla-app/src/dev.rs @@ -363,6 +363,13 @@ impl Plugin for DevProbesPlugin { if std::env::var("WOW_PROBE_CHEST").is_ok() { app.add_plugins(crate::capture::ProbeChestPlugin); } + // The GameObject-questgiver live probe: `WOW_PROBE_GOQUEST=1` parks at the Goldshire + // wanted poster and reports the dialog status the server answers for it, below and + // above the quest's own MinLevel — the numeric answer to "quest objects are never + // status-queried" (decision 1872; see `capture::ProbeGoQuestPlugin`). + if std::env::var("WOW_PROBE_GOQUEST").is_ok() { + app.add_plugins(crate::capture::ProbeGoQuestPlugin); + } // The openable-item live probe: `WOW_PROBE_CLAM=1` stocks a clam, right-clicks it // through the live VM's own `UseContainerItem` and reports whether a loot window opens // on the item's own guid — the numeric answer to the director's "clams don't open" diff --git a/crates/benilla-app/src/minimap/blips/dots.rs b/crates/benilla-app/src/minimap/blips/dots.rs index 47cf3ef6..bedfff19 100644 --- a/crates/benilla-app/src/minimap/blips/dots.rs +++ b/crates/benilla-app/src/minimap/blips/dots.rs @@ -244,10 +244,19 @@ pub(in crate::minimap) fn emit_tracking_dots( } }; // Cell 0 — tracked GameObjects (gold): template lockId through Lock.dbc. + // + // **No quest-status precedence on this leg** (decision 1872). This loop used to skip a + // GameObject whose status was 7, mirroring the unit loop below — but the classifier's + // `cmp dword ptr [edi+0xcb8],7` at `0x4eac31` is reachable **only** from the UNIT and PLAYER + // legs (machine-enumerated predecessors, wow-re `minimap-poi-questdot.md` via + // `questgiver-marker.md` §W14.9): the GameObject leg at `0x4eab43` falls straight into + // `0x5ed2b0`, GameObject *tracking*, and emits category 0. A GameObject never draws a quest + // dot in the reference, so nothing about a quest status may suppress its tracking dot — and + // a GameObject can no longer *hold* a status here anyway (`net/apply` drops it, §W14.1). if tracking.resources != 0 { if let Some(locks) = locks { for (guid, net, tf, _) in candidates.iter() { - if net.kind != EntityKind::GameObject || statuses.get(&guid.0).copied() == Some(7) { + if net.kind != EntityKind::GameObject { continue; } let Some(t) = templates.get(guid.0) else { diff --git a/crates/benilla-app/src/net/apply/quests.rs b/crates/benilla-app/src/net/apply/quests.rs index ea30bc43..7c44c6f7 100644 --- a/crates/benilla-app/src/net/apply/quests.rs +++ b/crates/benilla-app/src/net/apply/quests.rs @@ -18,9 +18,36 @@ use crate::ui_quest_share::QuestShare; use super::super::{ClientCommand, NetCommands}; /// A questgiver dialog status for one NPC (`SMSG_QUESTGIVER_STATUS`) — the `!`/`?` marker's -/// [`crate::messages::dialog_status`] value. Stored per guid now; the world marker is a later -/// slice (decision 0088). +/// [`crate::messages::dialog_status`] value, stored per guid for the marker layer and the minimap +/// quest dot. +/// +/// **A GameObject's answer is DROPPED, and that is the reference's own behaviour** (decision 1872, +/// wow-re `questgiver-marker.md` §W14.1): the handler `0x5dc9f0` resolves the packet's GUID with +/// `0x5dca22 mov ecx,8` — typemask `TYPEMASK_UNIT` — and `0x468460` is a bitmask AND against +/// `OBJECT_FIELD_TYPE`, so a GameObject's `0x21` misses bit 3, returns NULL, and the handler exits +/// at `0x5dca2f` without ever reaching the `UNIT_NPC_FLAGS` test, let alone the marker. +/// +/// This is not a hypothetical branch here: benilla now *sends* the query for quest-flagged +/// GameObjects, exactly as the reference does ([`crate::quest_markers`]), and **vmangos answers +/// it** — `GetObjectByTypeMask(guid, TYPEMASK_CREATURE_OR_GAMEOBJECT)`, `QuestHandler.cpp:41` — +/// where the real 1.12 service never did (zero GameObject GUIDs across 1292 in the sniff corpus). +/// So the drop is what keeps a vmangos-only answer from putting a `!` over a wanted poster that +/// the reference client leaves bare. +/// +/// The test is the GUID's own shape rather than a live type lookup on purpose: it gives the same +/// partition as typemask 8 for anything a server can send us, and it cannot be defeated by a status +/// that arrives in the same drain as its object's create block (descriptors are flushed at the end +/// of the drain, so a type test would read a not-yet-seeded store and drop a *unit's* answer that +/// would then never be re-asked for). The reference's second conjunct — `UNIT_NPC_FLAGS & 0x2` on +/// the resolved unit — is deliberately not modelled here for that same ordering reason; +/// [`crate::quest_markers::query`]'s teardown leg covers the flag-clearing case from the other end. pub(super) fn quest_giver_status(npc: u64, status: u32, quest: &mut QuestGiver) { + use benilla_protocol::guid; + if !(guid::is_player(npc) || guid::is_creature_or_pet(npc)) { + debug!("net: dropping a non-unit questgiver status ({npc:#x} → {status}) — typemask 8"); + quest.refuse_status(npc, status); + return; + } quest.set_status(npc, status); } @@ -248,6 +275,53 @@ pub(super) fn quest_confirm_accept(c: QuestConfirmAccept, share: &mut QuestShare mod tests { use super::*; + /// **The typemask-8 refusal** (decision 1872, wow-re `questgiver-marker.md` §W14.1). benilla + /// now sends `CMSG_QUESTGIVER_STATUS_QUERY` for quest-flagged GameObjects because the reference + /// does — and vmangos, unlike the real 1.12 service, *answers*. The reference's handler drops + /// that answer at the lookup (`0x5dca22 mov ecx,8`), so we must too: otherwise a wanted poster + /// would wear a gold `!` the reference client never puts there. + /// + /// The control is the half that must not change: a creature's answer, and a player's, still + /// land — typemask 8 is `TYPEMASK_UNIT`, which a creature (`0x9`) and a player (`0x19`) both + /// carry. + #[test] + fn a_gameobjects_dialog_status_is_dropped_and_a_creatures_is_not() { + use benilla_protocol::messages::dialog_status; + + // The Goldshire `Wanted Poster` (HIGHGUID_GAMEOBJECT), an elevator (HIGHGUID_TRANSPORT — + // a GameObject in every respect but its high word), a creature, and a player. + const POSTER: u64 = 0xf110_0000_0044_68db; + const ELEVATOR: u64 = 0xf120_0000_0384_1092; + const CREATURE: u64 = 0xf130_0000_0060_0abc; + const PLAYER: u64 = 0x0000_0000_0000_0007; + + let mut quest = QuestGiver::default(); + for (guid, status) in [ + (POSTER, dialog_status::AVAILABLE), + (ELEVATOR, dialog_status::AVAILABLE), + (CREATURE, dialog_status::AVAILABLE), + (PLAYER, dialog_status::REWARD2), + ] { + quest_giver_status(guid, status, &mut quest); + } + assert_eq!( + quest.status(POSTER), + None, + "a GameObject GUID misses typemask bit 3 and the handler exits at 0x5dca2f" + ); + assert_eq!(quest.status(ELEVATOR), None, "…and so does a transport GO"); + assert_eq!( + quest.status(CREATURE), + Some(dialog_status::AVAILABLE), + "the control: a creature's answer is what this packet is FOR" + ); + assert_eq!( + quest.status(PLAYER), + Some(dialog_status::REWARD2), + "and a player passes the same typemask, as it does in the reference" + ); + } + fn open_detail(quest_id: u32) -> QuestGiver { let mut giver = QuestGiver::default(); giver.open( diff --git a/crates/benilla-app/src/quest_markers/query.rs b/crates/benilla-app/src/quest_markers/query.rs index d2cee234..ea191849 100644 --- a/crates/benilla-app/src/quest_markers/query.rs +++ b/crates/benilla-app/src/quest_markers/query.rs @@ -1,5 +1,14 @@ //! The **query** half of the overhead questgiver markers: when to ask the server what an NPC's -//! `!`/`?` should be. The rendering half is [`super`]. +//! `!`/`?` should be — and, for a GameObject, when to ask a question whose answer is thrown away. +//! The rendering half is [`super`]. +//! +//! **A GameObject is queried and never rendered** (decision 1872, wow-re `questgiver-marker.md` +//! §W14). The reference sends `CMSG_QUESTGIVER_STATUS_QUERY` for a quest-flagged GameObject from +//! both object sweeps, but its answer handler `0x5dc9f0` resolves the GUID with typemask **8** +//! (`0x468460` is a bitmask AND against `OBJECT_FIELD_TYPE`), so a GameObject's `0x21` returns NULL +//! and the packet dies at `0x5dca2f` — and even if it did not, a GameObject has no `+0xcb8` status +//! slot, no `+0xb2c` marker slot, and (11 models out of ~1600, none of them a poster) no +//! attachment 18 to hang a marker from. So: **send the query, drop the answer, render nothing.** //! //! The server only ever *answers* `CMSG_QUESTGIVER_STATUS_QUERY` (vmangos `QuestHandler.cpp`) — it //! never pushes — so every refresh point is the client's own to trigger, and a status that is never @@ -11,11 +20,27 @@ use std::collections::HashMap; use bevy::prelude::*; +use benilla_protocol::EntityKind; + use crate::net::{ClientCommand, GuidIndex, NetCommands, NetEntity, ObjectStore, SelfPlayer}; +use crate::target::cursor_mode::go_reaction; +use crate::target::ring::Factions; use crate::ui_quest::QuestGiver; /// `UNIT_NPC_FLAGS` questgiver service bit (vanilla; `update_object.rs`'s accessor doc). const NPC_FLAG_QUESTGIVER: u32 = 0x2; +/// `GAMEOBJECT_FLAGS` bit **2** (mask `0x4`) — the whole of the sweep's GameObject gate, and the +/// one place a GameObject GUID can reach this opcode at all. +/// +/// Byte-pinned (wow-re `questgiver-marker.md` §W14.6): `0x5eb0ef mov eax,[edx+0xc]; +/// 0x5eb0f2 shr eax,0x2; 0x5eb0f5 test al,1` — shift **2**, so mask `0x4`, not the `0x2` the unit +/// leg tests 25 bytes later. `[+0xc]` is absolute field index 9 = `GAMEOBJECT_FLAGS`, +/// named from the binary's own UpdateField name table (`0x83b8a8` → the string at `0x83be84`). +/// The *name* `GO_FLAG_INTERACT_COND` is vmangos's, not the image's — but the bit is settled from +/// shipped content as well as from the branch: across the genuine 2005-06 sniff corpus, all 122 +/// GameObjects the real client sent a status query for carry it, and none without it does, against +/// a 79.2 % base rate. +const GO_FLAG_INTERACT_COND: u32 = 0x4; /// Ask the dialog status of every questgiver-flagged creature, and re-ask when the answer could /// have changed. The server only ever *answers* queries (vmangos `QuestHandler.cpp`), so every @@ -42,8 +67,28 @@ const NPC_FLAG_QUESTGIVER: u32 = 0x2; /// leaves [`GuidIndex`], which is the create-path query (the reference caches the answer *on* the /// unit at `unit+0xcb8`, so its cache dies with the object — ours is a map and needs the prune). /// +/// **Per GameObject — on a sweep, and ONLY on a sweep.** A quest-giving GameObject (a wanted +/// poster, a suspicious barrel) is a questgiver on the wire exactly as a creature is: the same +/// sweep callback tests typemask bit 5 and sends the same opcode for it (`0x5eb0a0` @ `0x5eb159`, +/// and its sibling `0x5eb3f0` @ `0x5eb456`), gated on [`GO_FLAG_INTERACT_COND`] and on the +/// GameObject's own reaction toward us being **> 1** ([`go_reaction`], the reference's `0x5f7fd0`). +/// The wire corpus shows it happening in 20 of 62 real sessions. +/// +/// But there is **no bring-up query for a GameObject** — the contrast with units is exact and it +/// is the point (wow-re §W14.8, a closed caller census: a GameObject GUID can reach this opcode +/// through exactly two instructions in the whole image and both are inside a sweep callback; the +/// `CGGameObject_C` ctor and its vtable slot 3 call no sender, where `CGUnit_C`'s slot 3 does). So +/// a GameObject is asked about only when a sweep happens to run while it is in view, and a player +/// who completes a quest elsewhere and then walks up to the turn-in object never asks about it at +/// all. That is a real, slightly surprising reference behaviour, and **a per-guid asked-set that +/// queries on first sight is a deviation, not a fix** — hence the `swept` gate below rather than +/// an [`unit_ask_key`] analogue. Nothing visible turns on any of this: the answer is dropped +/// ([`crate::net::apply`]'s own typemask gate), which is decision 1872's whole point. +/// /// **The teardown leg** — an NPC whose questgiver bit goes off loses its cached status, so the -/// marker goes with it (`0x5eb0a0`'s own branch), and is re-asked if the bit returns. +/// marker goes with it (`0x5eb0a0`'s own branch), and is re-asked if the bit returns. There is no +/// GameObject counterpart: a GameObject has no status to invalidate (`+0xcb8` is `0x9a8` bytes past +/// the end of a `0x310`-byte `CGGameObject_C`), and all 8 of `0x6073f0`'s call sites are unit-side. /// /// **Still not covered**, and why: the reference also sweeps when an *item* moves between /// containers (an `ITEM` typeid watch, `0x5d9375` — quest availability can depend on carried @@ -55,8 +100,9 @@ const NPC_FLAG_QUESTGIVER: u32 = 0x2; #[allow(clippy::type_complexity)] // a Bevy system: each param is one resource, the app's convention pub(super) fn query_statuses( self_q: Query, With>, - units: Query<(&crate::net::Guid, &ObjectStore), (With, Without)>, + objects: Query<(&crate::net::Guid, &NetEntity, &ObjectStore), Without>, index: Res, + factions: Option>, mut quest: ResMut, commands: Res, mut state: Local, @@ -71,7 +117,13 @@ pub(super) fn query_statuses( state.fields = self_generation(&store.0); } let generation = state.fields ^ (u64::from(quest.reask_epoch()) << 32); - if state.generation != generation { + // **This frame IS the sweep.** Both of the reference's object sweeps (`0x5eb070` and the full + // re-query `0x5eb3c0`) walk every object in the manager once, synchronously, when one of the + // 13 local-player state changes fires them; a changed generation is that moment, here. The + // GameObject leg below fires only on it, because that is the only way a GameObject GUID ever + // reaches the wire (§W14.8). + let swept = state.generation != generation; + if swept { state.generation = generation; state.asked.clear(); } @@ -79,29 +131,59 @@ pub(super) fn query_statuses( // its "already asked" mark and its cached status, so re-entering view re-asks from scratch. state.asked.retain(|guid, _| index.0.contains_key(guid)); quest.retain_statuses(|npc| index.0.contains_key(&npc)); - for (guid, obj) in &units { - if obj.0.unit_npc_flags() & NPC_FLAG_QUESTGIVER == 0 { - // No longer a questgiver: drop the stale answer (and the marker with it) — - // **unconditionally**, never "only if we had asked". The reference's sweep callback - // (`0x5eb0a0`) tears the marker down on the flag test alone, with no prior-asked - // precondition (decision 0647), and the gate we used to put here was disarmed by the - // very sweep that arrives with it: an escort's giver drops `UNIT_NPC_FLAGS` in the same - // server tick as the quest-log write (vmangos `FollowerAI::StartFollow`, - // `ScriptedEscortAI`'s "disable npcflags"), so the quest-log change bumps the - // generation, `asked` is cleared above, `remove` finds nothing, and the cached - // AVAILABLE status — with its `!` — was frozen over the NPC for the whole escort, with - // no way back: the flag stays off, so this arm never queries either (B257). - state.asked.remove(&guid.0); - quest.clear_status(guid.0); - continue; - } - // Re-ask when this unit's own key moves — its service bits or its faction template. The - // reference does the same per-unit query off those field watches. - let key = unit_ask_key(&obj.0); - if state.asked.insert(guid.0, key) != Some(key) { - let _ = commands - .0 - .send(ClientCommand::QuestgiverStatusQuery { npc: guid.0 }); + for (guid, net, obj) in &objects { + match net.kind { + // `0x5eb0a0` @ `0x5eb0d3`: `cmp eax,9; je` — typemask EXACTLY `OBJECT|UNIT`, a plain + // creature. A player is `0x19` and falls out of the sweep here, before any flag test. + EntityKind::Unit => { + if obj.0.unit_npc_flags() & NPC_FLAG_QUESTGIVER == 0 { + // No longer a questgiver: drop the stale answer (and the marker with it) — + // **unconditionally**, never "only if we had asked". The reference's sweep + // callback (`0x5eb0a0`) tears the marker down on the flag test alone, with no + // prior-asked precondition (decision 0647), and the gate we used to put here + // was disarmed by the very sweep that arrives with it: an escort's giver drops + // `UNIT_NPC_FLAGS` in the same server tick as the quest-log write (vmangos + // `FollowerAI::StartFollow`, `ScriptedEscortAI`'s "disable npcflags"), so the + // quest-log change bumps the generation, `asked` is cleared above, `remove` + // finds nothing, and the cached AVAILABLE status — with its `!` — was frozen + // over the NPC for the whole escort, with no way back: the flag stays off, so + // this arm never queries either (B257). + state.asked.remove(&guid.0); + quest.clear_status(guid.0); + continue; + } + // Re-ask when this unit's own key moves — its service bits or its faction template. + // The reference does the same per-unit query off those field watches. + let key = unit_ask_key(&obj.0); + if state.asked.insert(guid.0, key) != Some(key) { + let _ = commands + .0 + .send(ClientCommand::QuestgiverStatusQuery { npc: guid.0 }); + } + } + // `0x5eb0da shr ecx,5; test cl,1` — typemask bit 5, `TYPEMASK_GAMEOBJECT`. Sweep-only, + // no asked-set, no teardown: see this function's doc and §W14.8. + EntityKind::GameObject if swept => { + if obj.0.gameobject_flags() & GO_FLAG_INTERACT_COND == 0 { + continue; + } + // `0x5eb101 cmp eax,1; jle` — the GameObject's reaction toward us must be at least + // **2**. Not a friendliness test: Unfriendly(2) and Neutral(3) both pass, and a + // faction-less GameObject resolves Neutral. `None` = unresolvable (no catalog yet) + // and passes, the same "no opinion" rule the cursor's own gate uses. + let reaction = go_reaction( + factions.as_deref(), + obj.0.gameobject_faction(), + Some(&store), + ); + if !reaction.is_none_or(|r| r > 1) { + continue; + } + let _ = commands + .0 + .send(ClientCommand::QuestgiverStatusQuery { npc: guid.0 }); + } + _ => {} } } } @@ -141,10 +223,15 @@ fn self_generation(fields: &benilla_protocol::ObjectFields) -> u64 { // packets, 70 → 2 HP, with zero bursts; the single packet crossing to 0 has the only burst in // the stretch). // - // One named over-refresh: our bit flips on BOTH crossings, so we sweep units on resurrect too. - // The reference sweeps GameObject questgivers there (`0x5eb3c0`) plus one conditional unit - // query — and benilla has no GameObject markers at all, so a unit sweep is the nearest thing we - // can do with what we actually render. It costs one extra sweep per resurrect. 0654. + // Our bit flips on BOTH crossings, and that is now known to be exactly right — the note this + // comment used to carry ("one named over-refresh... the reference sweeps GameObject questgivers + // there") was wrong on both halves and is CORRECTED by wow-re §W14.5/§W14.11. `0x5eb3c0` is not + // a GameObject sweep: `0x468380` applies no type filter at all, so it walks **every** object in + // the manager — it is the FULL re-query sweep, and its creature arm is the heavier one + // (`0x607380`: an unconditional marker teardown plus two queries, questgiver `0x182` and taxi + // `0x1aa`), the GameObject arm the lighter. So the death edge sweeps (`0x5eb070`) and the + // **revive** edge sweeps too (`0x5eb3c0`), and one sweep per crossing is the reference's own + // behaviour rather than a deviation we were paying for. 0654, 1872. fold(u64::from(fields.unit_health().unwrap_or(1) == 0)); fold(u64::from(fields.player_flags())); fold(u64::from(fields.player_money().unwrap_or(0))); @@ -399,6 +486,146 @@ mod tests { ); } + /// **A GameObject is asked about on a SWEEP, and only on a sweep** (decision 1872, wow-re + /// `questgiver-marker.md` §W14.6/§W14.8). Three things are being pinned here, and the third is + /// the one a client "improves" without noticing: + /// + /// - the gate is `GAMEOBJECT_FLAGS` bit 2 (mask `0x4`) — `0x5eb0ef shr eax,0x2; test al,1` — + /// and a quest-less GameObject beside it is never asked about at all; + /// - a sweep asks exactly once, and the frames between sweeps are silent; + /// - **there is no bring-up query.** A GameObject entering view asks nothing; it waits for the + /// next sweep. The unit control in the same world does the opposite on the same frame, which + /// is what makes this a contrast and not an accident of ordering. + #[test] + fn a_gameobject_is_asked_on_a_sweep_and_never_at_first_sight() { + use crate::net::{Guid, NetEntity}; + use benilla_protocol::ObjectFields; + + /// The Goldshire `Wanted Poster`'s real guid (`HIGHGUID_GAMEOBJECT`, template 68, spawn + /// 26843) and its real `GAMEOBJECT_FLAGS` — INTERACT_COND | NODESPAWN, read off the live + /// wire by `WOW_PROBE_GOQUEST`. + const POSTER: u64 = 0xf110_0000_0044_68db; + const POSTER_FLAGS: u32 = 0x24; + /// A GameObject next to it with no quest condition: a plain door, `NODESPAWN` only. + const DOOR: u64 = 0xf110_0000_0045_0001; + const DOOR_FLAGS: u32 = 0x20; + /// The control: an ordinary creature questgiver, which the same sweep treats differently. + const NPC: u64 = 0x2222; + const FIELD_LEVEL: u16 = 34; + const FIELD_NPC_FLAGS: u16 = 147; + const FIELD_GAMEOBJECT_FLAGS: u16 = 9; + + let (tx, rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.insert_resource(NetCommands(tx)) + .init_resource::() + .init_resource::() + .add_systems(Update, query_statuses); + + let spawn = |app: &mut App, guid: u64, kind: EntityKind, fields: &[(u16, u32)]| { + let e = app + .world_mut() + .spawn(( + NetEntity { + kind, + display_id: None, + scale: 1.0, + }, + Guid(guid), + ObjectStore(ObjectFields::from_pairs(fields)), + )) + .id(); + app.world_mut() + .resource_mut::() + .0 + .insert(guid, e); + }; + app.world_mut().spawn(( + SelfPlayer, + Guid(1), + ObjectStore(ObjectFields::from_pairs(&[(FIELD_LEVEL, 5)])), + )); + // Drain per update, then count per guid — several objects are in flight at once here. + let asked = |app: &mut App| -> Vec { + app.update(); + rx.try_iter() + .filter_map(|c| match c { + ClientCommand::QuestgiverStatusQuery { npc } => Some(npc), + _ => None, + }) + .collect() + }; + + // Settle the generation first: the very first frame in a fresh world IS a sweep (nothing + // has been folded yet), so "first sight" can only be asked of a later frame. + assert!(asked(&mut app).is_empty(), "nothing in view yet"); + + spawn( + &mut app, + POSTER, + EntityKind::GameObject, + &[(FIELD_GAMEOBJECT_FLAGS, POSTER_FLAGS)], + ); + spawn( + &mut app, + DOOR, + EntityKind::GameObject, + &[(FIELD_GAMEOBJECT_FLAGS, DOOR_FLAGS)], + ); + spawn( + &mut app, + NPC, + EntityKind::Unit, + &[(FIELD_NPC_FLAGS, NPC_FLAG_QUESTGIVER)], + ); + + assert_eq!( + asked(&mut app), + vec![NPC], + "first sight: the creature is asked from its own create path, the poster is NOT — \ + the GameObject class has no bring-up query (§W14.8)" + ); + assert!( + asked(&mut app).is_empty(), + "and the frames after it stay silent" + ); + + // A sweep: any of the reference's 13 local-player triggers. The packet epoch stands in for + // its four packet handlers. + app.world_mut().resource_mut::().bump_reask(); + let swept = asked(&mut app); + assert!( + swept.contains(&POSTER), + "the sweep asks about the poster — {swept:x?}" + ); + assert!( + swept.contains(&NPC), + "and re-asks the creature, as it always did — {swept:x?}" + ); + assert!( + !swept.contains(&DOOR), + "but never the GameObject without GAMEOBJECT_FLAGS bit 2 — {swept:x?}" + ); + assert_eq!( + swept.iter().filter(|g| **g == POSTER).count(), + 1, + "exactly once per sweep" + ); + + assert!( + asked(&mut app).is_empty(), + "between sweeps, nothing — a GameObject has no per-object key to re-ask on" + ); + + // ...and it is not one-shot: the next sweep asks again, because the reference keeps no + // per-GameObject memo of having asked (there is nowhere on a CGGameObject_C to keep one). + app.world_mut().resource_mut::().bump_reask(); + assert!( + asked(&mut app).contains(&POSTER), + "and again on the next sweep" + ); + } + /// The rest of the reference's trigger set (0654): the remaining self-descriptor watches, the /// packet epoch that stands in for its four packet handlers, and the per-unit key that catches /// a unit's own service bits or faction moving. Each leg must re-ask exactly once. diff --git a/crates/benilla-app/src/ui_quest.rs b/crates/benilla-app/src/ui_quest.rs index 8f8a9ffa..16e11f82 100644 --- a/crates/benilla-app/src/ui_quest.rs +++ b/crates/benilla-app/src/ui_quest.rs @@ -71,6 +71,18 @@ pub(crate) struct QuestGiver { messages: Vec, /// The re-ask epoch — see [`Self::bump_reask`]. reask: u32, + /// The last `SMSG_QUESTGIVER_STATUS` we **refused**, and how many we have refused this session + /// — `(guid, status)`. + /// + /// The only trace of a packet class benilla deliberately throws on the floor. benilla asks the + /// server about quest-flagged GameObjects because the reference does, and vmangos — unlike the + /// real 1.12 service, which never answered one — replies; the reference's own handler drops + /// that reply at the typemask-8 lookup and so does ours (decision 1872, + /// [`crate::net::apply`]'s `quest_giver_status`). Without this, the *whole* GameObject half of + /// the feature is invisible from inside the client: a correct drop and a query that was never + /// sent both read as "no status", which is exactly the pair a live probe has to tell apart. + refused: Option<(u64, u32)>, + refused_count: u32, } impl QuestGiver { @@ -141,6 +153,17 @@ impl QuestGiver { self.reask } + /// Record a dialog status the typemask gate refused — see [`Self::refused`]. + pub(crate) fn refuse_status(&mut self, npc: u64, status: u32) { + self.refused = Some((npc, status)); + self.refused_count = self.refused_count.saturating_add(1); + } + + /// The last refused `(guid, status)` and the session's refusal count — see the field. + pub(crate) fn refused(&self) -> (Option<(u64, u32)>, u32) { + (self.refused, self.refused_count) + } + /// The stored dialog status for `npc`, if any. The store-now half of DIALOG_STATUS /// (decision 0088): no consumer yet — the `!`/`?` world marker is a later nameplate slice — so /// this accessor is deliberately unused for now. diff --git a/crates/benilla-app/src/ui_script/action_bar_tests.rs b/crates/benilla-app/src/ui_script/action_bar_tests.rs index bde05ff0..7b3e9a97 100644 --- a/crates/benilla-app/src/ui_script/action_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/action_bar_tests.rs @@ -30,13 +30,25 @@ fn shipped_action_bar_drives_end_to_end() { ); if file == "ActionBar.xml" { assert_eq!( - report.frames, 62, + report.frames, 59, "bar + XP StatusBar (+ its numerals overlay) + exhaustion tick + max-level rail + art frame + 12 buttons (each with a Cooldown child) + 2 page buttons + the performance meter and its hover button, \ - + BonusActionBarFrame and its 12 buttons with their Cooldown children (25 — hidden, as the reference's is; decision 1223), + ReputationWatchBar with its status bar and its numerals overlay (3 — hidden, ref ReputationFrame.xml:869-994)" + + BonusActionBarFrame and its 12 buttons with their Cooldown children (25 — hidden, as the reference's is; decision 1223) — and NO ReputationWatchBar: its three frames went home to the reference's own ReputationFrame.xml with 1875" ); } } + // `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code, + // safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since + // 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too + // or the tick raises on its first event. + // In manifest order: the fonts its check-box labels colour from, the panel templates those + // boxes inherit through, then the pane. + super::test_ui::load_ui(&s, "Fonts.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml"); + // A warrior in battle stance: offset 1 ⇒ the bar shows actions 73..84. s.set_bonus_bar_offset(1); s.set_action( @@ -164,6 +176,18 @@ fn load_action_bar(s: &UiScript) { report.errors ); } + + // `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code, + // safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since + // 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too + // or the tick raises on its first event. + // In manifest order: the fonts its check-box labels colour from, the panel templates those + // boxes inherit through, then the pane. + super::test_ui::load_ui(s, "Fonts.xml"); + super::test_ui::load_ui(s, r"Interface\FrameXML\UIPanelTemplates.lua"); + super::test_ui::load_ui(s, r"Interface\FrameXML\UIPanelTemplates.xml"); + super::test_ui::load_ui(s, r"Interface\FrameXML\OptionsFrameTemplates.xml"); + super::test_ui::load_ui(s, r"Interface\FrameXML\ReputationFrame.xml"); } /// The state/feedback layer (decision 0137 phase 4) through the REAL shipped XML: a pushed @@ -1273,20 +1297,24 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() { let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); for file in [ + // Fonts first: the pane's check-box labels colour from `RED_FONT_COLOR` in their own OnLoad. + "Fonts.xml", "UIParent.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", "MultiBars.xml", ] { - let text = std::fs::read_to_string( - std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("assets/ui") - .join(file), - ) - .unwrap(); - let doc = benilla_ui::framexml::parse(&text).unwrap(); - let report = benilla_ui::loader::load(&s, &doc, &|_| None); - assert!(report.errors.is_empty(), "{file}: {:?}", report.errors); + // `test_ui::load_ui`, not a disk read: this list names chain entries now (the reputation + // pane and the templates it inherits through), and `assets/ui` cannot answer for those. + super::test_ui::load_ui(&s, file); } assert_eq!(s.eval::("return CURRENT_ACTIONBAR_PAGE").unwrap(), 1); @@ -1413,6 +1441,18 @@ fn bonus_bar_slides_up_with_sound_and_down_without() { let report = benilla_ui::loader::load(&s, &doc, &|_| None); assert!(report.errors.is_empty(), "{file}: {:?}", report.errors); } + + // `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code, + // safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since + // 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too + // or the tick raises on its first event. + // In manifest order: the fonts its check-box labels colour from, the panel templates those + // boxes inherit through, then the pane. + super::test_ui::load_ui(&s, "Fonts.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml"); s.fire_event("PLAYER_ENTERING_WORLD", vec![]); s.tick(10.0); // a nonzero clock epoch let _ = s.take_sounds(); @@ -1592,6 +1632,18 @@ fn bonus_bar_turnaround_continues_from_position() { let report = benilla_ui::loader::load(&s, &doc, &|_| None); assert!(report.errors.is_empty(), "{file}: {:?}", report.errors); } + + // `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code, + // safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since + // 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too + // or the tick raises on its first event. + // In manifest order: the fonts its check-box labels colour from, the panel templates those + // boxes inherit through, then the pane. + super::test_ui::load_ui(&s, "Fonts.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml"); s.tick(10.0); let _ = s.take_sounds(); @@ -1647,6 +1699,18 @@ fn the_page_arrows_do_not_steal_each_other_s_clicks() { let report = benilla_ui::loader::load(&s, &doc, &|_| None); assert!(report.errors.is_empty(), "{file}: {:?}", report.errors); } + + // `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code, + // safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since + // 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too + // or the tick raises on its first event. + // In manifest order: the fonts its check-box labels colour from, the panel templates those + // boxes inherit through, then the pane. + super::test_ui::load_ui(&s, "Fonts.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml"); // The post-login state, which is what a player clicks into. Without it `ExhaustionTick_Update` // never runs, and the rested marker — DIALOG strata, declared CENTER on the XP strip, which is // exactly where the arrows are — sits unhidden over both and eats every click. That is the diff --git a/crates/benilla-app/src/ui_script/basic_controls_tests.rs b/crates/benilla-app/src/ui_script/basic_controls_tests.rs index 525f909d..f17253b9 100644 --- a/crates/benilla-app/src/ui_script/basic_controls_tests.rs +++ b/crates/benilla-app/src/ui_script/basic_controls_tests.rs @@ -13,7 +13,7 @@ use super::test_ui::load_ui as load_xml; fn basic_controls() -> UiScript { let mut s = UiScript::new().unwrap(); load_xml(&s, "Fonts.xml"); - load_xml(&s, "BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); s.set_screen_size(1024.0, 768.0); s.resolve(); s diff --git a/crates/benilla-app/src/ui_script/exp_bar_tests.rs b/crates/benilla-app/src/ui_script/exp_bar_tests.rs index 971c08a5..b7b1a54e 100644 --- a/crates/benilla-app/src/ui_script/exp_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/exp_bar_tests.rs @@ -22,6 +22,14 @@ fn exp_bar_harness() -> UiScript { "Interface\\FrameXML\\TextStatusBar.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", ] { super::test_ui::load_ui(&s, file); } diff --git a/crates/benilla-app/src/ui_script/fade_tests.rs b/crates/benilla-app/src/ui_script/fade_tests.rs index 13bdf5b6..2776ef2f 100644 --- a/crates/benilla-app/src/ui_script/fade_tests.rs +++ b/crates/benilla-app/src/ui_script/fade_tests.rs @@ -106,3 +106,75 @@ fn a_started_fade_still_ramps_and_the_driver_reparks_after() { ); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } + +/// **The FLASH kit alternates and stops** — the fade kit's twin, transcribed from `UIParent.lua` +/// because two stock windows call it and nothing answered (1879). +/// +/// Driven rather than merely loaded: a kit that is present but never exercised is exactly how +/// twelve faux lists shipped unable to scroll (1868), so this runs a real flash to completion. +#[test] +fn a_flash_alternates_then_stops_and_the_driver_reparks() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "UiPanels.xml"); + load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); + load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); + s.resolve(); + + // Parked until something asks — the whole point of the driver over UIParent's own OnUpdate. + assert!( + !s.eval::("return BenillaFlashDriver:IsShown()") + .unwrap(), + "an empty FLASHFRAMES parks the driver" + ); + + s.run( + r#"Probe = CreateFrame("Frame", "BenillaFlashProbe", UIParent) + Probe:SetWidth(10) Probe:SetHeight(10) Probe:SetPoint("TOPLEFT") + UIFrameFlash(Probe, 0.05, 0.05, 0.3, nil, 0, 0)"#, + ) + .unwrap(); + assert!( + s.eval::("return UIFrameIsFlashing(BenillaFlashProbe) == 1") + .unwrap(), + "the frame is on the flash list" + ); + assert!( + s.eval::("return BenillaFlashDriver:IsShown()") + .unwrap(), + "UIFrameFlash is the single inserter and wakes the driver" + ); + + // Re-arming an already-flashing frame is a no-op, not a second entry (ref UIParent.lua:1234). + s.run("UIFrameFlash(BenillaFlashProbe, 1, 1, 1, nil, 0, 0)") + .unwrap(); + assert_eq!( + s.eval::("return table.getn(FLASHFRAMES)").unwrap(), + 1, + "already flashing: the reference returns rather than re-arming" + ); + + // Run past flashDuration: the frame leaves both lists, is restored to full alpha, and — with + // `showWhenDone` nil — ends hidden. + for _ in 0..40 { + s.tick(0.016); + s.resolve(); + } + assert!(s.errors().is_empty(), "flashing raised: {:?}", s.errors()); + assert_eq!( + s.eval::("return table.getn(FLASHFRAMES)").unwrap(), + 0, + "the flash finished and the frame left the list" + ); + assert!( + !s.eval::("return BenillaFlashProbe:IsShown()") + .unwrap(), + "showWhenDone was nil, so it ends hidden" + ); + assert!( + !s.eval::("return BenillaFlashDriver:IsShown()") + .unwrap(), + "and the driver re-parks on the first empty tick" + ); +} diff --git a/crates/benilla-app/src/ui_script/friends_tests.rs b/crates/benilla-app/src/ui_script/friends_tests.rs index f56713d9..7c649486 100644 --- a/crates/benilla-app/src/ui_script/friends_tests.rs +++ b/crates/benilla-app/src/ui_script/friends_tests.rs @@ -38,7 +38,26 @@ fn setup() -> UiScript { // guards, because there RaidFrame.xml is FrameXML and always loaded — so the guard belongs in // the harness's load order, not in shipped Lua defending against a state the client cannot be // in (decision 1549). - load_xml(&s, "RaidFrame.xml"); + // Stock `RaidFrame_OnLoad` reconciles the party frames the moment it loads + // (`RaidOptionsFrame_UpdatePartyFrames` -> `HidePartyFrame`/`ShowPartyFrame`), so this pane + // drags in the whole unit-frame cluster — and it must be the REAL frames: loading only + // `PartyFrame.lua` for the two names gets "attempt to index a nil value" the first time one + // iterates. The manifest already seats these far above RaidFrame (1874). + load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); + // The reference's own `BasicControls.xml`, which is manifest entry 3 — far above the pane + // (1874). It is here for the error/message pair and `TEXT`. NOTE: unlike `raid_tests`, this + // harness does not load `UIParent.xml`, so `UIParentLoadAddOn` — which 1881 moved there out + // of BasicControls — is absent. Nothing this file exercises reaches it; if a future + // assertion drives `RaidFrame_LoadUI`, that is the line to add. + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, r"Interface\FrameXML\TextStatusBar.lua"); + load_xml(&s, r"Interface\FrameXML\TextStatusBar.xml"); + load_xml(&s, r"Interface\FrameXML\BuffFrame.xml"); + load_xml(&s, r"Interface\FrameXML\UnitFrame.xml"); + load_xml(&s, r"Interface\FrameXML\CombatFeedback.xml"); + load_xml(&s, r"Interface\FrameXML\PartyFrame.xml"); + load_xml(&s, r"Interface\FrameXML\RaidFrame.xml"); + load_xml(&s, r"Interface\AddOns\Blizzard_RaidUI\Blizzard_RaidUI.xml"); s } diff --git a/crates/benilla-app/src/ui_script/guild_tests.rs b/crates/benilla-app/src/ui_script/guild_tests.rs index ea49a88b..495bc850 100644 --- a/crates/benilla-app/src/ui_script/guild_tests.rs +++ b/crates/benilla-app/src/ui_script/guild_tests.rs @@ -204,7 +204,7 @@ fn setup() -> UiScript { }), ); load_xml(&s, "Fonts.xml"); - load_xml(&s, "BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); load_xml(&s, "MoneyFrame.xml"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); @@ -221,7 +221,20 @@ fn setup() -> UiScript { // guards, because there RaidFrame.xml is FrameXML and always loaded — so the guard belongs in // the harness's load order, not in shipped Lua defending against a state the client cannot be // in (decision 1549). - load_xml(&s, "RaidFrame.xml"); + // Stock `RaidFrame_OnLoad` reconciles the party frames the moment it loads + // (`RaidOptionsFrame_UpdatePartyFrames` -> `HidePartyFrame`/`ShowPartyFrame`), so this pane + // drags in the whole unit-frame cluster — and it must be the REAL frames: loading only + // `PartyFrame.lua` for the two names gets "attempt to index a nil value" the first time one + // iterates. The manifest already seats these far above RaidFrame (1874). + load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); + load_xml(&s, r"Interface\FrameXML\TextStatusBar.lua"); + load_xml(&s, r"Interface\FrameXML\TextStatusBar.xml"); + load_xml(&s, r"Interface\FrameXML\BuffFrame.xml"); + load_xml(&s, r"Interface\FrameXML\UnitFrame.xml"); + load_xml(&s, r"Interface\FrameXML\CombatFeedback.xml"); + load_xml(&s, r"Interface\FrameXML\PartyFrame.xml"); + load_xml(&s, r"Interface\FrameXML\RaidFrame.xml"); + load_xml(&s, r"Interface\AddOns\Blizzard_RaidUI\Blizzard_RaidUI.xml"); s } diff --git a/crates/benilla-app/src/ui_script/help_frame_tests.rs b/crates/benilla-app/src/ui_script/help_frame_tests.rs index 5f220f43..9460b186 100644 --- a/crates/benilla-app/src/ui_script/help_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/help_frame_tests.rs @@ -20,7 +20,7 @@ fn setup() -> UiScript { s.set_screen_size(1024.0, 768.0); for file in [ "Fonts.xml", - "BasicControls.xml", + "Interface\\FrameXML\\BasicControls.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", "ScrollTemplates.xml", diff --git a/crates/benilla-app/src/ui_script/honor_frame_tests.rs b/crates/benilla-app/src/ui_script/honor_frame_tests.rs index 3f5ca03b..73eb93c9 100644 --- a/crates/benilla-app/src/ui_script/honor_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/honor_frame_tests.rs @@ -368,7 +368,7 @@ fn shown_inspect_honor_page() -> UiScript { for file in [ "Fonts.xml", // `TEXT`, the stock level line's formatter. - "BasicControls.xml", + "Interface\\FrameXML\\BasicControls.xml", "MoneyFrame.xml", "UiPanels.xml", r"Interface\FrameXML\UIPanelTemplates.lua", diff --git a/crates/benilla-app/src/ui_script/inspect_tests.rs b/crates/benilla-app/src/ui_script/inspect_tests.rs index 3ac7f5cf..cf2be346 100644 --- a/crates/benilla-app/src/ui_script/inspect_tests.rs +++ b/crates/benilla-app/src/ui_script/inspect_tests.rs @@ -116,7 +116,7 @@ fn armed() -> UiScript { load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "Fonts.xml"); // `TEXT`, which the stock `InspectPaperDollFrame_SetLevel` formats its level line through. - load_xml(&s, "BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); load_xml(&s, "MoneyFrame.xml"); load_xml(&s, "UiPanels.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -163,7 +163,7 @@ fn shipped_inspect_frame_loads_clean() { load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "Fonts.xml"); // `TEXT`, which the stock `InspectPaperDollFrame_SetLevel` formats its level line through. - load_xml(&s, "BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); load_xml(&s, "MoneyFrame.xml"); load_xml(&s, "UiPanels.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); diff --git a/crates/benilla-app/src/ui_script/instance_tests.rs b/crates/benilla-app/src/ui_script/instance_tests.rs index 852712a2..b8d4bacb 100644 --- a/crates/benilla-app/src/ui_script/instance_tests.rs +++ b/crates/benilla-app/src/ui_script/instance_tests.rs @@ -15,6 +15,7 @@ use super::test_ui::load_ui as load_xml; /// `ResetInstances` a queue. #[test] fn the_three_bindings_have_the_reference_shapes() { + let _data = benilla_formats::wow_data_or_skip!(); let mut s = UiScript::new().unwrap(); // Nothing pushed yet: the app has not said where we are. @@ -66,13 +67,17 @@ fn the_three_bindings_have_the_reference_shapes() { /// and Yes is the only thing that sends. #[test] fn the_self_menu_row_gates_on_the_binding_and_confirms_before_sending() { + let _data = benilla_formats::wow_data_or_skip!(); let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); // The row labels a bare harness has no GlobalStrings.lua for. Verbatim 1.12 values - // (`RESET_INSTANCES` l.3342, `CONFIRM_RESET_INSTANCES` l.851, `YES` l.5463, `NO` l.2794) — - // production runs the player's own string table at boot. + // (`RESET_INSTANCES` l.3342, `CONFIRM_RESET_INSTANCES` l.851, `YES` l.5463, `NO` l.2794, + // `GROUP` l.2029) — production runs the player's own string table at boot. s.run( r#" + -- The stock raid pane concatenates this into each of its eight group headers inside + -- their own OnLoad, so it has to exist before the addon loads (1874). + GROUP = "Group" RESET_INSTANCES = "Reset all instances" CONFIRM_RESET_INSTANCES = "Do you really want to reset all of your instances?" YES = "Yes" @@ -100,7 +105,8 @@ fn the_self_menu_row_gates_on_the_binding_and_confirms_before_sending() { "Interface\\FrameXML\\PartyFrame.xml", "Interface\\FrameXML\\TargetFrame.xml", "Interface\\FrameXML\\PetFrame.xml", - "RaidFrame.xml", + r"Interface\FrameXML\RaidFrame.xml", + r"Interface\AddOns\Blizzard_RaidUI\Blizzard_RaidUI.xml", ] { load_xml(&s, file); } diff --git a/crates/benilla-app/src/ui_script/macro_tests.rs b/crates/benilla-app/src/ui_script/macro_tests.rs index 2dc6d7b0..dd965904 100644 --- a/crates/benilla-app/src/ui_script/macro_tests.rs +++ b/crates/benilla-app/src/ui_script/macro_tests.rs @@ -90,7 +90,7 @@ fn harness_with(player: &str) -> UiScript { for file in [ r"Interface\FrameXML\GlobalStrings.lua", "Fonts.xml", - "BasicControls.xml", // `TEXT` + "Interface\\FrameXML\\BasicControls.xml", // `TEXT` "MoneyFrame.xml", "UiPanels.xml", // The chain's `PanelTemplates_SelectTab` reaches for `GameTooltip` unguarded. diff --git a/crates/benilla-app/src/ui_script/merchant_tests.rs b/crates/benilla-app/src/ui_script/merchant_tests.rs index 01c2a90c..2c1ea576 100644 --- a/crates/benilla-app/src/ui_script/merchant_tests.rs +++ b/crates/benilla-app/src/ui_script/merchant_tests.rs @@ -1078,6 +1078,9 @@ fn trade_recipient_money_renders_the_digit_not_ellipsis() { load_xml(&s, f); } load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // the BenillaMoney_* helpers + // TradeFrame's money entry is the chain's own since 1882 — its OnLoad calls MoneyInputFrame_*. + load_xml(&s, "Interface\\FrameXML\\MoneyInputFrame.lua"); + load_xml(&s, "Interface\\FrameXML\\MoneyInputFrame.xml"); load_xml(&s, "TradeFrame.xml"); s.set_text_measurer(Box::new(super::FixedWidthFont(6.0))); diff --git a/crates/benilla-app/src/ui_script/minimap_tests.rs b/crates/benilla-app/src/ui_script/minimap_tests.rs index a5831bb9..a4504a81 100644 --- a/crates/benilla-app/src/ui_script/minimap_tests.rs +++ b/crates/benilla-app/src/ui_script/minimap_tests.rs @@ -134,7 +134,7 @@ fn game_time_session(hour: u32, minute: u32) -> UiScript { s.run("LocalizeFrames()").unwrap(); load_xml(&s, "Fonts.xml"); // `TEXT()`, which the reference's own tooltip and label formatting passes every string through. - load_xml(&s, "BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, "MinimapCluster.xml"); load_xml(&s, "Interface\\FrameXML\\GameTime.xml"); diff --git a/crates/benilla-app/src/ui_script/multibar_stance_tests.rs b/crates/benilla-app/src/ui_script/multibar_stance_tests.rs index c64e2762..8d46287f 100644 --- a/crates/benilla-app/src/ui_script/multibar_stance_tests.rs +++ b/crates/benilla-app/src/ui_script/multibar_stance_tests.rs @@ -23,6 +23,14 @@ fn load_action_bar(s: &UiScript) { "GameTooltip.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", ] { load_xml(s, file); } @@ -429,6 +437,14 @@ fn multibar_hover_renders_the_buttons_own_action() { "GameTooltip.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", "MultiBars.xml", ] { load_xml(&s, file); diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index c38de37e..9851e5ae 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -750,6 +750,14 @@ fn interface_harness() -> UiScript { "Interface\\FrameXML\\ColorPickerFrame.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", "ScrollTemplates.xml", "Interface\\FrameXML\\MerchantFrame.xml", "QuestFrame.xml", @@ -801,6 +809,14 @@ fn actionbars_harness() -> UiScript { "UIParent.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", "MultiBars.xml", ], ); diff --git a/crates/benilla-app/src/ui_script/pet_bar_tests.rs b/crates/benilla-app/src/ui_script/pet_bar_tests.rs index e709849f..ec4c68b5 100644 --- a/crates/benilla-app/src/ui_script/pet_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/pet_bar_tests.rs @@ -13,11 +13,21 @@ fn load_pet_bar(s: &UiScript) { for file in [ "MoneyFrame.xml", "UiPanels.xml", + "Fonts.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", "UIParent.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + "Fonts.xml", + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", "PetActionBar.xml", ] { load_xml(s, file); @@ -353,11 +363,21 @@ fn pet_bar_row(with_multibar: bool) -> (usize, f32) { for file in [ "MoneyFrame.xml", "UiPanels.xml", + "Fonts.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", "UIParent.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + "Fonts.xml", + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", ] { load_xml(&s, file); } @@ -643,6 +663,14 @@ fn load_pet_bar_with_tooltip(s: &UiScript) { "GameTooltip.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", "PetActionBar.xml", ] { load_xml(s, file); diff --git a/crates/benilla-app/src/ui_script/petition_tests.rs b/crates/benilla-app/src/ui_script/petition_tests.rs index eaefc04c..775feced 100644 --- a/crates/benilla-app/src/ui_script/petition_tests.rs +++ b/crates/benilla-app/src/ui_script/petition_tests.rs @@ -91,7 +91,7 @@ fn setup() -> UiScript { // never paints. load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "Fonts.xml"); - load_xml(&s, "BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); load_xml(&s, "MoneyFrame.xml"); load_xml(&s, "UiPanels.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); diff --git a/crates/benilla-app/src/ui_script/raid_tests.rs b/crates/benilla-app/src/ui_script/raid_tests.rs index 3dd04134..b0b1d329 100644 --- a/crates/benilla-app/src/ui_script/raid_tests.rs +++ b/crates/benilla-app/src/ui_script/raid_tests.rs @@ -35,7 +35,26 @@ fn setup() -> UiScript { load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "FriendsFrame.xml"); - load_xml(&s, "RaidFrame.xml"); + // Stock `RaidFrame_OnLoad` reconciles the party frames the moment it loads + // (`RaidOptionsFrame_UpdatePartyFrames` -> `HidePartyFrame`/`ShowPartyFrame`), so this pane + // drags in the whole unit-frame cluster — and it must be the REAL frames: loading only + // `PartyFrame.lua` for the two names gets "attempt to index a nil value" the first time one + // iterates. The manifest already seats these far above RaidFrame (1874). + load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); + // The reference's own `BasicControls.xml`, which is manifest entry 3 — far above the pane + // (1874). `UIParentLoadAddOn` used to be what this line was here for; 1881 moved that + // function to `UIParent.xml`, its home in the reference, and this harness already loads + // `UIParent.xml` above, so the `RaidFrame_LoadUI` arm on `RAID_ROSTER_UPDATE` still + // resolves. What is left here is the error/message pair and `TEXT`. + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, r"Interface\FrameXML\TextStatusBar.lua"); + load_xml(&s, r"Interface\FrameXML\TextStatusBar.xml"); + load_xml(&s, r"Interface\FrameXML\BuffFrame.xml"); + load_xml(&s, r"Interface\FrameXML\UnitFrame.xml"); + load_xml(&s, r"Interface\FrameXML\CombatFeedback.xml"); + load_xml(&s, r"Interface\FrameXML\PartyFrame.xml"); + load_xml(&s, r"Interface\FrameXML\RaidFrame.xml"); + load_xml(&s, r"Interface\AddOns\Blizzard_RaidUI\Blizzard_RaidUI.xml"); s } @@ -738,7 +757,18 @@ fn the_raid_info_panel_lists_the_saved_lockouts() { .unwrap(), 0 ); - assert!(!visible(&s, "RaidInfoInstance1")); + // **The stale row STAYS, and that is the reference's own defect.** `RaidInfoFrame_Update` + // wraps its whole row loop in `if ( savedInstances > 0 )` (RaidFrame.lua:93-106), so the + // count dropping to zero never reaches the `Hide()` in its else-arm — the last lockout + // remains painted under an already-open panel. Our retired file hid them; the migration + // reverts that, and it is the director's call, not ours to re-author (1874). + // + // What DOES protect the player is the line above: the button is disabled, so the flyout + // cannot be reopened onto the stale row. + assert!( + visible(&s, "RaidInfoInstance1"), + "the reference leaves the last row standing when the count hits zero" + ); // The button toggles the flyout. assert!(visible(&s, "RaidInfoFrame")); @@ -779,12 +809,21 @@ fn a_player_with_no_lockouts_loses_the_raid_info_button_on_the_second_answer() { "an empty lockout list is a dead button" ); - // And the panel behind it is emptied, not left in the state the XML loaded: every row away, - // and no scroll bar standing over a list of nothing. + // **The panel IS left in the state the XML loaded, and that is the reference's own defect.** + // `RaidInfoFrame_Update` wraps its entire row loop in `if ( savedInstances > 0 )` + // (RaidFrame.lua:93-106), so with a lockout list that has never had anything in it the loop + // never runs at all and no row is ever hidden — they keep the visibility stock + // `Blizzard_RaidUI.xml` declared. Our retired file cleared them; the migration reverts that, + // and a look the migration reverts is the director's call, not ours to re-author (1874). + // + // What protects the player is the line above: the button is disabled, so this panel cannot be + // reached by a click at all. The rows below are only visible because the test showed the frame + // by hand. s.run("RaidInfoFrame:Show()").unwrap(); - assert!(!visible(&s, "RaidInfoInstance1")); - assert!(!visible(&s, "RaidInfoInstance10")); - assert!(!visible(&s, "RaidInfoScrollFrameScrollBar")); + assert!( + visible(&s, "RaidInfoInstance1") && visible(&s, "RaidInfoInstance10"), + "with no lockouts the reference's update never runs, so the XML's own rows stand" + ); s.run("RaidInfoFrame:Hide()").unwrap(); // The button is dead to a real click, not merely drawn grey — the half the director asked for @@ -909,51 +948,10 @@ fn the_ready_check_popup_opens_and_answers() { ); } -// ───────────────────────────────────────────────────────────────────────────────────────────── -// The reference-geometry diff (decision 0675's discipline) -// ───────────────────────────────────────────────────────────────────────────────────────────── - -/// Every element this file shares with the reference carries the reference's numbers. -/// -/// The pane's specification is TWO reference files — `RaidFrame.xml` from FrameXML and -/// `Blizzard_RaidUI.xml` from the load-on-demand addon — so this runs twice. The addon half is -/// read straight out of the patch chain rather than from an extraction on disk: `_extracted_framexml/` -/// holds FrameXML only, and requiring someone to have extracted the addon first would leave half -/// the window's geometry guarded by nothing. -#[test] -fn the_geometry_matches_both_reference_files() { - let _data = benilla_formats::wow_data_or_skip!(); - // The FrameXML half: the pane, its two buttons, the info flyout and the lockout row template. - if let Some(reference) = super::framexml_diff::reference("RaidFrame.xml") { - super::framexml_diff::assert_geometry_matches( - "RaidFrame.xml", - &reference, - &[ - // The reference's close button inherits `UIPanelCloseButton`, which carries the - // 32x32; no such template ships in this house (FriendsFrame's own close button - // says the same), so ours states the size the template would have given it. The - // ANCHOR offsets match; only the extra size pair differs. - "RaidInfoCloseButton", - ], - 10, - ); - } - - // The addon half: the row, the slot, the group frame and the ready-check popup. - let Some(data) = benilla_formats::wow_data() else { - return; - }; - let chain = benilla_formats::open_chain(&data).expect("open chain"); - let bytes = chain - .read("Interface\\AddOns\\Blizzard_RaidUI\\Blizzard_RaidUI.xml") - .expect("Blizzard_RaidUI.xml is in the patch chain"); - super::framexml_diff::assert_geometry_matches_text( - "RaidFrame.xml", - &String::from_utf8_lossy(&bytes), - &[], - 10, - ); -} +// The reference-geometry diff that stood here is GONE with the transcription it policed (1874). +// It compared our `RaidFrame.xml`'s numbers against the reference's; the pane is now the +// reference's own file off the player's chain, so there is nothing left to diff — the geometry is +// the reference's by construction rather than by assertion. /// **A drag the cursor carries off the window edge ENDS — it does not glue the row to the mouse /// for the rest of the session** (B310). diff --git a/crates/benilla-app/src/ui_script/reputation_frame_tests.rs b/crates/benilla-app/src/ui_script/reputation_frame_tests.rs index 867e857d..71f95396 100644 --- a/crates/benilla-app/src/ui_script/reputation_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/reputation_frame_tests.rs @@ -218,8 +218,9 @@ fn the_reputation_page_opens_on_the_windows_third_tab() { "slot 9 has nothing to hold" ); assert!(!shown(&mut s, "ReputationHeader9"), "neither twin shows"); + // No trough line here: it was ours, and the stock pane declares none (1875). Asserting a frame + // that cannot exist would pass for the wrong reason. assert!(!visible(&mut s, "ReputationListScrollFrameScrollBar")); - assert!(!visible(&mut s, "ReputationListScrollFrameScrollBarTrough")); // Switching away hides it again through the same one-page-at-a-time switch. s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap(); @@ -383,28 +384,11 @@ fn the_scroll_offset_rebinds_the_fixed_row_slots() { visible(&mut s, "ReputationListScrollFrameScrollBar"), "21 rows in 15 slots raises the scroll bar" ); - assert!( - visible(&mut s, "ReputationListScrollFrameScrollBarTrough"), - "and the trough it rides in comes up with it (the kit shows the pair together)" - ); - - // **The trough fits this window to the pixel, and that is not a coincidence.** - // `BenillaScrollTrough_Seat`'s 21-above / 20-below / 8-left hang was DERIVED from - // ReputationFrame's own reference anchors (ScrollTemplates.xml cites ref l.573-599 against - // ref-UIPanelTemplates.xml l.166-181), so the seated trough has to land back on the reference's - // own numbers here: TOPRIGHT + (-2, +5), BOTTOMRIGHT + (+29, -4), 31 wide. Miss it by 4 and the - // arrow buttons ride out of their sockets onto the caps, which is what B224 reported. - let d = |s: &mut UiScript, expr: &str| s.eval::(&format!("return {expr}")).unwrap(); - let sf_right = d(&mut s, "ReputationListScrollFrame:GetRight()"); - let sf_top = d(&mut s, "ReputationListScrollFrame:GetTop()"); - let sf_bottom = d(&mut s, "ReputationListScrollFrame:GetBottom()"); - let t = "ReputationListScrollFrameScrollBarTrough"; - assert_eq!(d(&mut s, &format!("{t}:GetLeft()")), sf_right - 2.0); - assert_eq!(d(&mut s, &format!("{t}:GetRight()")), sf_right + 29.0); - assert_eq!(d(&mut s, &format!("{t}:GetTop()")), sf_top + 5.0); - assert_eq!(d(&mut s, &format!("{t}:GetBottom()")), sf_bottom - 4.0); - assert_eq!(d(&mut s, &format!("{t}:GetWidth()")), 31.0); - + // **The trough is GONE with our pane.** `ReputationListScrollFrameScrollBarTrough` was the one + // name in this window that was never the reference's (1844) — it belongs to our own scroll + // templates, and stock `ReputationFrame.xml` declares no such thing. B224's law (the 21/20/8 + // hang that drops each arrow into its socket) is unchanged and still asserted, by the + // keybindings page's own trough, which is a window we still own (1875). // Scroll three rows down: every slot re-binds, and slot 1 stops being a header. s.run("FauxScrollFrame_SetOffset(ReputationListScrollFrame, 3) ReputationFrame_Update()") .unwrap(); diff --git a/crates/benilla-app/src/ui_script/spellbook_tests.rs b/crates/benilla-app/src/ui_script/spellbook_tests.rs index 12227315..68f6d7aa 100644 --- a/crates/benilla-app/src/ui_script/spellbook_tests.rs +++ b/crates/benilla-app/src/ui_script/spellbook_tests.rs @@ -127,6 +127,14 @@ fn shipped_spellbook_drives_end_to_end() { load_xml(&s, "GameTooltip.xml"); load_xml(&s, "Cooldown.xml"); load_xml(&s, "ActionBar.xml"); + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); + load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); + load_xml(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml"); + load_xml(&s, r"Interface\FrameXML\ReputationFrame.xml"); load_xml(&s, "SpellBookFrame.xml"); s.fire_event("PLAYER_ENTERING_WORLD", vec![]); @@ -657,7 +665,7 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() { for file in [ r"Interface\FrameXML\GlobalStrings.lua", "Fonts.xml", - "BasicControls.xml", // `TEXT` + "Interface\\FrameXML\\BasicControls.xml", // `TEXT` "MoneyFrame.xml", "UiPanels.xml", // `ShowMacroFrame` lives here since 1848. @@ -675,6 +683,14 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() { r"Interface\FrameXML\ClassTrainerFrameTemplates.xml", "MicroMenu.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", r"Interface\AddOns\Blizzard_MacroUI\Blizzard_MacroUI.xml", "SpellBookFrame.xml", ] { diff --git a/crates/benilla-app/src/ui_script/test_ui.rs b/crates/benilla-app/src/ui_script/test_ui.rs index 03412c29..ef17d6aa 100644 --- a/crates/benilla-app/src/ui_script/test_ui.rs +++ b/crates/benilla-app/src/ui_script/test_ui.rs @@ -143,7 +143,7 @@ fn read(req: &str) -> Option> { pub(super) const MERCHANT_UI: &[&str] = &[ "Interface\\FrameXML\\GlobalStrings.lua", "Fonts.xml", - "BasicControls.xml", // TEXT() + "Interface\\FrameXML\\BasicControls.xml", // TEXT() "Interface\\FrameXML\\ItemButtonTemplate.xml", "MoneyFrame.xml", "UiPanels.xml", @@ -220,7 +220,11 @@ pub(super) const CHARACTER_UI: &[&str] = &[ // concatenates `SPELL_STAT0_NAME`..`4` on every repaint. "Interface\\FrameXML\\GlobalStrings.lua", "Fonts.xml", - "BasicControls.xml", // TEXT(), which every one of those label sets goes through + // `GetText` — the reference's gendered-string helper, which stock + // `ReputationFrame.lua:65` calls for every row's standing label. Manifest line 94 + // (1875). + r"Interface\FrameXML\LocaleProperties.lua", + "Interface\\FrameXML\\BasicControls.xml", // TEXT(), which every one of those label sets goes through "Interface\\FrameXML\\ItemButtonTemplate.xml", // PaperDollItemSlotButtonTemplate's base "MoneyFrame.xml", "UIParent.xml", // Model_OnLoad/_Rotate*/_OnUpdate — the model panes' turntable @@ -267,7 +271,11 @@ pub(super) const CHARACTER_UI: &[&str] = &[ "Interface\\FrameXML\\CharacterFrame.xml", "Interface\\FrameXML\\PaperDollFrame.xml", "Interface\\FrameXML\\PetPaperDollFrame.xml", - "ReputationFrame.xml", + // `updateContainerFrameAnchors` — `ReputationWatchBar_Update` hard-calls it when the bar + // moves (ReputationFrame.lua:248), because in the reference the bar's presence reflows the + // bag row. It comes with `ContainerFrame.xml`, manifest 585 against the pane's 693 (1875). + "Interface\\FrameXML\\ContainerFrame.xml", + r"Interface\FrameXML\ReputationFrame.xml", "SkillFrame.xml", "Interface\\FrameXML\\HonorFrame.xml", ]; @@ -286,7 +294,7 @@ pub(super) const BAG_UI: &[&str] = &[ // `MainMenuBarBackpackButton`'s OnEnter calls (`GameTooltip:SetText(TEXT(BACKPACK_TOOLTIP)…)`) // and `BagSlotButton_OnEnter` calls for `EQUIP_CONTAINER`. Manifest entry 3, and not optional // for the bag bar since 1751's third window made that bar the reference's own. - "BasicControls.xml", + "Interface\\FrameXML\\BasicControls.xml", // `UIParent` itself: the twelve `ContainerFrame`s declare `parent="UIParent"`, and // `updateContainerFrameAnchors` anchors each open bag to `frame:GetParent()` while // `OpenAllBags` opens with `if not UIParent:IsVisible() then return end`. Without it the diff --git a/crates/benilla-app/src/ui_script/zone_text_tests.rs b/crates/benilla-app/src/ui_script/zone_text_tests.rs index 6aa0daaa..33ceb8d9 100644 --- a/crates/benilla-app/src/ui_script/zone_text_tests.rs +++ b/crates/benilla-app/src/ui_script/zone_text_tests.rs @@ -42,7 +42,7 @@ fn harness() -> UiScript { load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "Fonts.xml"); // `TEXT()`, which AutoFollowStatus_OnEvent puts its message through. - load_xml(&s, "BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); load_xml(&s, "MoneyFrame.xml"); load_xml(&s, "UiPanels.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); diff --git a/crates/benilla-app/tests/auction_frame.rs b/crates/benilla-app/tests/auction_frame.rs index c8588c3a..816471b6 100644 --- a/crates/benilla-app/tests/auction_frame.rs +++ b/crates/benilla-app/tests/auction_frame.rs @@ -17,12 +17,16 @@ mod common; /// Every one of these is a real dependency: UiPanels for the panel manager + tab kit + StaticPopup /// engine, ScrollTemplates for the faux lists, UIPanelTemplates for the button/input/checkbox /// templates, UIDropDownMenu for the rarity capsule, MoneyFrame for `SmallMoneyFrameTemplate` + -/// the `MoneyTypeInfo` table this window registers `AUCTION_DEPOSIT` into, The `BenillaMoneyInput_*` money-entry +/// the `MoneyTypeInfo` table this window registers `AUCTION_DEPOSIT` into, The `MoneyInputFrame_*` money-entry /// helpers used to mean loading MerchantFrame.xml as well; 1751 moved that kit to MoneyFrame.xml /// on its way to the chain, so the dependency is gone. -const FILES: [&str; 9] = [ +const FILES: [&str; 11] = [ "Fonts.xml", "MoneyFrame.xml", + // The four money-entry frames come off the chain since 1882 — `MoneyInputFrameTemplate` and + // the `MoneyInputFrame_*` verbs, replacing our own verbatim copy of both. + r"Interface\FrameXML\MoneyInputFrame.lua", + r"Interface\FrameXML\MoneyInputFrame.xml", "UiPanels.xml", "GameTooltip.xml", "Interface\\FrameXML\\UIDropDownMenu.xml", @@ -368,7 +372,7 @@ fn the_bid_and_buyout_gates_read_the_purse() { "50s is affordable on 2g" ); assert_eq!( - s.eval::("return BenillaMoneyInput_GetCopper('BrowseBidPrice')") + s.eval::("return MoneyInputFrame_GetCopper(BrowseBidPrice)") .unwrap(), 1000, "with no bids the required bid IS the minimum bid" @@ -529,9 +533,9 @@ fn the_create_gate_and_the_deposit() { ); // With no item, the form does not even reach the price checks — the buyout error stays hidden. - s.run("BenillaMoneyInput_SetCopper('StartPrice', 10000)") + s.run("MoneyInputFrame_SetCopper(StartPrice, 10000)") .unwrap(); - s.run("BenillaMoneyInput_SetCopper('BuyoutPrice', 5000)") + s.run("MoneyInputFrame_SetCopper(BuyoutPrice, 5000)") .unwrap(); s.run("AuctionsFrameAuctions_ValidateAuction()").unwrap(); assert!(!s @@ -604,8 +608,7 @@ fn the_create_gate_and_the_deposit() { .unwrap()); // Clear the buyout and the form opens; pressing Create sends exactly what is on screen. - s.run("BenillaMoneyInput_SetCopper('BuyoutPrice', 0)") - .unwrap(); + s.run("MoneyInputFrame_SetCopper(BuyoutPrice, 0)").unwrap(); s.run("AuctionsFrameAuctions_ValidateAuction()").unwrap(); assert!( s.eval::("return AuctionsCreateAuctionButton:IsEnabled() ~= 0") diff --git a/crates/benilla-app/tests/character_stats.rs b/crates/benilla-app/tests/character_stats.rs index 456f7fb9..20c5ff80 100644 --- a/crates/benilla-app/tests/character_stats.rs +++ b/crates/benilla-app/tests/character_stats.rs @@ -36,7 +36,7 @@ use benilla_ui::script::{UiScript, UnitCombatStats, UnitState}; const FILES: [&str; 12] = [ "Interface\\FrameXML\\GlobalStrings.lua", "Fonts.xml", - "BasicControls.xml", + "Interface\\FrameXML\\BasicControls.xml", // The reference's own since 1751 window 24 — `common::load_ui` speaks both stores. "Interface\\FrameXML\\ItemButtonTemplate.xml", "MoneyFrame.xml", diff --git a/crates/benilla-app/tests/mail_frame.rs b/crates/benilla-app/tests/mail_frame.rs index 8d082f80..41a39fac 100644 --- a/crates/benilla-app/tests/mail_frame.rs +++ b/crates/benilla-app/tests/mail_frame.rs @@ -12,9 +12,13 @@ const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui"); /// The mail window's load prefix — the app's own order (`ui_script/mod.rs`), members only. /// MerchantFrame.xml rides along because MailFrame.xml reuses its global `BenillaMoney_*` coin /// helpers (postage display), so a load error in either fails here. -const FILES: [&str; 7] = [ +const FILES: [&str; 9] = [ "Fonts.xml", "MoneyFrame.xml", + // The send tab's money entry comes off the chain since 1882 — `MoneyInputFrameTemplate` and + // the `MoneyInputFrame_*` verbs. Seated straight after MoneyFrame.xml, benilla.toc's order. + r"Interface\FrameXML\MoneyInputFrame.lua", + r"Interface\FrameXML\MoneyInputFrame.xml", "UiPanels.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", diff --git a/crates/benilla-app/tests/talent_frame.rs b/crates/benilla-app/tests/talent_frame.rs index b48e2f5e..a0faf258 100644 --- a/crates/benilla-app/tests/talent_frame.rs +++ b/crates/benilla-app/tests/talent_frame.rs @@ -25,7 +25,7 @@ const FILES: [&str; 13] = [ "Interface\\FrameXML\\GlobalStrings.lua", "Fonts.xml", // `TEXT`. - "BasicControls.xml", + "Interface\\FrameXML\\BasicControls.xml", "Interface\\FrameXML\\ItemButtonTemplate.xml", "MoneyFrame.xml", "UiPanels.xml", diff --git a/crates/benilla-app/tests/trade_frame.rs b/crates/benilla-app/tests/trade_frame.rs index 7a49d3f4..d88e14e8 100644 --- a/crates/benilla-app/tests/trade_frame.rs +++ b/crates/benilla-app/tests/trade_frame.rs @@ -13,9 +13,14 @@ const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui"); /// The trade window's load prefix — the app's own order (`ui_script/mod.rs`), members only. /// MerchantFrame.xml rides along because TradeFrame.xml reuses its global `BenillaMoney_*` coin /// helpers (the two gold displays), so a load error in either fails here. -const FILES: [&str; 7] = [ +const FILES: [&str; 9] = [ "Fonts.xml", "MoneyFrame.xml", + // The money entry comes off the chain since 1882 — `MoneyInputFrameTemplate` and the + // `MoneyInputFrame_*` verbs the window's OnLoad calls. Seated straight after MoneyFrame.xml, + // which is benilla.toc's own order. + r"Interface\FrameXML\MoneyInputFrame.lua", + r"Interface\FrameXML\MoneyInputFrame.xml", "UiPanels.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", diff --git a/crates/benilla-ui/src/framexml.rs b/crates/benilla-ui/src/framexml.rs index 829d54f3..e8e7c57e 100644 --- a/crates/benilla-ui/src/framexml.rs +++ b/crates/benilla-ui/src/framexml.rs @@ -323,14 +323,32 @@ pub fn expand( element: &Element, templates: &HashMap<&str, &Element>, warnings: &mut Vec, +) -> Element { + expand_known(element, templates, &HashSet::new(), warnings) +} + +/// [`expand`], plus the names that are known to be **font objects** rather than element templates. +/// +/// A `` names a font, not a template, and the font is +/// applied later by its own path (`apply_fontstring_font`) — so the name must be skipped here +/// silently rather than warned as unknown. `Loader::expand_region` already made that distinction +/// for an INSTANCE; it could not make it one level down, and stock `BuffFrame.xml` is exactly that +/// case: `BuffButtonDurationTemplate` is a virtual `` that inherits a font object, so +/// every instance of it warned twenty-six times over (decision 1874). +pub fn expand_known( + element: &Element, + templates: &HashMap<&str, &Element>, + fonts: &HashSet<&str>, + warnings: &mut Vec, ) -> Element { let mut active = HashSet::new(); - expand_inner(element, templates, warnings, &mut active) + expand_inner(element, templates, fonts, warnings, &mut active) } fn expand_inner( element: &Element, templates: &HashMap<&str, &Element>, + fonts: &HashSet<&str>, warnings: &mut Vec, active: &mut HashSet, ) -> Element { @@ -372,13 +390,17 @@ fn expand_inner( .map(|(_, v)| *v) }); let Some(template) = hit else { - warnings.push(format!( - "unknown template '{name}' referenced by inherits; skipping" - )); + // A FONT OBJECT is not an unknown template: it is a different namespace, applied by + // `apply_fontstring_font` after this. Skip it without a word (1874). + if !fonts.contains(name) { + warnings.push(format!( + "unknown template '{name}' referenced by inherits; skipping" + )); + } active.remove(name); continue; }; - let expanded_template = expand_inner(template, templates, warnings, active); + let expanded_template = expand_inner(template, templates, fonts, warnings, active); active.remove(name); base = Some(match base { None => expanded_template, diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs index 53f2cffd..e97c3d01 100644 --- a/crates/benilla-ui/src/loader/mod.rs +++ b/crates/benilla-ui/src/loader/mod.rs @@ -593,8 +593,15 @@ impl Loader<'_> { let templates = model.framexml_templates.borrow(); let view: HashMap<&str, &Element> = templates.iter().map(|(k, v)| (k.as_str(), v)).collect(); + // The font namespace, so a font object inside an inherit chain is skipped rather than + // warned as an unknown template (1874). + let fonts = model.framexml_fonts.borrow(); + let font_names: std::collections::HashSet<&str> = + fonts.keys().map(|k| k.as_str()).collect(); let mut warns = Vec::new(); - let out = framexml::expand(el, &view, &mut warns); + let out = framexml::expand_known(el, &view, &font_names, &mut warns); + drop(font_names); + drop(fonts); drop(view); drop(templates); drop(model); diff --git a/crates/benilla-ui/src/script/client.rs b/crates/benilla-ui/src/script/client.rs index af806090..69cf39ef 100644 --- a/crates/benilla-ui/src/script/client.rs +++ b/crates/benilla-ui/src/script/client.rs @@ -42,6 +42,25 @@ const BUILD_DATE: &str = "Sep 19 2006"; pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let g = lua.globals(); + // `GetLocale()` -> one string, and `IsMacClient()` -> nil, both ARITY-EXACT in the reference's + // own shape table (`re/audit/binding-shapes.tsv`: `GetLocale 0x46ce40`/`0x48d8b0` is + // `0 -> 1 (string?)`, `IsMacClient 0x48c980` is `0 -> 1 (nil)`, both `exact`/`agree`). + // + // **`IsMacClient` answering nil is the whole verb, not a placeholder.** The table records the + // return KIND as `(nil)` — this binding pushes nil on the PC build the reference was carved + // from, and there is nothing else for it to say. FrameXML branches on it for Mac-only key + // labels; nil takes the PC arm, which is the arm this client wants. + // + // `GetLocale` is the two-registration case the shape gate warns about (1843): a glue copy and + // an in-game copy, same shape. Ours answers the one locale this client ships against — the + // enUS `GlobalStrings.lua` the manifest loads and 1804's reference defaults are read from. + // Decision 1880. + g.set( + "GetLocale", + lua.create_function(|lua, ()| lua.create_string("enUS"))?, + )?; + g.set("IsMacClient", lua.create_function(|_, ()| Ok(Value::Nil))?)?; + // version, build, date — three, and no fourth (decision 1842) g.set( "GetBuildInfo", @@ -286,4 +305,26 @@ mod tests { "a malformed script must raise, not vanish" ); } + + /// `GetLocale` answers one string and `IsMacClient` answers nil — the reference's own shapes + /// (`binding-shapes.tsv`, both `exact`/`agree`), and the arity is the half a caller branches on. + #[test] + fn the_client_identity_pair_answers_its_reference_arity() { + let s = UiScript::new().unwrap(); + assert_eq!( + s.eval::("return select('#', GetLocale())").unwrap(), + 1, + "GetLocale pushes exactly one value" + ); + assert_eq!(s.eval::("return GetLocale()").unwrap(), "enUS"); + assert_eq!( + s.eval::("return select('#', IsMacClient())").unwrap(), + 1, + "IsMacClient pushes one value, and it is nil" + ); + assert!( + s.eval::("return IsMacClient() == nil").unwrap(), + "nil is the PC arm, which is the arm this client wants" + ); + } } diff --git a/crates/benilla-ui/src/script/reputation.rs b/crates/benilla-ui/src/script/reputation.rs index acfd01cc..8dfc4275 100644 --- a/crates/benilla-ui/src/script/reputation.rs +++ b/crates/benilla-ui/src/script/reputation.rs @@ -445,19 +445,33 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // CollapseFactionHeader(index) / ExpandFactionHeader(index) — a VISIBLE header index; `0` or a // non-header row acts on EVERY header (the client's own fall-through). + // **Both fire `UPDATE_FACTION`, and that is the engine's own doing, not a convenience.** + // `CollapseFactionHeader 0x4d6a50` and `ExpandFactionHeader 0x4d6aa0` both reach `0x4d6400`, + // which tail-jumps to `0x4d5c40` — and `0x4d5dab`, inside that function, is one of the FOUR + // whole-image fire sites of `UPDATE_FACTION` (wow-re `reputation-panel-law.md` §7.5 and §8, + // both VERIFIED). The stock pane has no other repaint path after a fold: its header `` + // calls the binding and nothing else (ReputationFrame.xml:9-15), and `ReputationFrame_OnEvent` + // repaints on `UPDATE_FACTION` alone. Our own retired file repainted itself, which is why this + // was invisible until the pane went stock (1875). g.set( "CollapseFactionHeader", lua.create_function(|lua, index: usize| { - let mut model = lua.app_data_mut::().expect("model app_data"); - set_collapsed(&mut model, index, true); + { + let mut model = lua.app_data_mut::().expect("model app_data"); + set_collapsed(&mut model, index, true); + } + super::event::fire_global(lua, "UPDATE_FACTION", &[]); Ok(()) })?, )?; g.set( "ExpandFactionHeader", lua.create_function(|lua, index: usize| { - let mut model = lua.app_data_mut::().expect("model app_data"); - set_collapsed(&mut model, index, false); + { + let mut model = lua.app_data_mut::().expect("model app_data"); + set_collapsed(&mut model, index, false); + } + super::event::fire_global(lua, "UPDATE_FACTION", &[]); Ok(()) })?, )?; From f0d1bacf0aa6e37257c30ff7c5171741111fc33a Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 4 Sep 2026 20:50:36 +0700 Subject: [PATCH 04/78] the help window is the reference's own, and a ding carries nine gains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the help window is the reference's own `HelpFrame.xml`, and ours was less than half its size, so this is mostly arrival: the category list, the per-category GM and harassment pages, the stuck page, the bug report, the PvP policy line; four things we had built go with the swap — one-click filing from Home, the Home unstick button, the downed-queue refusal message, and ticket abandonment, a capability loss rather than a look change, since `AbandonTicket` exists in neither the binding table nor the 1.12 FrameXML corpus and any return of it goes on top of the stock window; `GMSURVEY_DISPLAY` arrives with no producer, a real 1.12 event (`SignalEvent` `0x5e797b`) whose whole UI is a LoadOnDemand addon not on our chain; and the stock file needs two orderings ours did not, the micro-button row before its GM status ask and the buff frame before its repoll timer (1889, superseding 1687) - the cast bar, the action-button regions and the font registry are the reference's own files too: our `CastingBar.xml` was `CastingBarFrame.xml` under another name, with the literal "Failed" where the reference reads the `FAILED` GlobalString (1886); `ActionButtonTemplate.xml` seats above our `ActionBar.xml` because the later-registered template wins, and it declares zero scripts, so the regions migrate while the handlers stay ours until `ActionButton.lua`'s verbs do (1887); `Fonts.xml` is manifest entry 0, all 51 fonts match by name, and its one real difference, `ITEM_QUALITY_COLORS`, moves to UIParent where the reference declares it (1888); the harnesses were the real cost — those reading our files off disk could not name a chain file and now read through the manifest's own rule — and the event-producer gate, having cried wolf at the cast bar's `SPELLCAST_INTERRUPTED` fired through a variable, now matches the fired tuple's shape - of the 189 events benilla fires, three are not in the client — `BAG_UPDATE_DELAYED` (a Classic Era name), `LOOT_UPDATE` and `UPDATE_LOOT_ROLL` (invented here, no listener anywhere) — found against the reference binary's own string table, and a gate now fails any fire of a name the client cannot dispatch unless it wears a `BENILLA_` prefix (1883); two payloads the reference gives that we did not: `UNIT_HAPPINESS` carries its unit token, without which no `UNIT_*` consumer reads it, and `CHARACTER_POINTS_CHANGED` carries the point deltas `ChatFrame.lua` compares unguarded, so nil raised (1884); and `PLAYER_LEVEL_UP` carries all nine — level, health and mana gain, talent points (not on the wire: the client derives `(newLevel >= 10) ? 1 : 0` at `0x5e407c`) and the five stat gains — matched to the ding by level, a demotion's absent gains sent as zeros since a nil raises where a zero reads as no line - the chain gap report could not run — one of its six probes had no player seated where a stock addon formats `UnitName("player")` at load, the other five each carrying an identical copy-pasted seed — and the repaired instrument prices each window's cost: 13 stock files load clean with nothing missing (the action bars, the cast bar, the fonts, the help, friends, game-menu and sound-options windows among them) and the near ones name their missing verb, with "loads clean" still not "swapped" (1885); and `UpdateNameplates`, recorded as the missing engine verb blocking the UIParent keystone, is FrameXML — defined in `UIOptionsFrame.lua`, absent from the binding table, its whole body four nameplate verbs that are already ours — so it is twelve lines transcribed into our options frame, the reference's own Show-then-Hide no-op included (1890 corrects 1878) Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/assets/ui/ActionBar.xml | 69 +- crates/benilla-app/assets/ui/CastingBar.xml | 294 ----- crates/benilla-app/assets/ui/Fonts.xml | 397 ------- crates/benilla-app/assets/ui/HelpFrame.xml | 1017 ----------------- crates/benilla-app/assets/ui/OptionsFrame.xml | 33 + crates/benilla-app/assets/ui/UIParent.xml | 44 + crates/benilla-app/assets/ui/benilla.toc | 41 +- .../benilla-app/src/net/apply/combat_log.rs | 4 + .../benilla-app/src/ui_action/feed_tests.rs | 5 +- crates/benilla-app/src/ui_chat/feed.rs | 24 + crates/benilla-app/src/ui_chat/tests.rs | 37 +- crates/benilla-app/src/ui_items/drain.rs | 18 +- crates/benilla-app/src/ui_items/feed.rs | 1 - crates/benilla-app/src/ui_loot.rs | 9 +- crates/benilla-app/src/ui_loot_roll.rs | 109 +- crates/benilla-app/src/ui_pet_stats.rs | 7 +- .../src/ui_script/action_bar_tests.rs | 104 +- .../src/ui_script/basic_controls_tests.rs | 2 +- .../benilla-app/src/ui_script/binder_tests.rs | 2 +- .../benilla-app/src/ui_script/buff_tests.rs | 9 +- .../benilla-app/src/ui_script/cast_tests.rs | 12 +- .../src/ui_script/character_tests.rs | 2 +- .../src/ui_script/chat_options_tests.rs | 2 +- .../src/ui_script/chat_resize_tests.rs | 2 +- .../benilla-app/src/ui_script/chat_tests.rs | 6 +- .../src/ui_script/cinematic_tests.rs | 2 +- .../src/ui_script/color_picker_tests.rs | 2 +- .../src/ui_script/combat_text_tests.rs | 2 +- .../src/ui_script/combo_frame_tests.rs | 2 +- .../benilla-app/src/ui_script/death_tests.rs | 2 +- .../src/ui_script/delete_item_tests.rs | 2 +- .../src/ui_script/dressup_tests.rs | 2 +- .../src/ui_script/dropdown_tests.rs | 2 +- .../benilla-app/src/ui_script/duel_tests.rs | 2 +- .../src/ui_script/durability_tests.rs | 4 +- .../src/ui_script/enchant_confirm_tests.rs | 2 +- .../benilla-app/src/ui_script/errors_tests.rs | 4 +- .../benilla-app/src/ui_script/escape_tests.rs | 4 +- .../src/ui_script/exp_bar_tests.rs | 3 +- .../src/ui_script/faux_scroll_tests.rs | 4 +- .../src/ui_script/friends_tests.rs | 3 +- .../src/ui_script/game_menu_tests.rs | 8 +- .../src/ui_script/group_loot_tests.rs | 24 +- .../benilla-app/src/ui_script/guild_tests.rs | 3 +- .../src/ui_script/help_frame_tests.rs | 295 +---- .../src/ui_script/honor_frame_tests.rs | 2 +- .../src/ui_script/inspect_tests.rs | 4 +- .../src/ui_script/instance_tests.rs | 2 +- .../src/ui_script/keybindings_tests.rs | 2 +- .../benilla-app/src/ui_script/macro_tests.rs | 2 +- crates/benilla-app/src/ui_script/manifest.rs | 2 +- .../src/ui_script/micro_menu_tests.rs | 19 +- .../src/ui_script/minimap_tests.rs | 10 +- .../src/ui_script/mirror_timer_tests.rs | 2 +- .../src/ui_script/money_frame_tests.rs | 4 +- .../src/ui_script/multibar_stance_tests.rs | 8 +- .../src/ui_script/options_tests.rs | 10 +- .../src/ui_script/panel_template_tests.rs | 2 +- .../benilla-app/src/ui_script/panel_tests.rs | 2 +- .../src/ui_script/perf_bar_tests.rs | 3 +- .../src/ui_script/pet_bar_tests.rs | 13 +- .../src/ui_script/pet_frame_tests.rs | 2 +- .../src/ui_script/petition_tests.rs | 2 +- .../src/ui_script/quest_share_tests.rs | 2 +- .../benilla-app/src/ui_script/quest_tests.rs | 20 +- .../src/ui_script/quest_timer_tests.rs | 2 +- .../src/ui_script/questlog_tests.rs | 38 +- .../benilla-app/src/ui_script/raid_tests.rs | 2 +- .../benilla-app/src/ui_script/reference_ui.rs | 242 ++-- .../src/ui_script/screenshot_tests.rs | 2 +- .../src/ui_script/shipped_xml_tests.rs | 42 +- .../src/ui_script/spellbook_tests.rs | 16 +- .../src/ui_script/static_popup_tests.rs | 2 +- .../benilla-app/src/ui_script/summon_tests.rs | 2 +- .../src/ui_script/talent_wipe_tests.rs | 2 +- .../src/ui_script/target_aura_tests.rs | 7 +- .../benilla-app/src/ui_script/taxi_tests.rs | 2 +- crates/benilla-app/src/ui_script/test_ui.rs | 17 +- .../src/ui_script/tooltip_anchor_tests.rs | 23 +- .../src/ui_script/tooltip_template_tests.rs | 6 +- .../src/ui_script/tot_frame_tests.rs | 3 +- .../src/ui_script/trainer_tests.rs | 2 +- .../src/ui_script/uiparent_tests.rs | 32 +- .../src/ui_script/unit_frame_tests.rs | 8 +- .../src/ui_script/unit_popup_tests.rs | 5 +- .../src/ui_script/world_map_tests.rs | 2 +- .../src/ui_script/world_state_tests.rs | 2 +- .../src/ui_script/zone_text_tests.rs | 2 +- crates/benilla-app/src/ui_talent.rs | 25 +- crates/benilla-app/src/ui_unit.rs | 43 +- crates/benilla-app/tests/auction_frame.rs | 7 +- crates/benilla-app/tests/character_stats.rs | 2 +- .../tests/create_frame_template.rs | 2 +- crates/benilla-app/tests/item_text_frame.rs | 2 +- crates/benilla-app/tests/mail_frame.rs | 2 +- crates/benilla-app/tests/talent_frame.rs | 2 +- crates/benilla-app/tests/trade_frame.rs | 2 +- crates/benilla-app/tests/tradeskill_frame.rs | 2 +- 98 files changed, 755 insertions(+), 2530 deletions(-) delete mode 100644 crates/benilla-app/assets/ui/CastingBar.xml delete mode 100644 crates/benilla-app/assets/ui/Fonts.xml delete mode 100644 crates/benilla-app/assets/ui/HelpFrame.xml diff --git a/crates/benilla-app/assets/ui/ActionBar.xml b/crates/benilla-app/assets/ui/ActionBar.xml index d27416c2..bd210cb5 100644 --- a/crates/benilla-app/assets/ui/ActionBar.xml +++ b/crates/benilla-app/assets/ui/ActionBar.xml @@ -1482,9 +1482,12 @@ does (decision 1636). --> - - - - - - - - - - - - - - - - - - - - - - - - - - - ActionButton_OnEvent() diff --git a/crates/benilla-app/assets/ui/CastingBar.xml b/crates/benilla-app/assets/ui/CastingBar.xml deleted file mode 100644 index 954627a2..00000000 --- a/crates/benilla-app/assets/ui/CastingBar.xml +++ /dev/null @@ -1,294 +0,0 @@ - - - - - - diff --git a/crates/benilla-app/assets/ui/Fonts.xml b/crates/benilla-app/assets/ui/Fonts.xml deleted file mode 100644 index 1996af64..00000000 --- a/crates/benilla-app/assets/ui/Fonts.xml +++ /dev/null @@ -1,397 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/crates/benilla-app/assets/ui/HelpFrame.xml b/crates/benilla-app/assets/ui/HelpFrame.xml deleted file mode 100644 index 7bdb7415..00000000 --- a/crates/benilla-app/assets/ui/HelpFrame.xml +++ /dev/null @@ -1,1017 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - HelpFrameButton_FitToText(HelpFrameHomeIssues) - HelpFrameButton_FitToText(BenillaHelpFrameHomeUnstick) - - - - - - - - - - - - - - - - - - - - - - - - - - - ScrollFrame_OnLoad() - local trough = getglobal(self:GetName() .. "ScrollBarTrough") - BenillaScrollTrough_Seat(trough, getglobal(self:GetName() .. "ScrollBar")) - trough:Show() - - - - - - - - ScrollingEdit_OnTextChanged() - ScrollingEdit_OnCursorChanged(arg1, arg2, arg3, arg4) - HideUIPanel(HelpFrame) - - - - - - - - - - - - self:RegisterEvent("UPDATE_TICKET") - self:RegisterEvent("GMSURVEY_DISPLAY") - - HelpFrameOpenTicket_OnEvent(event) - - - - - - - HelpFrame_OnLoad(self) - HelpFrame_OnShow(self) - HelpFrame_OnEvent() - - PlaySound("igCharacterInfoClose") - if UpdateMicroButtons then UpdateMicroButtons() end - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if TOOLTIP_DEFAULT_COLOR then - self:SetBackdropBorderColor(TOOLTIP_DEFAULT_COLOR.r, TOOLTIP_DEFAULT_COLOR.g, TOOLTIP_DEFAULT_COLOR.b) - end - if TOOLTIP_DEFAULT_BACKGROUND_COLOR then - self:SetBackdropColor(TOOLTIP_DEFAULT_BACKGROUND_COLOR.r, TOOLTIP_DEFAULT_BACKGROUND_COLOR.g, TOOLTIP_DEFAULT_BACKGROUND_COLOR.b) - end - self:RegisterEvent("UPDATE_TICKET") - self:RegisterEvent("PLAYER_ENTERING_WORLD") - - TicketStatusFrame_OnEvent(self) - TicketStatus_OnUpdate(arg1) - - TicketStatus_OnClick() - - - diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 34096572..7aa4871e 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -240,6 +240,39 @@ -- reference at typical resolutions reads nearer two-thirds. ERA_WINDOW_SCALE = 0.78 + -- ── UpdateNameplates (ref UIOptionsFrame.lua l.768-780, verbatim) ──────────────────── + -- Its reference home is `UIOptionsFrame.lua` and this file is our stand-in for that window + -- (0950/0992), so the function belongs here rather than beside its caller. + -- + -- **Decision 1878 recorded this as "a missing ENGINE verb, and 1203 says build it or leave + -- it loudly absent", and named it the thing blocking the UIParent keystone. It is neither.** + -- It is FrameXML — `UIOptionsFrame.lua:768`, absent from wow-re's binding-shape table — and + -- every verb it calls is already ours: `ShowNameplates`/`HideNameplates`/ + -- `ShowFriendNameplates`/`HideFriendNameplates` are the four CVar-backed bindings in + -- `script/cvars.rs`, all argc=0 arity=0, matching the reference's shapes. + -- + -- `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON` are `RegisterForSave` globals the reference drives + -- from the keybinding alone (1.12's own options UI has no nameplate row). Nil is a normal + -- state for them and the else-branches are the reference's own answer to it, so this needs + -- no seeding — which is why the whole thing is twelve lines rather than a build. + -- + -- The reference's odd second arm is kept as it stands: on FRIENDNAMEPLATES_ON it calls + -- Show and then Hide, which is a no-op in the real client too. Transcribed, not corrected; + -- fixing a reference bug here would be inventing a difference. + function UpdateNameplates() + if ( NAMEPLATES_ON ) then + ShowNameplates() + else + HideNameplates() + end + if ( FRIENDNAMEPLATES_ON ) then + ShowFriendNameplates() + HideFriendNameplates() + else + HideFriendNameplates() + end + end + function OptionsFrame_UpdateScale() -- The era panel is registered `checkFit = 1`: the panel manager also shrinks it to -- fit a small screen (UIPanelUpdateScaleForFit: min over width/height ratios diff --git a/crates/benilla-app/assets/ui/UIParent.xml b/crates/benilla-app/assets/ui/UIParent.xml index 572a53de..7d9d9705 100644 --- a/crates/benilla-app/assets/ui/UIParent.xml +++ b/crates/benilla-app/assets/ui/UIParent.xml @@ -215,6 +215,40 @@ -- statements on the same event). `MultiActionBar_Update` is existence-guarded because the -- ~25 per-window harnesses that load this file load no bars; the binding itself is engine -- level and always installed. Decision 1500. + -- ── ITEM_QUALITY_COLORS (ref UIParent.lua l.65-72) ─────────────────────────────── + -- Its reference home is THIS file. It lived in our own `Fonts.xml` until 1888 put + -- that file on the chain — the reference's `Fonts.xml` does not declare it, so the + -- table had to come back here or vanish. Every reader is below UIParent.xml in the + -- manifest (GameTooltip 169, UnitPopup 203, AuctionFrame 928; UnitPopup's is a + -- FILE-SCOPE read inside a table literal, which is the one that makes the order + -- load-bearing rather than incidental). + -- The item-quality palette — **built from the engine verb, exactly as the reference + -- builds it** (ref UIParent.lua l.65-72, verbatim shape; decision 1199): + -- + -- ITEM_QUALITY_COLORS = { } + -- for i = -1, 6 do … = GetItemQualityColor(i) end + -- + -- It used to be seven hand-typed rows here. They were CLOSE and not exact — 0.62 where + -- the client's byte is 0x9d/255 = 0.6157, 0.64/0.21/0.93 where Epic is + -- 0xa3/0x35/0xee — and they carried no `.hex`, which the reference's table has and + -- which addons splice straight into a chat link. `GetItemQualityColor` is now a real + -- engine binding over wow-5875-re RF-0055's byte-verified table, so the rounding and + -- the second source both go. + -- + -- The loop starts at -1 like the reference's, and the -1 row is not a curiosity: it is the + -- colour a loot row wears while its item template is uncached, which is the value + -- `GetLootSlotInfo` answers there. `GetItemQualityColor`'s clamp is UNSIGNED, so -1 lands + -- on Common/white with 7-and-up (decision 1805, correcting 1199's "reads before the + -- array"). + ITEM_QUALITY_COLORS = { } + for i = -1, 6 do + ITEM_QUALITY_COLORS[i] = { } + ITEM_QUALITY_COLORS[i].r, + ITEM_QUALITY_COLORS[i].g, + ITEM_QUALITY_COLORS[i].b, + ITEM_QUALITY_COLORS[i].hex = GetItemQualityColor(i) + end + -- ── UIParentLoadAddOn (ref UIParent.lua l.638-647, and now our slot for it) ────── -- It lived in our own BasicControls.xml until 1881 migrated that file to the chain; -- the reference never put it there, so the move is back to where it belongs. @@ -285,6 +319,16 @@ end end + -- ref UIParent.lua l.194-196, verbatim — the fifth squatter, and the smallest. Stock + -- `HelpFrame.xml` calls it when a GM survey is waiting (its l.1487), and its whole body is + -- `UIParentLoadAddOn("Blizzard_GMSurveyUI")` — a verb 1881 already moved here and an addon + -- that really is on the player's chain (patch-2.MPQ). So this is the reference's own + -- function, not a stand-in: 1889 needed it because the stock help window is on the chain + -- now and ours is gone. + function GMSurveyFrame_LoadUI() + UIParentLoadAddOn("Blizzard_GMSurveyUI") + end + -- ref UIParent.lua's own `ShowMacroFrame`, moved here from our MacroFrame.xml when 1848 -- retired that file — the third such squatter after `InspectUnit` (1832) and -- `ToggleTalentFrame` (1833), and by now the expected shape. diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index b7edb5fc..4a59df41 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -38,7 +38,13 @@ # The named virtual Font objects (decision 0084) — must load first so any FontString's # `inherits="GameFontNormal"` resolves against a registered object. -Fonts.xml +# ══ THE FONT REGISTRY — the reference's own, off the player's chain (1751, 1888) ══ +# All 51 `` objects, name for name, plus the colour codes, `CHAT_FONT_HEIGHTS`, +# `MATERIAL_TEXT_COLOR_TABLE`, `MATERIAL_TITLETEXT_COLOR_TABLE` and `RAID_CLASS_COLORS`. +# Ours was a transcription differing only in formatting (it inlined ``), plus one +# addition: `ITEM_QUALITY_COLORS`, whose reference home is `UIParent.lua` and which 1888 moved +# to our UIParent.xml so it survives this swap. +Interface\FrameXML\Fonts.xml # ══ THE STRINGS — the reference's own first line, seated second here (1848) ══ # **Second, not first, and that is deliberate.** Manifest entry 0 is the FONT REGISTRY: @@ -337,6 +343,19 @@ TalentWipeConfirm.xml # The shared CooldownFrame_SetTimer (the ref's Cooldown.lua file split) — before its # consumers (ActionBar's buttons, the multibars, the stance bar, BagFrame's slots). Cooldown.xml +# ══ THE ACTION-BUTTON REGIONS — the reference's own, off the player's chain (1751) ══ +# `ActionButtonTemplate`: the REGIONS-ONLY half of the reference's two-level split — icon, count, +# hotkey, name, border, flash, cooldown and normal texture. It declares NO scripts and calls +# nothing, so it needs no `.lua` beside it. The handler half is `ActionBarButtonTemplate`, which +# ActionBar.xml still declares and which inherits this; those handlers stay ours until +# `ActionButton.lua`'s 21 verbs migrate (we have 10 of them, under the reference's own names). +# +# Ours was a transcription of this exact file — the eight `$parent*` child names match to the +# letter — so the swap is a deletion. +# +# **It must come BEFORE ActionBar.xml.** A template is registered by name and the later line +# wins, so seated below it our transcription would take the name straight back. +Interface\FrameXML\ActionButtonTemplate.xml ActionBar.xml # The four OPTIONAL extra bars (bottom left/right 61-72 / 49-60, vertical right/left # 25-36 / 37-48), all shipping hidden until the player ticks their row (decision 1500): @@ -493,9 +512,16 @@ Interface\FrameXML\ColorPickerFrame.xml # WorldMapRaid1..40 with inherits=, so the virtual Frames have to be registered before it. WorldMapFrameTemplates.xml WorldMapFrame.xml -# The cast bar (decision 0137 phase 1) — the extracted 1.12 CastingBarFrame, driven by -# the SPELLCAST_* events `crate::ui_cast` fires. Only needs Fonts before it. -CastingBar.xml +# ══ THE CAST BAR — the reference's own, off the player's chain (1751) ══ +# `CastingBarFrame` + its Border/Flash/Spark/Text children, driven by the SPELLCAST_* events +# `crate::ui_cast` fires (decision 0137 phase 1). Our `CastingBar.xml` was a transcription of this +# very file under a different name — all five frame names identical — so the swap is a deletion. +# +# The file SOURCES ITS OWN LUA (` - - - - - - ActionButton_OnEvent() - BenillaActionButton_OnClick(self) - BenillaActionButton_OnDragStart(self) - BenillaActionButton_OnReceiveDrag(self) - ActionButton_OnUpdate(arg1) - BenillaActionButton_OnEnter(self) - if GameTooltip then GameTooltip:Hide() end - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - BenillaExpBarOverlay_OnLoad() - - - - - - - BenillaExpBar_OnLoad(self) - BenillaExpBar_OnEvent(self) - BenillaExpBar_OnEnter(self) - BenillaExpBar_OnLeave(self) - ExhaustionTick_OnUpdate(arg1) - - if this:IsShown() and TextStatusBar_OnValueChanged then - TextStatusBar_OnValueChanged() - end - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - BenillaActionButton_OnLoad(self, 1) - - - - BenillaActionButton_OnLoad(self, 2) - - - - BenillaActionButton_OnLoad(self, 3) - - - - BenillaActionButton_OnLoad(self, 4) - - - - BenillaActionButton_OnLoad(self, 5) - - - - BenillaActionButton_OnLoad(self, 6) - - - - BenillaActionButton_OnLoad(self, 7) - - - - BenillaActionButton_OnLoad(self, 8) - - - - BenillaActionButton_OnLoad(self, 9) - - - - BenillaActionButton_OnLoad(self, 10) - - - - BenillaActionButton_OnLoad(self, 11) - - - - BenillaActionButton_OnLoad(self, 12) - - - - - - - - - - this:RegisterEvent("PLAYER_ENTERING_WORLD") - this:RegisterEvent("BAG_UPDATE") - - MainMenuBar_UpdateKeyRing() - - - - - - - - - - - - - - - - - - - - - - BenillaPerformanceBar_OnLoad(self) - BenillaPerformanceBar_OnUpdate(self, arg1) - - - - - BenillaActionBar_OnLoad(self) - BenillaActionBar_OnEvent(self) - - - - - - - - - - - - - - - - - - - - - - - - - - - BenillaActionButton_OnLoad(self, 1) self.isBonus = 1 - - - - BenillaActionButton_OnLoad(self, 2) self.isBonus = 1 - - - - BenillaActionButton_OnLoad(self, 3) self.isBonus = 1 - - - - BenillaActionButton_OnLoad(self, 4) self.isBonus = 1 - - - - BenillaActionButton_OnLoad(self, 5) self.isBonus = 1 - - - - BenillaActionButton_OnLoad(self, 6) self.isBonus = 1 - - - - BenillaActionButton_OnLoad(self, 7) self.isBonus = 1 - - - - BenillaActionButton_OnLoad(self, 8) self.isBonus = 1 - - - - BenillaActionButton_OnLoad(self, 9) self.isBonus = 1 - - - - BenillaActionButton_OnLoad(self, 10) self.isBonus = 1 - - - - BenillaActionButton_OnLoad(self, 11) self.isBonus = 1 - - - - BenillaActionButton_OnLoad(self, 12) self.isBonus = 1 - - - - BenillaBonusBar_OnLoad(self) - BonusActionBar_OnUpdate(arg1) - - - - - - diff --git a/crates/benilla-app/assets/ui/MicroMenu.xml b/crates/benilla-app/assets/ui/MicroMenu.xml index b0c77503..9ecd108f 100644 --- a/crates/benilla-app/assets/ui/MicroMenu.xml +++ b/crates/benilla-app/assets/ui/MicroMenu.xml @@ -85,6 +85,13 @@ - - - - - - - - - - - BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON") - - - - BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON") - - - - BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON") - - - - BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON") - - - - BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON") - - - - BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON") - - - - BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON") - - - - BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON") - - - - BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON") - - - - BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON") - - - - BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON") - - - - BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON") - - - - - - - - - - - - - - BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON") - - - - BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON") - - - - BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON") - - - - BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON") - - - - BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON") - - - - BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON") - - - - BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON") - - - - BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON") - - - - BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON") - - - - BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON") - - - - BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON") - - - - BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON") - - - - - - - - - - - - - - - BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON") - - - - BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON") - - - - BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON") - - - - BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON") - - - - BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON") - - - - BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON") - - - - BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON") - - - - BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON") - - - - BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON") - - - - BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON") - - - - BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON") - - - - BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON") - - - - - - - - - - - - - BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON") - - - - BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON") - - - - BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON") - - - - BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON") - - - - BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON") - - - - BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON") - - - - BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON") - - - - BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON") - - - - BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON") - - - - BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON") - - - - BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON") - - - - BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON") - - - - diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 7aa4871e..9ee7e028 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -190,6 +190,111 @@ - - - - - - - - - - - - - - - - - - - - - - - BenillaShapeshiftButton_OnClick(self) - BenillaShapeshiftButton_OnEnter(self) - BenillaShapeshiftButton_OnLeave(self) - - - - - - - - - - - - - - - - - - - - BenillaShapeshiftButton_OnLoad(self, 1) - - - - BenillaShapeshiftButton_OnLoad(self, 2) - - - - BenillaShapeshiftButton_OnLoad(self, 3) - - - - BenillaShapeshiftButton_OnLoad(self, 4) - - - - BenillaShapeshiftButton_OnLoad(self, 5) - - - - BenillaShapeshiftButton_OnLoad(self, 6) - - - - BenillaShapeshiftButton_OnLoad(self, 7) - - - - BenillaShapeshiftButton_OnLoad(self, 8) - - - - BenillaShapeshiftButton_OnLoad(self, 9) - - - - BenillaShapeshiftButton_OnLoad(self, 10) - - - - ShapeshiftBar_OnLoad() - ShapeshiftBar_OnEvent() - - UIParent_ManageFramePositions() - UIParent_ManageFramePositions() - - - diff --git a/crates/benilla-app/assets/ui/UIParent.xml b/crates/benilla-app/assets/ui/UIParent.xml index 7d9d9705..ebfead66 100644 --- a/crates/benilla-app/assets/ui/UIParent.xml +++ b/crates/benilla-app/assets/ui/UIParent.xml @@ -74,8 +74,8 @@ XML anchor: every layout pass re-anchors it to a base offset plus per-bar deltas - either bottom multibar, the pet/stance bar, the reputation watch bar - fired from those bars' OnShow/OnHide (ref BonusActionBarFrame.xml:350-355 et al.) and applied once at - startup. The loader's post-load call (ui_script/mod.rs) is our startup seam; the stance - bar's OnShow/OnHide (StanceBar.xml) is the one live re-fire today. + startup. The loader's post-load call (ui_script/mod.rs) is our startup seam; the pet + bar's OnShow/OnHide and the options rows' setters (1938) are the live re-fires today. Departures, each named: - Lua 5.1 dialect (the 1.12 client ran 5.0): pairs() iteration, table.insert for @@ -207,7 +207,7 @@ -- `CMSG_SET_ACTIONBAR_TOGGLES` and waits for the field to come back (wow-re -- `system/ui/scratch/action-bar-toggles.md` §5: the setter's every store is into its own -- stack frame, and nothing in the binary fires on a change to that byte). So the live UI - -- state is the four Lua globals `MultiBars.xml` owns, and `GetActionBarToggles()` is read + -- state is the four Lua globals stock `MultiActionBars.lua` reads, and `GetActionBarToggles()` is read -- exactly ONCE, here, to seed them. Reading it again later would fight the optimistic -- value the options rows just set and lose a round trip's worth of clicks. -- @@ -810,6 +810,12 @@ -- while the stance bar is up starts 20px past the last stance button. That case is -- rare but real — a possessed or charmed unit gives a pet bar to a class that also -- has forms. + -- Update shapeshift bar appearance — ref UIParent.lua l.1705-1732, the reference's own + -- arm of this pass: with the bottom-left bar up the stance bar loses its shelf (end + -- caps + middle strip) and its rings shrink to 50; otherwise the shelf shows (the + -- middle only past two forms) and the rings are 64. Stock BonusActionBarFrame.lua + -- carries a `ShapeshiftBar_UpdatePosition` with the same body that nothing calls; the + -- pass IS the caller. (Our StanceBar.xml did this through a listener until 1938.) local bottomLeftUp = MultiBarBottomLeft and MultiBarBottomLeft:IsShown() if ( SlidingActionBarTexture0 and SlidingActionBarTexture1 ) then if ( bottomLeftUp ) then @@ -820,6 +826,28 @@ SlidingActionBarTexture1:Show() end end + if ( ShapeshiftBarFrame ) then + local numForms = GetNumShapeshiftForms and GetNumShapeshiftForms() or 0 + if ( bottomLeftUp ) then + ShapeshiftBarLeft:Hide() + ShapeshiftBarRight:Hide() + ShapeshiftBarMiddle:Hide() + for i=1, numForms do + getglobal("ShapeshiftButton"..i.."NormalTexture"):SetWidth(50) + getglobal("ShapeshiftButton"..i.."NormalTexture"):SetHeight(50) + end + else + if ( numForms > 2 ) then + ShapeshiftBarMiddle:Show() + end + ShapeshiftBarLeft:Show() + ShapeshiftBarRight:Show() + for i=1, numForms do + getglobal("ShapeshiftButton"..i.."NormalTexture"):SetWidth(64) + getglobal("ShapeshiftButton"..i.."NormalTexture"):SetHeight(64) + end + end + end if ( PetActionBarFrame ) then PETACTIONBAR_XPOS = 36 local forms = GetNumShapeshiftForms and GetNumShapeshiftForms() or 0 diff --git a/crates/benilla-app/assets/ui/UiPanels.xml b/crates/benilla-app/assets/ui/UiPanels.xml index aa92c6c3..32e4f5d0 100644 --- a/crates/benilla-app/assets/ui/UiPanels.xml +++ b/crates/benilla-app/assets/ui/UiPanels.xml @@ -24,6 +24,11 @@ its comment names the reference row it stands in for. --> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - BenillaQuestGreetingPanel_OnShow() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - QuestMoneyFrame_OnLoad() - - - - - - - - - BenillaQuestDetailPanel_OnShow() - BenillaQuestDetailPanel_OnUpdate(this, arg1) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - SmallMoneyFrame_OnLoad(); MoneyFrame_SetType("STATIC"); - - - - - - - - - - - - - - - BenillaQuestProgressPanel_OnShow() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - QuestMoneyFrame_OnLoad() - - - - - - - - - - - - - - - - - - BenillaQuestRewardPanel_OnShow() - - - - QuestFrame_OnLoad() - QuestFrame_OnEvent(event) - BenillaQuestFrame_OnUpdate() - - PlaySound("igQuestListOpen") - - getglobal("QuestFrameGreetingPanel"):Hide() - getglobal("QuestFrameDetailPanel"):Hide() - getglobal("QuestFrameProgressPanel"):Hide() - getglobal("QuestFrameRewardPanel"):Hide() - CloseQuest() - PlaySound("igQuestListClose") - - - - diff --git a/crates/benilla-app/assets/ui/QuestLogFrame.xml b/crates/benilla-app/assets/ui/QuestLogFrame.xml deleted file mode 100644 index 2082fcc8..00000000 --- a/crates/benilla-app/assets/ui/QuestLogFrame.xml +++ /dev/null @@ -1,1999 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - getglobal("QuestLogTrackTracking"):SetVertexColor(1.0, 0, 0) - - - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - GameTooltip:SetText("Shift-click a quest to add or remove a quest from your quest watch list.", nil, nil, nil, nil, 1) - - - GameTooltip:Hide() - - - - - - - - - - - - - - - - - - - - - - - FauxScrollFrame_OnVerticalScroll(QUESTLOG_QUEST_HEIGHT, BenillaQuestLogList_Update) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - QuestMoneyFrame_OnLoad() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - BenillaQuestLogFrame_OnLoad(self) - BenillaQuestLogFrame_OnEvent(self) - - QuestLog_OnUpdate(arg1) - - BenillaQuestLogFrame_OnMouseWheel(arg1) - - if UpdateMicroButtons then UpdateMicroButtons() end PlaySound("igQuestLogOpen") BenillaQuestLogFrame_Update() - if UpdateMicroButtons then UpdateMicroButtons() end PlaySound("igQuestLogClose") - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - BenillaQuestWatchFrame_OnLoad(self) - BenillaQuestWatchFrame_OnEvent(self) - BenillaQuestWatchFrame_OnUpdate(self, arg1) - - - diff --git a/crates/benilla-app/assets/ui/QuestShareFrame.xml b/crates/benilla-app/assets/ui/QuestShareFrame.xml index 2000a050..cc510130 100644 --- a/crates/benilla-app/assets/ui/QuestShareFrame.xml +++ b/crates/benilla-app/assets/ui/QuestShareFrame.xml @@ -6,7 +6,7 @@ registration and the one-line arm) — and it fixes the dialog key, the field names and the one verb by name, so those are reproduced exactly. - WHY THIS IS ITS OWN FILE, next to QuestFrame.xml and QuestLogFrame.xml rather than inside + WHY THIS IS ITS OWN FILE, next to the (stock, since 1944) QuestFrame.xml and QuestLogFrame.xml rather than inside either. It belongs to neither window. The confirm has no window at all — it is raised by `crate::ui_quest_share` off `SMSG_QUEST_CONFIRM_ACCEPT`, exactly as DuelFrame.xml's two dialogs are raised off the duel packets, and answering it sends a packet neither quest diff --git a/crates/benilla-app/assets/ui/UiPanels.xml b/crates/benilla-app/assets/ui/UiPanels.xml index 32e4f5d0..bbdb08ee 100644 --- a/crates/benilla-app/assets/ui/UiPanels.xml +++ b/crates/benilla-app/assets/ui/UiPanels.xml @@ -2000,6 +2000,40 @@ hideOnEscape = 1, } + -- The quest log's abandon confirm and its with-items twin — ref StaticPopup.lua l.749-775, + -- in the reference's own home, which is this file (the same move RENAME_GUILD made). They + -- lived in our QuestLogFrame.xml until 1944 took that window stock; stock's Abandon button + -- calls `StaticPopup_Show("ABANDON_QUEST", GetAbandonQuestName())` and registers nothing. + -- The wording is the GlobalStrings key by name, as this file spells its dialogs (the + -- reference wraps the same globals in TEXT()); the with-items twin is declared as the + -- reference declares it, whatever `GetAbandonQuestItems()` answers. + StaticPopupDialogs["ABANDON_QUEST"] = { + text = ABANDON_QUEST_CONFIRM, + button1 = YES, + button2 = NO, + OnAccept = function() + AbandonQuest() + PlaySound("igQuestLogAbandonQuest") + end, + timeout = 0, + whileDead = 1, + exclusive = 1, + hideOnEscape = 1, + } + StaticPopupDialogs["ABANDON_QUEST_WITH_ITEMS"] = { + text = ABANDON_QUEST_CONFIRM_WITH_ITEMS, + button1 = YES, + button2 = NO, + OnAccept = function() + AbandonQuest() + PlaySound("igQuestLogAbandonQuest") + end, + timeout = 0, + whileDead = 1, + exclusive = 1, + hideOnEscape = 1, + } + -- ref UIParent.lua l.324-343 + l.356-360, the four arms on one driver. Each equip arm -- hides the OTHER dialog first — the two questions are mutually exclusive by construction -- (one item, one destination), and the hide is what cancels the record the new question diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index 57244593..6f087c4c 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -976,10 +976,16 @@ CraftFrame.xml # every verb the stock file calls at runtime — not just at load — was checked by hand against # the bindings before this line changed. Interface\FrameXML\LootFrame.xml -# The questgiver window (decision 0088): four sub-panels over the Era quest API. Loaded after -# MerchantFrame (grouping; the BenillaMoney coin helpers now live in MoneyFrame.xml) and GossipFrame (shared parchment -# art conventions); its title/panel-button labels use the auto-centred ButtonText slot. -QuestFrame.xml +# ══ THE QUESTGIVER WINDOW — the reference's own, off the player's chain (1751, 1944) ══ +# Stock QuestFrame.xml sources QuestFrame.lua and INCLUDES QuestFrameTemplates.xml (the panel, +# item and title-button templates the quest log below inherits too), so one row brings the +# three-file unit 1913 measured. Its `QuestFrameItems_Update` paints all three windows' reward +# grids — the shared function that made the quest log un-migratable alone until the money went +# stock (1937). Needs, all above: Fonts (QuestFont/QuestTitleFont), MoneyFrame (the two +# MoneyFrameTemplate frames, resolved at LOAD), UiPanels, UIPanelTemplates. Its two saved-variable +# flags (QUEST_FADING_DISABLE, AUTO_QUEST_WATCH) are declared where the reference declares them — +# the options window's init (OptionsFrame.xml, far above). +Interface\FrameXML\QuestFrame.xml # The two guild-charter windows (decision 1672): the guild registrar — the window the "How do I # form a guild?" gossip row opens — and the charter itself, the sheet nine people sign. **Both are # the reference's own files off the player's chain now** (1751, windows 12 and 13). Loaded here, @@ -1002,11 +1008,13 @@ Interface\FrameXML\GuildRegistrarFrame.xml # close/action buttons `inherits=` at LOAD), and the QuestGreeting parchment family it shares with # GuildRegistrarFrame directly above. Interface\FrameXML\PetitionFrame.xml -# The quest log window (decision 0088 arc, the quest-log slice): durable player state, not an -# NPC session — loaded after MerchantFrame (grouping; the coin helpers are in MoneyFrame.xml now) and -# alongside QuestFrame (same arc); the 'L' binding (ui_script/input.rs) calls its -# ToggleQuestLog(). -QuestLogFrame.xml +# ══ THE QUEST LOG — the reference's own, off the player's chain (1751, 1944) ══ +# Stock QuestLogFrame.xml sources QuestLogFrame.lua; the 'L' binding calls its ToggleQuestLog(), +# and its reward hovers are GameTooltip:SetQuestLogItem / SetQuestLogRewardSpell (engine, 1944). +# After QuestFrame.xml (QuestItemTemplate, QuestMoneyFrame_OnLoad, QuestFrameItems_Update), +# MicroMenu.xml (QuestLog_OnShow calls UpdateMicroButtons unguarded) and the same kit as the +# giver; the reference's toc has the pair at l.53-54. +Interface\FrameXML\QuestLogFrame.xml # The timed-quest countdown window under the minimap (decision 1150): the reference's own # file split. After MinimapCluster (its anchor), UIParent (the manage pass that re-seats it, # and SecondsToTime), GameTooltip (its row hover), and QuestLogFrame — whose window a row diff --git a/crates/benilla-app/src/capture/probes/live_fps.rs b/crates/benilla-app/src/capture/probes/live_fps.rs index 3020335f..a066fd3b 100644 --- a/crates/benilla-app/src/capture/probes/live_fps.rs +++ b/crates/benilla-app/src/capture/probes/live_fps.rs @@ -52,6 +52,11 @@ impl Plugin for LiveFpsPlugin { samples: Vec::new(), cpu_samples: Vec::new(), cpu_prev: None, + churn_samples: Vec::new(), + entities_prev: 0, + decodes_prev: 0, + load_samples: Vec::new(), + paced_samples: Vec::new(), cpu_at_start: None, sys_at_start: None, occluded_now: false, @@ -87,6 +92,22 @@ struct LiveFps { /// frame was CPU work or a wait is the first question about a stutter. cpu_samples: Vec, cpu_prev: Option, + /// Per frame, beside `cpu_samples`: entities added net of removed, mesh assets added, + /// model materials added — the three spike signatures a crowd produces (a spell kit + /// spawning, a body dressing, a first-seen material realizing). Printed for the worst + /// frames only. + churn_samples: Vec<(i32, u32, u32, u32)>, + entities_prev: usize, + decodes_prev: u32, + /// Live particles and the GPU meter's freshest reading per frame — the two "same work, + /// more of it" signatures the churn triple cannot carry. + load_samples: Vec<(u32, f32)>, + /// The PACED clock's delta per frame (`Time`, `frame_pace`): under vsync with + /// pipelined rendering the raw delta is mis-split into long/short pairs that sum to exact + /// refresh multiples, so `samples`' tail counts presentation jitter as frames. The pacer + /// snaps a cadenced delta to whole refreshes; a paced delta of two periods is a frame the + /// display actually skipped. + paced_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, @@ -144,6 +165,10 @@ type ScreenParams<'w, 's> = ( ), With, >, + // Asset adds per frame, for the worst-frame annotation (`LiveFps::churn_samples`). + MessageReader<'w, 's, AssetEvent>, + MessageReader<'w, 's, AssetEvent>, + Res<'w, Time>, ); #[derive(SystemParam)] @@ -196,6 +221,18 @@ fn drive_live_fps( &screen.5, &screen.6, ); + let (mesh_events, mat_events) = (&mut screen.7, &mut screen.8); + let paced_ms = screen.9.delta_secs() * 1000.0; + // Read every frame (a reader that only reads inside the window would report the whole + // backlog on its first sampled frame). + let mesh_added = mesh_events + .read() + .filter(|e| matches!(e, AssetEvent::Added { .. })) + .count() as u32; + let mat_added = mat_events + .read() + .filter(|e| matches!(e, AssetEvent::Added { .. })) + .count() as u32; // 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() { @@ -276,12 +313,31 @@ fn drive_live_fps( }; probe.cpu_prev = cpu_now; probe.cpu_samples.push(cpu_ms); + let n_entities = entities.iter().count(); + let d_entities = if probe.churn_samples.is_empty() { + 0 + } else { + n_entities as i32 - probe.entities_prev as i32 + }; + probe.entities_prev = n_entities; + let decodes = crate::sound::kit_decodes(); + let d_decodes = decodes.saturating_sub(probe.decodes_prev); + probe.decodes_prev = decodes; + probe + .churn_samples + .push((d_entities, mesh_added, mat_added, d_decodes)); + let mut gpu_now = 0.0; if let Some(gpu) = gpu { let ns = gpu.0.load(std::sync::atomic::Ordering::Relaxed); if ns > 0 { - gpu_samples.push(ns as f32 / 1.0e6); + gpu_now = ns as f32 / 1.0e6; + gpu_samples.push(gpu_now); } } + probe + .load_samples + .push((census.live_particles() as u32, gpu_now)); + probe.paced_samples.push(paced_ms); if probe.samples.len() < probe.frames { return; } @@ -382,14 +438,25 @@ fn drive_live_fps( c.sort_by(f32::total_cmp); let cat = |q: f32| c[(((c.len() - 1) as f32) * q).round() as usize]; let over = probe.samples.iter().filter(|&&f| f > 16.9).count(); + // Real skipped frames: paced deltas of two refreshes or more. + let period = 1000.0 / 60.0; + let dropped = probe + .paced_samples + .iter() + .filter(|&&f| f > 1.5 * period) + .count(); + let paced_max = probe.paced_samples.iter().copied().fold(0.0f32, f32::max); let mut worst: Vec = (0..probe.samples.len()).collect(); worst.sort_by(|&a, &b| probe.samples[b].total_cmp(&probe.samples[a])); let worst = worst .iter() .take(6) .map(|&i| { + let (de, dm, dmat, dsnd) = + probe.churn_samples.get(i).copied().unwrap_or((0, 0, 0, 0)); + let (np, g) = probe.load_samples.get(i).copied().unwrap_or((0, 0.0)); format!( - "{i}:{:.1}/{:.1}", + "{i}:{:.1}/{:.1}(e{de:+},m+{dm},mat+{dmat},snd+{dsnd},p{np},g{g:.1})", probe.samples[i], probe.cpu_samples.get(i).copied().unwrap_or(0.0) ) @@ -398,7 +465,7 @@ fn drive_live_fps( .join(","); format!( " cpu_ms={per_frame_ms:.2} cpu_pct={:.0} cpu_p95={:.2} cpu_p99={:.2} \ - frames_over={over} worst=[{worst}]", + frames_over={over} dropped={dropped} paced_max={paced_max:.1} worst=[{worst}]", per_frame_ms / mean as f64 * 100.0, cat(0.95), cat(0.99), @@ -590,8 +657,21 @@ fn print_vis_census(seen: &benilla_world::world_census::CensusReport) { .map(|(name, vis, gated)| format!("{name}={vis}/gated{gated}")) .collect::>() .join(" "); + let resident = seen + .kinds + .iter() + .zip(seen.resident.iter()) + .map(|((name, _, _), n)| format!("{name}={n}")) + .collect::>() + .join(" "); + let why = seen + .why + .iter() + .map(|(w, n)| format!("{w}={n}")) + .collect::>() + .join(" "); println!( - "VIS_CENSUS visible-submeshes {line} | tagged={} hidden={} exempt={} no_aabb={}", + "VIS_CENSUS visible-submeshes {line} | resident {resident} | why {why} | tagged={} hidden={} exempt={} no_aabb={}", seen.tagged, seen.hidden, seen.exempt, seen.no_aabb ); // The escapees always print: a tagged, bounded object the cull left un-hidden is a defect by diff --git a/crates/benilla-app/src/go_templates.rs b/crates/benilla-app/src/go_templates.rs index d44abbc1..16656e9d 100644 --- a/crates/benilla-app/src/go_templates.rs +++ b/crates/benilla-app/src/go_templates.rs @@ -56,6 +56,11 @@ pub(crate) struct GoTemplate { /// `Some` with a nonzero `page_id` is what makes a right-click *read* it; a type-9 template /// with no page (vanilla ships a handful) opens nothing at all, exactly like the reference. pub(crate) text_page: Option, + /// The `PageTextMaterial.dbc` id `GetQuestBackgroundMaterial` answers for a quest sourced from + /// this object — by `GAMEOBJECT_TYPE_ID`: QUESTGIVER (2) and GOOBER (9) read `data[2]`, CHEST + /// (10) reads `data[9]`, every other type answers none (`0x5f5950`, wow-re + /// quest-material-reward-spell-bindings.md §1). + pub(crate) quest_material: Option, } /// A MO_TRANSPORT template's path tuple (`gameobject_template.data0..2`, decision 0438). @@ -134,6 +139,11 @@ impl GameObjectTemplates { page_id: data[0].max(0) as u32, material: data[2].max(0) as u32, }); + let quest_material = match type_id { + 2 | 9 => Some(data[2].max(0) as u32), + 10 => Some(data[9].max(0) as u32), + _ => None, + }; self.templates.insert( entry, GoTemplate { @@ -143,6 +153,7 @@ impl GameObjectTemplates { meeting_stone_area, mo_transport, text_page, + quest_material, }, ); } @@ -183,6 +194,29 @@ mod tests { assert_eq!(go_lock_slot(19), None); // MAILBOX — no lock (opens by USE) } + /// The quest-giver material arm — by type: QUESTGIVER (2) and GOOBER (9) read `data[2]`, + /// CHEST (10) reads `data[9]`, anything else none (`0x5f5950`, wow-re + /// quest-material-reward-spell-bindings.md §1). + #[test] + fn insert_captures_the_quest_material_by_type() { + let mut t = GameObjectTemplates::default(); + let mut data = [0i32; 24]; + data[2] = 2; + data[9] = 3; + t.insert(100, 2, "Wanted Poster".into(), &data); + t.insert(101, 9, "Book".into(), &data); + t.insert(102, 10, "Chest".into(), &data); + t.insert(103, 15, "Boat".into(), &data); + assert_eq!(t.templates[&100].quest_material, Some(2)); + assert_eq!(t.templates[&101].quest_material, Some(2)); + assert_eq!( + t.templates[&102].quest_material, + Some(3), + "a chest reads data[9]" + ); + assert_eq!(t.templates[&103].quest_material, None); + } + #[test] fn insert_captures_mo_transport_tuple() { let mut t = GameObjectTemplates::default(); diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index d33a95dc..07659941 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -791,11 +791,11 @@ pub fn run(build: BuildId) -> AppExit { .add_plugins(UiLootPlugin) .add_plugins(UiLootRollPlugin) // The questgiver window (decision 0088): fills from the net drain's QuestGiver and drives - // QuestFrame.xml's four sub-panels over the Era quest API. + // the stock questgiver window's four sub-panels over the Era quest API (1944). .add_plugins(UiQuestPlugin) // The quest-log window (decision 0088's deferred second slice): fills from the self player's // PLAYER_QUEST_LOG descriptor slots + the SMSG_QUEST_QUERY_RESPONSE template cache, and drives - // QuestLogFrame.xml over the Era quest-log API. + // the stock quest log over the Era quest-log API (1944). .add_plugins(UiQuestLogPlugin) // The party quest-share (decision 1733): the verdict lines on a quest we pushed, and the // escort-quest confirm. Neither is bound to a window, so it is its own plugin rather than a diff --git a/crates/benilla-app/src/sound/kit.rs b/crates/benilla-app/src/sound/kit.rs index 987acbeb..31d8b19e 100644 --- a/crates/benilla-app/src/sound/kit.rs +++ b/crates/benilla-app/src/sound/kit.rs @@ -1052,10 +1052,16 @@ impl SoundKits { .with_context(|| format!("reading {path}"))?; let data = mixer::sfx_from_bytes(bytes)?; self.cache.insert(key, data.clone()); + DECODES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); Ok(data) } } +/// First-play decodes so far — a chain read plus a decode on the main thread, once per +/// distinct kit file per session. `FPS_PROBE` reads it per frame to annotate a tail frame +/// (a raid's buff wave meets dozens of distinct spell sounds in its first minute). +pub(crate) static DECODES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + /// Startup: load the kit catalog off the chain (absent → no resource; every play site tolerates /// that, the same optional-catalog rule as `Creatures`). pub(super) fn load_sound_kits(mut commands: Commands, assets: Option>) { diff --git a/crates/benilla-app/src/sound/mod.rs b/crates/benilla-app/src/sound/mod.rs index e8a38b07..9a2a18ea 100644 --- a/crates/benilla-app/src/sound/mod.rs +++ b/crates/benilla-app/src/sound/mod.rs @@ -50,6 +50,10 @@ mod water; mod weather; mod zone; pub(crate) use emote::EmoteSounds; +/// First-play kit decodes so far (`kit::DECODES`) — the probe's tail annotation. +pub(crate) fn kit_decodes() -> u32 { + kit::DECODES.load(std::sync::atomic::Ordering::Relaxed) +} pub(crate) use glue::GlueSound; pub(crate) use greeting::NpcGreetingRequest; pub(crate) use message::MessageSounds; diff --git a/crates/benilla-app/src/ui_item_text.rs b/crates/benilla-app/src/ui_item_text.rs index 28db88d0..bf54a382 100644 --- a/crates/benilla-app/src/ui_item_text.rs +++ b/crates/benilla-app/src/ui_item_text.rs @@ -207,6 +207,33 @@ struct Readable { page_head: u32, } +/// The material a quest sourced from `guid` paints on — `GetQuestBackgroundMaterial 0x502230`, +/// whose body is `ItemTextGetMaterial`'s: the source object by guid, an item's template +/// `PageMaterial` or a GameObject's template data by type, the id named through +/// `PageTextMaterial.dbc`; a creature, an uncached template or an id with no row is `None`, which +/// is FrameXML's "Parchment". A GameObject whose template is not cached yet is asked for. +pub(crate) fn object_material( + guid: u64, + items: &mut Items, + go_templates: &mut GameObjectTemplates, + materials: Option<&PageMaterials>, + commands: &NetCommands, +) -> Option { + let name = |id: u32| materials.and_then(|m| m.0.name(id)).map(str::to_string); + if benilla_protocol::guid::is_gameobject(guid) { + return match go_templates.get(guid) { + Some(go) => go.quest_material.and_then(name), + None => { + go_templates.request(guid, commands); + None + } + }; + } + let entry = items.object(guid)?.object_entry()?; + let t = items.template(entry, guid, commands)?; + name(t.page_material) +} + /// Resolve the object's title + material + page head, whether it is an item or a GameObject. The /// reference does exactly this — one guid lookup with typemask 1, then virtual getters — so a /// single resolve serves both sources. @@ -498,3 +525,78 @@ mod tests { assert!(matches!(&sess.source, ReadSource::Pages { visited } if visited.is_empty())); } } + +#[cfg(test)] +mod quest_material_tests { + use super::*; + + fn go_guid(entry: u64) -> u64 { + (u64::from(benilla_protocol::guid::HIGH_GAMEOBJECT) << 48) | (entry << 24) | 1 + } + + /// `GetQuestBackgroundMaterial`'s resolve, the reader's twin: a GameObject source answers its + /// template data by type through `PageTextMaterial.dbc`; a creature source answers nothing + /// (FrameXML's Parchment); an uncached GameObject is asked for and answers nothing meanwhile. + #[test] + fn a_quest_sourced_from_an_object_paints_the_objects_material() { + let mut items = Items::default(); + let mut gos = GameObjectTemplates::default(); + let (tx, rx) = crossbeam_channel::unbounded(); + let commands = NetCommands(tx); + let catalog = PageMaterials(benilla_formats::PageTextMaterialCatalog::from_rows(&[ + (1, "Parchment"), + (2, "Stone"), + (3, "Marble"), + ])); + let mut data = [0i32; 24]; + data[2] = 2; + data[9] = 3; + gos.insert(100, 2, "Wanted Poster".into(), &data); + gos.insert(102, 10, "Chest".into(), &data); + assert_eq!( + object_material( + go_guid(100), + &mut items, + &mut gos, + Some(&catalog), + &commands + ) + .as_deref(), + Some("Stone") + ); + assert_eq!( + object_material( + go_guid(102), + &mut items, + &mut gos, + Some(&catalog), + &commands + ) + .as_deref(), + Some("Marble"), + "a chest reads data[9]" + ); + // A creature quest-giver: neither template, nothing asked. + let creature = (u64::from(benilla_protocol::guid::HIGH_UNIT) << 48) | (6 << 24) | 1; + assert_eq!( + object_material(creature, &mut items, &mut gos, Some(&catalog), &commands), + None + ); + assert!(rx.try_recv().is_err(), "a creature source asks for nothing"); + // An uncached object: nothing yet, and the template is asked for. + assert_eq!( + object_material( + go_guid(777), + &mut items, + &mut gos, + Some(&catalog), + &commands + ), + None + ); + assert!( + rx.try_recv().is_ok(), + "an uncached object's template is asked for" + ); + } +} diff --git a/crates/benilla-app/src/ui_quest.rs b/crates/benilla-app/src/ui_quest.rs index 79dffcfb..aeb711ee 100644 --- a/crates/benilla-app/src/ui_quest.rs +++ b/crates/benilla-app/src/ui_quest.rs @@ -22,14 +22,14 @@ use benilla_protocol::messages::{ use bevy::prelude::*; use benilla_ui::script::{ - QuestAction, QuestItemView, QuestPanel, QuestState, ScriptValue, UiScript, + QuestAction, QuestItemView, QuestPanel, QuestRewardSpell, QuestState, UiScript, }; use crate::entities::ItemDisplays; use crate::items::Items; use crate::names::NameCache; use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer}; -use crate::ui_action::{show_messages, ui_error_text, MessageSink, Shown, UiError}; +use crate::ui_action::{show_messages, ui_error_text, MessageSink, Shown, Spells, UiError}; use crate::ui_script::UiInput; use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession}; @@ -397,12 +397,35 @@ fn resolve_items( /// Build the Lua-facing snapshot from the open view — `None` when no window is open. Every /// server-authored text runs the shared chat-macro substitution (`$N`/`$B`/`$G` — /// [`crate::npc_text`]): the wire delivers quest text un-expanded, the client substitutes. +/// The quest's reward spell as the getters answer it: `rewSpell` off the packet, resolved to the +/// spell's name and icon through the spell catalog (`Spell.dbc`). Zero — no spell — is `None`; an +/// id the catalog cannot place still counts (the id is real), with nothing to paint. +pub(crate) fn reward_spell_view( + spell_id: u32, + spells: Option<&Spells>, +) -> Option { + if spell_id == 0 { + return None; + } + let d = spells.and_then(|s| s.catalog.get(spell_id)); + Some(QuestRewardSpell { + spell_id, + name: d.map(|d| d.name.clone()), + texture: d.and_then(|d| d.icon.clone()), + // `isTradeskillSpell` is `Spell.dbc` col 6 `Attributes` bit 5 (`0x20`) — `0x501e59`, + // wow-re quest-material-reward-spell-bindings.md §3 (1161 of 22357 rows set; craft-cast + // spells set, `Pattern:` teaching spells clear). + tradeskill: d.is_some_and(|d| d.attributes & 0x20 != 0), + }) +} + fn snapshot( giver: &QuestGiver, items: &mut Items, icons: Option<&ItemDisplays>, commands: &NetCommands, macros: &crate::npc_text::MacroContext, + spells: Option<&Spells>, ) -> Option { let sub = |t: &str| crate::npc_text::substitute(t, macros); Some(match giver.view.as_ref()? { @@ -432,6 +455,7 @@ fn snapshot( choices: resolve_items(&d.choices, items, icons, commands), rewards: resolve_items(&d.rewards, items, icons, commands), reward_money: d.money.max(0) as u32, + reward_spell: reward_spell_view(d.reward_spell, spells), ..Default::default() }, QuestView::Progress(p) => QuestState { @@ -450,6 +474,7 @@ fn snapshot( choices: resolve_items(&o.choices, items, icons, commands), rewards: resolve_items(&o.rewards, items, icons, commands), reward_money: o.money.max(0) as u32, + reward_spell: reward_spell_view(o.reward_spell, spells), ..Default::default() }, }) @@ -479,6 +504,9 @@ fn feed_quest( mut names: ResMut, states: Res, self_q: Query<(&ObjectStore, &Guid), With>, + spells: Option>, + mut go_templates: ResMut, + materials: Option>, mut sink: MessageSink, mut last: Local>>, mut last_name: Local>>, @@ -518,7 +546,24 @@ fn feed_quest( subject: player.as_ref(), states: &states, }, + spells.as_deref(), ); + // The panel's background material is the SOURCE object's — an item's page material, a + // GameObject's template data — never a creature's, and never on the wire (1946; wow-re + // quest-material-reward-spell-bindings.md §1). Resolved the way the reader window resolves its + // own, since the two bindings share a body. + let fresh = fresh.map(|mut st| { + st.background_material = giver.npc.and_then(|source| { + crate::ui_item_text::object_material( + source, + &mut items, + &mut go_templates, + materials.as_deref(), + &commands, + ) + }); + st + }); let npc_name = giver .npc .and_then(|g| names.resolve(g, &commands).map(str::to_string)); @@ -531,7 +576,9 @@ fn feed_quest( return; } script.set_quest(fresh.clone()); - let name_arg = || vec![ScriptValue::Str(npc_name.clone().unwrap_or_default())]; + // The reference's QUEST_GREETING/DETAIL/PROGRESS/COMPLETE carry no argument: the window + // reads `UnitName("npc")` for its title (stock QuestFrame.lua:63), and the "npc" unit is + // the session's (ui_unit.rs). Ours used to pass the name as arg1 (1944 dropped it). match (&*last, &fresh) { (_, Some(f)) if switched => { // A different giver → close the old panel, open the new (both kits play). QUEST_FINISHED @@ -539,7 +586,7 @@ fn feed_quest( // drain the pending actions so it does NOT clear the giver we just re-opened. Safe: a // switch is net-driven, so no user action is queued this frame to lose. script.fire_event("QUEST_FINISHED", vec![]); - script.fire_event(panel_event(f.panel), name_arg()); + script.fire_event(panel_event(f.panel), vec![]); let _ = script.take_quest_actions(); } (_, Some(f)) => { @@ -551,7 +598,7 @@ fn feed_quest( } else { panel_event(f.panel) }; - script.fire_event(event, name_arg()); + script.fire_event(event, vec![]); } (Some(_), None) => script.fire_event("QUEST_FINISHED", vec![]), (None, None) => {} @@ -838,6 +885,7 @@ mod tests { subject: Some(&player), states: &crate::world_state::WorldStates::default(), }, + None, ) .expect("open"); assert_eq!(snap.panel, QuestPanel::Detail); @@ -882,6 +930,7 @@ mod tests { subject: None, states: &crate::world_state::WorldStates::default(), }, + None, ) .unwrap(); assert_eq!(snap.panel, QuestPanel::Progress); diff --git a/crates/benilla-app/src/ui_quest_log.rs b/crates/benilla-app/src/ui_quest_log.rs index 527aa20b..563cff05 100644 --- a/crates/benilla-app/src/ui_quest_log.rs +++ b/crates/benilla-app/src/ui_quest_log.rs @@ -53,6 +53,7 @@ use crate::entities::ItemDisplays; use crate::items::Items; use crate::names::NameCache; use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer}; +use crate::ui_action::Spells; use crate::ui_script::UiInput; use crate::ui_unit::UnitFeed; @@ -330,6 +331,7 @@ fn build_detail( icons: Option<&ItemDisplays>, commands: &NetCommands, macros: &crate::npc_text::MacroContext, + spells: Option<&Spells>, ) -> QuestLogDetail { let (required_money, reward_money) = money_split(template.money); let rewards = template @@ -354,6 +356,7 @@ fn build_detail( reward_money, choices, rewards, + reward_spell: crate::ui_quest::reward_spell_view(template.reward_spell, spells), } } @@ -486,6 +489,7 @@ fn feed_quest_log( header_names: Option>, tag_names: Option>, states: Res, + spells: Option>, mut last: Local>, mut prior_quest_ids: Local>>>, ) { @@ -702,9 +706,16 @@ fn feed_quest_log( .filter(|e| !e.is_header) .map(|e| e.quest_id) .and_then(|quest_id| { - quest_log - .template(quest_id, &commands) - .map(|t| build_detail(t, &mut items, icons.as_deref(), &commands, ¯os)) + quest_log.template(quest_id, &commands).map(|t| { + build_detail( + t, + &mut items, + icons.as_deref(), + &commands, + ¯os, + spells.as_deref(), + ) + }) }); let fresh = QuestLogState { @@ -745,8 +756,6 @@ fn feed_quest_log( script.set_quest_log(fresh.clone()); script.fire_event("QUEST_LOG_UPDATE", vec![]); if !progressed.is_empty() { - // Post-push = post-prune: the current watch list, the byte law's index space. - let watched = script.quest_log_watched(); for quest in progressed { for line in quest.changed_lines { script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(line)]); @@ -759,13 +768,13 @@ fn feed_quest_log( }; script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(msg)]); } - let watch_index = watched - .iter() - .position(|&id| id == quest.quest_id) - .map_or(0, |p| p as i64 + 1); - script.fire_event("QUEST_WATCH_UPDATE", vec![ScriptValue::Int(watch_index)]); + // QUEST_WATCH_UPDATE's arg1 is the quest's LOG index: stock QuestLog_OnEvent hands it + // to AutoQuestWatch_Update(questIndex), whose end is AddQuestWatch(questIndex) + // (QuestLogFrame.lua:68, 752-769). Ours used to pass the watch-list position and + // fire a BENILLA_QUEST_PROGRESS twin carrying the log index; the twin went with the + // window (1944). script.fire_event( - "BENILLA_QUEST_PROGRESS", + "QUEST_WATCH_UPDATE", vec![ScriptValue::Int(i64::from(quest.index))], ); } @@ -805,8 +814,6 @@ fn advanced(now: &QuestLogObjectiveView, was: &QuestLogObjectiveView) -> bool { /// One quest whose objectives advanced between log states — the announce unit of /// [`feed_quest_log`]. struct QuestProgress { - /// The quest id (stable identity — the watch list is id-keyed). - quest_id: u32, /// The quest's 1-based index in the NEW log (the Era API's quest-log index space). index: u32, /// The quest title (the COMPLETE toast's `%s`; empty falls to the UNKNOWN form). @@ -846,7 +853,6 @@ fn quests_with_progressed_objectives( .collect(); let completed = e.complete == 1 && prev.complete != 1; (!changed_lines.is_empty() || completed).then_some(QuestProgress { - quest_id: e.quest_id, index: i as u32 + 1, title: e.title.clone(), changed_lines, diff --git a/crates/benilla-app/src/ui_script/bottom_hud_tests.rs b/crates/benilla-app/src/ui_script/bottom_hud_tests.rs index 96f484a6..3d9da8a7 100644 --- a/crates/benilla-app/src/ui_script/bottom_hud_tests.rs +++ b/crates/benilla-app/src/ui_script/bottom_hud_tests.rs @@ -77,7 +77,7 @@ fn shipped_xml() -> Vec<(String, String)> { }) .collect(); out.sort(); - assert!(out.len() >= 40, "only {} xml files swept", out.len()); + assert!(out.len() >= 38, "only {} xml files swept", out.len()); out } diff --git a/crates/benilla-app/src/ui_script/durability_tests.rs b/crates/benilla-app/src/ui_script/durability_tests.rs index 383ae05f..29216d43 100644 --- a/crates/benilla-app/src/ui_script/durability_tests.rs +++ b/crates/benilla-app/src/ui_script/durability_tests.rs @@ -389,6 +389,7 @@ fn the_quest_tracker_stacks_below_the_durability_guy() { for f in [ "Interface\\FrameXML\\Fonts.xml", "MoneyFrame.xml", + "Interface\\FrameXML\\GlobalStrings.lua", "UiPanels.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", @@ -397,9 +398,11 @@ fn the_quest_tracker_stacks_below_the_durability_guy() { "MinimapCluster.xml", "ScrollTemplates.xml", "Interface\\FrameXML\\DurabilityFrame.xml", - "Interface\\FrameXML\\GlobalStrings.lua", - "QuestFrame.xml", - "QuestLogFrame.xml", + "Interface\\FrameXML\\BasicControls.xml", + "Interface\\FrameXML\\ItemButtonTemplate.xml", + "Interface\\FrameXML\\QuestFrame.xml", + "MicroMenu.xml", + "Interface\\FrameXML\\QuestLogFrame.xml", ] { load_xml(&s, f); } diff --git a/crates/benilla-app/src/ui_script/errors_tests.rs b/crates/benilla-app/src/ui_script/errors_tests.rs index eba2c091..09ff622f 100644 --- a/crates/benilla-app/src/ui_script/errors_tests.rs +++ b/crates/benilla-app/src/ui_script/errors_tests.rs @@ -111,6 +111,7 @@ fn an_error_toast_draws_over_an_open_panel_window() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -122,9 +123,12 @@ fn an_error_toast_draws_over_an_open_panel_window() { // pane's UIPanelScrollFrameTemplate). A MISSING template is a loader *warning*, so an // under-loaded list passes and then dies on the first FauxScrollFrame_Update. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); // A left-slot panel open, and the toast raised after it — the order that must not decide. s.eval::<()>("ShowUIPanel(QuestLogFrame)").unwrap(); diff --git a/crates/benilla-app/src/ui_script/friends_tests.rs b/crates/benilla-app/src/ui_script/friends_tests.rs index 7cb9001c..1056c9ca 100644 --- a/crates/benilla-app/src/ui_script/friends_tests.rs +++ b/crates/benilla-app/src/ui_script/friends_tests.rs @@ -17,6 +17,7 @@ fn setup() -> UiScript { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); @@ -44,7 +45,6 @@ fn setup() -> UiScript { // drags in the whole unit-frame cluster — and it must be the REAL frames: loading only // `PartyFrame.lua` for the two names gets "attempt to index a nil value" the first time one // iterates. The manifest already seats these far above RaidFrame (1874). - load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); // The reference's own `BasicControls.xml`, which is manifest entry 3 — far above the pane // (1874). It is here for the error/message pair and `TEXT`. NOTE: unlike `raid_tests`, this // harness does not load `UIParent.xml`, so `UIParentLoadAddOn` — which 1881 moved there out diff --git a/crates/benilla-app/src/ui_script/guild_tests.rs b/crates/benilla-app/src/ui_script/guild_tests.rs index 298e31ab..e9f4e52d 100644 --- a/crates/benilla-app/src/ui_script/guild_tests.rs +++ b/crates/benilla-app/src/ui_script/guild_tests.rs @@ -206,6 +206,7 @@ fn setup() -> UiScript { load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); @@ -227,7 +228,6 @@ fn setup() -> UiScript { // drags in the whole unit-frame cluster — and it must be the REAL frames: loading only // `PartyFrame.lua` for the two names gets "attempt to index a nil value" the first time one // iterates. The manifest already seats these far above RaidFrame (1874). - load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); load_xml(&s, r"Interface\FrameXML\TextStatusBar.lua"); load_xml(&s, r"Interface\FrameXML\TextStatusBar.xml"); load_xml(&s, r"Interface\FrameXML\BuffFrame.xml"); diff --git a/crates/benilla-app/src/ui_script/help_frame_tests.rs b/crates/benilla-app/src/ui_script/help_frame_tests.rs index e6ce90cd..9c591cc2 100644 --- a/crates/benilla-app/src/ui_script/help_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/help_frame_tests.rs @@ -30,6 +30,7 @@ fn setup() -> UiScript { // row, whose OnLoad calls `SmallMoneyFrame_OnLoad` — the TOC's own order (1580's // talent-wipe fixture hit this first). "MoneyFrame.xml", + "Interface\\FrameXML\\GlobalStrings.lua", "UiPanels.xml", // The stock file's `HelpFrame_OnShow` calls `UpdateMicroButtons()` before // `GetGMStatus()`, so without the micro row the OnShow raises and the status ask never @@ -39,7 +40,6 @@ fn setup() -> UiScript { "Interface\\FrameXML\\TextStatusBar.lua", "Interface\\FrameXML\\TextStatusBar.xml", "UIParent.xml", - "Interface\\FrameXML\\GlobalStrings.lua", "Interface\\FrameXML\\MainMenuBar.xml", "Interface\\FrameXML\\ActionBarFrame.xml", "Interface\\FrameXML\\BonusActionBarFrame.xml", diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 5ad18478..51cda487 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -718,6 +718,7 @@ fn interface_harness() -> UiScript { &[ "Interface\\FrameXML\\Fonts.xml", "MoneyFrame.xml", + "Interface\\FrameXML\\GlobalStrings.lua", "UiPanels.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", @@ -750,7 +751,6 @@ fn interface_harness() -> UiScript { "Interface\\FrameXML\\ColorPickerFrame.xml", "Cooldown.xml", "Interface\\FrameXML\\ActionButtonTemplate.xml", - "Interface\\FrameXML\\GlobalStrings.lua", "Interface\\FrameXML\\MainMenuBar.xml", "Interface\\FrameXML\\ActionBarFrame.xml", "Interface\\FrameXML\\BonusActionBarFrame.xml", @@ -765,8 +765,11 @@ fn interface_harness() -> UiScript { "ScrollTemplates.xml", "Interface\\FrameXML\\MerchantFrame.xml", "Interface\\FrameXML\\GlobalStrings.lua", - "QuestFrame.xml", - "QuestLogFrame.xml", + "Interface\\FrameXML\\BasicControls.xml", + "Interface\\FrameXML\\ItemButtonTemplate.xml", + "Interface\\FrameXML\\QuestFrame.xml", + "MicroMenu.xml", + "Interface\\FrameXML\\QuestLogFrame.xml", ], ); harness_on(s) diff --git a/crates/benilla-app/src/ui_script/pet_bar_tests.rs b/crates/benilla-app/src/ui_script/pet_bar_tests.rs index ac57e4dd..5c9ae6f6 100644 --- a/crates/benilla-app/src/ui_script/pet_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/pet_bar_tests.rs @@ -12,6 +12,7 @@ use super::test_ui::load_ui as load_xml; fn load_pet_bar(s: &UiScript) { for file in [ "MoneyFrame.xml", + "Interface\\FrameXML\\GlobalStrings.lua", "UiPanels.xml", "Interface\\FrameXML\\Fonts.xml", r"Interface\FrameXML\UIPanelTemplates.lua", @@ -21,7 +22,6 @@ fn load_pet_bar(s: &UiScript) { "Interface\\FrameXML\\ActionButtonTemplate.xml", "Interface\\FrameXML\\TextStatusBar.lua", "Interface\\FrameXML\\TextStatusBar.xml", - "Interface\\FrameXML\\GlobalStrings.lua", "Interface\\FrameXML\\MainMenuBar.xml", "GameTooltip.xml", "Interface\\FrameXML\\ActionBarFrame.xml", @@ -369,6 +369,7 @@ fn pet_bar_row(with_multibar: bool) -> (usize, f32) { s.set_screen_size(1024.0, 768.0); for file in [ "MoneyFrame.xml", + "Interface\\FrameXML\\GlobalStrings.lua", "UiPanels.xml", "Interface\\FrameXML\\Fonts.xml", r"Interface\FrameXML\UIPanelTemplates.lua", @@ -378,7 +379,6 @@ fn pet_bar_row(with_multibar: bool) -> (usize, f32) { "Interface\\FrameXML\\ActionButtonTemplate.xml", "Interface\\FrameXML\\TextStatusBar.lua", "Interface\\FrameXML\\TextStatusBar.xml", - "Interface\\FrameXML\\GlobalStrings.lua", "Interface\\FrameXML\\MainMenuBar.xml", "GameTooltip.xml", "Interface\\FrameXML\\ActionBarFrame.xml", @@ -677,6 +677,7 @@ fn load_pet_bar_with_tooltip(s: &UiScript) { for file in [ "Interface\\FrameXML\\Fonts.xml", "MoneyFrame.xml", + "Interface\\FrameXML\\GlobalStrings.lua", "UiPanels.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", @@ -686,7 +687,6 @@ fn load_pet_bar_with_tooltip(s: &UiScript) { "Interface\\FrameXML\\ActionButtonTemplate.xml", "Interface\\FrameXML\\TextStatusBar.lua", "Interface\\FrameXML\\TextStatusBar.xml", - "Interface\\FrameXML\\GlobalStrings.lua", "Interface\\FrameXML\\MainMenuBar.xml", "Interface\\FrameXML\\ActionBarFrame.xml", "Interface\\FrameXML\\BonusActionBarFrame.xml", diff --git a/crates/benilla-app/src/ui_script/petition_tests.rs b/crates/benilla-app/src/ui_script/petition_tests.rs index 0580d442..fa905ee6 100644 --- a/crates/benilla-app/src/ui_script/petition_tests.rs +++ b/crates/benilla-app/src/ui_script/petition_tests.rs @@ -102,7 +102,10 @@ fn setup() -> UiScript { // without this the rows build with no art at all and nothing goes red — which is exactly the // failure `load_ui_strict` exists to turn into a red test. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); // `ChatFrameEditBox`, which the reference's own purchase button indexes on every click to // decide where focus goes after the name box closes — a nil there raises before the charter is // bought. Ours guarded it; the reference does not. diff --git a/crates/benilla-app/src/ui_script/quest_tests.rs b/crates/benilla-app/src/ui_script/quest_tests.rs index a5af95e1..319a1269 100644 --- a/crates/benilla-app/src/ui_script/quest_tests.rs +++ b/crates/benilla-app/src/ui_script/quest_tests.rs @@ -19,6 +19,7 @@ fn questgiver_show_hide_plays_open_and_close_kits() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -27,8 +28,11 @@ fn questgiver_show_hide_plays_open_and_close_kits() { // MerchantFrame.xml (the same documented cross-window dep the bag tests load). load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); // Hidden at load: no open sound (never transitions on startup). assert!( @@ -82,14 +86,18 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); let panels = [ "QuestFrameGreetingPanel", @@ -106,10 +114,15 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() { active_titles: vec!["Report to Goldshire".into()], ..QuestState::default() })); - s.fire_event( - "QUEST_GREETING", - vec![ScriptValue::Str("Deputy Willem".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Deputy Willem".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_GREETING", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); for name in panels { assert_eq!( @@ -132,10 +145,15 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() { title: "A Threat Within".into(), ..QuestState::default() })); - s.fire_event( - "QUEST_DETAIL", - vec![ScriptValue::Str("Deputy Willem".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Deputy Willem".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_DETAIL", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); for name in panels { assert_eq!( @@ -163,14 +181,18 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); let choice = |name: &str, quality: u32| benilla_ui::script::QuestItemView { item_id: 0, @@ -191,10 +213,15 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() { reward_money: 150, ..QuestState::default() })); - s.fire_event( - "QUEST_DETAIL", - vec![ScriptValue::Str("Marshal McBride".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Marshal McBride".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_DETAIL", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); assert!(s.eval::("return QuestDetailItem1:IsShown()").unwrap()); @@ -208,7 +235,7 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() { assert_eq!( s.eval::("return QuestDetailItemChooseText:GetText()") .unwrap(), - "You may choose one of these rewards:" + "You will be able to choose one of these rewards:" ); assert_eq!( s.eval::("return QuestDetailItemReceiveText:GetText()") @@ -235,8 +262,11 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() { // arm at all: an unmodified click must not raise and must not set an itemChoice. s.run("QuestDetailItem1:Click()").ok(); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + // The reward panel's `itemChoice` is set only by its own OnShow (stock QuestFrame.lua:104, + // `= 0`) and a reward-row click; on the detail panel it is simply unset. assert_eq!( - s.eval::("return QuestFrame.itemChoice").unwrap(), + s.eval::("return QuestFrameRewardPanel.itemChoice or 0") + .unwrap(), 0, "a detail-panel row never selects a reward" ); @@ -250,14 +280,18 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); let choice = |name: &str| benilla_ui::script::QuestItemView { item_id: 0, @@ -275,10 +309,15 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() { choices: vec![choice("Worn Sword"), choice("Worn Mace")], ..QuestState::default() })); - s.fire_event( - "QUEST_COMPLETE", - vec![ScriptValue::Str("Marshal McBride".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Marshal McBride".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_COMPLETE", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); assert!( @@ -317,24 +356,33 @@ fn greeting_goodbye_button_closes_the_window() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest(Some(QuestState { panel: QuestPanel::Greeting, greeting: "Hello".into(), ..QuestState::default() })); - s.fire_event( - "QUEST_GREETING", - vec![ScriptValue::Str("Deputy Willem".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Deputy Willem".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_GREETING", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); assert!(s.eval::("return QuestFrame:IsVisible()").unwrap()); @@ -362,14 +410,18 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); // Planted, not shipped: instant text is OFF out of the box (see the doc comment). s.eval::<()>(r#"QUEST_FADING_DISABLE = "1""#).unwrap(); s.set_quest(Some(QuestState { @@ -378,10 +430,15 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() { body: "Kill kobolds.".into(), ..QuestState::default() })); - s.fire_event( - "QUEST_DETAIL", - vec![ScriptValue::Str("Deputy Willem".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Deputy Willem".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_DETAIL", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); assert!(s .eval::("return QuestFrameAcceptButton:IsShown()") @@ -478,14 +535,18 @@ fn write_on_still_fades_when_instant_text_is_off() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.eval::<()>(r#"QUEST_FADING_DISABLE = "0""#).unwrap(); s.set_quest(Some(QuestState { panel: QuestPanel::Detail, @@ -493,10 +554,15 @@ fn write_on_still_fades_when_instant_text_is_off() { body: "Kill kobolds.".into(), ..QuestState::default() })); - s.fire_event( - "QUEST_DETAIL", - vec![ScriptValue::Str("Deputy Willem".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Deputy Willem".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_DETAIL", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); // Mid-write (reveal edge at char 4 of 13): the quill scratches, Accept stays dead. @@ -543,14 +609,18 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest(Some(QuestState { panel: QuestPanel::Detail, @@ -558,10 +628,15 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() { body: "Kill things.".into(), ..QuestState::default() })); - s.fire_event( - "QUEST_DETAIL", - vec![ScriptValue::Str("Marshal McBride".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Marshal McBride".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_DETAIL", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); assert_eq!( s.eval::("return getglobal('QuestFrameNpcNameText'):GetText() or ''") @@ -608,16 +683,24 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() { fn greeting_panel_title_rows_grow_to_their_wrapped_titles() { let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); + // Stock sizes each row at show time — `SetHeight(GetTextHeight() + 2)` right after SetText + // (QuestFrame.lua:245) — which the reference can do because its text measure is synchronous. + // So is the app's (measure.rs); the harness installs the same seam's stand-in. + s.set_text_measurer(Box::new(super::FixedWidthFont(7.0))); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest(Some(QuestState { panel: QuestPanel::Greeting, @@ -629,10 +712,15 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() { ], ..QuestState::default() })); - s.fire_event( - "QUEST_GREETING", - vec![ScriptValue::Str("Deputy Willem".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Deputy Willem".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_GREETING", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); let answer_measures = |s: &mut UiScript| { @@ -665,7 +753,7 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() { }; let (t1, b1, h1) = row(1); let (t2, b2, h2) = row(2); - assert!(h1 >= 28.0 && h2 >= 28.0, "both titles wrap: {h1}, {h2}"); + assert!(h1 >= 24.0 && h2 >= 24.0, "both titles wrap: {h1}, {h2}"); assert!( (t1 - b1 - (h1 + 2.0)).abs() < 0.5, "row 1 is its wrapped title + 2: got {}, title {h1}", @@ -698,14 +786,18 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit its menus build from @@ -737,10 +829,15 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() { )], ..QuestState::default() })); - s.fire_event( - "QUEST_COMPLETE", - vec![ScriptValue::Str("Marshal McBride".into())], + s.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Marshal McBride".into()), + ..Default::default() + }), ); + s.fire_event("QUEST_COMPLETE", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); // The regression this fork could break: a PLAIN click still picks the choice (the ref's third @@ -748,7 +845,8 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() { // move it if they leaked into the select arm. s.run("QuestRewardItem2:Click()").unwrap(); assert_eq!( - s.eval::("return QuestFrame.itemChoice").unwrap(), + s.eval::("return QuestFrameRewardPanel.itemChoice") + .unwrap(), 2, "a plain click still selects the reward choice" ); @@ -772,7 +870,8 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() { "shift-click posted choice 1's link" ); assert_eq!( - s.eval::("return QuestFrame.itemChoice").unwrap(), + s.eval::("return QuestFrameRewardPanel.itemChoice") + .unwrap(), 2, "the shift arm returns — it must NOT also select the clicked choice" ); @@ -803,7 +902,8 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() { "ctrl-click opened the room wearing choice 1" ); assert_eq!( - s.eval::("return QuestFrame.itemChoice").unwrap(), + s.eval::("return QuestFrameRewardPanel.itemChoice") + .unwrap(), 2, "the ctrl arm returns — it must NOT also select the clicked choice" ); diff --git a/crates/benilla-app/src/ui_script/quest_timer_tests.rs b/crates/benilla-app/src/ui_script/quest_timer_tests.rs index 67b27fff..6f18c5d7 100644 --- a/crates/benilla-app/src/ui_script/quest_timer_tests.rs +++ b/crates/benilla-app/src/ui_script/quest_timer_tests.rs @@ -37,8 +37,11 @@ fn harness() -> UiScript { load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); load_xml(&s, "Interface\\FrameXML\\QuestTimerFrame.xml"); s } diff --git a/crates/benilla-app/src/ui_script/questlog_tests.rs b/crates/benilla-app/src/ui_script/questlog_tests.rs index 221b20e9..15bf7fd8 100644 --- a/crates/benilla-app/src/ui_script/questlog_tests.rs +++ b/crates/benilla-app/src/ui_script/questlog_tests.rs @@ -71,6 +71,7 @@ fn eight_entries() -> QuestLogState { // lands, so the fixture carries that exact shape. link: Some(HAMMER_LINK.into()), }], + reward_spell: None, }), } } @@ -85,6 +86,7 @@ fn shipped_questlog_frame_loads_clean() { let s = UiScript::new().unwrap(); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -95,9 +97,12 @@ fn shipped_questlog_frame_loads_clean() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); } /// The whole contract in one end-to-end drive: open plays the kit and renders row 1 + the count + @@ -111,6 +116,7 @@ fn shipped_questlog_frame_drives_end_to_end() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -121,9 +127,12 @@ fn shipped_questlog_frame_drives_end_to_end() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(eight_entries()); @@ -147,7 +156,7 @@ fn shipped_questlog_frame_drives_end_to_end() { // Row 1 carries entry 1's title (indented), the count line reads "Quests: |cffffffff8/20|r", and nothing having been // selected, the first non-header entry auto-selects (pin §2's SetFirstValidSelection). assert!(s - .eval::("return QuestLogTitle1Text:GetText()") + .eval::("return QuestLogTitle1NormalText:GetText()") .unwrap() .contains("Quest 1")); assert_eq!( @@ -194,10 +203,12 @@ fn shipped_questlog_frame_drives_end_to_end() { .unwrap(), "You will receive:" ); - assert_eq!( - s.eval::("return QuestLogItemChooseText:GetText()") + // …hidden, not blanked: stock QuestFrameItems_Update Show()s/Hide()s the choose text + // (QuestFrame.lua:454-473) and leaves its string alone. + assert!( + !s.eval::("return QuestLogItemChooseText:IsShown()") .unwrap(), - "" + "no choices: the choose text is hidden" ); // No choices, so the pool's first slot IS the fixed reward and the SECOND is the first // unused one. (One pool now — the ref's shape; there is no separate "choice row 1".) @@ -220,7 +231,7 @@ fn shipped_questlog_frame_drives_end_to_end() { }) .expect("a 'Quest 3' row text quad"); let before = s - .eval::("return QuestLogTitle1Text:GetText()") + .eval::("return QuestLogTitle1NormalText:GetText()") .unwrap(); s.mouse_wheel(wx, wy, -1.0); assert!(s.errors().is_empty(), "wheel errors: {:?}", s.errors()); @@ -230,7 +241,7 @@ fn shipped_questlog_frame_drives_end_to_end() { // The migration reverts our step; how far a notch should travel is a look call, recorded in // 1846 rather than re-authored back in. assert_ne!( - s.eval::("return QuestLogTitle1Text:GetText()") + s.eval::("return QuestLogTitle1NormalText:GetText()") .unwrap(), before, "wheel-down scrolled the list" @@ -240,7 +251,7 @@ fn shipped_questlog_frame_drives_end_to_end() { // Abandon, No path: marks the selection, shows the registry's ABANDON_QUEST entry on the // shared StaticPopup engine (decision 0308 §3), but No drains nothing and hides it. - s.run("BenillaQuestLogAbandonButton_OnClick()").unwrap(); + s.run("QuestLogFrameAbandonButton:Click()").unwrap(); assert!(s.eval::("return StaticPopup1:IsShown()").unwrap()); assert_eq!( s.eval::("return GetAbandonQuestName()").unwrap(), @@ -269,7 +280,7 @@ fn shipped_questlog_frame_drives_end_to_end() { ); // Abandon, Yes path: the pinned index (1, the selection at click time) drains, plus the kit. - s.run("BenillaQuestLogAbandonButton_OnClick()").unwrap(); + s.run("QuestLogFrameAbandonButton:Click()").unwrap(); s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap(); assert_eq!(s.take_quest_log_abandons(), vec![1]); // Show → igMainMenuOpen; the ref OnClick runs OnAccept FIRST (the abandon kit), THEN hides @@ -306,8 +317,12 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); + // Stock seats the watch check at update time off `questNormalText:GetWidth()` (QuestLogFrame.lua:205-215), + // synchronously — the app's measure seam is synchronous too (measure.rs); its stand-in here. + s.set_text_measurer(Box::new(super::FixedWidthFont(6.0))); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -318,11 +333,23 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + // A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()` + // (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944). + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); + load_xml(&s, "ChatFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(eight_entries()); + // The app fires QUEST_LOG_UPDATE when the log lands; stock QuestLog_OnEvent's arm runs the + // first QuestWatch_Update, which is what hides an empty tracker. + s.fire_event("QUEST_LOG_UPDATE", vec![]); s.run("ToggleQuestLog()").unwrap(); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); @@ -403,9 +430,12 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() { .unwrap(), " - Kobold Vermin slain: 3/10" ); - // A manual watch is permanent: no auto-watch timer entry rides it. + // A manual watch is permanent: stock's shift-click inserts it with QUEST_WATCH_NO_EXPIRE + // (QuestLogFrame.lua:496), the timer that AutoQuestWatch_OnUpdate never counts down. assert!(s - .eval::("return BENILLA_QUEST_WATCH_TIMERS[\"Quest 1\"] == nil") + .eval::( + "return QUEST_WATCH_LIST[1] ~= nil and QUEST_WATCH_LIST[1].timer == QUEST_WATCH_NO_EXPIRE" + ) .unwrap()); // Shift-click again unwatches: the checkbox clears and — nothing left watched — the whole HUD @@ -433,6 +463,7 @@ fn watch_guards_no_op_without_erroring() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -444,9 +475,18 @@ fn watch_guards_no_op_without_erroring() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + // A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()` + // (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944). + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); + load_xml(&s, "ChatFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(eight_entries()); s.run("ToggleQuestLog()").unwrap(); @@ -482,8 +522,9 @@ fn watch_guards_no_op_without_erroring() { ); assert_eq!( s.eval::("return GetQuestLogSelection()").unwrap(), - 2, - "the click still selects" + 1, + "a shift-click never selects — stock QuestLogTitleButton_OnClick's shift branch returns \ + before the plain-click arm's QuestLog_SetSelection (QuestLogFrame.lua:472-500)" ); // Fill the watch list to the cap (5) with quests 2-6 via the Lua API directly — the engine-level @@ -532,6 +573,7 @@ fn progress_auto_watches_for_five_minutes() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -542,14 +584,22 @@ fn progress_auto_watches_for_five_minutes() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + // AUTO_QUEST_WATCH is the options window's (UIOptionsFrame_Init in the reference; 1944), so the + // auto-watch needs that window loaded, as the manifest has it long before the log. + load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); + load_xml(&s, "KeyBindingsPage.xml"); + load_xml(&s, "OptionsFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(eight_entries()); // A kill credit lands: the engine fires the event with the quest's 1-based log index. s.fire_event( - "BENILLA_QUEST_PROGRESS", + "QUEST_WATCH_UPDATE", vec![benilla_ui::script::ScriptValue::Int(1)], ); assert!(s.errors().is_empty(), "auto-watch errors: {:?}", s.errors()); @@ -557,15 +607,20 @@ fn progress_auto_watches_for_five_minutes() { assert!(s .eval::("return QuestWatchFrame:IsVisible()") .unwrap()); - assert!(s - .eval::("return BENILLA_QUEST_WATCH_TIMERS[\"Quest 1\"] ~= nil") - .unwrap()); + assert!( + s.eval::( + "return QUEST_WATCH_LIST[1] ~= nil and QUEST_WATCH_LIST[1].index == 1 \ + and QUEST_WATCH_LIST[1].timer == MAX_QUEST_WATCH_TIMER" + ) + .unwrap(), + "the reference's QUEST_WATCH_LIST carries the armed timer (QuestLogFrame.lua:752-769)" + ); // 299 s in it still holds; fresh progress re-arms the timer; expiry unwatches and hides. s.tick(299.0); assert!(s.eval::("return IsQuestWatched(1)").unwrap()); s.fire_event( - "BENILLA_QUEST_PROGRESS", + "QUEST_WATCH_UPDATE", vec![benilla_ui::script::ScriptValue::Int(1)], ); s.tick(299.0); @@ -599,25 +654,35 @@ fn the_auto_watch_flag_is_the_references_uvar_and_gates_the_watch() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); + // The flag is declared by the options window's init since 1944, as the reference declares it + // in UIOptionsFrame_Init — so the window that owns it loads first, as in the manifest. + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); + load_xml(&s, "ScrollTemplates.xml"); + load_xml(&s, "KeyBindingsPage.xml"); + load_xml(&s, "OptionsFrame.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits // UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error — // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(eight_entries()); assert_eq!(s.eval::("return AUTO_QUEST_WATCH").unwrap(), "1"); s.run(r#"AUTO_QUEST_WATCH = "0""#).unwrap(); s.fire_event( - "BENILLA_QUEST_PROGRESS", + "QUEST_WATCH_UPDATE", vec![benilla_ui::script::ScriptValue::Int(1)], ); assert!( @@ -627,7 +692,7 @@ fn the_auto_watch_flag_is_the_references_uvar_and_gates_the_watch() { s.run(r#"AUTO_QUEST_WATCH = "1""#).unwrap(); s.fire_event( - "BENILLA_QUEST_PROGRESS", + "QUEST_WATCH_UPDATE", vec![benilla_ui::script::ScriptValue::Int(1)], ); assert!(s.eval::("return IsQuestWatched(1)").unwrap()); @@ -658,8 +723,12 @@ fn the_row_tag_is_its_own_right_flush_string_and_the_state_word_wins() { load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); let mut state = eight_entries(); // Row 1: a plain elite quest. Row 2: an elite quest that is COMPLETE — the state word wins. @@ -695,11 +764,11 @@ fn the_row_tag_is_its_own_right_flush_string_and_the_state_word_wins() { // The title is the bare (indented) name — the state word lives on the tag string now, so // appending it here too would double it. let title1 = s - .eval::("return QuestLogTitle1Text:GetText()") + .eval::("return QuestLogTitle1NormalText:GetText()") .unwrap(); assert_eq!(title1, " Quest 1"); let title2 = s - .eval::("return QuestLogTitle2Text:GetText()") + .eval::("return QuestLogTitle2NormalText:GetText()") .unwrap(); assert_eq!(title2, " Quest 2", "no \"(Complete)\" on the title"); @@ -722,6 +791,7 @@ fn empty_quest_log_hides_rows_and_disables_abandon() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -732,9 +802,12 @@ fn empty_quest_log_hides_rows_and_disables_abandon() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(QuestLogState::default()); s.run("ToggleQuestLog()").unwrap(); @@ -742,22 +815,24 @@ fn empty_quest_log_hides_rows_and_disables_abandon() { // A bare FontString region has no Show/Hide/IsVisible in this engine (only Frames/Buttons do — // `region.rs`'s method table) — the empty-state message is a toggled SetText, read back here. + // The empty log says the reference's QUESTLOG_NO_QUESTS_TEXT (GlobalStrings.lua:3225) through + // stock QuestLogNoQuestsText; "Your quest log is empty." was ours (1944). assert_eq!( s.eval::("return QuestLogNoQuestsText:GetText()") .unwrap(), - "Your quest log is empty." + "No Active Quests" ); assert!(!s.eval::("return QuestLogTitle1:IsVisible()").unwrap()); assert!(!s .eval::("return QuestLogFrameAbandonButton:IsEnabled() ~= 0") .unwrap()); - // The Description header is now Lua-managed (BenillaQuestLogDetail_Clear blanks it) rather than - // a static `text=` — it used to float "Description" over the empty-log parchment with no - // selection (this task's fix; QuestLogFrame.xml's header comment on the FontString). - assert_eq!( - s.eval::("return QuestLogDescriptionTitle:GetText()") + // With no selection the reference hides the whole detail pane (stock + // `QuestLog_UpdateQuestDetails`: `QuestLogDetailScrollFrame:Hide()`), header included — it + // keeps its static `text="QUEST_DESCRIPTION"`; ours used to blank the header instead (1944). + assert!( + !s.eval::("return QuestLogDetailScrollFrame:IsVisible()") .unwrap(), - "" + "no selection: the detail pane is hidden, header and all" ); assert_eq!( s.eval::("return QuestLogQuestCount:GetText()") @@ -777,6 +852,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -787,9 +863,12 @@ fn reward_rows_follow_the_refs_two_per_row_layout() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); let mut state = eight_entries(); state.detail = Some(QuestLogDetail { @@ -826,6 +905,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() { usable: true, ..Default::default() }], + reward_spell: None, }); s.set_quest_log(state); s.run("ToggleQuestLog()").unwrap(); @@ -850,7 +930,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() { assert_eq!( s.eval::("return QuestLogItemChooseText:GetText()") .unwrap(), - "You may choose one of these rewards:" + "You will be able to choose one of these rewards:" ); // With choices present, the receive text reads "...also..." (QuestFrame.lua:461-462) — it @@ -886,6 +966,24 @@ fn reward_rows_follow_the_refs_two_per_row_layout() { /// ResizeChild`) well past the pane — load-bearing for the scroll/clip tests below even /// engine-only (no font atlas, so every auto-height FontString measures 0 here — this fixture /// overflows on the FIXED-height portions alone: 10×12px objective rows + the reward row). +/// The measure round-trip the app's resolve pass drives with its font engine: answer every +/// FontString the layout asked about with the 6px-per-glyph, 12px-per-line stand-in the +/// synchronous `FixedWidthFont(6.0)` uses, so the two agree. +fn answer_measures(s: &mut UiScript) { + let answers: Vec<(u32, f32, f32, u64)> = s + .fontstrings_needing_measure() + .into_iter() + .map(|r| { + let ink = r.text.chars().count() as f32 * 6.0; + match r.wrap_width { + Some(w) if w > 0.0 && ink > w => (r.id, w, (ink / w).ceil() * 12.0, r.key), + _ => (r.id, ink, 12.0, r.key), + } + }) + .collect(); + s.set_measured_text_unwrapped(&answers); +} + fn overflowing_entry() -> QuestLogState { let objectives = (1..=10) .map(|i| QuestLogObjectiveView { @@ -921,6 +1019,7 @@ fn overflowing_entry() -> QuestLogState { usable: true, ..Default::default() }], + reward_spell: None, }), } } @@ -938,6 +1037,7 @@ fn overflowing_detail_content_clips_to_the_scrollframe_rect() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -948,9 +1048,12 @@ fn overflowing_detail_content_clips_to_the_scrollframe_rect() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(overflowing_entry()); s.run("ToggleQuestLog()").unwrap(); @@ -1002,8 +1105,10 @@ fn wheel_over_the_detail_pane_changes_vertical_scroll() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); + s.set_text_measurer(Box::new(super::FixedWidthFont(6.0))); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -1014,13 +1119,21 @@ fn wheel_over_the_detail_pane_changes_vertical_scroll() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(overflowing_entry()); s.run("ToggleQuestLog()").unwrap(); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + // The detail pane's scroll range is the union of its measured content (0128): the layout's + // measure round-trip has to be answered, as the app's resolve pass answers it every frame. + s.resolve(); + answer_measures(&mut s); + s.resolve(); // the measured span reaches the scroll range, and the bar, on this pass s.resolve(); let quads = s.extract(); @@ -1053,8 +1166,10 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not let _data = benilla_formats::wow_data_or_skip!(); let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); + s.set_text_measurer(Box::new(super::FixedWidthFont(6.0))); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -1065,16 +1180,27 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(overflowing_entry()); s.run("ToggleQuestLog()").unwrap(); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + // The detail pane's scroll range is the union of its measured content (0128): the layout's + // measure round-trip has to be answered, as the app's resolve pass answers it every frame. + s.resolve(); + answer_measures(&mut s); + s.resolve(); // the measured span reaches the scroll range, and the bar, on this pass s.resolve(); // GetVerticalScrollRange (SetVerticalScroll's clamp) reads resolved rects. - s.run("QuestLogDetailScrollFrame:SetVerticalScroll(10)") + // Scroll THROUGH the bar, as the wheel and the thumb do (ScrollFrameTemplate_OnMouseWheel sets + // the bar; its OnValueChanged moves the frame): a reselect's `ScrollBar:SetValue(0)` is what + // snaps the pane back, and a bar already at 0 would fire no change over a frame-only scroll. + s.run("QuestLogDetailScrollFrameScrollBar:SetValue(10)") .unwrap(); assert_eq!( s.eval::("return QuestLogDetailScrollFrame:GetVerticalScroll()") @@ -1093,8 +1219,7 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not ); // A manual reselect (the row-click path — no doNotScroll) DOES reset it. - s.run(r#"BenillaQuestLogTitle_OnClick(QuestLogTitle1, "LeftButton")"#) - .unwrap(); + s.run("QuestLogTitle1:Click()").unwrap(); assert!(s.errors().is_empty(), "click errors: {:?}", s.errors()); assert_eq!( s.eval::("return QuestLogDetailScrollFrame:GetVerticalScroll()") @@ -1117,6 +1242,7 @@ fn reward_row_hover_serves_the_shared_item_tooltip() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -1132,9 +1258,12 @@ fn reward_row_hover_serves_the_shared_item_tooltip() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(eight_entries()); s.run("ToggleQuestLog()").unwrap(); @@ -1264,6 +1393,7 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -1274,9 +1404,15 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + // A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()` + // (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944). + load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit its menus build from @@ -1349,6 +1485,7 @@ fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_i s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -1359,9 +1496,15 @@ fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_i // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + // A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()` + // (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944). + load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit its menus build from load_xml(&s, "ChatFrame.xml"); // ChatFrameEditBox lives here @@ -1445,6 +1588,7 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -1455,9 +1599,12 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); // Solo, quest 1 sharable and selected by the auto-selection. s.set_quest_log(entries_sharable(&[1])); @@ -1489,7 +1636,7 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() { // Select an unsharable quest: party unchanged, button goes dark. s.run("SelectQuestLogEntry(2)").unwrap(); - s.run("BenillaQuestLogFrame_Update()").unwrap(); + s.run("QuestLog_Update()").unwrap(); assert!( s.eval::("return GetQuestLogPushable() == nil") .unwrap(), @@ -1503,7 +1650,7 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() { // Leaving the party darkens it again from the other side. s.run("SelectQuestLogEntry(1)").unwrap(); - s.run("BenillaQuestLogFrame_Update()").unwrap(); + s.run("QuestLog_Update()").unwrap(); assert!(s .eval::("return QuestFramePushQuestButton:IsEnabled() ~= 0") .unwrap()); @@ -1524,6 +1671,7 @@ fn share_quest_is_dark_on_an_empty_log_even_in_a_party() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -1534,9 +1682,12 @@ fn share_quest_is_dark_on_an_empty_log_even_in_a_party() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(QuestLogState::default()); s.set_party(party(4)); @@ -1557,6 +1708,7 @@ fn share_quest_click_queues_the_selected_quests_id() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -1567,16 +1719,19 @@ fn share_quest_click_queues_the_selected_quests_id() { // an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar // silently. load_xml(&s, "ScrollTemplates.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(entries_sharable(&[1, 5])); s.set_party(party(1)); s.run("ToggleQuestLog()").unwrap(); s.run("SelectQuestLogEntry(5)").unwrap(); - s.run("BenillaQuestLogFrame_Update()").unwrap(); + s.run("QuestLog_Update()").unwrap(); s.run("QuestFramePushQuestButton:Click()").unwrap(); assert_eq!(s.take_quest_log_pushes(), vec![5]); assert!(s.take_quest_log_pushes().is_empty(), "drained"); diff --git a/crates/benilla-app/src/ui_script/raid_tests.rs b/crates/benilla-app/src/ui_script/raid_tests.rs index f176faa4..2e2e3882 100644 --- a/crates/benilla-app/src/ui_script/raid_tests.rs +++ b/crates/benilla-app/src/ui_script/raid_tests.rs @@ -26,6 +26,7 @@ fn setup() -> UiScript { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "UIParent.xml"); load_xml(&s, "GameTooltip.xml"); @@ -40,7 +41,6 @@ fn setup() -> UiScript { // drags in the whole unit-frame cluster — and it must be the REAL frames: loading only // `PartyFrame.lua` for the two names gets "attempt to index a nil value" the first time one // iterates. The manifest already seats these far above RaidFrame (1874). - load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); // The reference's own `BasicControls.xml`, which is manifest entry 3 — far above the pane // (1874). `UIParentLoadAddOn` used to be what this line was here for; 1881 moved that // function to `UIParent.xml`, its home in the reference, and this harness already loads diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index 195f4941..61d88e2e 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -2047,6 +2047,12 @@ mod tests { ("SHOW_COMPARE_TOOLTIP", "PaperDollFrame.lua"), ("SYSMSG", "UIErrorsFrame.lua"), ("UNIT_DEFENSE", "PetPaperDollFrame.lua"), + ( + "UNIT_QUEST_LOG_CHANGED", + "QuestLogFrame.lua — a party member's quest-log fields changing (the reference \ + fires it off the unit's PLAYER_QUEST_LOG_* descriptor updates); benilla's unit \ + feed does not derive it yet (1944)", + ), ( "UNIT_MODEL_CHANGED", "four files — the paperdoll model refresh", @@ -2115,10 +2121,20 @@ mod tests { } let lines: Vec<&str> = text.lines().map(str::trim).collect(); for (i, t) in lines.iter().enumerate() { + // …or the value of a `match` arm (`QuestPanel::Progress => + // "QUEST_PROGRESS",` — `ui_quest.rs`'s `panel_event`). + let (t, arm_value) = match t.find("=> \"") { + Some(k) => (t[k + 3..].trim_end_matches(','), true), + None => (*t, false), + }; let Some(lit) = t.strip_prefix('"').and_then(|x| x.strip_suffix('"')) else { continue; }; + if arm_value && caps(lit) { + fired.insert(lit.to_string()); + continue; + } let arm = i > 0 && lines[i - 1].ends_with('{') && lines.get(i + 1).is_some_and(|n| n.starts_with('}')); diff --git a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs index 12e32535..7a8274be 100644 --- a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs +++ b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs @@ -30,7 +30,7 @@ fn every_shipped_ui_xml_parses() { // Never let the sweep pass by finding nothing — a moved assets dir would otherwise turn this // into a test that guards zero files while staying green. assert!( - checked >= 40, + checked >= 38, // 41 until 1944 took the two quest files stock "only {checked} xml files swept — sweep broke" ); } @@ -753,7 +753,7 @@ fn no_shipped_script_sets_a_global_string_key_as_display_text() { } assert!(offenders.is_empty(), "{}", offenders.join("\n")); // The sweep must never pass by finding nothing to sweep. - assert!(swept >= 40, "only {swept} xml files swept — sweep broke"); + assert!(swept >= 38, "only {swept} xml files swept — sweep broke"); } /// **The `$parentTextureFrame` idiom's contract, over the whole shipped UI**: a frame whose art is diff --git a/crates/benilla-app/src/ui_script/spellbook_tests.rs b/crates/benilla-app/src/ui_script/spellbook_tests.rs index 9a5181ef..ae651c47 100644 --- a/crates/benilla-app/src/ui_script/spellbook_tests.rs +++ b/crates/benilla-app/src/ui_script/spellbook_tests.rs @@ -121,6 +121,7 @@ fn shipped_spellbook_drives_end_to_end() { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); @@ -130,7 +131,6 @@ fn shipped_spellbook_drives_end_to_end() { load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua"); load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml"); load_xml(&s, "UIParent.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml"); load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml"); load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml"); diff --git a/crates/benilla-app/src/ui_script/taxi_tests.rs b/crates/benilla-app/src/ui_script/taxi_tests.rs index 9e7b1c11..eb5debf1 100644 --- a/crates/benilla-app/src/ui_script/taxi_tests.rs +++ b/crates/benilla-app/src/ui_script/taxi_tests.rs @@ -16,6 +16,7 @@ fn taxi_script() -> UiScript { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); // TaxiNodeOnButtonEnter's tooltip + SetTooltipMoney load_xml(&s, "Interface\\FrameXML\\UIErrorsFrame.xml"); // BenillaErrorsFrame_AddMessage — DrawOneHopLines' refusal @@ -25,7 +26,6 @@ fn taxi_script() -> UiScript { // · UIPanelTemplates (.lua then .xml) — `TaxiCloseButton` inherits `UIPanelCloseButton`, // which lives there and NOT in our UiPanels.xml. Without it the close button loads as a // bare Button with no handler and a click does nothing at all. - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua"); load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\TaxiFrame.xml"); diff --git a/crates/benilla-app/src/ui_script/trainer_tests.rs b/crates/benilla-app/src/ui_script/trainer_tests.rs index 2674fc28..9c9558e2 100644 --- a/crates/benilla-app/src/ui_script/trainer_tests.rs +++ b/crates/benilla-app/src/ui_script/trainer_tests.rs @@ -26,6 +26,7 @@ fn trainer_script() -> UiScript { s.set_screen_size(1024.0, 768.0); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); @@ -33,7 +34,6 @@ fn trainer_script() -> UiScript { load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // the filter dropdown's kit load_xml(&s, "ScrollTemplates.xml"); // the faux-scroll bar kit load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // the merchant kit; the money frames are MoneyFrame.xml's (1937) - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "TrainerFrame.xml"); s.run( "TRAINER_FILTER_AVAILABLE = 1 TRAINER_FILTER_UNAVAILABLE = 1 TRAINER_FILTER_USED = 1 \ diff --git a/crates/benilla-app/src/ui_script/unit_frame_tests.rs b/crates/benilla-app/src/ui_script/unit_frame_tests.rs index a32e12eb..08e086ae 100644 --- a/crates/benilla-app/src/ui_script/unit_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/unit_frame_tests.rs @@ -646,13 +646,19 @@ fn shipped_target_frame_runs_the_level_law() { load_unit_frames(&s); // GetDifficultyColor's own load chain (the quest log window, its ref home). load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "UiPanels.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); - load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); - load_xml(&s, "QuestFrame.xml"); - load_xml(&s, "QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, "UIParent.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); + load_xml(&s, "MicroMenu.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); // The player at level 3, both feeds (the snapshot UnitLevel("player") reads; the req state // the −1 gate and GetQuestGreenRange read) — the app keeps the two in step. s.set_player_req_state(PlayerReqState { diff --git a/crates/benilla-app/src/ui_script/unit_popup_tests.rs b/crates/benilla-app/src/ui_script/unit_popup_tests.rs index 8a03bf8e..0b45350d 100644 --- a/crates/benilla-app/src/ui_script/unit_popup_tests.rs +++ b/crates/benilla-app/src/ui_script/unit_popup_tests.rs @@ -466,6 +466,7 @@ fn load_pet_menu_frames(s: &UiScript) { "Interface\\FrameXML\\Fonts.xml", "UIParent.xml", "MoneyFrame.xml", + "Interface\\FrameXML\\GlobalStrings.lua", "UiPanels.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", @@ -483,7 +484,6 @@ fn load_pet_menu_frames(s: &UiScript) { "Interface\\FrameXML\\PetFrame.xml", "Cooldown.xml", "Interface\\FrameXML\\ActionButtonTemplate.xml", - "Interface\\FrameXML\\GlobalStrings.lua", "Interface\\FrameXML\\MainMenuBar.xml", "Interface\\FrameXML\\ActionBarFrame.xml", "Interface\\FrameXML\\BonusActionBarFrame.xml", diff --git a/crates/benilla-app/tests/mail_frame.rs b/crates/benilla-app/tests/mail_frame.rs index cf329162..a07b2a3b 100644 --- a/crates/benilla-app/tests/mail_frame.rs +++ b/crates/benilla-app/tests/mail_frame.rs @@ -19,11 +19,11 @@ const FILES: [&str; 10] = [ // the `MoneyInputFrame_*` verbs. Seated straight after MoneyFrame.xml, benilla.toc's order. r"Interface\FrameXML\MoneyInputFrame.lua", r"Interface\FrameXML\MoneyInputFrame.xml", + "Interface\\FrameXML\\GlobalStrings.lua", "UiPanels.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", "GameTooltip.xml", - "Interface\\FrameXML\\GlobalStrings.lua", "MailFrame.xml", ]; diff --git a/crates/benilla-formats/src/page_text_material.rs b/crates/benilla-formats/src/page_text_material.rs index f70f2415..91a67f26 100644 --- a/crates/benilla-formats/src/page_text_material.rs +++ b/crates/benilla-formats/src/page_text_material.rs @@ -33,6 +33,14 @@ pub struct PageTextMaterialCatalog { } impl PageTextMaterialCatalog { + /// A catalog from `(id, basename)` rows — the shipped six are 1 Parchment · 2 Stone · 3 Marble + /// · 4 Silver · 5 Bronze · 6 Valentine (wow-re quest-material-reward-spell-bindings.md §1). + pub fn from_rows(rows: &[(u32, &str)]) -> Self { + Self { + by_id: rows.iter().map(|&(id, n)| (id, n.to_string())).collect(), + } + } + /// The basename for a material id, or `None` for `0`/an id the table doesn't carry — the /// reference's own `nil`, which `ItemTextFrame.lua` substitutes with "Parchment" (and then /// hides the corner art, since Parchment has none). diff --git a/crates/benilla-protocol/src/guid.rs b/crates/benilla-protocol/src/guid.rs index 0f838c57..9c16e366 100644 --- a/crates/benilla-protocol/src/guid.rs +++ b/crates/benilla-protocol/src/guid.rs @@ -72,6 +72,10 @@ pub fn is_transport(guid: u64) -> bool { matches!(high(guid), HIGH_MO_TRANSPORT | HIGH_TRANSPORT) } +pub fn is_gameobject(guid: u64) -> bool { + high(guid) == HIGH_GAMEOBJECT +} + /// The embedded template entry, for the guid families that carry one. Two different layouts, both /// VERIFIED against vmangos: /// diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 30d1e326..c10a7d72 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -207,7 +207,9 @@ pub use model::{TextureProbe, TextureSizeProbe}; pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo}; pub use pet::{PetActionView, PetStats}; pub use pvp::{HonorState, InspectHonorData}; -pub use quest::{QuestAction, QuestItemView, QuestPanel, QuestSelect, QuestState}; +pub use quest::{ + QuestAction, QuestItemView, QuestPanel, QuestRewardSpell, QuestSelect, QuestState, +}; pub use quest_log::{QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState}; pub(crate) use region::{apply_font_parts, implicit_creation_anchor_lua}; pub use reputation::{FactionEntry, ReputationSend, ReputationState}; diff --git a/crates/benilla-ui/src/script/quest.rs b/crates/benilla-ui/src/script/quest.rs index d0018a7e..b83d7c7e 100644 --- a/crates/benilla-ui/src/script/quest.rs +++ b/crates/benilla-ui/src/script/quest.rs @@ -68,6 +68,20 @@ pub struct QuestItemView { /// One open questgiver panel: the active sub-panel plus every field the four panels might read. /// Pushed whole by the app; `None` means no quest window is open. +/// The quest's reward spell — `rewSpell` on `SMSG_QUESTGIVER_QUEST_DETAILS` / +/// `SMSG_QUESTGIVER_OFFER_REWARD` and on `SMSG_QUEST_QUERY_RESPONSE` — resolved by the app to +/// the name and icon `GetRewardSpell` / `GetQuestLogRewardSpell` answer (stock +/// `QuestFrameItems_Update` counts it as one more reward slot, `rewardType = "spell"`, and the +/// slot's hover is `GameTooltip:SetQuestRewardSpell()`). `tradeskill` is the third return the +/// reference derives from the spell itself (1944 — the derivation is wow-re's to pin). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct QuestRewardSpell { + pub spell_id: u32, + pub name: Option, + pub texture: Option, + pub tradeskill: bool, +} + #[derive(Clone, Debug, PartialEq)] pub struct QuestState { pub panel: QuestPanel, @@ -95,6 +109,12 @@ pub struct QuestState { pub required_money: u32, /// Whether the turn-in is completable (`IsQuestCompletable`, progress panel). pub completable: bool, + /// The reward spell, if the quest teaches one (`GetRewardSpell`; detail/reward panels). + pub reward_spell: Option, + /// The panel's background material (`GetQuestBackgroundMaterial` — nil is the reference's own + /// "Parchment" fallback in `QuestFrame_GetMaterial`). What fills it on the wire is 1944's + /// wow-re question; until the binary says, nothing does, and the verb answers nil honestly. + pub background_material: Option, } impl Default for QuestState { @@ -113,6 +133,8 @@ impl Default for QuestState { reward_money: 0, required_money: 0, completable: false, + reward_spell: None, + background_material: None, } } } @@ -170,7 +192,36 @@ impl super::UiScript { } /// `GetQuestItemInfo(type, index)` reads from this vector by `type`; unknown types → `None`. -fn item_vec<'a>(state: &'a QuestState, kind: &str) -> Option<&'a Vec> { +/// The three returns of `GetRewardSpell` / `GetQuestLogRewardSpell`: `texture, name, +/// isTradeskillSpell` — the third a 1/nil boolean in the reference's convention. +pub(super) fn reward_spell_returns( + lua: &Lua, + spell: Option, +) -> mlua::Result { + let Some(sp) = spell else { + return Ok(MultiValue::from_vec(vec![ + Value::Nil, + Value::Nil, + Value::Nil, + ])); + }; + let texture = match &sp.texture { + Some(t) => Value::String(lua.create_string(t)?), + None => Value::Nil, + }; + let name = match &sp.name { + Some(n) => Value::String(lua.create_string(n)?), + None => Value::Nil, + }; + let tradeskill = if sp.tradeskill { + Value::Integer(1) + } else { + Value::Nil + }; + Ok(MultiValue::from_vec(vec![texture, name, tradeskill])) +} + +pub(super) fn item_vec<'a>(state: &'a QuestState, kind: &str) -> Option<&'a Vec> { match kind { "choice" => Some(&state.choices), "reward" => Some(&state.rewards), @@ -315,12 +366,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // GetRewardSpell() → nil (spell-reward rows out of scope in v1, decision 0088). + // GetRewardSpell() → texture, name, isTradeskillSpell (three returns, 0x501df0; three nils on + // every empty path, the reference's own `(nil,nil,nil)` kind — 1842). Real since 1944: the + // app resolves `rewSpell` off the giver packets; stock's `if ( GetRewardSpell() ) then` counts + // the slot. g.set( "GetRewardSpell", - // THREE values on every reachable path, and the reference's own kinds include - // `(nil,nil,nil)` — so the empty answer is three nils, not one (decision 1842). - lua.create_function(|_, ()| Ok((Value::Nil, Value::Nil, Value::Nil)))?, + lua.create_function(|lua, ()| { + let spell = { + let model = lua.app_data_ref::().expect("model app_data"); + model.quest.as_ref().and_then(|q| q.reward_spell.clone()) + }; + reward_spell_returns(lua, spell) + })?, )?; // ── Intents ─────────────────────────────────────────────────────────────────────────────────── @@ -357,6 +415,57 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { install_action(lua, "CloseQuest", super::QuestAction::Close)?; install_action(lua, "CompleteQuest", super::QuestAction::Continue)?; + // GetQuestBackgroundMaterial() → nil | string (0x502230; 0 args, 1 return). The reference's + // `QuestFrame_GetMaterial` (QuestFrame.lua:597-603) treats nil as "Parchment" and any string + // as the name of a material whose four `$parentMaterial*` quadrants and text colours + // (`GetMaterialTextColors`) the panel switches to. Answered from the pushed panel state. + g.set( + "GetQuestBackgroundMaterial", + lua.create_function(|lua, ()| { + let material = { + let model = lua.app_data_ref::().expect("model app_data"); + model + .quest + .as_ref() + .and_then(|q| q.background_material.clone()) + }; + match material { + Some(m) => Ok(Value::String(lua.create_string(&m)?)), + None => Ok(Value::Nil), + } + })?, + )?; + + // QuestChooseRewardError() — 0x5021a0, 0 args, 0 returns: the Complete button pressed with + // choices on offer and none picked (stock `QuestRewardCompleteButton_OnClick`, + // QuestFrame.lua:96-100). Sixteen bytes — `push 0x98; call 0x496720` — the game-error row + // `ERR_QUEST_MUST_CHOOSE` (wow-re quest-material-reward-spell-bindings.md §2): the row's sound + // cue `igQuestFailed` plays first, then kind 2 routes the GlobalStrings text through + // `UI_ERROR_MESSAGE` — synchronously, as SignalEvent is — and UIErrorsFrame prints it. The + // text is read from the same global the FrameXML shows, so a localised chain shows its own + // string; the shipped English is the fallback when no GlobalStrings is loaded. + g.set( + "QuestChooseRewardError", + lua.create_function(|lua, ()| { + { + let mut model = lua.app_data_mut::().expect("model app_data"); + model + .sound_queue + .push(super::SoundRequest::KitName("igQuestFailed".to_string())); + } + let text: String = lua + .globals() + .get::>("ERR_QUEST_MUST_CHOOSE")? + .unwrap_or_else(|| "You must choose a reward.".to_string()); + super::tick::fire_event_into( + lua, + "UI_ERROR_MESSAGE", + vec![super::ScriptValue::Str(text)], + ); + Ok(()) + })?, + )?; + // ConfirmAcceptQuest() — the escort confirm's Yes (ref StaticPopup.lua:731-733, raised by // QUEST_ACCEPT_CONFIRM). No argument and no quest id: the client answers whichever confirm it // is holding, so the engine counts the calls and the app supplies the id (decision 1733). @@ -369,16 +478,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // GetQuestReward(choice) — the reward panel's Complete button: queue the chosen choice index - // (Era passes the 1-based item id; the app maps it to the 0-based wire index). A quest with no - // choice rewards passes 0/nil. + // GetQuestReward(choice) — the reward panel's Complete button. FrameXML passes the 1-based row + // (`QuestFrameRewardPanel.itemChoice = this:GetID()`, stock QuestFrame.lua:97-101) and the + // wire wants the 0-based choice (`CMSG_QUESTGIVER_CHOOSE_REWARD`'s reward index — vmangos + // indexes `RewChoiceItemId[reward]` with it), so the conversion is the binding's, as it is the + // client's; our own Lua used to do the -1 itself (1944). A quest with no choice rewards + // passes 0, which stays 0. g.set( "GetQuestReward", lua.create_function(|lua, choice: Option| { let mut model = lua.app_data_mut::().expect("model app_data"); - model - .quest_actions - .push(super::QuestAction::Reward(choice.unwrap_or(0))); + model.quest_actions.push(super::QuestAction::Reward( + choice.unwrap_or(0).saturating_sub(1), + )); Ok(()) })?, )?; @@ -431,6 +543,43 @@ mod tests { } } + #[test] + fn background_material_answers_the_pushed_material_or_nil() { + let mut s = UiScript::new().unwrap(); + s.set_quest(Some(QuestState::default())); + assert!(s + .eval::("return GetQuestBackgroundMaterial() == nil") + .unwrap()); + s.set_quest(Some(QuestState { + background_material: Some("Stone".into()), + ..QuestState::default() + })); + assert_eq!( + s.eval::("return GetQuestBackgroundMaterial()") + .unwrap(), + "Stone" + ); + } + + #[test] + fn choose_reward_error_fires_the_error_event_with_the_global_string() { + let mut s = UiScript::new().unwrap(); + s.run( + "ERR_QUEST_MUST_CHOOSE = 'Pick one.' \ + local f = CreateFrame('Frame') f:RegisterEvent('UI_ERROR_MESSAGE') \ + f:SetScript('OnEvent', function() SEEN = arg1 end) \ + N = table.getn({QuestChooseRewardError()})", + ) + .unwrap(); + assert_eq!(s.eval::("return SEEN").unwrap(), "Pick one."); + assert_eq!(s.eval::("return N").unwrap(), 0, "zero return values"); + assert_eq!( + s.take_sounds(), + vec![crate::script::SoundRequest::KitName("igQuestFailed".into())], + "row 0x98's cue plays with the message" + ); + } + #[test] fn detail_panel_getters_read() { let mut s = UiScript::new().unwrap(); @@ -572,14 +721,14 @@ mod tests { assert_eq!(s.take_quest_actions(), vec![QuestAction::Accept]); s.run("CompleteQuest()").unwrap(); // progress -> reward - s.run("GetQuestReward(2)").unwrap(); // choose reward index 2 + s.run("GetQuestReward(2)").unwrap(); // row 2 (1-based) → wire choice 1 (0-based) s.run("GetQuestReward()").unwrap(); // no-choice quest -> 0 s.run("DeclineQuest()").unwrap(); assert_eq!( s.take_quest_actions(), vec![ QuestAction::Continue, - QuestAction::Reward(2), + QuestAction::Reward(1), QuestAction::Reward(0), QuestAction::Close, ] diff --git a/crates/benilla-ui/src/script/quest_log.rs b/crates/benilla-ui/src/script/quest_log.rs index 6801197e..a8bd15a5 100644 --- a/crates/benilla-ui/src/script/quest_log.rs +++ b/crates/benilla-ui/src/script/quest_log.rs @@ -137,6 +137,9 @@ pub struct QuestLogDetail { pub choices: Vec, /// Fixed rewards (`GetNumQuestLogRewards`/`GetQuestLogRewardInfo`). pub rewards: Vec, + /// The selected quest's reward spell (`rewSpell` on `SMSG_QUEST_QUERY_RESPONSE`), as + /// `GetQuestLogRewardSpell` answers it. + pub reward_spell: Option, } /// A quest-log reward row is the same shape as a questgiver panel row. @@ -687,11 +690,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { )?; // ── v1 stubs (each the seam of a named later slice — see the module doc) ───────────────────── + // GetQuestLogRewardSpell() → texture, name, isTradeskillSpell (0x4e1130): the selected + // quest's reward spell, three nils when it has none. g.set( "GetQuestLogRewardSpell", - // THREE values on every reachable path, and the reference's own kinds include - // `(nil,nil,nil)` — so the empty answer is three nils, not one (decision 1842). - lua.create_function(|_, ()| Ok((Value::Nil, Value::Nil, Value::Nil)))?, + lua.create_function(|lua, ()| { + let spell = { + let model = lua.app_data_ref::().expect("model app_data"); + model + .quest_log + .detail + .as_ref() + .and_then(|d| d.reward_spell.clone()) + }; + super::quest::reward_spell_returns(lua, spell) + })?, )?; g.set( "IsUnitOnQuest", @@ -874,6 +887,7 @@ mod tests { usable: true, link: Some("|cffffffff|Hitem:2024:0:0:0|h[Militia Hammer]|h|r".into()), }], + reward_spell: None, }), } } diff --git a/crates/benilla-ui/src/script/scrollframe.rs b/crates/benilla-ui/src/script/scrollframe.rs index 9377fc2b..4c03a7c2 100644 --- a/crates/benilla-ui/src/script/scrollframe.rs +++ b/crates/benilla-ui/src/script/scrollframe.rs @@ -130,6 +130,29 @@ fn subtree_span(model: &Model, f: FrameHandle) -> Option<(f32, f32)> { span } +/// Every FontString under `f` (its own regions, then its visible children's), in tree order. +fn subtree_font_strings(model: &Model, f: FrameHandle) -> Vec { + let mut out = Vec::new(); + let Some(frame) = model.arena.frame(f) else { + return out; + }; + for &rh in &frame.regions { + if model + .arena + .region(rh) + .is_some_and(|r| matches!(r.kind, crate::widget::RegionKind::FontString)) + { + out.push(rh); + } + } + for &ch in &frame.children { + if model.arena.frame(ch).is_some_and(|c| c.effective_visible) { + out.extend(subtree_font_strings(model, ch)); + } + } + out +} + pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let m = lua.create_table()?; @@ -229,6 +252,26 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "UpdateScrollChildRect", lua.create_function(|lua, this: Table| { let h = frame_handle_of(lua, &this)?; + // The reference derives the range from a child whose text is already laid out — its + // measure is synchronous. Ours measures on the metric reads and on the host's + // round-trip, which lands a frame later; stock `QuestLog_UpdateQuestDetails` calls + // this right after the SetTexts that fill the pane, and read a range of 0 (1944). So + // the child's FontStrings are measured here, through the installed measurer, before + // the layout that the range reads; with no measurer installed the round-trip fills + // them as before. + let strings: Vec = { + let model = lua.app_data_ref::().expect("model app_data"); + match model.arena.frame(h).map(|f| &f.kind_state) { + Some(KindState::Scroll(sf)) => sf + .child + .map(|c| subtree_font_strings(&model, c)) + .unwrap_or_default(), + _ => Vec::new(), + } + }; + for rh in strings { + super::measure::ensure_measured(lua, rh); + } let (id, range) = { let mut model = lua.app_data_mut::().expect("model app_data"); super::UiScript::resolve_layout(&mut model); diff --git a/crates/benilla-ui/src/script/tests/scrollframe.rs b/crates/benilla-ui/src/script/tests/scrollframe.rs index 0e49b1e8..d9951e13 100644 --- a/crates/benilla-ui/src/script/tests/scrollframe.rs +++ b/crates/benilla-ui/src/script/tests/scrollframe.rs @@ -490,3 +490,43 @@ fn hit_test_denies_a_button_clipped_out_and_admits_it_once_scrolled_into_view() "scrolled into view, the button hits again" ); } + +/// `UpdateScrollChildRect` right after the SetTexts that fill a pane reads a range that already +/// counts the wrapped text — the reference's measure is synchronous, and with a font engine +/// installed so is ours (1944; stock `QuestLog_UpdateQuestDetails` is the caller that found it). +#[test] +fn update_scroll_child_rect_measures_the_childs_text_before_it_reads_the_range() { + struct Rows; + impl crate::script::TextMeasure for Rows { + fn measure(&mut self, req: &crate::script::MeasureRequest) -> (f32, f32, f32) { + let natural = req.text.chars().count() as f32 * 7.0; + match req.wrap_width { + Some(w) if w > 0.0 && natural > w => (w, (natural / w).ceil() * 14.0, natural), + _ => (natural, 14.0, natural), + } + } + } + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.set_text_measurer(Box::new(Rows)); + s.run( + r#" + local sf = CreateFrame("ScrollFrame", "SF", UIParent) + sf:SetPoint("TOPLEFT", 0, 0); sf:SetSize(100, 50) + local child = CreateFrame("Frame", "SFChild", sf) + child:SetSize(100, 50); sf:SetScrollChild(child) + local fs = child:CreateFontString("SFText", "ARTWORK") + fs:SetPoint("TOPLEFT", 0, 0); fs:SetWidth(70) + sf:SetScript("OnScrollRangeChanged", function() RANGE = arg2 end) + fs:SetText(string.rep("x", 40)) -- 280px of ink over a 70px column: four 14px rows + sf:UpdateScrollChildRect() + "#, + ) + .unwrap(); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + assert_eq!( + s.eval::("return RANGE").unwrap(), + 6.0, + "56px of measured text under a 50px frame: the range is known at the call, not a frame later" + ); +} diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/set.rs b/crates/benilla-ui/src/script/tests/tooltip_item/set.rs index 260d261b..d853536c 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/set.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/set.rs @@ -166,3 +166,67 @@ fn item_set_block_counts_and_colors() { assert_eq!(s.take_item_set_asks(), vec![1], "the set ask recorded"); assert!(s.take_errors().is_empty()); } + +/// `GameTooltip:SetQuestItem(type, index)` and `SetQuestLogItem(type, index)` — the quest-giver +/// panels' and the quest log's reward hovers (stock QuestFrameTemplates.xml:148, +/// QuestLogFrame.xml:113): the row's item by id through the shared renderer; a row that is not +/// there leaves the tooltip empty (1944). +#[test] +fn quest_item_hovers_render_the_rows_item_by_id() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.set_item_template( + 2024, + ItemTemplateView { + name: "Militia Hammer".into(), + quality: 1, + ..Default::default() + }, + ); + let row = || QuestItemView { + item_id: 2024, + name: Some("Militia Hammer".into()), + quality: 1, + ..Default::default() + }; + s.set_quest(Some(QuestState { + panel: QuestPanel::Detail, + choices: vec![row()], + ..QuestState::default() + })); + s.run( + r#" + local a = CreateFrame("Button", "Q1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local tt = CreateFrame("GameTooltip", "TT") + tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetQuestItem("choice", 1) + "#, + ) + .unwrap(); + assert_eq!(lines_of(&mut s)[0].0, "Militia Hammer"); + + s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestItem("choice", 2)"#) + .unwrap(); + assert_eq!( + s.eval::("return TT:NumLines()").unwrap(), + 0, + "an out-of-range row shows nothing" + ); + s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestItem("reward", 1)"#) + .unwrap(); + assert_eq!(s.eval::("return TT:NumLines()").unwrap(), 0); + + s.set_quest_log(QuestLogState { + detail: Some(QuestLogDetail { + rewards: vec![row()], + ..Default::default() + }), + ..Default::default() + }); + s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestLogItem("reward", 1)"#) + .unwrap(); + assert_eq!(lines_of(&mut s)[0].0, "Militia Hammer"); + s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestLogItem("choice", 1)"#) + .unwrap(); + assert_eq!(s.eval::("return TT:NumLines()").unwrap(), 0); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tests/tooltip_spell.rs b/crates/benilla-ui/src/script/tests/tooltip_spell.rs index 41317931..1d64889a 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_spell.rs @@ -992,3 +992,86 @@ fn the_remaining_line_is_gated_on_until_cancelled_not_on_the_duration() { ); assert!(s.take_errors().is_empty()); } + +/// `GetRewardSpell()` / `GetQuestLogRewardSpell()` answer `texture, name, isTradeskillSpell` — +/// three nils when the quest teaches nothing (the reference's own `(nil,nil,nil)` kind) — and +/// `GameTooltip:SetQuestRewardSpell()` / `SetQuestLogRewardSpell()` render that spell (1944). +#[test] +fn quest_reward_spell_getters_and_hovers() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.set_spell_tooltip(133, fireball()); + s.run( + r#" + local a = CreateFrame("Button", "Q1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10) + local tt = CreateFrame("GameTooltip", "TT") + "#, + ) + .unwrap(); + + s.set_quest(Some(QuestState::default())); + assert_eq!( + s.eval::("return select('#', GetRewardSpell())") + .unwrap(), + 3, + "three values on the empty path" + ); + assert!(s + .eval::( + "local t, n, ts = GetRewardSpell() return t == nil and n == nil and ts == nil" + ) + .unwrap()); + s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestRewardSpell()"#) + .unwrap(); + assert_eq!(s.eval::("return TT:NumLines()").unwrap(), 0); + + let reward = QuestRewardSpell { + spell_id: 133, + name: Some("Fireball".into()), + texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()), + tradeskill: false, + }; + s.set_quest(Some(QuestState { + panel: QuestPanel::Reward, + reward_spell: Some(reward.clone()), + ..QuestState::default() + })); + assert_eq!( + s.eval::<(String, String, Option)>("return GetRewardSpell()") + .unwrap(), + ( + "Interface\\Icons\\Spell_Fire_FlameBolt".to_string(), + "Fireball".to_string(), + None + ) + ); + s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestRewardSpell()"#) + .unwrap(); + s.resolve(); + assert_eq!( + s.eval::("return TTTextLeft1:GetText()").unwrap(), + "Fireball" + ); + + s.set_quest_log(QuestLogState { + detail: Some(QuestLogDetail { + reward_spell: Some(reward), + ..Default::default() + }), + ..Default::default() + }); + assert_eq!( + s.eval::<(String, String, Option)>("return GetQuestLogRewardSpell()") + .unwrap() + .1, + "Fireball" + ); + s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestLogRewardSpell()"#) + .unwrap(); + s.resolve(); + assert_eq!( + s.eval::("return TTTextLeft1:GetText()").unwrap(), + "Fireball" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tooltip_item/mod.rs b/crates/benilla-ui/src/script/tooltip_item/mod.rs index 1678a10d..9a23a1d2 100644 --- a/crates/benilla-ui/src/script/tooltip_item/mod.rs +++ b/crates/benilla-ui/src/script/tooltip_item/mod.rs @@ -159,6 +159,26 @@ fn render_by_id( /// (rings, trinkets, one-hand weapons, and a two-hander displacing both hands) fire two /// shopping tooltips, ref PaperDollFrame.lua's `arg2`. Twin of the app-side /// `ui_items::find_equip_slot` (the equip-click fit rule) — one law, two consumers. +/// The quest-item hover: the row's item by id, or an empty tooltip when the row is not there. +fn set_quest_item_view( + lua: &Lua, + this: &Table, + item: Option, +) -> mlua::Result<()> { + match item { + Some(it) => render_by_id(lua, this, it.item_id, it.name.clone(), Some(it.quality)), + None => { + let h = frame_handle_of(lua, this)?; + { + let mut model = lua.app_data_mut::().expect("model app_data"); + clear_content(&mut model, h); + } + super::tooltip::show_or_hide_empty(lua, h); + Ok(()) + } + } +} + fn equip_slots_for(inventory_type: u32) -> &'static [u32] { match inventory_type { 1 => &[1], // head @@ -292,6 +312,46 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { )?, )?; + // GameTooltip:SetQuestItem(type, index) / SetQuestLogItem(type, index) — the quest-giver + // panels' and the quest log's reward/choice/required hovers (stock QuestFrameTemplates.xml:148, + // QuestLogFrame.xml:113; bindings 0x533610 / 0x533760, 3 args with self, 0 returns). `type` is + // the same "choice" | "reward" | "required" key `GetQuestItemInfo` takes, `index` the 1-based + // slot; the item is rendered by id through the shared renderer, with the row's own name and + // quality as the fallbacks a template that has not arrived yet paints from. An unknown type or + // an out-of-range index shows nothing — the reference's tooltip stays empty too. + m.set( + "SetQuestItem", + lua.create_function(|lua, (this, kind, index): (Table, String, usize)| { + let item = { + let model = lua.app_data_ref::().expect("model app_data"); + model.quest.as_ref().and_then(|q| { + crate::script::quest::item_vec(q, &kind) + .and_then(|v| index.checked_sub(1).and_then(|n| v.get(n))) + .cloned() + }) + }; + set_quest_item_view(lua, &this, item) + })?, + )?; + m.set( + "SetQuestLogItem", + lua.create_function(|lua, (this, kind, index): (Table, String, usize)| { + let item = { + let model = lua.app_data_ref::().expect("model app_data"); + model.quest_log.detail.as_ref().and_then(|d| { + let v = match kind.as_str() { + "choice" => Some(&d.choices), + "reward" => Some(&d.rewards), + _ => None, + }; + v.and_then(|v| index.checked_sub(1).and_then(|n| v.get(n))) + .cloned() + }) + }; + set_quest_item_view(lua, &this, item) + })?, + )?; + // GameTooltip:SetHyperlink(link) — the chat-link tooltip (ref ItemRef.lua's SetItemRef → // ItemRefTooltip:SetHyperlink). Accepts the full escaped link or a bare "item:"; // non-item links (player/spell/quest) have no tooltip surface yet and no-op. diff --git a/crates/benilla-ui/src/script/tooltip_spell.rs b/crates/benilla-ui/src/script/tooltip_spell.rs index be537e6c..f654b83a 100644 --- a/crates/benilla-ui/src/script/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tooltip_spell.rs @@ -318,6 +318,27 @@ pub(super) fn set_spell_with_talent( /// Shared entry: clear, render (or record the ask and show nothing but the name if the caller /// knows one), show. +/// The reward-spell hover: the spell by id with the quest's own name as the fallback, or an +/// empty tooltip. +fn set_reward_spell( + lua: &Lua, + this: &Table, + spell: Option<(u32, Option)>, +) -> mlua::Result<()> { + match spell { + Some((id, name)) => set_spell_by_id(lua, this, id, name, SpellRenderOpts::default(), None), + None => { + let h = frame_handle_of(lua, this)?; + { + let mut model = lua.app_data_mut::().expect("model app_data"); + clear_content(&mut model, h); + } + super::tooltip::show_or_hide_empty(lua, h); + Ok(()) + } + } +} + fn set_spell_by_id( lua: &Lua, this: &Table, @@ -614,6 +635,39 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { // table). Not the spell title's gold, and nothing about the bound spell: 1.12 has no // `#showtooltip`. This arm was a `_ => Ok(())` left from before 0983 shipped macros, which is // why a macro on the bar hovered to nothing (the director, 2026-08-27, after 1636). + // GameTooltip:SetQuestRewardSpell() / SetQuestLogRewardSpell() — the hover of the reward + // slot stock marks `rewardType = "spell"` (QuestFrameTemplates.xml:150, QuestLogFrame.xml:115; + // bindings 0x535bb0 / 0x535c60, self only, 0 returns): the quest's reward spell by id through + // the spell renderer, an empty tooltip when the quest rewards none. + m.set( + "SetQuestRewardSpell", + lua.create_function(|lua, this: Table| { + let id = { + let model = lua.app_data_ref::().expect("model app_data"); + model + .quest + .as_ref() + .and_then(|q| q.reward_spell.as_ref()) + .map(|s| (s.spell_id, s.name.clone())) + }; + set_reward_spell(lua, &this, id) + })?, + )?; + m.set( + "SetQuestLogRewardSpell", + lua.create_function(|lua, this: Table| { + let id = { + let model = lua.app_data_ref::().expect("model app_data"); + model + .quest_log + .detail + .as_ref() + .and_then(|d| d.reward_spell.as_ref()) + .map(|s| (s.spell_id, s.name.clone())) + }; + set_reward_spell(lua, &this, id) + })?, + )?; m.set( "SetAction", lua.create_function(|lua, (this, slot): (Table, u32)| { diff --git a/crates/benilla-world/src/model_render.rs b/crates/benilla-world/src/model_render.rs index ff514415..1037a2a1 100644 --- a/crates/benilla-world/src/model_render.rs +++ b/crates/benilla-world/src/model_render.rs @@ -1192,6 +1192,15 @@ pub struct ModelPart { pub blend: ModelBlend, } +/// **Why this submesh is an entity at all** — the world streamer's answer, one static label per +/// batch, for the census (`VIS_CENSUS … why …`). A city keeps thousands of hidden model +/// submeshes resident on the entity path while the retained pass draws the world (5.6k ADT +/// doodad submeshes at the Stormwind auction house, 1945), and every one of them rides every +/// per-`Mesh3d` sweep; which divert declined it is the first question about pricing them. +/// Absent on units, GameObjects and everything the app lane spawns. +#[derive(Component, Clone, Copy)] +pub struct EntityPathWhy(pub &'static str); + /// Ordering handle so the one system allowed to *override* the model-`Visibility` authority — the /// self-avatar first-person hide ([`crate::player`]) — can run **after** it and win the frame. #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/crates/benilla-world/src/rig_anim/pose.rs b/crates/benilla-world/src/rig_anim/pose.rs index eaba5057..973488a3 100644 --- a/crates/benilla-world/src/rig_anim/pose.rs +++ b/crates/benilla-world/src/rig_anim/pose.rs @@ -261,7 +261,10 @@ struct Active { fn evaluate_rig_poses( mut rigs: Query<(&AnimationPlayer, &ModelAnimations, &mut RigPose), Without>, ) { - for (player, anims, mut rig) in &mut rigs { + // Parallel over rigs (decision 1945): a rig reads its shared clips and writes only its own + // pose, and a raid stands ~400 live rigs / ~12k bones — 0.4 ms of one thread's frame when + // walked serially, and this system sits on the main thread's critical path. + rigs.par_iter_mut().for_each(|(player, anims, mut rig)| { let src = &anims.pose; // This rig's contributing animations, ascending by node index (Bevy's effective blend // order — sorted children folded through a LIFO stack, see the module doc). @@ -314,7 +317,7 @@ fn evaluate_rig_poses( blend_rig(src, many, &mut rig); } } - } + }); } /// The multi-animation path (a cross-fade, a masked overlay, the grip): merge-walk the diff --git a/crates/benilla-world/src/static_gx/cull.rs b/crates/benilla-world/src/static_gx/cull.rs index 24cc627a..8b83bb7a 100644 --- a/crates/benilla-world/src/static_gx/cull.rs +++ b/crates/benilla-world/src/static_gx/cull.rs @@ -116,6 +116,7 @@ fn spawn_exile(commands: &mut Commands, f: &GxFader, alpha: f32, admitted: bool) kind: crate::model_render::ModelKind::Doodad, blend: b.blend_mode, }, + crate::model_render::EntityPathWhy("exile"), crate::interact::PickMesh(b.geometry.clone()), bevy::mesh::MeshTag(crate::mesh_tag::alpha_bits(alpha)), crate::model_fade::DoodadFade { diff --git a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs index eed8e4f9..9b7b4ff3 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs @@ -402,10 +402,30 @@ pub fn spawn_model_entities( // prop lane admits steady interior props + never-fade exterior props while an // exterior FADER prop stays on the entity path (the exile protocol has no prop // shape — that keep is today's default look) and is TALLIED, never silent. + // The census label for a batch that stays on the entity path (`EntityPathWhy`): + // refined below as each divert declines it. + let mut why: &'static str = "gx-off"; if let Some((gx, site)) = staticgx.as_mut() { - let facts = if !crate::static_gx::enabled() || shared_geometry[batch_idx] { + let facts = if !crate::static_gx::enabled() { + None + } else if shared_geometry[batch_idx] { + why = "shared-geometry"; None } else { + why = match site { + crate::static_gx::GxSite::Doodad { .. } if is_wmo => "doodad-site-wmo", + crate::static_gx::GxSite::Doodad { .. } if !class.merges() => "no-merge", + crate::static_gx::GxSite::Doodad { .. } if class.interior_prop => { + "interior-prop" + } + crate::static_gx::GxSite::Doodad { .. } => "fader-lane-off", + crate::static_gx::GxSite::Wmo { .. } if !is_wmo => "wmo-site-m2", + crate::static_gx::GxSite::Wmo { .. } if !class.merges() => "wmo-no-merge", + crate::static_gx::GxSite::Wmo { .. } => "wmo-no-group", + crate::static_gx::GxSite::Prop { .. } if is_wmo => "prop-site-wmo", + crate::static_gx::GxSite::Prop { .. } if !class.merges() => "prop-no-merge", + crate::static_gx::GxSite::Prop { .. } => "exterior-fader-prop", + }; match site { crate::static_gx::GxSite::Doodad { owner } if !is_wmo && class.merges() && !class.interior_prop => @@ -469,6 +489,7 @@ pub fn spawn_model_entities( } }; if let Some((owner, wmo, prop, fade)) = facts { + why = "gx-declined"; if gx.divert(crate::static_gx::GxBatch { geometry: &sub.geometry, transform, @@ -616,6 +637,7 @@ pub fn spawn_model_entities( kind, blend: sub.blend, }, + crate::model_render::EntityPathWhy(why), // The picker's triangles (decision 0857): the render forms are `RENDER_WORLD`-only, // so the inspector/probe rays read the model's resident geometry. The caster // centres a card at its pivot, the same bake the render form draws with. @@ -650,6 +672,7 @@ pub fn spawn_model_entities( kind, blend: sub.blend, }, + crate::model_render::EntityPathWhy(why), // The picker's triangles (decision 0857) — same rule as the card above. crate::interact::PickMesh(sub.geometry.clone()), mesh_tag, diff --git a/crates/benilla-world/src/world_census.rs b/crates/benilla-world/src/world_census.rs index 685ad1fb..fc092866 100644 --- a/crates/benilla-world/src/world_census.rs +++ b/crates/benilla-world/src/world_census.rs @@ -79,6 +79,13 @@ pub struct CensusReport { /// Visible submeshes per model subsystem — `(column name, visible, of-those-gated)` — in a /// fixed column order, because a census whose columns move cannot be diffed across runs. pub kinds: [(&'static str, usize, usize); 4], + /// Resident submeshes per model subsystem, same column order as `kinds` — hidden or not. + /// A city's hidden twins (the retained pass draws them) ride every per-`Mesh3d` sweep + /// whether they draw or not, and this is the count that prices them. + pub resident: [usize; 4], + /// Resident submeshes per `EntityPathWhy` label (the streamer's reason a batch is an + /// entity), most first; `"-"` for the untagged (units, GameObjects, the app lane). + pub why: Vec<(&'static str, usize)>, /// Of every [`ExteriorScene`]-tagged submesh: how many exist, how many the cull wrote `Hidden` /// on, how many were exempt (the camera's own placement — decision 0784), and how many carry /// **no `Aabb`**, which is the cull's fail-open arm admitting them unconditionally. @@ -191,6 +198,12 @@ impl WorldCensus<'_, '_> { } /// Snapshot this frame. + /// Live particles this instant — the one census number cheap enough to sample per frame + /// (a fold over the emitters, no part walk): a spell burst's signature on a tail frame. + pub fn live_particles(&self) -> usize { + self.emitters.iter().map(|p| p.live()).sum() + } + pub fn take(&self) -> CensusReport { let own_instance = self .claim @@ -199,13 +212,17 @@ impl WorldCensus<'_, '_> { .map(|c| c.room.instance); let mut kinds = [(0usize, 0usize); 4]; + let mut resident = [0usize; 4]; + let mut why: HashMap<&'static str, usize> = HashMap::new(); let mut labels: HashMap<(String, bool), usize> = HashMap::new(); let mut escaped: HashMap<(String, bool), usize> = HashMap::new(); let (mut tagged, mut hidden, mut exempt_n, mut no_aabb) = (0, 0, 0, 0); let (mut submeshes, mut drawn) = (0usize, 0usize); - for (vis, part, gated, object, want, aabb, card, group) in self.parts.iter() { + for (vis, part, gated, object, want, aabb, card, group, path_why) in self.parts.iter() { submeshes += 1; + resident[kind_index(part.kind)] += 1; + *why.entry(path_why.map_or("-", |w| w.0)).or_default() += 1; drawn += usize::from(vis.get()); if gated { // The camera's own placement is not exterior scene to itself (decision 0784) and @@ -266,6 +283,12 @@ impl WorldCensus<'_, '_> { CensusReport { submeshes, drawn, + why: { + let mut v: Vec<_> = why.into_iter().collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0))); + v + }, + resident, kinds: std::array::from_fn(|i| (KIND_COLUMNS[i], kinds[i].0, kinds[i].1)), tagged, hidden, @@ -352,6 +375,7 @@ type CensusData = ( Option<&'static Aabb>, Has, Option<&'static WmoGroupVis>, + Option<&'static crate::model_render::EntityPathWhy>, ); /// The census column order, pinned: entry `i` names [`kind_index`]'s slot `i`. Column positions diff --git a/third_party/kira/BENILLA.md b/third_party/kira/BENILLA.md new file mode 100644 index 00000000..d2fb6c33 --- /dev/null +++ b/third_party/kira/BENILLA.md @@ -0,0 +1,39 @@ +# benilla's `kira` fork — what differs from upstream, and how to check + +Upstream: [`tesselode/kira`](https://github.com/tesselode/kira), MIT OR Apache-2.0, version +`0.12.1` — the version the workspace lock resolves to. Wired in through `[patch.crates-io]` in +the workspace root, the way `lua-src` is. The crate is copied from the cargo registry as +published (`src/`, `Cargo.toml`, `README.md`) plus the two licence files from the repository; +upstream files are CRLF and are left so, except the one file patched. + +## Why a fork exists at all + +The mixer runs on benilla's own audio render thread (`sound/output`), and with a raid buffing +itself — a dozen live voices, most of them spatial — that thread cost **~1.5 ms of every 60 Hz +frame** on the M2 Air (decision 1945: a sampled profile of the crowd rig's Blackrock leg with +sound on, `Track::process` two thirds of it). Upstream's sub-track `process` spatializes **every +frame**: two vector normalizes, a length, the attenuation curve and a dB→amplitude `powf` per +frame per spatial track, then two more `powf`s per frame for the volume tween and the fade. At +48 kHz × 12 tracks that is ~1.7 M `powf`s a second plus the vector math, for gains that change +imperceptibly across a 10 ms chunk. FMOD 3 — the mixer the real client delegates to — updates +pan and attenuation per mixer update, not per sample. + +## The one patch — `src/track/sub.rs` + +`Track::process` evaluates the spatial gains (attenuation, left/right ear gain, the mono fold) +and the volume × fade amplitude at the chunk's two ends and interpolates them linearly across +the chunk. `SpatialData::spatial_gains` is `spatialize` factored into a value; `spatialize` +itself is kept, unused, so the factoring can be diffed against it. A chunk is the output +device's buffer — 512 frames, ~10.7 ms, `sound/output::DEVICE_BUFFER_FRAMES`. + +What changes audibly: nothing a listener can name. A source crossing an ear's axis within one +chunk pans linearly in gain over 10 ms instead of following the dot product per sample; a +volume tween ramps linearly in amplitude across each chunk instead of in decibels. Both are +below the granularity the reference itself updates at. + +## How to check + +`git diff --no-index /kira-0.12.1/src/track/sub.rs third_party/kira/src/track/sub.rs` +(after `sed 's/\r$//'` on the upstream copy) shows the patch and nothing else; every other file +is byte-identical to the registry crate. The mixer's own tests (`cargo test -p benilla-app +sound::`) run against the fork. diff --git a/third_party/kira/Cargo.toml b/third_party/kira/Cargo.toml new file mode 100644 index 00000000..673bcf46 --- /dev/null +++ b/third_party/kira/Cargo.toml @@ -0,0 +1,182 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "kira" +version = "0.12.1" +authors = ["Andrew Minnich "] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Expressive audio library for games" +readme = "README.md" +keywords = [ + "gamedev", + "audio", + "music", +] +categories = [ + "game-development", + "multimedia::audio", +] +license = "MIT OR Apache-2.0" +repository = "https://github.com/tesselode/kira" +resolver = "2" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = [ + "--cfg", + "docsrs", +] + +[features] +aac = [ + "symphonia", + "symphonia/aac", +] +adpcm = [ + "symphonia", + "symphonia/adpcm", +] +aiff = [ + "symphonia", + "symphonia/aiff", +] +alac = [ + "symphonia", + "symphonia/alac", +] +caf = [ + "symphonia", + "symphonia/caf", +] +default = [ + "cpal", + "mp3", + "ogg", + "vorbis", + "flac", + "wav", + "pcm", +] +flac = [ + "symphonia", + "symphonia/flac", +] +isomp4 = [ + "symphonia", + "symphonia/isomp4", +] +mkv = [ + "symphonia", + "symphonia/mkv", +] +mp1 = [ + "symphonia", + "symphonia/mp1", +] +mp2 = [ + "symphonia", + "symphonia/mp2", +] +mp3 = [ + "symphonia", + "symphonia/mp3", +] +mpa = [ + "symphonia", + "symphonia/mpa", +] +ogg = [ + "symphonia", + "symphonia/ogg", +] +pcm = [ + "symphonia", + "symphonia/pcm", +] +vorbis = [ + "symphonia", + "symphonia/vorbis", +] +wav = [ + "symphonia", + "symphonia/wav", +] + +[lib] +name = "kira" +path = "src/lib.rs" + +[[test]] +name = "change_sample_rate" +path = "tests/change_sample_rate.rs" + +[[test]] +name = "streaming_sound_stops_on_error" +path = "tests/streaming_sound_stops_on_error.rs" + +[[test]] +name = "sync_send" +path = "tests/sync_send.rs" + +[dependencies.assert_no_alloc] +version = "1.1.2" +optional = true + +[dependencies.atomic-arena] +version = "0.1.2" + +[dependencies.glam] +version = "0.33.0" +features = ["mint"] + +[dependencies.mint] +version = "0.5.9" + +[dependencies.pastey] +version = "0.2.1" + +[dependencies.rtrb] +version = "0.3.2" + +[dependencies.serde] +version = "1.0.219" +features = ["derive"] +optional = true + +[dependencies.symphonia] +version = "0.5.4" +optional = true +default-features = false + +[dependencies.triple_buffer] +version = "9.0.0" + +[dev-dependencies.approx] +version = "0.5.1" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.cpal] +version = "0.17.0" +optional = true + +[target.'cfg(target_arch = "wasm32")'.dependencies.cpal] +version = "0.17.0" +features = ["wasm-bindgen"] +optional = true + +[target.'cfg(target_arch = "wasm32")'.dependencies.send_wrapper] +version = "0.6.0" diff --git a/third_party/kira/LICENSE-APACHE b/third_party/kira/LICENSE-APACHE new file mode 100644 index 00000000..dfc4a784 --- /dev/null +++ b/third_party/kira/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021 Andrew Minnich + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/kira/LICENSE-MIT b/third_party/kira/LICENSE-MIT new file mode 100644 index 00000000..b7618ccf --- /dev/null +++ b/third_party/kira/LICENSE-MIT @@ -0,0 +1,7 @@ +Copyright 2021 Andrew Minnich + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third_party/kira/README.md b/third_party/kira/README.md new file mode 100644 index 00000000..51e6fc3e --- /dev/null +++ b/third_party/kira/README.md @@ -0,0 +1,143 @@ +# Kira + +#### [crates.io](https://crates.io/crates/kira) | [docs](https://docs.rs/kira/) + +Kira is a backend-agnostic library to create expressive audio for games. It +provides tweens for smoothly adjusting properties of sounds, a flexible mixer +for applying effects to audio, a clock system for precisely timing audio events, +and spatial audio support. + +## Examples + +Playing a sound multiple times simultaneously: + +```rust +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::StaticSoundData, +}; + +// Create an audio manager. This plays sounds and manages resources. +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let sound_data = StaticSoundData::from_file("sound.ogg")?; +manager.play(sound_data.clone())?; +// After a couple seconds... +manager.play(sound_data.clone())?; +// Cloning the sound data will not use any extra memory. +``` + +Gradually speeding up a sound over time: + +```rust +use std::time::Duration; + +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::StaticSoundData, + Tween, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let sound_data = StaticSoundData::from_file("sound.ogg")?; +let mut sound = manager.play(sound_data)?; +// Start smoothly adjusting the playback rate parameter. +sound.set_playback_rate( + 2.0, + Tween { + duration: Duration::from_secs(3), + ..Default::default() + }, +); +``` + +Playing a sound with a low-pass filter applied (this makes the audio sound +muffled): + +```rust +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::StaticSoundData, + track::{ + TrackBuilder, + effect::filter::FilterBuilder, + }, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +// Create a mixer sub-track with a filter. +let track = manager.add_sub_track({ + let mut builder = TrackBuilder::new(); + builder.add_effect(FilterBuilder::new().cutoff(1000.0)); + builder +})?; +// Play the sound on the track. +let sound_data = StaticSoundData::from_file("sound.ogg")?.output_destination(&track); +manager.play(sound_data)?; +``` + +Playing sounds in time with a musical beat: + +```rust +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::StaticSoundData, + clock::ClockSpeed, +}; + +const TEMPO: f64 = 120.0; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +// Create a clock that ticks 120 times per minute. In this case, +// each tick is one musical beat. We can use a tick to represent any +// arbitrary amount of time. +let mut clock = manager.add_clock(ClockSpeed::TicksPerMinute(TEMPO))?; +// Play a sound 2 ticks (beats) from now. +let sound_data_1 = StaticSoundData::from_file("sound1.ogg")? + .start_time(clock.time() + 2); +manager.play(sound_data_1)?; +// Play a different sound 4 ticks (beats) from now. +let sound_data_2 = StaticSoundData::from_file("sound2.ogg")? + .start_time(clock.time() + 4); +manager.play(sound_data_2)?; +// Start the clock. +clock.start()?; +``` + +## Platform support + +Kira is mainly meant for desktop platforms. Most testing has occurred on +Windows, but it has been used successfully on Mac and Linux. + +Kira can also be used in wasm environments with the following limitations: + +- Static sounds cannot be loaded from files +- Streaming sounds are not supported because they make heavy use of threads + +If you'd like to help improve wasm support, please reach out! + +## Roadmap + +Features I'd like to have: + +- C API +- Spatial audio features: + - Doppler effect + +## Contributing + +I'd love for other people to get involved with development! Since the library is +still in the early stages, I'm open to all kinds of input - bug reports, feature +requests, design critiques, etc. Feel free to open an issue or pull request! + +## License + +This project is licensed under either of + +- Apache License, Version 2.0 (LICENSE-APACHE) +- MIT license (LICENSE-MIT) + +at your option. + +Unless you explicitly state otherwise, any contribution intentionally submitted +for inclusion in `kira` by you, as defined in the Apache-2.0 license, shall be +dual licensed as above, without any additional terms or conditions. diff --git a/third_party/kira/src/backend.rs b/third_party/kira/src/backend.rs new file mode 100644 index 00000000..f33e823b --- /dev/null +++ b/third_party/kira/src/backend.rs @@ -0,0 +1,40 @@ +//! Communication between Kira and a low-level audio API. + +#[cfg(feature = "cpal")] +pub mod cpal; +pub mod mock; +mod renderer; +pub(crate) mod resources; + +pub use renderer::*; + +#[cfg(feature = "cpal")] +/// The default backend used by [`AudioManager`](crate::AudioManager)s. +/// +/// If the `cpal` feature is enabled, this will be the cpal backend. Otherwise, +/// it will be the mock backend. +pub type DefaultBackend = cpal::CpalBackend; +#[cfg(not(feature = "cpal"))] +/// The default backend used by [`AudioManager`](crate::AudioManager)s. +/// +/// If the `cpal` feature is enabled, this will be the cpal backend. Otherwise, +/// it will be the mock backend. +pub type DefaultBackend = mock::MockBackend; + +/// Connects a [`Renderer`] to a lower level audio API. +pub trait Backend: Sized { + /// Settings for this backend. + type Settings; + + /// Errors that can occur when using this backend. + type Error; + + /// Starts the backend and returns itself and the initial sample rate. + fn setup( + settings: Self::Settings, + internal_buffer_size: usize, + ) -> Result<(Self, u32), Self::Error>; + + /// Sends the renderer to the backend to start audio playback. + fn start(&mut self, renderer: Renderer) -> Result<(), Self::Error>; +} diff --git a/third_party/kira/src/backend/cpal.rs b/third_party/kira/src/backend/cpal.rs new file mode 100644 index 00000000..71aa241c --- /dev/null +++ b/third_party/kira/src/backend/cpal.rs @@ -0,0 +1,29 @@ +//! Plays audio using [cpal](https://crates.io/crates/cpal). + +#![cfg_attr(docsrs, doc(cfg(feature = "cpal")))] + +mod error; +use cpal::{Device, StreamConfig}; +pub use error::*; + +/// Settings for the cpal backend. +#[derive(Clone, Default)] +pub struct CpalBackendSettings { + /// The output audio device to use. If [`None`], the default output + /// device will be used. + pub device: Option, + /// A StreamConfig given by Cpal. If [`None`], the default supported + /// config will be used. You can also get a supported config of your + /// choosing using Cpal functions. + pub config: Option, +} + +#[cfg(target_arch = "wasm32")] +mod wasm; +#[cfg(target_arch = "wasm32")] +pub use wasm::CpalBackend; + +#[cfg(not(target_arch = "wasm32"))] +mod desktop; +#[cfg(not(target_arch = "wasm32"))] +pub use desktop::CpalBackend; diff --git a/third_party/kira/src/backend/cpal/desktop.rs b/third_party/kira/src/backend/cpal/desktop.rs new file mode 100644 index 00000000..77ad57f1 --- /dev/null +++ b/third_party/kira/src/backend/cpal/desktop.rs @@ -0,0 +1,166 @@ +mod renderer_with_cpu_usage; +mod stream_manager; + +use std::sync::Mutex; + +use renderer_with_cpu_usage::RendererWithCpuUsage; +use rtrb::Consumer; +use stream_manager::{StreamManager, StreamManagerController}; + +use crate::backend::{Backend, Renderer}; +use cpal::{ + BufferSize, Device, StreamConfig, StreamError, + traits::{DeviceTrait, HostTrait}, +}; + +use super::{CpalBackendSettings, Error}; + +enum State { + Empty, + Uninitialized { + device: Device, + config: StreamConfig, + }, + Initialized { + stream_manager_controller: StreamManagerController, + }, +} + +/// A backend that uses [cpal](https://crates.io/crates/cpal) to +/// connect a [`Renderer`] to the operating system's audio driver. +pub struct CpalBackend { + state: State, + /// Whether the device was specified by the user. + custom_device: bool, + buffer_size: BufferSize, + cpu_usage_consumer: Option>>, +} + +impl CpalBackend { + /** + Returns the oldest reported CPU usage in the queue. + + The formula for the CPU usage is time elapsed / time allotted, where + - time elapsed is the amount of time it took to fill the audio buffer + requested by the OS + - time allotted is the maximum amount of time Kira could take to process + audio and still finish in time to avoid audio stuttering (num frames / sample + rate) + */ + #[cfg_attr(docsrs, doc(cfg(not(wasm32))))] + pub fn pop_cpu_usage(&mut self) -> Option { + self.cpu_usage_consumer + .as_mut() + .unwrap() + .get_mut() + .unwrap() + .pop() + .ok() + } + + /// Returns the oldest available stream error in the queue. + #[cfg_attr(docsrs, doc(cfg(not(wasm32))))] + pub fn pop_error(&mut self) -> Option { + if let State::Initialized { + stream_manager_controller, + } = &mut self.state + { + stream_manager_controller.pop_handled_error() + } else { + None + } + } + + /** + Returns the number of unhandled stream errors discarded because of overcrowding. + This increases either: + + 1. When `cpal` produces errors faster than they are polled by `kira`, or + 2. When `kira` produces errors faster than they are polled by [`Self::pop_error`]. + */ + #[must_use] + #[cfg_attr(docsrs, doc(cfg(not(wasm32))))] + pub fn num_stream_errors_discarded(&self) -> Option { + if let State::Initialized { + stream_manager_controller, + } = &self.state + { + Some(stream_manager_controller.num_stream_errors_discarded()) + } else { + None + } + } +} + +impl Backend for CpalBackend { + type Settings = CpalBackendSettings; + + type Error = Error; + + fn setup( + settings: Self::Settings, + _internal_buffer_size: usize, + ) -> Result<(Self, u32), Self::Error> { + let host = cpal::default_host(); + + let (device, custom_device) = if let Some(device) = settings.device { + (device, true) + } else { + ( + host.default_output_device() + .ok_or(Error::NoDefaultOutputDevice)?, + false, + ) + }; + + let config = if let Some(config) = settings.config { + config + } else { + device.default_output_config()?.config() + }; + + let sample_rate = config.sample_rate; + let buffer_size = config.buffer_size; + + Ok(( + Self { + state: State::Uninitialized { device, config }, + custom_device, + buffer_size, + cpu_usage_consumer: None, + }, + sample_rate, + )) + } + + fn start(&mut self, renderer: Renderer) -> Result<(), Self::Error> { + let state = std::mem::replace(&mut self.state, State::Empty); + if let State::Uninitialized { device, config } = state { + let (renderer, cpu_usage_consumer) = RendererWithCpuUsage::new(renderer); + self.state = State::Initialized { + stream_manager_controller: StreamManager::start( + renderer, + device, + config, + self.custom_device, + self.buffer_size, + )?, + }; + self.cpu_usage_consumer = Some(Mutex::new(cpu_usage_consumer)); + } else { + panic!("Cannot initialize the backend multiple times") + } + Ok(()) + } +} + +impl Drop for CpalBackend { + fn drop(&mut self) { + if let State::Initialized { + stream_manager_controller, + } = &self.state + { + stream_manager_controller.stop(); + } + } +} diff --git a/third_party/kira/src/backend/cpal/desktop/renderer_with_cpu_usage.rs b/third_party/kira/src/backend/cpal/desktop/renderer_with_cpu_usage.rs new file mode 100644 index 00000000..c455b162 --- /dev/null +++ b/third_party/kira/src/backend/cpal/desktop/renderer_with_cpu_usage.rs @@ -0,0 +1,53 @@ +use std::{ + ops::{Deref, DerefMut}, + time::Instant, +}; + +use rtrb::{Consumer, Producer, RingBuffer}; + +use crate::backend::Renderer; + +const CPU_USAGE_RINGBUFFER_CAPACITY: usize = 100; + +pub struct RendererWithCpuUsage { + renderer: Renderer, + cpu_usage_producer: Producer, +} + +impl RendererWithCpuUsage { + pub fn new(renderer: Renderer) -> (Self, Consumer) { + let (cpu_usage_producer, cpu_usage_consumer) = + RingBuffer::new(CPU_USAGE_RINGBUFFER_CAPACITY); + ( + Self { + renderer, + cpu_usage_producer, + }, + cpu_usage_consumer, + ) + } + + pub fn process(&mut self, out: &mut [f32], num_channels: u16, sample_rate: u32) { + let allotted_time = out.len() as f32 / num_channels as f32 / sample_rate as f32; + let start_time = Instant::now(); + self.renderer.process(out, num_channels); + let end_time = Instant::now(); + let process_duration = end_time - start_time; + let cpu_usage = process_duration.as_secs_f32() / allotted_time; + self.cpu_usage_producer.push(cpu_usage).ok(); + } +} + +impl Deref for RendererWithCpuUsage { + type Target = Renderer; + + fn deref(&self) -> &Self::Target { + &self.renderer + } +} + +impl DerefMut for RendererWithCpuUsage { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.renderer + } +} diff --git a/third_party/kira/src/backend/cpal/desktop/stream_manager.rs b/third_party/kira/src/backend/cpal/desktop/stream_manager.rs new file mode 100644 index 00000000..3eae856d --- /dev/null +++ b/third_party/kira/src/backend/cpal/desktop/stream_manager.rs @@ -0,0 +1,284 @@ +mod send_on_drop; + +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + time::Duration, +}; + +use super::renderer_with_cpu_usage::RendererWithCpuUsage; +use cpal::{ + BufferSize, Device, Stream, StreamConfig, StreamError, + traits::{DeviceTrait, HostTrait, StreamTrait}, +}; +use rtrb::{Consumer, Producer, PushError, RingBuffer}; +use send_on_drop::SendOnDrop; + +use super::super::Error; + +const CHECK_STREAM_INTERVAL: Duration = Duration::from_millis(500); + +#[allow(clippy::large_enum_variant)] +enum State { + Empty, + Idle { + renderer: RendererWithCpuUsage, + }, + Running { + stream: Stream, + renderer_consumer: Consumer, + }, +} + +pub(super) struct StreamManagerController { + should_drop: Arc, + num_stream_errors_discarded: Arc, + handled_stream_error_consumer: Mutex>, +} + +impl StreamManagerController { + pub fn stop(&self) { + self.should_drop.store(true, Ordering::SeqCst); + } + + #[must_use] + pub fn num_stream_errors_discarded(&self) -> u64 { + self.num_stream_errors_discarded.load(Ordering::Acquire) + } + + pub fn pop_handled_error(&mut self) -> Option { + self.handled_stream_error_consumer + .get_mut() + .unwrap() + .pop() + .ok() + } +} + +/// Starts a cpal stream and restarts it if needed +/// in the case of device changes or disconnections. +pub(super) struct StreamManager { + state: State, + device_id: String, + sample_rate: u32, + custom_device: bool, + buffer_size: BufferSize, +} + +impl StreamManager { + pub fn start( + renderer: RendererWithCpuUsage, + device: Device, + mut config: StreamConfig, + custom_device: bool, + buffer_size: BufferSize, + ) -> Result { + let should_drop = Arc::new(AtomicBool::new(false)); + let should_drop_clone = should_drop.clone(); + + let num_stream_errors_discarded = Arc::new(AtomicU64::new(0)); + let num_stream_errors_discarded_clone = num_stream_errors_discarded.clone(); + + let (mut handled_stream_error_producer, handled_stream_error_consumer) = + RingBuffer::new(64); + + let (mut initial_result_producer, mut initial_result_consumer) = RingBuffer::new(1); + + std::thread::spawn(move || { + let mut stream_manager = StreamManager { + state: State::Idle { renderer }, + device_id: device_id(&device), + sample_rate: config.sample_rate, + custom_device, + buffer_size, + }; + let mut unhandled_stream_error_consumer = match stream_manager.start_stream( + &device, + &mut config, + num_stream_errors_discarded.clone(), + ) { + Ok(unhandled_stream_error_consumer) => { + initial_result_producer.push(Ok(())).unwrap(); + unhandled_stream_error_consumer + } + Err(err) => { + initial_result_producer.push(Err(err)).unwrap(); + return; + } + }; + loop { + std::thread::sleep(CHECK_STREAM_INTERVAL); + if should_drop.load(Ordering::SeqCst) { + break; + } + stream_manager.check_stream( + &mut unhandled_stream_error_consumer, + &mut handled_stream_error_producer, + &num_stream_errors_discarded, + ); + } + }); + + loop { + if let Ok(result) = initial_result_consumer.pop() { + result?; + break; + } + std::thread::sleep(Duration::from_micros(100)); + } + + Ok(StreamManagerController { + should_drop: should_drop_clone, + num_stream_errors_discarded: num_stream_errors_discarded_clone, + handled_stream_error_consumer: Mutex::new(handled_stream_error_consumer), + }) + } + + /// Restarts the stream if the audio device gets disconnected. + fn check_stream( + &mut self, + unhandled_stream_error_consumer: &mut Consumer, + handled_stream_error_producer: &mut Producer, + num_stream_errors_discarded: &Arc, + ) { + if let State::Running { .. } = &self.state { + while let Ok(error) = unhandled_stream_error_consumer.pop() { + match error { + // check for device disconnection + StreamError::DeviceNotAvailable | StreamError::StreamInvalidated => { + self.stop_stream(); + if let Ok((device, mut config)) = default_device_and_config() { + // TODO: gracefully handle errors that occur in this function + *unhandled_stream_error_consumer = self + .start_stream( + &device, + &mut config, + num_stream_errors_discarded.clone(), + ) + .unwrap(); + } + } + StreamError::BackendSpecific { err: _ } | StreamError::BufferUnderrun => {} + } + match handled_stream_error_producer.push(error) { + Ok(()) => {} + Err(PushError::Full(_stream_error)) => { + num_stream_errors_discarded.fetch_add(1, Ordering::AcqRel); + } + } + } + // check for device changes if a custom device hasn't been specified + // Disabled on macos due to audio artifacts that seem to occur when the device is + // queried while playing. + // see: https://github.com/tesselode/kira/issues/38 + #[cfg(not(target_os = "macos"))] + if !self.custom_device { + if let Ok((device, mut config)) = default_device_and_config() { + let device_id = device_id(&device); + let sample_rate = config.sample_rate; + if device_id != self.device_id || sample_rate != self.sample_rate { + self.stop_stream(); + *unhandled_stream_error_consumer = self + .start_stream(&device, &mut config, num_stream_errors_discarded.clone()) + .unwrap(); + } + } + } + } + } + + fn start_stream( + &mut self, + device: &Device, + config: &mut StreamConfig, + num_stream_errors_discarded: Arc, + ) -> Result, Error> { + let mut renderer = + if let State::Idle { renderer } = std::mem::replace(&mut self.state, State::Empty) { + renderer + } else { + panic!("trying to start a stream when the stream manager is not idle"); + }; + config.buffer_size = self.buffer_size; // this won't change anything if the buffer size is BufferSize::Default + let device_id = device_id(device); + let sample_rate = config.sample_rate; + if sample_rate != self.sample_rate { + renderer.on_change_sample_rate(sample_rate); + } + self.device_id = device_id; + self.sample_rate = sample_rate; + let (mut renderer_wrapper, renderer_consumer) = SendOnDrop::new(renderer); + let (mut unhandled_stream_error_producer, unhandled_stream_error_consumer) = + RingBuffer::new(64); + let channels = config.channels; + let stream = device.build_output_stream( + config, + move |data: &mut [f32], _| { + #[cfg(feature = "assert_no_alloc")] + assert_no_alloc::assert_no_alloc(|| { + process_renderer(&mut renderer_wrapper, data, channels, sample_rate); + }); + #[cfg(not(feature = "assert_no_alloc"))] + process_renderer(&mut renderer_wrapper, data, channels, sample_rate); + }, + move |error| match unhandled_stream_error_producer.push(error) { + Ok(()) => {} + Err(PushError::Full(_stream_error)) => { + num_stream_errors_discarded.fetch_add(1, Ordering::AcqRel); + } + }, + None, + )?; + stream.play()?; + self.state = State::Running { + stream, + renderer_consumer, + }; + Ok(unhandled_stream_error_consumer) + } + + fn stop_stream(&mut self) { + if let State::Running { + mut renderer_consumer, + stream, + .. + } = std::mem::replace(&mut self.state, State::Empty) + { + drop(stream); + let renderer = renderer_consumer + .pop() + .expect("Could not retrieve the renderer after dropping a stream"); + self.state = State::Idle { renderer }; + } else { + panic!("Trying to stop the stream when it's not running") + } + } +} + +fn default_device_and_config() -> Result<(Device, StreamConfig), Error> { + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or(Error::NoDefaultOutputDevice)?; + let config = device.default_output_config()?.config(); + Ok((device, config)) +} + +fn device_id(device: &Device) -> String { + match device.id() { + Ok(id) => format!("{}", id), + Err(_) => "device id unavailable".to_string(), + } +} + +fn process_renderer( + renderer: &mut SendOnDrop, + data: &mut [f32], + channels: u16, + sample_rate: u32, +) { + renderer.on_start_processing(); + renderer.process(data, channels, sample_rate); +} diff --git a/third_party/kira/src/backend/cpal/desktop/stream_manager/send_on_drop.rs b/third_party/kira/src/backend/cpal/desktop/stream_manager/send_on_drop.rs new file mode 100644 index 00000000..7244ac74 --- /dev/null +++ b/third_party/kira/src/backend/cpal/desktop/stream_manager/send_on_drop.rs @@ -0,0 +1,49 @@ +use std::ops::{Deref, DerefMut}; + +use rtrb::{Consumer, Producer, RingBuffer}; + +/// Wraps `T` so that when it's dropped, it gets sent +/// back through a thread channel. +/// +/// This allows us to retrieve the data after a closure +/// that takes ownership of the data is dropped because of, +/// for instance, a cpal error. +pub struct SendOnDrop { + data: Option, + producer: Producer, +} + +impl SendOnDrop { + pub fn new(data: T) -> (Self, Consumer) { + let (producer, consumer) = RingBuffer::new(1); + ( + Self { + data: Some(data), + producer, + }, + consumer, + ) + } +} + +impl Deref for SendOnDrop { + type Target = T; + + fn deref(&self) -> &Self::Target { + self.data.as_ref().unwrap() + } +} + +impl DerefMut for SendOnDrop { + fn deref_mut(&mut self) -> &mut Self::Target { + self.data.as_mut().unwrap() + } +} + +impl Drop for SendOnDrop { + fn drop(&mut self) { + self.producer + .push(self.data.take().unwrap()) + .expect("send on drop producer full"); + } +} diff --git a/third_party/kira/src/backend/cpal/error.rs b/third_party/kira/src/backend/cpal/error.rs new file mode 100644 index 00000000..5acb2ecc --- /dev/null +++ b/third_party/kira/src/backend/cpal/error.rs @@ -0,0 +1,58 @@ +use std::fmt::{Display, Formatter}; + +use cpal::{BuildStreamError, DefaultStreamConfigError, PlayStreamError}; + +/// Errors that can occur when using the cpal backend. +#[derive(Debug)] +pub enum Error { + /// A default audio output device could not be determined. + NoDefaultOutputDevice, + /// An error occurred when getting the default output configuration. + DefaultStreamConfigError(DefaultStreamConfigError), + /// An error occurred when building the audio stream. + BuildStreamError(BuildStreamError), + /// An error occurred when starting the audio stream. + PlayStreamError(PlayStreamError), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Error::NoDefaultOutputDevice => { + f.write_str("Cannot find the default audio output device") + } + Error::DefaultStreamConfigError(error) => error.fmt(f), + Error::BuildStreamError(error) => error.fmt(f), + Error::PlayStreamError(error) => error.fmt(f), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::DefaultStreamConfigError(error) => Some(error), + Error::BuildStreamError(error) => Some(error), + Error::PlayStreamError(error) => Some(error), + _ => None, + } + } +} + +impl From for Error { + fn from(v: DefaultStreamConfigError) -> Self { + Self::DefaultStreamConfigError(v) + } +} + +impl From for Error { + fn from(v: BuildStreamError) -> Self { + Self::BuildStreamError(v) + } +} + +impl From for Error { + fn from(v: PlayStreamError) -> Self { + Self::PlayStreamError(v) + } +} diff --git a/third_party/kira/src/backend/cpal/wasm.rs b/third_party/kira/src/backend/cpal/wasm.rs new file mode 100644 index 00000000..b370febf --- /dev/null +++ b/third_party/kira/src/backend/cpal/wasm.rs @@ -0,0 +1,78 @@ +use crate::backend::{Backend, Renderer}; +use cpal::{ + Device, Stream, StreamConfig, + traits::{DeviceTrait, HostTrait, StreamTrait}, +}; +use send_wrapper::SendWrapper; + +use super::{CpalBackendSettings, Error}; + +enum State { + Empty, + Uninitialized { + device: Device, + config: StreamConfig, + }, + Initialized { + _stream: Stream, + }, +} + +/// A backend that uses [cpal](https://crates.io/crates/cpal) to +/// connect a [`Renderer`] to the operating system's audio driver. +pub struct CpalBackend { + state: SendWrapper, +} + +impl Backend for CpalBackend { + type Settings = CpalBackendSettings; + + type Error = Error; + + fn setup( + settings: Self::Settings, + _internal_buffer_size: usize, + ) -> Result<(Self, u32), Self::Error> { + let host = cpal::default_host(); + let device = if let Some(device) = settings.device { + device + } else { + host.default_output_device() + .ok_or(Error::NoDefaultOutputDevice)? + }; + let config = if let Some(config) = settings.config { + config + } else { + device.default_output_config()?.config() + }; + let sample_rate = config.sample_rate; + Ok(( + Self { + state: SendWrapper::new(State::Uninitialized { device, config }), + }, + sample_rate, + )) + } + + fn start(&mut self, mut renderer: Renderer) -> Result<(), Self::Error> { + if let State::Uninitialized { device, config } = + std::mem::replace(&mut *self.state, State::Empty) + { + let channels = config.channels; + let stream = device.build_output_stream( + &config, + move |data: &mut [f32], _| { + renderer.on_start_processing(); + renderer.process(data, channels); + }, + move |_| {}, + None, + )?; + stream.play()?; + self.state = SendWrapper::new(State::Initialized { _stream: stream }); + } else { + panic!("Cannot initialize the backend multiple times") + } + Ok(()) + } +} diff --git a/third_party/kira/src/backend/mock.rs b/third_party/kira/src/backend/mock.rs new file mode 100644 index 00000000..1013ab0d --- /dev/null +++ b/third_party/kira/src/backend/mock.rs @@ -0,0 +1,99 @@ +//! Useful for testing and benchmarking. + +use std::sync::Mutex; + +use super::{Backend, Renderer}; + +enum State { + Uninitialized, + Initialized { renderer: Mutex }, +} + +/// Settings for the mock backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MockBackendSettings { + /// The sample rate that the [`Renderer`] should run at. + pub sample_rate: u32, +} + +impl Default for MockBackendSettings { + fn default() -> Self { + Self { sample_rate: 1 } + } +} + +/// A backend that does not connect to any lower-level +/// audio APIs, but allows manually calling +/// [`Renderer::on_start_processing`] and [`Renderer::process`]. +/// +/// This is useful for testing and benchmarking. +pub struct MockBackend { + sample_rate: u32, + state: State, + frames: Vec, +} + +impl MockBackend { + /// Changes the sample rate of the [`Renderer`]. + pub fn set_sample_rate(&mut self, sample_rate: u32) { + self.sample_rate = sample_rate; + if let State::Initialized { renderer } = &mut self.state { + renderer + .get_mut() + .expect("mutex poisoned") + .on_change_sample_rate(sample_rate); + } + } + + /// Calls the [`on_start_processing`](Renderer::on_start_processing) + /// callback of the [`Renderer`]. + pub fn on_start_processing(&mut self) { + if let State::Initialized { renderer } = &mut self.state { + renderer + .get_mut() + .expect("mutex poisoned") + .on_start_processing(); + } else { + panic!("backend is not initialized") + } + } + + /// Calls the [`process`](Renderer::process) callback of the [`Renderer`]. + pub fn process(&mut self) { + if let State::Initialized { renderer } = &mut self.state { + renderer + .get_mut() + .expect("mutex poisoned") + .process(&mut self.frames, 2) + } else { + panic!("backend is not initialized") + } + } +} + +impl Backend for MockBackend { + type Settings = MockBackendSettings; + + type Error = (); + + fn setup( + settings: Self::Settings, + internal_buffer_size: usize, + ) -> Result<(Self, u32), Self::Error> { + Ok(( + Self { + sample_rate: settings.sample_rate, + state: State::Uninitialized, + frames: vec![0.0; internal_buffer_size * 2], + }, + settings.sample_rate, + )) + } + + fn start(&mut self, renderer: Renderer) -> Result<(), Self::Error> { + self.state = State::Initialized { + renderer: Mutex::new(renderer), + }; + Ok(()) + } +} diff --git a/third_party/kira/src/backend/renderer.rs b/third_party/kira/src/backend/renderer.rs new file mode 100644 index 00000000..9fae8bd6 --- /dev/null +++ b/third_party/kira/src/backend/renderer.rs @@ -0,0 +1,126 @@ +use std::sync::{ + Arc, + atomic::{AtomicU32, Ordering}, +}; + +use crate::Frame; + +use super::resources::Resources; + +#[derive(Debug)] +pub(crate) struct RendererShared { + pub(crate) sample_rate: AtomicU32, +} + +impl RendererShared { + #[must_use] + pub fn new(sample_rate: u32) -> Self { + Self { + sample_rate: AtomicU32::new(sample_rate), + } + } +} + +/// Produces [`Frame`]s of audio data to be consumed by a +/// low-level audio API. +/// +/// You will probably not need to interact with [`Renderer`]s +/// directly unless you're writing a [`Backend`](super::Backend). +pub struct Renderer { + dt: f64, + shared: Arc, + resources: Resources, + internal_buffer_size: usize, + temp_buffer: Vec, +} + +impl Renderer { + #[must_use] + pub(crate) fn new( + shared: Arc, + internal_buffer_size: usize, + resources: Resources, + ) -> Self { + Self { + dt: 1.0 / shared.sample_rate.load(Ordering::SeqCst) as f64, + shared, + resources, + internal_buffer_size, + temp_buffer: vec![Frame::ZERO; internal_buffer_size], + } + } + + /// Called by the backend when the sample rate of the + /// audio output changes. + pub fn on_change_sample_rate(&mut self, sample_rate: u32) { + self.dt = 1.0 / sample_rate as f64; + self.shared.sample_rate.store(sample_rate, Ordering::SeqCst); + self.resources.mixer.on_change_sample_rate(sample_rate); + } + + /// Called by the backend when it's time to process + /// a new batch of samples. + pub fn on_start_processing(&mut self) { + self.resources.mixer.on_start_processing(); + self.resources.clocks.on_start_processing(); + self.resources.listeners.on_start_processing(); + self.resources.modulators.on_start_processing(); + } + + /// Produces the next [`Frame`]s of audio. + pub fn process(&mut self, out: &mut [f32], num_channels: u16) { + for chunk in out.chunks_mut(self.internal_buffer_size * num_channels as usize) { + self.process_chunk(chunk, num_channels); + } + } + + fn process_chunk(&mut self, chunk: &mut [f32], num_channels: u16) { + let num_frames = chunk.len() / num_channels as usize; + + self.resources.modulators.process( + self.dt * num_frames as f64, + &self.resources.clocks, + &self.resources.listeners, + ); + self.resources.clocks.update( + self.dt * num_frames as f64, + &self.resources.modulators, + &self.resources.listeners, + ); + self.resources.listeners.update( + self.dt * num_frames as f64, + &self.resources.clocks, + &self.resources.modulators, + ); + + self.resources.mixer.process( + &mut self.temp_buffer[..num_frames], + self.dt, + &self.resources.clocks, + &self.resources.modulators, + &self.resources.listeners, + ); + + // convert from frames to requested number of channels + for (i, channels) in chunk.chunks_mut(num_channels.into()).enumerate() { + let mut frame = self.temp_buffer[i]; + frame.left = frame.left.clamp(-1.0, 1.0); + frame.right = frame.right.clamp(-1.0, 1.0); + if num_channels == 1 { + channels[0] = (frame.left + frame.right) / 2.0; + } else { + channels[0] = frame.left; + channels[1] = frame.right; + /* + if there's more channels, send silence to them. if we don't, + we might get bad sounds outputted to those channels. + (https://github.com/tesselode/kira/issues/50) + */ + for channel in channels.iter_mut().skip(2) { + *channel = 0.0; + } + } + } + self.temp_buffer.fill(Frame::ZERO); + } +} diff --git a/third_party/kira/src/backend/resources.rs b/third_party/kira/src/backend/resources.rs new file mode 100644 index 00000000..ec7876ee --- /dev/null +++ b/third_party/kira/src/backend/resources.rs @@ -0,0 +1,309 @@ +pub(crate) mod clocks; +pub(crate) mod listeners; +pub(crate) mod mixer; +pub(crate) mod modulators; + +#[cfg(test)] +mod test; + +use std::{ + fmt::{Debug, Formatter}, + sync::Mutex, +}; + +use crate::{ + ResourceLimitReached, + listener::Listener, + track::{MainTrackBuilder, MainTrackHandle, SendTrack, Track}, +}; +use atomic_arena::{Arena, Controller, Key}; +use listeners::Listeners; +use rtrb::{Consumer, Producer, RingBuffer}; + +use crate::{clock::Clock, manager::Capacities, modulator::Modulator}; + +use self::{clocks::Clocks, mixer::Mixer, modulators::Modulators}; + +pub(crate) struct ResourceStorage { + pub(crate) resources: Arena, + new_resource_consumer: Consumer<(Key, T)>, + unused_resource_producer: Producer, +} + +impl ResourceStorage { + #[must_use] + pub fn new(capacity: usize) -> (Self, ResourceController) { + let (new_resource_producer, new_resource_consumer) = RingBuffer::new(capacity); + let (unused_resource_producer, unused_resource_consumer) = RingBuffer::new(capacity); + let resources = Arena::new(capacity); + let arena_controller = resources.controller(); + ( + Self { + resources, + new_resource_consumer, + unused_resource_producer, + }, + ResourceController { + arena_controller, + new_resource_producer: Mutex::new(new_resource_producer), + unused_resource_consumer: Mutex::new(unused_resource_consumer), + }, + ) + } + + pub fn remove_and_add(&mut self, remove_test: impl FnMut(&T) -> bool) { + for (_, resource) in self.resources.drain_filter(remove_test) { + self.unused_resource_producer + .push(resource) + .unwrap_or_else(|_| panic!("unused resource producer is full")); + } + while let Ok((key, resource)) = self.new_resource_consumer.pop() { + self.resources + .insert_with_key(key, resource) + .expect("error inserting resource"); + } + } + + #[must_use] + pub fn get_mut(&mut self, key: Key) -> Option<&mut T> { + self.resources.get_mut(key) + } + + #[must_use] + pub fn iter(&self) -> atomic_arena::iter::Iter<'_, T> { + self.resources.iter() + } + + #[must_use] + pub fn iter_mut(&mut self) -> atomic_arena::iter::IterMut<'_, T> { + self.resources.iter_mut() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.resources.is_empty() + } +} + +impl<'a, T> IntoIterator for &'a mut ResourceStorage { + type Item = (Key, &'a mut T); + + type IntoIter = atomic_arena::iter::IterMut<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.iter_mut() + } +} + +pub(crate) struct SelfReferentialResourceStorage { + pub(crate) resources: Arena, + keys: Vec, + new_resource_consumer: Consumer<(Key, T)>, + unused_resource_producer: Producer, + dummy: T, +} + +impl SelfReferentialResourceStorage { + pub fn new(capacity: usize) -> (Self, ResourceController) + where + T: Default, + { + let (new_resource_producer, new_resource_consumer) = RingBuffer::new(capacity); + let (unused_resource_producer, unused_resource_consumer) = RingBuffer::new(capacity); + let resources = Arena::new(capacity); + let arena_controller = resources.controller(); + ( + Self { + resources, + keys: Vec::with_capacity(capacity), + new_resource_consumer, + unused_resource_producer, + dummy: T::default(), + }, + ResourceController { + arena_controller, + new_resource_producer: Mutex::new(new_resource_producer), + unused_resource_consumer: Mutex::new(unused_resource_consumer), + }, + ) + } + + pub fn remove_and_add(&mut self, remove_test: impl FnMut(&T) -> bool) { + self.remove_unused(remove_test); + while let Ok((key, resource)) = self.new_resource_consumer.pop() { + self.resources + .insert_with_key(key, resource) + .expect("error inserting resource"); + self.keys.push(key); + } + } + + #[cfg(test)] + #[must_use] + pub fn get_mut(&mut self, key: Key) -> Option<&mut T> { + self.resources.get_mut(key) + } + + #[must_use] + pub fn iter_mut(&mut self) -> atomic_arena::iter::IterMut<'_, T> { + self.resources.iter_mut() + } + + pub fn for_each(&mut self, mut f: impl FnMut(&mut T, &mut Arena)) { + for key in &self.keys { + std::mem::swap(&mut self.resources[*key], &mut self.dummy); + f(&mut self.dummy, &mut self.resources); + std::mem::swap(&mut self.resources[*key], &mut self.dummy); + } + } + + fn remove_unused(&mut self, mut remove_test: impl FnMut(&T) -> bool) { + let mut i = 0; + while i < self.keys.len() && !self.unused_resource_producer.is_full() { + let key = self.keys[i]; + let resource = &mut self.resources[key]; + if remove_test(resource) { + let resource = self.resources.remove(key).unwrap(); + self.unused_resource_producer + .push(resource) + .unwrap_or_else(|_| panic!("unused resource producer is full")); + self.keys.remove(i); + } else { + i += 1; + } + } + } +} + +impl<'a, T> IntoIterator for &'a mut SelfReferentialResourceStorage { + type Item = (Key, &'a mut T); + + type IntoIter = atomic_arena::iter::IterMut<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.iter_mut() + } +} + +pub(crate) struct ResourceController { + pub arena_controller: Controller, + pub new_resource_producer: Mutex>, + pub unused_resource_consumer: Mutex>, +} + +impl ResourceController { + pub fn insert(&mut self, resource: T) -> Result { + let key = self.try_reserve()?; + self.insert_with_key(key, resource); + Ok(key) + } + + pub fn try_reserve(&self) -> Result { + self.arena_controller + .try_reserve() + .map_err(|_| ResourceLimitReached) + } + + pub fn insert_with_key(&mut self, key: Key, resource: T) { + self.remove_unused(); + self.new_resource_producer + .get_mut() + .expect("new resource producer mutex poisoned") + .push((key, resource)) + .unwrap_or_else(|_| panic!("new resource producer full")); + } + + fn remove_unused(&mut self) { + let unused_resource_consumer = &mut self + .unused_resource_consumer + .get_mut() + .expect("unused resource consumer mutex poisoned"); + while unused_resource_consumer.pop().is_ok() {} + } + + #[must_use] + pub fn capacity(&self) -> usize { + self.arena_controller.capacity() + } + + #[must_use] + pub fn len(&self) -> usize { + self.arena_controller.len() + } +} + +impl Debug for ResourceController { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResourceController") + .field("arena_controller", &self.arena_controller) + .field("new_resource_producer", &ProducerDebug) + .field("unused_resource_consumer", &ConsumerDebug) + .finish() + } +} + +struct ProducerDebug; + +impl Debug for ProducerDebug { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Producer").finish() + } +} + +struct ConsumerDebug; + +impl Debug for ConsumerDebug { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Consumer").finish() + } +} + +pub(crate) struct Resources { + pub mixer: Mixer, + pub clocks: Clocks, + pub modulators: Modulators, + pub listeners: Listeners, +} + +pub(crate) struct ResourceControllers { + pub sub_track_controller: ResourceController, + pub send_track_controller: ResourceController, + pub clock_controller: ResourceController, + pub modulator_controller: ResourceController>, + pub listener_controller: ResourceController, + pub main_track_handle: MainTrackHandle, +} + +pub(crate) fn create_resources( + capacities: Capacities, + main_track_builder: MainTrackBuilder, + sample_rate: u32, + internal_buffer_size: usize, +) -> (Resources, ResourceControllers) { + let (mixer, sub_track_controller, send_track_controller, main_track_handle) = Mixer::new( + capacities.sub_track_capacity, + capacities.send_track_capacity, + sample_rate, + internal_buffer_size, + main_track_builder, + ); + let (clocks, clock_controller) = Clocks::new(capacities.clock_capacity); + let (modulators, modulator_controller) = Modulators::new(capacities.modulator_capacity); + let (listeners, listener_controller) = Listeners::new(capacities.listener_capacity); + ( + Resources { + mixer, + clocks, + modulators, + listeners, + }, + ResourceControllers { + sub_track_controller, + send_track_controller, + clock_controller, + modulator_controller, + listener_controller, + main_track_handle, + }, + ) +} diff --git a/third_party/kira/src/backend/resources/clocks.rs b/third_party/kira/src/backend/resources/clocks.rs new file mode 100644 index 00000000..372da994 --- /dev/null +++ b/third_party/kira/src/backend/resources/clocks.rs @@ -0,0 +1,38 @@ +use crate::{clock::Clock, info::Info}; + +use super::{ + ResourceController, SelfReferentialResourceStorage, listeners::Listeners, + modulators::Modulators, +}; + +pub(crate) struct Clocks(pub(crate) SelfReferentialResourceStorage); + +impl Clocks { + #[must_use] + pub(crate) fn new(capacity: usize) -> (Self, ResourceController) { + let (storage, controller) = SelfReferentialResourceStorage::new(capacity); + (Self(storage), controller) + } + + pub(crate) fn on_start_processing(&mut self) { + self.0 + .remove_and_add(|clock| clock.shared().is_marked_for_removal()); + for (_, clock) in &mut self.0 { + clock.on_start_processing(); + } + } + + pub(crate) fn update(&mut self, dt: f64, modulators: &Modulators, listeners: &Listeners) { + self.0.for_each(|clock, others| { + clock.update( + dt, + &Info::new( + others, + &modulators.0.resources, + &listeners.0.resources, + None, + ), + ); + }); + } +} diff --git a/third_party/kira/src/backend/resources/listeners.rs b/third_party/kira/src/backend/resources/listeners.rs new file mode 100644 index 00000000..8353aaca --- /dev/null +++ b/third_party/kira/src/backend/resources/listeners.rs @@ -0,0 +1,32 @@ +use crate::{info::Info, listener::Listener}; + +use super::{ + ResourceController, SelfReferentialResourceStorage, clocks::Clocks, modulators::Modulators, +}; + +pub(crate) struct Listeners(pub(crate) SelfReferentialResourceStorage); + +impl Listeners { + #[must_use] + pub(crate) fn new(capacity: usize) -> (Self, ResourceController) { + let (storage, controller) = SelfReferentialResourceStorage::new(capacity); + (Self(storage), controller) + } + + pub(crate) fn on_start_processing(&mut self) { + self.0 + .remove_and_add(|listener| listener.shared.is_marked_for_removal()); + for (_, listener) in &mut self.0 { + listener.on_start_processing(); + } + } + + pub(crate) fn update(&mut self, dt: f64, clocks: &Clocks, modulators: &Modulators) { + self.0.for_each(|listener, others| { + listener.update( + dt, + &Info::new(&clocks.0.resources, &modulators.0.resources, others, None), + ); + }); + } +} diff --git a/third_party/kira/src/backend/resources/mixer.rs b/third_party/kira/src/backend/resources/mixer.rs new file mode 100644 index 00000000..e3bc678f --- /dev/null +++ b/third_party/kira/src/backend/resources/mixer.rs @@ -0,0 +1,112 @@ +use crate::{ + frame::Frame, + info::Info, + track::{MainTrack, MainTrackBuilder, MainTrackHandle, SendTrack, Track}, +}; + +use super::{ + ResourceController, ResourceStorage, clocks::Clocks, listeners::Listeners, + modulators::Modulators, +}; + +pub(crate) struct Mixer { + main_track: MainTrack, + sub_tracks: ResourceStorage, + send_tracks: ResourceStorage, + temp_buffer: Vec, +} + +impl Mixer { + #[must_use] + pub fn new( + sub_track_capacity: usize, + send_track_capacity: usize, + sample_rate: u32, + internal_buffer_size: usize, + main_track_builder: MainTrackBuilder, + ) -> ( + Self, + ResourceController, + ResourceController, + MainTrackHandle, + ) { + let (mut main_track, main_track_handle) = main_track_builder.build(internal_buffer_size); + main_track.init_effects(sample_rate); + let (sub_tracks, sub_track_controller) = ResourceStorage::new(sub_track_capacity); + let (send_tracks, send_track_controller) = ResourceStorage::new(send_track_capacity); + ( + Self { + main_track, + sub_tracks, + send_tracks, + temp_buffer: vec![Frame::ZERO; internal_buffer_size], + }, + sub_track_controller, + send_track_controller, + main_track_handle, + ) + } + + pub fn on_change_sample_rate(&mut self, sample_rate: u32) { + self.main_track.on_change_sample_rate(sample_rate); + for (_, track) in &mut self.sub_tracks { + track.on_change_sample_rate(sample_rate); + } + for (_, track) in &mut self.send_tracks { + track.on_change_sample_rate(sample_rate); + } + } + + pub fn on_start_processing(&mut self) { + self.sub_tracks + .remove_and_add(|track| track.should_be_removed()); + for (_, track) in &mut self.sub_tracks { + track.on_start_processing(); + } + self.send_tracks + .remove_and_add(|track| track.shared().is_marked_for_removal()); + for (_, track) in &mut self.send_tracks { + track.on_start_processing(); + } + self.main_track.on_start_processing(); + } + + pub fn process( + &mut self, + out: &mut [Frame], + dt: f64, + clocks: &Clocks, + modulators: &Modulators, + listeners: &Listeners, + ) { + for (_, track) in &mut self.sub_tracks { + track.process( + &mut self.temp_buffer[..out.len()], + dt, + clocks, + modulators, + listeners, + None, + &mut self.send_tracks, + ); + for (summed_out, sound_out) in out.iter_mut().zip(self.temp_buffer.iter().copied()) { + *summed_out += sound_out; + } + self.temp_buffer.fill(Frame::ZERO); + } + let info = Info::new( + &clocks.0.resources, + &modulators.0.resources, + &listeners.0.resources, + None, + ); + for (_, track) in &mut self.send_tracks { + track.process(&mut self.temp_buffer[..out.len()], dt, &info); + for (summed_out, sound_out) in out.iter_mut().zip(self.temp_buffer.iter().copied()) { + *summed_out += sound_out; + } + self.temp_buffer.fill(Frame::ZERO); + } + self.main_track.process(out, dt, &info); + } +} diff --git a/third_party/kira/src/backend/resources/modulators.rs b/third_party/kira/src/backend/resources/modulators.rs new file mode 100644 index 00000000..1d7b589d --- /dev/null +++ b/third_party/kira/src/backend/resources/modulators.rs @@ -0,0 +1,51 @@ +use crate::{info::Info, modulator::Modulator}; + +use super::{ + ResourceController, SelfReferentialResourceStorage, clocks::Clocks, listeners::Listeners, +}; + +pub(crate) struct Modulators(pub(crate) SelfReferentialResourceStorage>); + +impl Modulators { + #[must_use] + pub fn new(capacity: usize) -> (Self, ResourceController>) { + let (storage, controller) = SelfReferentialResourceStorage::new(capacity); + (Self(storage), controller) + } + + pub fn on_start_processing(&mut self) { + self.0.remove_and_add(|modulator| modulator.finished()); + for (_, modulator) in &mut self.0 { + modulator.on_start_processing(); + } + } + + pub fn process(&mut self, dt: f64, clocks: &Clocks, listeners: &Listeners) { + self.0.for_each(|modulator, others| { + modulator.update( + dt, + &Info::new(&clocks.0.resources, others, &listeners.0.resources, None), + ); + }); + } +} + +struct DummyModulator; + +impl Modulator for DummyModulator { + fn update(&mut self, _dt: f64, _info: &Info) {} + + fn value(&self) -> f64 { + 0.0 + } + + fn finished(&self) -> bool { + false + } +} + +impl Default for Box { + fn default() -> Self { + Box::new(DummyModulator) + } +} diff --git a/third_party/kira/src/backend/resources/test.rs b/third_party/kira/src/backend/resources/test.rs new file mode 100644 index 00000000..c0997e5f --- /dev/null +++ b/third_party/kira/src/backend/resources/test.rs @@ -0,0 +1,79 @@ +use crate::{ResourceLimitReached, backend::resources::SelfReferentialResourceStorage}; + +use super::ResourceStorage; + +#[test] +fn resource_storage() { + let (mut storage, mut controller) = ResourceStorage::new(2); + + // add + let one = controller.insert(1).unwrap(); + let two = controller.insert(2).unwrap(); + assert_eq!(controller.insert(3), Err(ResourceLimitReached)); + storage.remove_and_add(|_| false); + + // get + assert_eq!(storage.get_mut(one), Some(&mut 1)); + assert_eq!(storage.get_mut(two), Some(&mut 2)); + + // iter + assert_eq!( + storage.iter_mut().collect::>(), + vec![(two, &mut 2), (one, &mut 1)] + ); + + // remove + storage.remove_and_add(|&x| x < 2); + assert_eq!(storage.get_mut(one), None); + assert_eq!(storage.get_mut(two), Some(&mut 2)); + assert_eq!(storage.iter_mut().collect::>(), vec![(two, &mut 2)]); + + // re-add + let three = controller.insert(3).unwrap(); + assert_eq!(controller.insert(4), Err(ResourceLimitReached)); + storage.remove_and_add(|_| false); + assert_eq!(storage.get_mut(two), Some(&mut 2)); + assert_eq!(storage.get_mut(three), Some(&mut 3)); + assert_eq!( + storage.iter_mut().collect::>(), + vec![(three, &mut 3), (two, &mut 2)] + ); +} + +#[test] +fn self_referential_resource_storage() { + let (mut storage, mut controller) = SelfReferentialResourceStorage::new(2); + + // add + let one = controller.insert(1).unwrap(); + let two = controller.insert(2).unwrap(); + assert_eq!(controller.insert(3), Err(ResourceLimitReached)); + storage.remove_and_add(|_| false); + + // get + assert_eq!(storage.get_mut(one), Some(&mut 1)); + assert_eq!(storage.get_mut(two), Some(&mut 2)); + + // iter + assert_eq!( + storage.iter_mut().collect::>(), + vec![(two, &mut 2), (one, &mut 1)] + ); + + // remove + storage.remove_and_add(|&x| x < 2); + assert_eq!(storage.get_mut(one), None); + assert_eq!(storage.get_mut(two), Some(&mut 2)); + assert_eq!(storage.iter_mut().collect::>(), vec![(two, &mut 2)]); + + // re-add + let three = controller.insert(3).unwrap(); + assert_eq!(controller.insert(4), Err(ResourceLimitReached)); + storage.remove_and_add(|_| false); + assert_eq!(storage.get_mut(two), Some(&mut 2)); + assert_eq!(storage.get_mut(three), Some(&mut 3)); + assert_eq!( + storage.iter_mut().collect::>(), + vec![(three, &mut 3), (two, &mut 2)] + ); +} diff --git a/third_party/kira/src/clock.rs b/third_party/kira/src/clock.rs new file mode 100644 index 00000000..519d3c5d --- /dev/null +++ b/third_party/kira/src/clock.rs @@ -0,0 +1,355 @@ +/*! +Precise timing for audio events. + +Clocks can be used to set the start times of sounds and tweens. To create a +clock, use [`AudioManager::add_clock`](crate::AudioManager::add_clock). + +```no_run +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + clock::ClockSpeed, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let mut clock = manager.add_clock(ClockSpeed::SecondsPerTick(1.0))?; +clock.start(); +# Result::<(), Box>::Ok(()) +``` + +You can specify the speed of the clock as seconds per tick, ticks per second, or +ticks per minute. + +Clocks are stopped when you first create them, so be sure to explicitly call +[`ClockHandle::start`] when you want the clock to start ticking. + +## Starting sounds on clock ticks + +Sounds can be set to only start playing when a clock has ticked a certain +number of times. You can configure this using +[`StaticSoundData::start_time`](crate::sound::static_sound::StaticSoundData::start_time) +or [`StreamingSoundData::start_time`](crate::sound::streaming::StreamingSoundData::start_time). + +```no_run +use kira::{ + clock::{ClockTime, ClockSpeed}, + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + StartTime, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let mut clock = manager.add_clock(ClockSpeed::SecondsPerTick(1.0))?; +manager.play( + StaticSoundData::from_file("sound.ogg")? + .start_time(StartTime::ClockTime(ClockTime { + clock: clock.id(), + ticks: 4, + fraction: 0.0, + })) +)?; +clock.start(); +# Result::<(), Box>::Ok(()) +``` + +As a shorthand, you can pass the [`ClockTime`] directly into +the `start_time` function. + +```no_run +# use kira::{ +# clock::{ClockTime, ClockSpeed}, +# AudioManager, +# AudioManagerSettings, +# DefaultBackend, +# sound::static_sound::{StaticSoundData, StaticSoundSettings}, +# StartTime, +# }; +# +# let mut manager = AudioManager::::new( +# AudioManagerSettings::default(), +# )?; +# let mut clock = manager.add_clock(ClockSpeed::SecondsPerTick(1.0))?; +manager.play( + StaticSoundData::from_file("sound.ogg")? + .start_time(ClockTime { + clock: clock.id(), + ticks: 4, + fraction: 0.0, + }) +)?; +# clock.start(); +# Result::<(), Box>::Ok(()) +``` + +As an even shorter hand, you can use [`ClockHandle::time`] to get the clock's +current [`ClockTime`], and then add to it to get a time in the future: + +```no_run +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + clock::ClockSpeed, +}; + +# let mut manager = AudioManager::::new( +# AudioManagerSettings::default(), +# )?; +# let mut clock = manager.add_clock(ClockSpeed::SecondsPerTick(1.0))?; +manager.play( + StaticSoundData::from_file("sound.ogg")? + .start_time(clock.time() + 4) +)?; +# clock.start(); +# Result::<(), Box>::Ok(()) +``` + +## Starting tweens on clock ticks + +You can also use clocks to set the start time of tweens. In this example, we set +the playback rate of a sound to start tweening when a clock reaches its third +tick. + +```no_run +use std::time::Duration; + +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + Tween, + clock::ClockSpeed, + StartTime, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let mut clock = manager.add_clock(ClockSpeed::SecondsPerTick(1.0))?; +let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; +sound.set_playback_rate( + 0.5, + Tween { + start_time: StartTime::ClockTime(clock.time() + 3), + duration: Duration::from_secs(2), + ..Default::default() + }, +); +clock.start(); +# Result::<(), Box>::Ok(()) +``` +*/ + +mod clock_speed; +mod handle; +mod time; + +#[cfg(test)] +mod test; + +use atomic_arena::Key; +pub use clock_speed::*; +pub use handle::*; +pub use time::*; + +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, +}; + +use crate::{ + Parameter, Value, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + info::Info, +}; + +/// A unique identifier for a clock. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ClockId(pub(crate) Key); + +#[derive(Debug)] +pub(crate) struct ClockShared { + ticking: AtomicBool, + ticks: AtomicU64, + fractional_position: AtomicU64, + removed: AtomicBool, +} + +impl ClockShared { + #[must_use] + pub fn new() -> Self { + Self { + ticking: AtomicBool::new(false), + ticks: AtomicU64::new(0), + fractional_position: AtomicU64::new(0.0f64.to_bits()), + removed: AtomicBool::new(false), + } + } + + #[must_use] + pub fn ticking(&self) -> bool { + self.ticking.load(Ordering::SeqCst) + } + + #[must_use] + pub fn ticks(&self) -> u64 { + self.ticks.load(Ordering::SeqCst) + } + + #[must_use] + pub fn fractional_position(&self) -> f64 { + f64::from_bits(self.fractional_position.load(Ordering::SeqCst)) + } + + #[must_use] + pub fn is_marked_for_removal(&self) -> bool { + self.removed.load(Ordering::SeqCst) + } + + pub fn mark_for_removal(&self) { + self.removed.store(true, Ordering::SeqCst); + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum State { + NotStarted, + Started { + ticks: u64, + fractional_position: f64, + }, +} + +pub(crate) struct Clock { + command_readers: CommandReaders, + shared: Arc, + ticking: bool, + speed: Parameter, + state: State, +} + +impl Clock { + #[must_use] + pub(crate) fn new(speed: Value, id: ClockId) -> (Self, ClockHandle) { + let (command_writers, command_readers) = command_writers_and_readers(); + let shared = Arc::new(ClockShared::new()); + ( + Self { + command_readers, + shared: shared.clone(), + ticking: false, + speed: Parameter::new(speed, ClockSpeed::TicksPerMinute(120.0)), + state: State::NotStarted, + }, + ClockHandle { + id, + shared, + command_writers, + }, + ) + } + + #[must_use] + pub(crate) fn without_handle(speed: Value) -> Self { + let (_, command_readers) = command_writers_and_readers(); + Self { + command_readers, + shared: Arc::new(ClockShared::new()), + ticking: false, + speed: Parameter::new(speed, ClockSpeed::TicksPerMinute(120.0)), + state: State::NotStarted, + } + } + + #[must_use] + pub(crate) fn shared(&self) -> Arc { + self.shared.clone() + } + + #[must_use] + pub(crate) fn state(&self) -> State { + self.state + } + + #[must_use] + pub(crate) fn ticking(&self) -> bool { + self.ticking + } + + pub(crate) fn on_start_processing(&mut self) { + read_commands_into_parameters!(self, speed); + if let Some(ticking) = self.command_readers.set_ticking.read() { + self.set_ticking(ticking); + } + if self.command_readers.reset.read().is_some() { + self.reset(); + } + self.update_shared(); + } + + fn set_ticking(&mut self, ticking: bool) { + self.ticking = ticking; + self.shared.ticking.store(ticking, Ordering::SeqCst); + } + + fn reset(&mut self) { + self.state = State::NotStarted; + self.shared.ticks.store(0, Ordering::SeqCst); + } + + fn update_shared(&mut self) { + let (ticks, fractional_position) = match &self.state { + State::NotStarted => (0, 0.0), + State::Started { + ticks, + fractional_position, + } => (*ticks, *fractional_position), + }; + self.shared.ticks.store(ticks, Ordering::SeqCst); + self.shared + .fractional_position + .store(fractional_position.to_bits(), Ordering::SeqCst); + } + + /// Updates the [`Clock`]. + /// + /// If the tick count changes this update, returns `Some(tick_number)`. + /// Otherwise, returns `None`. + pub(crate) fn update(&mut self, dt: f64, info: &Info) -> Option { + self.speed.update(dt, info); + if !self.ticking { + return None; + } + let mut new_tick_count = None; + if self.state == State::NotStarted { + self.state = State::Started { + ticks: 0, + fractional_position: 0.0, + }; + new_tick_count = Some(0); + } + if let State::Started { + ticks, + fractional_position: tick_timer, + } = &mut self.state + { + *tick_timer += self.speed.value().as_ticks_per_second() * dt; + while *tick_timer >= 1.0 { + *tick_timer -= 1.0; + *ticks += 1; + new_tick_count = Some(*ticks); + } + } else { + panic!("clock state should be Started by now"); + } + new_tick_count + } +} + +impl Default for Clock { + fn default() -> Self { + Self::without_handle(Value::Fixed(ClockSpeed::TicksPerSecond(0.0))) + } +} + +command_writers_and_readers! { + set_speed: ValueChangeCommand, + set_ticking: bool, + reset: (), +} diff --git a/third_party/kira/src/clock/clock_speed.rs b/third_party/kira/src/clock/clock_speed.rs new file mode 100644 index 00000000..b79e0947 --- /dev/null +++ b/third_party/kira/src/clock/clock_speed.rs @@ -0,0 +1,121 @@ +use crate::{Value, tween::Tweenable}; + +/// The rate that a [clock](crate::clock) ticks at. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ClockSpeed { + /// The clock ticks every x seconds. + SecondsPerTick(f64), + /// The clock ticks x times per second. + TicksPerSecond(f64), + /// The clock ticks x times per minute. + TicksPerMinute(f64), +} + +impl ClockSpeed { + /// Returns the [`ClockSpeed`] as a number of seconds between each tick. + #[must_use] + pub fn as_seconds_per_tick(&self) -> f64 { + match self { + ClockSpeed::SecondsPerTick(seconds_per_tick) => *seconds_per_tick, + ClockSpeed::TicksPerSecond(ticks_per_second) => 1.0 / *ticks_per_second, + ClockSpeed::TicksPerMinute(ticks_per_minute) => 60.0 / *ticks_per_minute, + } + } + + /// Returns the [`ClockSpeed`] as a number of ticks per second. + #[must_use] + pub fn as_ticks_per_second(&self) -> f64 { + match self { + ClockSpeed::SecondsPerTick(seconds_per_tick) => 1.0 / *seconds_per_tick, + ClockSpeed::TicksPerSecond(ticks_per_second) => *ticks_per_second, + ClockSpeed::TicksPerMinute(ticks_per_minute) => *ticks_per_minute / 60.0, + } + } + + /// Returns the [`ClockSpeed`] as a number of ticks per minute. + #[must_use] + pub fn as_ticks_per_minute(&self) -> f64 { + match self { + ClockSpeed::SecondsPerTick(seconds_per_tick) => 60.0 / *seconds_per_tick, + ClockSpeed::TicksPerSecond(ticks_per_second) => *ticks_per_second * 60.0, + ClockSpeed::TicksPerMinute(ticks_per_minute) => *ticks_per_minute, + } + } +} + +impl Tweenable for ClockSpeed { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + match b { + ClockSpeed::SecondsPerTick(b) => ClockSpeed::SecondsPerTick(Tweenable::interpolate( + a.as_seconds_per_tick(), + b, + amount, + )), + ClockSpeed::TicksPerSecond(b) => ClockSpeed::TicksPerSecond(Tweenable::interpolate( + a.as_ticks_per_second(), + b, + amount, + )), + ClockSpeed::TicksPerMinute(b) => ClockSpeed::TicksPerMinute(Tweenable::interpolate( + a.as_ticks_per_minute(), + b, + amount, + )), + } + } +} + +impl From for Value { + fn from(clock_speed: ClockSpeed) -> Self { + Value::Fixed(clock_speed) + } +} + +#[cfg(test)] +#[test] +#[allow(clippy::float_cmp)] +fn test() { + const SECONDS_PER_TICK: f64 = 0.5; + const TICKS_PER_SECOND: f64 = 2.0; + const TICKS_PER_MINUTE: f64 = 120.0; + + assert_eq!( + ClockSpeed::SecondsPerTick(SECONDS_PER_TICK).as_seconds_per_tick(), + SECONDS_PER_TICK + ); + assert_eq!( + ClockSpeed::SecondsPerTick(SECONDS_PER_TICK).as_ticks_per_second(), + TICKS_PER_SECOND + ); + assert_eq!( + ClockSpeed::SecondsPerTick(SECONDS_PER_TICK).as_ticks_per_minute(), + TICKS_PER_MINUTE + ); + + assert_eq!( + ClockSpeed::TicksPerSecond(TICKS_PER_SECOND).as_seconds_per_tick(), + SECONDS_PER_TICK + ); + assert_eq!( + ClockSpeed::TicksPerSecond(TICKS_PER_SECOND).as_ticks_per_second(), + TICKS_PER_SECOND + ); + assert_eq!( + ClockSpeed::TicksPerSecond(TICKS_PER_SECOND).as_ticks_per_minute(), + TICKS_PER_MINUTE + ); + + assert_eq!( + ClockSpeed::TicksPerMinute(TICKS_PER_MINUTE).as_seconds_per_tick(), + SECONDS_PER_TICK + ); + assert_eq!( + ClockSpeed::TicksPerMinute(TICKS_PER_MINUTE).as_ticks_per_second(), + TICKS_PER_SECOND + ); + assert_eq!( + ClockSpeed::TicksPerMinute(TICKS_PER_MINUTE).as_ticks_per_minute(), + TICKS_PER_MINUTE + ); +} diff --git a/third_party/kira/src/clock/handle.rs b/third_party/kira/src/clock/handle.rs new file mode 100644 index 00000000..2ace734e --- /dev/null +++ b/third_party/kira/src/clock/handle.rs @@ -0,0 +1,81 @@ +use std::sync::{Arc, atomic::Ordering}; + +use crate::command::handle_param_setters; + +use super::{ClockId, ClockShared, ClockSpeed, ClockTime, CommandWriters}; + +/// Controls a clock. +/// +/// When a [`ClockHandle`] is dropped, the corresponding clock +/// will be removed. +#[derive(Debug)] +pub struct ClockHandle { + pub(crate) id: ClockId, + pub(crate) shared: Arc, + pub(crate) command_writers: CommandWriters, +} + +impl ClockHandle { + /// Returns the unique identifier for the clock. + #[must_use] + pub fn id(&self) -> ClockId { + self.id + } + + /// Returns `true` if the clock is currently ticking + /// and `false` if not. + #[must_use] + pub fn ticking(&self) -> bool { + self.shared.ticking() + } + + /// Returns the current time of the clock. + #[must_use] + pub fn time(&self) -> ClockTime { + ClockTime { + clock: self.id, + ticks: self.shared.ticks(), + fraction: self.shared.fractional_position(), + } + } + + handle_param_setters! { + /// Sets the speed of the clock. + speed: ClockSpeed, + } + + /// Starts or resumes the clock. + pub fn start(&mut self) { + self.command_writers.set_ticking.write(true) + } + + /// Pauses the clock. + pub fn pause(&mut self) { + self.command_writers.set_ticking.write(false) + } + + /// Stops and resets the clock. + pub fn stop(&mut self) { + self.command_writers.set_ticking.write(false); + self.command_writers.reset.write(()); + // store 0 as the current time so any reads that happen + // immediately after a stop don't errantly get the previous + // clock time + self.shared.ticks.store(0, Ordering::SeqCst); + self.shared + .fractional_position + .store(0.0f64.to_bits(), Ordering::SeqCst); + } +} + +impl Drop for ClockHandle { + fn drop(&mut self) { + self.shared.mark_for_removal(); + } +} + +impl From<&ClockHandle> for ClockId { + fn from(handle: &ClockHandle) -> Self { + handle.id() + } +} diff --git a/third_party/kira/src/clock/test.rs b/third_party/kira/src/clock/test.rs new file mode 100644 index 00000000..e01eb30f --- /dev/null +++ b/third_party/kira/src/clock/test.rs @@ -0,0 +1,209 @@ +use std::time::Duration; + +use atomic_arena::Arena; + +use crate::{StartTime, Tween, Value, clock::ClockTime, info::MockInfoBuilder}; + +use super::{Clock, ClockId, ClockSpeed}; + +/// Tests that a `Clock` is stopped when it's first created. +#[test] +fn initially_stopped() { + let (mut clock, handle) = Clock::new( + Value::Fixed(ClockSpeed::SecondsPerTick(1.0)), + fake_clock_id(), + ); + for _ in 0..3 { + assert!(!handle.ticking()); + assert_eq!(handle.time().ticks, 0); + clock.update(1.0, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + } +} + +/// Tests that a `Clock` ticks. +#[test] +fn basic_behavior() { + let (mut clock, mut handle) = Clock::new( + Value::Fixed(ClockSpeed::SecondsPerTick(1.0)), + fake_clock_id(), + ); + handle.start(); + clock.on_start_processing(); + for i in 0..3 { + assert!(handle.ticking()); + assert_eq!(handle.time().ticks, i); + assert_eq!( + clock.update(1.0, &MockInfoBuilder::new().build()), + Some(i + 1) + ); + clock.on_start_processing(); + } +} + +/// Tests that a `Clock` can be paused. +#[test] +fn pause() { + let (mut clock, mut handle) = Clock::new( + Value::Fixed(ClockSpeed::SecondsPerTick(1.0)), + fake_clock_id(), + ); + handle.start(); + clock.on_start_processing(); + clock.update(1.5, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 1); + handle.pause(); + clock.on_start_processing(); + // the clock should not be ticking + for _ in 0..3 { + clock.update(1.0, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert!(!handle.ticking()); + assert_eq!(handle.time().ticks, 1); + } + handle.start(); + clock.on_start_processing(); + // make sure we've preserved the fractional position from before + // pausing + clock.update(0.4, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 1); + clock.update(0.1, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 2); +} + +/// Tests that a `Clock` can be stopped. +#[test] +fn stop() { + let (mut clock, mut handle) = Clock::new( + Value::Fixed(ClockSpeed::SecondsPerTick(1.0)), + fake_clock_id(), + ); + handle.start(); + clock.on_start_processing(); + clock.update(1.5, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + handle.stop(); + clock.on_start_processing(); + // the clock should not be ticking + for _ in 0..3 { + clock.update(1.0, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert!(!handle.ticking()); + assert_eq!(handle.time().ticks, 0); + } + handle.start(); + clock.on_start_processing(); + // make sure the fractional position has been reset + clock.update(0.9, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 0); + clock.update(0.1, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 1); +} + +/// Tests that the speed of a [`Clock`] can be changed after creation. +#[test] +fn set_speed() { + let (mut clock, mut handle) = Clock::new( + Value::Fixed(ClockSpeed::SecondsPerTick(1.0)), + fake_clock_id(), + ); + handle.start(); + clock.on_start_processing(); + handle.set_speed( + Value::Fixed(ClockSpeed::SecondsPerTick(0.5)), + Tween { + duration: Duration::ZERO, + ..Default::default() + }, + ); + clock.on_start_processing(); + clock.update(1.0, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 2); + clock.update(1.0, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 4); +} + +/// Tests that a clock speed tween properly responds to ticks from +/// other clocks when the start time is set to a clock time. +#[test] +fn set_speed_with_clock_time_start() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let (mut clock, mut handle) = Clock::new( + Value::Fixed(ClockSpeed::SecondsPerTick(1.0)), + fake_clock_id(), + ); + handle.start(); + clock.on_start_processing(); + handle.set_speed( + Value::Fixed(ClockSpeed::SecondsPerTick(0.5)), + Tween { + duration: Duration::ZERO, + start_time: StartTime::ClockTime(ClockTime { + clock: clock_id, + ticks: 1, + fraction: 0.0, + }), + ..Default::default() + }, + ); + clock.on_start_processing(); + + clock.update(1.0, &info); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 1); + clock.update(1.0, &info); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 2); + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.build() + }; + + clock.update(1.0, &info); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 4); + clock.update(1.0, &info); + clock.on_start_processing(); + assert_eq!(handle.time().ticks, 6); +} + +/// Tests that a clock correctly reports its fractional position. +#[test] +fn fractional_position() { + let (mut clock, mut handle) = Clock::new( + Value::Fixed(ClockSpeed::SecondsPerTick(1.0)), + fake_clock_id(), + ); + assert_eq!(handle.time().fraction, 0.0); + // the clock is not started yet, so the fractional position should remain at 0 + clock.update(1.0, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().fraction, 0.0); + // start the clock + handle.start(); + clock.on_start_processing(); + clock.update(0.5, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().fraction, 0.5); + clock.update(0.75, &MockInfoBuilder::new().build()); + clock.on_start_processing(); + assert_eq!(handle.time().fraction, 0.25); +} + +fn fake_clock_id() -> ClockId { + let mut arena = Arena::new(1); + let key = arena.insert(()).unwrap(); + ClockId(key) +} diff --git a/third_party/kira/src/clock/time.rs b/third_party/kira/src/clock/time.rs new file mode 100644 index 00000000..5ad9220b --- /dev/null +++ b/third_party/kira/src/clock/time.rs @@ -0,0 +1,212 @@ +#[cfg(test)] +mod test; + +use std::{ + cmp::Ordering, + ops::{Add, AddAssign, Sub, SubAssign}, +}; + +use super::ClockId; + +/** +An instant in time associated with a clock. + +`ClockTime`s implement [`PartialOrd`]. They can be compared as long +as both times are associated with the same clock. If the clocks are +different, `a.cmp(b)` will return `None`. +*/ +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ClockTime { + /// The clock this time is associated with. + pub clock: ClockId, + /// The elapsed time in whole ticks. + pub ticks: u64, + /// The amount of time since the last tick as a fraction of a tick. + /// + /// This will always be in the range of `0.0` (inclusive) to `1.0` (exclusive). + pub fraction: f64, +} + +impl ClockTime { + /** + Creates a [`ClockTime`] for a clock with the specified number of + whole ticks. + + # Example + + ```no_run + use kira::{ + AudioManager, + AudioManagerSettings, + DefaultBackend, + clock::{ClockSpeed, ClockTime}, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let clock = manager.add_clock(ClockSpeed::TicksPerMinute(120.0))?; + assert_eq!( + ClockTime::from_ticks_u64(&clock, 3), + ClockTime { + clock: clock.id(), + ticks: 3, + fraction: 0.0, + }, + ); + # Ok::<(), Box>(()) + ``` + */ + pub fn from_ticks_u64(clock: impl Into, ticks: u64) -> Self { + Self { + clock: clock.into(), + ticks, + fraction: 0.0, + } + } + + /** + Creates a [`ClockTime`] for a clock with the specified number of + ticks. + + # Example + + ```no_run + use kira::{ + AudioManager, + AudioManagerSettings, + DefaultBackend, + clock::{ClockSpeed, ClockTime}, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let clock = manager.add_clock(ClockSpeed::TicksPerMinute(120.0))?; + assert_eq!( + ClockTime::from_ticks_f64(&clock, 3.5), + ClockTime { + clock: clock.id(), + ticks: 3, + fraction: 0.5, + }, + ); + # Ok::<(), Box>(()) + ``` + */ + pub fn from_ticks_f64(clock: impl Into, ticks: f64) -> Self { + Self { + clock: clock.into(), + ticks: ticks as u64, + fraction: ticks.fract(), + } + } +} + +impl Add for ClockTime { + type Output = ClockTime; + + fn add(self, ticks: u64) -> Self::Output { + Self { + clock: self.clock, + ticks: self.ticks + ticks, + fraction: self.fraction, + } + } +} + +impl AddAssign for ClockTime { + fn add_assign(&mut self, ticks: u64) { + self.ticks += ticks; + } +} + +impl Add for ClockTime { + type Output = ClockTime; + + fn add(self, ticks: f64) -> Self::Output { + debug_assert!( + ticks.is_finite(), + "Cannot add a non-finite amount to ClockTime: {ticks:?}" + ); + + // This implementation does not allow adding negative numbers, use the Sub impl instead + if ticks.is_sign_negative() { + return self.sub(-ticks); + } + + let fraction = (self.fraction + ticks).fract(); + let ticks = self.ticks + (self.fraction + ticks).trunc() as u64; + + Self { + clock: self.clock, + ticks, + fraction, + } + } +} + +impl AddAssign for ClockTime { + fn add_assign(&mut self, ticks: f64) { + *self = *self + ticks; + } +} + +impl Sub for ClockTime { + type Output = ClockTime; + + fn sub(self, ticks: u64) -> Self::Output { + Self { + clock: self.clock, + ticks: self.ticks - ticks, + fraction: self.fraction, + } + } +} + +impl SubAssign for ClockTime { + fn sub_assign(&mut self, ticks: u64) { + self.ticks -= ticks; + } +} + +impl Sub for ClockTime { + type Output = ClockTime; + + fn sub(self, ticks: f64) -> Self::Output { + debug_assert!( + ticks.is_finite(), + "Cannot subtract a non-finite amount from ClockTime: {ticks:?}" + ); + + // This implementation does not allow subtracting negative numbers, use the Add impl instead + if ticks.is_sign_negative() { + return self.add(-ticks); + } + + let fraction = ((self.fraction - ticks).fract() + 1.0) % 1.0; + let ticks = self + .ticks + .saturating_sub((ticks - self.fraction).ceil() as u64); + + Self { + clock: self.clock, + ticks, + fraction, + } + } +} + +impl SubAssign for ClockTime { + fn sub_assign(&mut self, ticks: f64) { + *self = *self - ticks; + } +} + +impl PartialOrd for ClockTime { + fn partial_cmp(&self, other: &Self) -> Option { + if self.clock != other.clock { + return None; + } + match self.ticks.cmp(&other.ticks) { + Ordering::Equal => self.fraction.partial_cmp(&other.fraction), + ordering => Some(ordering), + } + } +} diff --git a/third_party/kira/src/clock/time/test.rs b/third_party/kira/src/clock/time/test.rs new file mode 100644 index 00000000..39344766 --- /dev/null +++ b/third_party/kira/src/clock/time/test.rs @@ -0,0 +1,254 @@ +use std::cmp::Ordering; + +use approx::assert_relative_eq; +use atomic_arena::Arena; + +use crate::clock::{ClockId, ClockTime}; + +#[test] +fn from_ticks_u64() { + let id = fake_clock_id(); + assert_eq!( + ClockTime::from_ticks_u64(id, 3), + ClockTime { + clock: id, + ticks: 3, + fraction: 0.0 + } + ) +} + +#[test] +fn from_ticks_f64() { + let id = fake_clock_id(); + assert_eq!( + ClockTime::from_ticks_f64(id, 3.5), + ClockTime { + clock: id, + ticks: 3, + fraction: 0.5 + } + ) +} + +#[test] +fn add_u64() { + let id = fake_clock_id(); + assert_eq!( + ClockTime { + clock: id, + ticks: 2, + fraction: 0.0, + } + 3, + ClockTime { + clock: id, + ticks: 5, + fraction: 0.0, + } + ); + assert_eq!( + ClockTime { + clock: id, + ticks: 2, + fraction: 0.5, + } + 2, + ClockTime { + clock: id, + ticks: 4, + fraction: 0.5, + } + ); +} + +#[test] +fn sub_u64() { + let id = fake_clock_id(); + assert_eq!( + ClockTime { + clock: id, + ticks: 5, + fraction: 0.0, + } - 3, + ClockTime { + clock: id, + ticks: 2, + fraction: 0.0, + } + ); + assert_eq!( + ClockTime { + clock: id, + ticks: 4, + fraction: 0.5, + } - 2, + ClockTime { + clock: id, + ticks: 2, + fraction: 0.5, + } + ); +} + +#[test] +fn add_f64() { + let id = fake_clock_id(); + let time = ClockTime { + clock: id, + ticks: 2, + fraction: 0.5, + } + 3.7; + assert_eq!(time.ticks, 6); + assert_relative_eq!(time.fraction, 0.2); + let time = ClockTime { + clock: id, + ticks: 2, + fraction: 0.5, + } + 3.5; + assert_eq!(time.ticks, 6); + assert_relative_eq!(time.fraction, 0.0); + let id = fake_clock_id(); + let time = ClockTime { + clock: id, + ticks: 5, + fraction: 0.5, + } + (-3.7); + assert_eq!(time.ticks, 1); + assert_relative_eq!(time.fraction, 0.8); +} + +#[test] +fn sub_f64() { + let id = fake_clock_id(); + let time = ClockTime { + clock: id, + ticks: 5, + fraction: 0.5, + } - 3.7; + assert_eq!(time.ticks, 1); + assert_relative_eq!(time.fraction, 0.8); + let time = ClockTime { + clock: id, + ticks: 5, + fraction: 0.5, + } - 3.5; + assert_eq!(time.ticks, 2); + assert_relative_eq!(time.fraction, 0.0); + let id = fake_clock_id(); + let time = ClockTime { + clock: id, + ticks: 2, + fraction: 0.5, + } - (-3.7); + assert_eq!(time.ticks, 6); + assert_relative_eq!(time.fraction, 0.2); +} + +#[test] +fn partial_cmp() { + let ids = fake_clock_ids(2); + assert_eq!( + ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.0 + } + .partial_cmp(&ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.0 + }), + Some(Ordering::Equal) + ); + assert_eq!( + ClockTime { + clock: ids[0], + ticks: 4, + fraction: 0.0 + } + .partial_cmp(&ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.0 + }), + Some(Ordering::Less) + ); + assert_eq!( + ClockTime { + clock: ids[0], + ticks: 6, + fraction: 0.0 + } + .partial_cmp(&ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.0 + }), + Some(Ordering::Greater) + ); + assert_eq!( + ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.0 + } + .partial_cmp(&ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.5 + }), + Some(Ordering::Less) + ); + assert_eq!( + ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.5 + } + .partial_cmp(&ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.0 + }), + Some(Ordering::Greater) + ); + assert_eq!( + ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.5 + } + .partial_cmp(&ClockTime { + clock: ids[1], + ticks: 5, + fraction: 0.0 + }), + None + ); + assert_eq!( + ClockTime { + clock: ids[0], + ticks: 5, + fraction: 0.5 + } + .partial_cmp(&ClockTime { + clock: ids[0], + ticks: 5, + fraction: f64::NAN, + }), + None + ); +} + +fn fake_clock_id() -> ClockId { + let mut arena = Arena::new(1); + let key = arena.insert(()).unwrap(); + ClockId(key) +} + +fn fake_clock_ids(count: usize) -> Vec { + let mut arena = Arena::new(count); + (0..count) + .map(|_| arena.insert(()).unwrap()) + .map(ClockId) + .collect() +} diff --git a/third_party/kira/src/command.rs b/third_party/kira/src/command.rs new file mode 100644 index 00000000..55256e01 --- /dev/null +++ b/third_party/kira/src/command.rs @@ -0,0 +1,180 @@ +/*! +Helpers for sending commands from the gameplay thread to the audio thread. +You'll only need to use this if you're making your own implementation of +[`Sound`](crate::sound::Sound), [`Effect`](crate::effect::Effect), +or [`Modulator`](crate::modulator::Modulator). + +This module provides a [`CommandWriter`] and [`CommandReader`] that can +transfer data from one thread to another without blocking or waiting on either +thread. `CommandReader::read` will return the latest data that was written to +the corresponding `CommandWriter` or return `None` if no new data was written +since the last read. + +Keep in mind that if multiple values are written to a `CommandWriter`, any newer +values will overwrite older values that haven't been read yet. Therefore, this +is only suitable when the reader only cares about the most recent value that +has been written; i.e. new values supercede all older values. If you need a +realtime-safe FIFO queue of multiple values, consider using a ring buffer, such as +[`RingBuffer`](rtrb::RingBuffer) from the [rtrb](https://crates.io/crates/rtrb) crate, +which Kira uses internally. +*/ + +use triple_buffer::{Input, Output, triple_buffer}; + +use crate::{Tween, Value}; + +/** Writes values that can be sent to a [`CommandReader`]. */ +#[derive(Debug)] +pub struct CommandWriter(Input>); + +impl CommandWriter { + /** Writes a new value, overwriting any previous values. */ + pub fn write(&mut self, command: T) { + self.0.write(Some(command)) + } +} + +/** Reads values that were written to a [`CommandWriter`]. */ +#[derive(Debug)] +pub struct CommandReader(Output>); + +impl CommandReader { + /** + * Returns the latest value that was written to the [`CommandWriter`], + * or `None` if no new values were written since the last read. + */ + #[must_use] + pub fn read(&mut self) -> Option { + if self.0.update() { + *self.0.output_buffer_mut() + } else { + None + } + } +} + +/** Creates a command writer/reader pair. */ +#[must_use] +pub fn command_writer_and_reader() -> (CommandWriter, CommandReader) { + let (input, output) = triple_buffer(&None); + (CommandWriter(input), CommandReader(output)) +} + +/** + * A command that holds a target [`Value`] and a [`Tween`]. + * + * Setting something to a [`Value`] with a given [`Tween`] is a common + * pattern in Kira. + * + * `CommandReader`s can be passed to [`Parameter`](crate::Parameter)s + * to quickly set the parameter to a new value read from the [`CommandReader`]. + */ +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ValueChangeCommand { + /// The new value to set something to. + pub target: Value, + /// The tween to use to smoothly transition the value. + pub tween: Tween, +} + +/** +Creates a set of command writers and readers and a constructor for them. +You'll only need to use this if you're making your own implementation of +[`Sound`](crate::sound::Sound), [`Effect`](crate::effect::Effect), +or [`Modulator`](crate::modulator::Modulator). + +# Example + +This macro call... + +```ignore +command_writers_and_readers! { + set_phase: f64, + set_frequency: ValueChangeCommand, +} +``` + +...will produce this code: + +```ignore +pub(crate) struct CommandWriters { + set_phase: kira::command::CommandWriter, + set_frequency: kira::command::CommandWriter>, +} + +pub(crate) struct CommandReaders { + set_phase: kira::command::CommandReader, + set_frequency: kira::command::CommandReader>, +} + +#[must_use] +pub(crate) fn command_writers_and_readers() -> (CommandWriters, CommandReaders) { + let (set_phase_writer, set_phase_reader) = kira::command::command_writer_and_reader(); + let (set_frequency_writer, set_frequency_reader) = kira::command::command_writer_and_reader(); + let command_writers = CommandWriters { + set_phase: set_phase_writer, + set_frequency: set_frequency_writer, + }; + let command_readers = CommandReaders { + set_phase: set_phase_reader, + set_frequency: set_frequency_reader, + }; + (command_writers, command_readers) +} +``` +*/ +#[macro_export] +macro_rules! command_writers_and_readers { + ($($field_name:ident: $type:ty),*$(,)?) => { + #[derive(Debug)] + pub(crate) struct CommandWriters { + $($field_name: $crate::command::CommandWriter<$type>),* + } + + #[derive(Debug)] + pub(crate) struct CommandReaders { + $($field_name: $crate::command::CommandReader<$type>),* + } + + #[must_use] + pub(crate) fn command_writers_and_readers() -> (CommandWriters, CommandReaders) { + $crate::pastey::paste! { + $(let ([<$field_name _writer>], [<$field_name _reader>]) = $crate::command::command_writer_and_reader();)* + let command_writers = CommandWriters { + $($field_name: [<$field_name _writer>]),* + }; + let command_readers = CommandReaders { + $($field_name: [<$field_name _reader>]),* + }; + (command_writers, command_readers) + } + } + }; +} + +macro_rules! read_commands_into_parameters { + ($self:ident, $($parameter_name:ident),*$(,)?) => { + $crate::pastey::paste! { + $($self.$parameter_name.read_command(&mut $self.command_readers.[]);)* + } + }; +} + +macro_rules! handle_param_setters { + ($($(#[$m:meta])* $name:ident: $type:ty),*$(,)?) => { + $crate::pastey::paste! { + $( + $(#[$m])* + pub fn [](&mut self, $name: impl Into<$crate::Value<$type>>, tween: $crate::tween::Tween) { + self.command_writers.[].write($crate::command::ValueChangeCommand { + target: $name.into(), + tween, + }) + } + )* + } + }; +} + +pub(crate) use handle_param_setters; +pub(crate) use read_commands_into_parameters; diff --git a/third_party/kira/src/decibels.rs b/third_party/kira/src/decibels.rs new file mode 100644 index 00000000..1420498f --- /dev/null +++ b/third_party/kira/src/decibels.rs @@ -0,0 +1,118 @@ +use std::ops::{Add, AddAssign, Sub, SubAssign}; + +use crate::{Value, tween::Tweenable}; + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +/// Represents a change in volume. +/// +/// Higher values increase the volume and lower values decrease it. +/// Setting the volume of a sound to -60dB or lower makes it silent. +pub struct Decibels(pub f32); + +impl Decibels { + /// The minimum decibel value at which a sound is considered + /// silent. + pub const SILENCE: Self = Self(-60.0); + /// The decibel value that produces no change in volume. + pub const IDENTITY: Self = Self(0.0); + + /// Converts decibels to amplitude, a linear volume measurement. + /// + /// This returns a number from `0.0`-`1.0` that you can multiply + /// a singal by to change its volume. + pub fn as_amplitude(self) -> f32 { + // adding a special case for db == 0.0 improves + // performance in the sound playback benchmarks + // by about 7% + if self == Self(0.0) { + return 1.0; + } + if self <= Self::SILENCE { + return 0.0; + } + 10.0f32.powf(self.0 / 20.0) + } +} + +impl Default for Decibels { + fn default() -> Self { + Self::IDENTITY + } +} + +impl Tweenable for Decibels { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + Self(Tweenable::interpolate(a.0, b.0, amount)) + } +} + +impl From for Decibels { + fn from(value: f32) -> Self { + Self(value) + } +} + +impl From for Value { + fn from(value: f32) -> Self { + Value::Fixed(Decibels(value)) + } +} + +impl From for Value { + fn from(value: Decibels) -> Self { + Value::Fixed(value) + } +} + +impl Add for Decibels { + type Output = Decibels; + + fn add(self, rhs: Decibels) -> Self::Output { + Self(self.0 + rhs.0) + } +} + +impl AddAssign for Decibels { + fn add_assign(&mut self, rhs: Decibels) { + self.0 += rhs.0; + } +} + +impl Sub for Decibels { + type Output = Decibels; + + fn sub(self, rhs: Decibels) -> Self::Output { + Self(self.0 - rhs.0) + } +} + +impl SubAssign for Decibels { + fn sub_assign(&mut self, rhs: Decibels) { + self.0 -= rhs.0; + } +} + +#[cfg(test)] +#[test] +#[allow(clippy::float_cmp)] +fn test() { + /// A table of dB values to the corresponding amplitudes. + // Data gathered from https://www.silisoftware.com/tools/db.php + const TEST_CALCULATIONS: [(Decibels, f32); 6] = [ + (Decibels::IDENTITY, 1.0), + (Decibels(3.0), 1.4125376), + (Decibels(12.0), 3.9810717), + (Decibels(-3.0), 0.70794576), + (Decibels(-12.0), 0.25118864), + (Decibels::SILENCE, 0.0), + ]; + + for (decibels, amplitude) in TEST_CALCULATIONS { + assert!((decibels.as_amplitude() - amplitude).abs() < 0.00001); + } + + // test some special cases + assert_eq!((Decibels::SILENCE - Decibels(100.0)).as_amplitude(), 0.0); +} diff --git a/third_party/kira/src/effect.rs b/third_party/kira/src/effect.rs new file mode 100644 index 00000000..8d492c03 --- /dev/null +++ b/third_party/kira/src/effect.rs @@ -0,0 +1,55 @@ +/*! +Modifies audio signals. + +Any type that implements [`EffectBuilder`] can be added to a mixer track by +using [`TrackBuilder::add_effect`](crate::track::TrackBuilder::add_effect). Kira +comes with a number of commonly used effects. + +If needed, you can create custom effects by implementing the [`EffectBuilder`] +and [`Effect`] traits. +*/ + +pub mod compressor; +pub mod delay; +pub mod distortion; +pub mod eq_filter; +pub mod filter; +pub mod panning_control; +pub mod reverb; +pub mod volume_control; + +use crate::{frame::Frame, info::Info}; + +/// Configures an effect. +pub trait EffectBuilder { + /// Allows the user to control the effect from gameplay code. + type Handle; + + /// Creates the effect and a handle to the effect. + #[must_use] + fn build(self) -> (Box, Self::Handle); +} + +/// Receives input audio from a mixer track and outputs modified audio. +/// +/// For performance reasons, avoid allocating and deallocating memory in any methods +/// of this trait besides [`on_change_sample_rate`](Effect::on_change_sample_rate). +#[allow(unused_variables)] +pub trait Effect: Send { + /// Called when the effect is first sent to the renderer. + fn init(&mut self, sample_rate: u32, internal_buffer_size: usize) {} + + /// Called when the sample rate of the renderer is changed. + fn on_change_sample_rate(&mut self, sample_rate: u32) {} + + /// Called whenever a new batch of audio samples is requested by the backend. + /// + /// This is a good place to put code that needs to run fairly frequently, + /// but not for every single audio sample. + fn on_start_processing(&mut self) {} + + /// Transforms a slice of input [`Frame`]s. + /// + /// `dt` is the time between each frame (in seconds). + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info); +} diff --git a/third_party/kira/src/effect/compressor.rs b/third_party/kira/src/effect/compressor.rs new file mode 100644 index 00000000..1faa0928 --- /dev/null +++ b/third_party/kira/src/effect/compressor.rs @@ -0,0 +1,129 @@ +//! Reduces (or increases) the dynamic range of audio. + +// Loosely based on https://www.musicdsp.org/en/latest/Effects/204-simple-compressor-class-c.html + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use std::time::Duration; + +use crate::{ + Decibels, Mix, Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + frame::Frame, + info::Info, +}; + +use super::Effect; + +struct Compressor { + command_readers: CommandReaders, + threshold: Parameter, + ratio: Parameter, + attack_duration: Parameter, + release_duration: Parameter, + makeup_gain: Parameter, + mix: Parameter, + envelope_follower: [f32; 2], +} + +impl Compressor { + #[must_use] + fn new(builder: CompressorBuilder, command_readers: CommandReaders) -> Self { + Self { + command_readers, + threshold: Parameter::new(builder.threshold, CompressorBuilder::DEFAULT_THRESHOLD), + ratio: Parameter::new(builder.ratio, CompressorBuilder::DEFAULT_RATIO), + attack_duration: Parameter::new( + builder.attack_duration, + CompressorBuilder::DEFAULT_ATTACK_DURATION, + ), + release_duration: Parameter::new( + builder.release_duration, + CompressorBuilder::DEFAULT_RELEASE_DURATION, + ), + makeup_gain: Parameter::new( + builder.makeup_gain, + CompressorBuilder::DEFAULT_MAKEUP_GAIN, + ), + mix: Parameter::new(builder.mix, CompressorBuilder::DEFAULT_MIX), + envelope_follower: [0.0; 2], + } + } +} + +impl Effect for Compressor { + fn on_start_processing(&mut self) { + read_commands_into_parameters!( + self, + threshold, + ratio, + attack_duration, + release_duration, + makeup_gain, + mix, + ); + } + + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) { + self.threshold.update(dt * input.len() as f64, info); + self.ratio.update(dt * input.len() as f64, info); + self.attack_duration.update(dt * input.len() as f64, info); + self.release_duration.update(dt * input.len() as f64, info); + self.makeup_gain.update(dt * input.len() as f64, info); + self.mix.update(dt * input.len() as f64, info); + + let threshold = self.threshold.value() as f32; + let ratio = self.ratio.value() as f32; + let attack_duration = self.attack_duration.value(); + let release_duration = self.release_duration.value(); + + let num_frames = input.len(); + for (i, frame) in input.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let makeup_gain = self.makeup_gain.interpolated_value(time_in_chunk); + let mix = self.mix.interpolated_value(time_in_chunk).0.clamp(0.0, 1.0); + + let input_decibels = [ + 20.0 * frame.left.abs().log10(), + 20.0 * frame.right.abs().log10(), + ]; + let over_decibels = input_decibels.map(|input| (input - threshold).max(0.0)); + for (i, envelope_follower) in self.envelope_follower.iter_mut().enumerate() { + let duration = if *envelope_follower > over_decibels[i] { + release_duration + } else { + attack_duration + }; + let speed = (-1.0 / (duration.as_secs_f64() / dt)).exp(); + *envelope_follower = + over_decibels[i] + speed as f32 * (*envelope_follower - over_decibels[i]); + } + let gain_reduction = self + .envelope_follower + .map(|envelope_follower| envelope_follower * ((1.0 / ratio) - 1.0)); + let amplitude = + gain_reduction.map(|gain_reduction| 10.0f32.powf(gain_reduction / 20.0)); + let makeup_gain_linear = 10.0f32.powf(makeup_gain.0 / 20.0); + let output = Frame { + left: amplitude[0] * frame.left, + right: amplitude[1] * frame.right, + } * makeup_gain_linear; + + *frame = output * mix.sqrt() + *frame * (1.0 - mix).sqrt() + } + } +} + +command_writers_and_readers! { + set_threshold: ValueChangeCommand, + set_ratio: ValueChangeCommand, + set_attack_duration: ValueChangeCommand, + set_release_duration: ValueChangeCommand, + set_makeup_gain: ValueChangeCommand, + set_mix: ValueChangeCommand, +} diff --git a/third_party/kira/src/effect/compressor/builder.rs b/third_party/kira/src/effect/compressor/builder.rs new file mode 100644 index 00000000..7bef94ba --- /dev/null +++ b/third_party/kira/src/effect/compressor/builder.rs @@ -0,0 +1,140 @@ +use std::time::Duration; + +use crate::{ + Decibels, Mix, Value, + effect::{Effect, EffectBuilder}, +}; + +use super::{Compressor, CompressorHandle, command_writers_and_readers}; + +/// Configures a compressor. +pub struct CompressorBuilder { + /// The volume above which volume will start to be decreased (in decibels). + pub threshold: Value, + /// How much the signal will be compressed. + /// + /// A ratio of `2.0` (or 2 to 1) means an increase of 3dB will + /// become an increase of 1.5dB. Ratios between `0.0` and `1.0` + /// will actually expand the audio. + pub ratio: Value, + /// How much time it takes for the volume attenuation to ramp up once + /// the input volume exceeds the threshold. + pub attack_duration: Value, + /// How much time it takes for the volume attenuation to relax once + /// the input volume dips below the threshold. + pub release_duration: Value, + /// The amount to change the volume after processing (in dB). + /// + /// This can be used to compensate for the decrease in volume resulting + /// from compression. This is only applied to the wet signal, nto the + /// dry signal. + pub makeup_gain: Value, + /// How much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + pub mix: Value, +} + +impl CompressorBuilder { + pub(crate) const DEFAULT_THRESHOLD: f64 = 0.0; + pub(crate) const DEFAULT_RATIO: f64 = 1.0; + pub(crate) const DEFAULT_ATTACK_DURATION: Duration = Duration::from_millis(10); + pub(crate) const DEFAULT_RELEASE_DURATION: Duration = Duration::from_millis(100); + pub(crate) const DEFAULT_MAKEUP_GAIN: Decibels = Decibels(0.0); + pub(crate) const DEFAULT_MIX: Mix = Mix::WET; + + /// Creates a new [`CompressorBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self { + threshold: Value::Fixed(Self::DEFAULT_THRESHOLD), + ratio: Value::Fixed(Self::DEFAULT_RATIO), + attack_duration: Value::Fixed(Self::DEFAULT_ATTACK_DURATION), + release_duration: Value::Fixed(Self::DEFAULT_RELEASE_DURATION), + makeup_gain: Value::Fixed(Self::DEFAULT_MAKEUP_GAIN), + mix: Value::Fixed(Self::DEFAULT_MIX), + } + } + + /// Sets the volume above which volume will start to be decreased (in decibels). + #[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"] + pub fn threshold(self, threshold: impl Into>) -> Self { + Self { + threshold: threshold.into(), + ..self + } + } + + /// Sets how much the signal will be compressed. + /// + /// A ratio of `2.0` (or 2 to 1) means an increase of 3dB will + /// become an increase of 1.5dB. Ratios between `0.0` and `1.0` + /// will actually expand the audio. + #[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"] + pub fn ratio(self, ratio: impl Into>) -> Self { + Self { + ratio: ratio.into(), + ..self + } + } + + /// Sets how much time it takes for the volume attenuation to ramp up once + /// the input volume exceeds the threshold. + #[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"] + pub fn attack_duration(self, attack_duration: impl Into>) -> Self { + Self { + attack_duration: attack_duration.into(), + ..self + } + } + + /// Sets how much time it takes for the volume attenuation to relax once + /// the input volume dips below the threshold. + #[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"] + pub fn release_duration(self, release_duration: impl Into>) -> Self { + Self { + release_duration: release_duration.into(), + ..self + } + } + + /// Sets the amount to change the volume after processing (in decibels). + /// + /// This can be used to compensate for the decrease in volume resulting + /// from compression. This is only applied to the wet signal, nto the + /// dry signal. + #[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"] + pub fn makeup_gain(self, makeup_gain: impl Into>) -> Self { + Self { + makeup_gain: makeup_gain.into(), + ..self + } + } + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + #[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"] + pub fn mix(self, mix: impl Into>) -> Self { + Self { + mix: mix.into(), + ..self + } + } +} + +impl Default for CompressorBuilder { + fn default() -> Self { + Self::new() + } +} + +impl EffectBuilder for CompressorBuilder { + type Handle = CompressorHandle; + + fn build(self) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Box::new(Compressor::new(self, command_readers)), + CompressorHandle { command_writers }, + ) + } +} diff --git a/third_party/kira/src/effect/compressor/handle.rs b/third_party/kira/src/effect/compressor/handle.rs new file mode 100644 index 00000000..ca400bab --- /dev/null +++ b/third_party/kira/src/effect/compressor/handle.rs @@ -0,0 +1,44 @@ +use std::time::Duration; + +use crate::{Decibels, Mix, command::handle_param_setters}; + +use super::CommandWriters; + +/// Controls a compressor. +#[derive(Debug)] +pub struct CompressorHandle { + pub(super) command_writers: CommandWriters, +} + +impl CompressorHandle { + handle_param_setters! { + /// Sets the volume above which volume will start to be decreased (in decibels). + threshold: f64, + + /// Sets how much the signal will be compressed. + /// + /// A ratio of `2.0` (or 2 to 1) means an increase of 3dB will + /// become an increase of 1.5dB. Ratios between `0.0` and `1.0` + /// will actually expand the audio. + ratio: f64, + + /// Sets how much time it takes for the volume attenuation to ramp up once + /// the input volume exceeds the threshold. + attack_duration: Duration, + + /// Sets how much time it takes for the volume attenuation to relax once + /// the input volume dips below the threshold. + release_duration: Duration, + + /// Sets the amount to change the volume after processing (in dB). + /// + /// This can be used to compensate for the decrease in volume resulting + /// from compression. This is only applied to the wet signal, nto the + /// dry signal. + makeup_gain: Decibels, + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + mix: Mix, + } +} diff --git a/third_party/kira/src/effect/delay.rs b/third_party/kira/src/effect/delay.rs new file mode 100644 index 00000000..b2f3d5e1 --- /dev/null +++ b/third_party/kira/src/effect/delay.rs @@ -0,0 +1,114 @@ +//! Adds echoes to a sound. + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use std::time::Duration; + +use crate::{ + Decibels, Mix, Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + frame::Frame, + info::Info, +}; + +use super::Effect; + +struct Delay { + command_readers: CommandReaders, + delay_time: Duration, + feedback: Parameter, + mix: Parameter, + buffer: Vec, + feedback_effects: Vec>, + temp_buffer: Vec, +} + +impl Delay { + /// Creates a new delay effect. + #[must_use] + fn new(builder: DelayBuilder, command_readers: CommandReaders) -> Self { + Self { + command_readers, + delay_time: builder.delay_time, + feedback: Parameter::new(builder.feedback, Decibels(-6.0)), + mix: Parameter::new(builder.mix, Mix(0.5)), + buffer: Vec::with_capacity(0), + feedback_effects: builder.feedback_effects, + temp_buffer: vec![], + } + } +} + +impl Effect for Delay { + fn init(&mut self, sample_rate: u32, internal_buffer_size: usize) { + let delay_time_frames = (self.delay_time.as_secs_f64() * sample_rate as f64) as usize; + self.buffer = vec![Frame::ZERO; delay_time_frames]; + self.temp_buffer = vec![Frame::ZERO; internal_buffer_size]; + for effect in &mut self.feedback_effects { + effect.init(sample_rate, internal_buffer_size); + } + } + + fn on_change_sample_rate(&mut self, sample_rate: u32) { + let delay_time_frames = (self.delay_time.as_secs_f64() * sample_rate as f64) as usize; + self.buffer = vec![Frame::ZERO; delay_time_frames]; + for effect in &mut self.feedback_effects { + effect.on_change_sample_rate(sample_rate); + } + } + + fn on_start_processing(&mut self) { + read_commands_into_parameters!(self, feedback, mix); + for effect in &mut self.feedback_effects { + effect.on_start_processing(); + } + } + + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) { + self.feedback.update(dt * input.len() as f64, info); + self.mix.update(dt * input.len() as f64, info); + + for input in input.chunks_mut(self.buffer.len()) { + let num_frames = input.len(); + + // read from the beginning of the buffer and apply effects and feedback gain + self.temp_buffer[..input.len()].copy_from_slice(&self.buffer[..input.len()]); + for effect in &mut self.feedback_effects { + effect.process(&mut self.temp_buffer[..input.len()], dt, info); + } + for (i, frame) in self.temp_buffer[..input.len()].iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let feedback = self.feedback.interpolated_value(time_in_chunk); + *frame *= feedback.as_amplitude(); + } + + // write input + read buffer to the end of the buffer + self.buffer.copy_within(input.len().., 0); + let write_range = self.buffer.len() - input.len()..; + for ((out, input), read) in self.buffer[write_range] + .iter_mut() + .zip(input.iter()) + .zip(&mut self.temp_buffer[..input.len()]) + { + *out = *input + *read; + } + + // output mix of input and read buffer + for (i, frame) in input.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let mix = self.mix.interpolated_value(time_in_chunk).0.clamp(0.0, 1.0); + *frame = self.temp_buffer[i] * mix.sqrt() + *frame * (1.0 - mix).sqrt() + } + } + } +} + +command_writers_and_readers! { + set_feedback: ValueChangeCommand, + set_mix: ValueChangeCommand, +} diff --git a/third_party/kira/src/effect/delay/builder.rs b/third_party/kira/src/effect/delay/builder.rs new file mode 100644 index 00000000..92a515c5 --- /dev/null +++ b/third_party/kira/src/effect/delay/builder.rs @@ -0,0 +1,94 @@ +use std::time::Duration; + +use crate::{ + Decibels, Mix, Value, + effect::{Effect, EffectBuilder}, +}; + +use super::{Delay, DelayHandle, command_writers_and_readers}; + +/// Configures a delay effect. +pub struct DelayBuilder { + /// The amount of time the input audio is delayed by. + pub(super) delay_time: Duration, + /// The amount of feedback. + pub(super) feedback: Value, + /// Effects that should be applied in the feedback loop. + pub(super) feedback_effects: Vec>, + /// How much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + pub(super) mix: Value, +} + +impl DelayBuilder { + /// Creates a new [`DelayBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets the amount of time the input audio is delayed by. + #[must_use = "This method consumes self and returns a modified DelayBuilder, so the return value should be used"] + pub fn delay_time(self, delay_time: Duration) -> Self { + Self { delay_time, ..self } + } + + /// Sets the amount of feedback. + #[must_use = "This method consumes self and returns a modified DelayBuilder, so the return value should be used"] + pub fn feedback(self, feedback: impl Into>) -> Self { + Self { + feedback: feedback.into(), + ..self + } + } + + /// Adds an effect to the feedback loop. + pub fn add_feedback_effect(&mut self, builder: B) -> B::Handle { + let (effect, handle) = builder.build(); + self.feedback_effects.push(effect); + handle + } + + /// Adds an effect to the feedback loop and returns the [`DelayBuilder`]. + /// + /// If you need a handle to the newly added effect, use [`DelayBuilder::add_feedback_effect`]. + pub fn with_feedback_effect(mut self, builder: B) -> Self { + self.add_feedback_effect(builder); + self + } + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. `0.0` means only the dry + /// signal will be heard. `1.0` means only the wet signal will + /// be heard. + #[must_use = "This method consumes self and returns a modified DelayBuilder, so the return value should be used"] + pub fn mix(self, mix: impl Into>) -> Self { + Self { + mix: mix.into(), + ..self + } + } +} + +impl Default for DelayBuilder { + fn default() -> Self { + Self { + delay_time: Duration::from_millis(500), + feedback: Value::Fixed(Decibels(-6.0)), + feedback_effects: vec![], + mix: Value::Fixed(Mix(0.5)), + } + } +} + +impl EffectBuilder for DelayBuilder { + type Handle = DelayHandle; + + fn build(self) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Box::new(Delay::new(self, command_readers)), + DelayHandle { command_writers }, + ) + } +} diff --git a/third_party/kira/src/effect/delay/handle.rs b/third_party/kira/src/effect/delay/handle.rs new file mode 100644 index 00000000..ba2fc170 --- /dev/null +++ b/third_party/kira/src/effect/delay/handle.rs @@ -0,0 +1,20 @@ +use crate::{Decibels, Mix, command::handle_param_setters}; + +use super::CommandWriters; + +/// Controls a delay effect. +#[derive(Debug)] +pub struct DelayHandle { + pub(super) command_writers: CommandWriters, +} + +impl DelayHandle { + handle_param_setters! { + /// Sets the amount of feedback. + feedback: Decibels, + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + mix: Mix, + } +} diff --git a/third_party/kira/src/effect/distortion.rs b/third_party/kira/src/effect/distortion.rs new file mode 100644 index 00000000..f273e0c7 --- /dev/null +++ b/third_party/kira/src/effect/distortion.rs @@ -0,0 +1,88 @@ +//! Makes a sound harsher and noisier. + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use crate::{ + Decibels, Mix, Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + effect::Effect, + frame::Frame, + info::Info, +}; + +/// Different types of distortion. +#[derive(Debug, Copy, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum DistortionKind { + /// The signal will be clamped to the -1.0 to 1.0 range. + /// + /// This creates a harsh distortion when the signal leaves + /// the -1.0 to 1.0 range. + HardClip, + /// The signal will be kept in the -1.0 to 1.0 range, + /// and the slope will gradually decrease as it reaches + /// -1.0 or 1.0. + /// + /// This creates a smoother distortion that gradually + /// becomes more prominent as the signal becomes louder. + SoftClip, +} + +impl Default for DistortionKind { + fn default() -> Self { + Self::HardClip + } +} + +struct Distortion { + command_readers: CommandReaders, + kind: DistortionKind, + drive: Parameter, + mix: Parameter, +} + +impl Effect for Distortion { + fn on_start_processing(&mut self) { + if let Some(kind) = self.command_readers.set_kind.read() { + self.kind = kind; + } + read_commands_into_parameters!(self, drive, mix); + } + + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) { + self.drive.update(dt * input.len() as f64, info); + self.mix.update(dt * input.len() as f64, info); + + let num_frames = input.len(); + for (i, frame) in input.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let drive = self.drive.interpolated_value(time_in_chunk).as_amplitude(); + let mix = self.mix.interpolated_value(time_in_chunk).0.clamp(0.0, 1.0); + + let mut output = *frame * drive; + output = match self.kind { + DistortionKind::HardClip => { + Frame::new(output.left.clamp(-1.0, 1.0), output.right.clamp(-1.0, 1.0)) + } + DistortionKind::SoftClip => Frame::new( + output.left / (1.0 + output.left.abs()), + output.right / (1.0 + output.right.abs()), + ), + }; + output /= drive; + + *frame = output * mix.sqrt() + *frame * (1.0 - mix).sqrt() + } + } +} + +command_writers_and_readers! { + set_kind: DistortionKind, + set_drive: ValueChangeCommand, + set_mix: ValueChangeCommand, +} diff --git a/third_party/kira/src/effect/distortion/builder.rs b/third_party/kira/src/effect/distortion/builder.rs new file mode 100644 index 00000000..ecafd1a8 --- /dev/null +++ b/third_party/kira/src/effect/distortion/builder.rs @@ -0,0 +1,82 @@ +use crate::{ + Decibels, Mix, Parameter, Value, + effect::{Effect, EffectBuilder}, +}; + +use super::{Distortion, DistortionKind, command_writers_and_readers, handle::DistortionHandle}; + +/// Configures a distortion effect. +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct DistortionBuilder { + /// The kind of distortion to use. + pub kind: DistortionKind, + /// The factor to multiply the signal by before applying + /// the distortion. + pub drive: Value, + /// How much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + pub mix: Value, +} + +impl DistortionBuilder { + /// Creates a new [`DistortionBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets the kind of distortion to use. + #[must_use = "This method consumes self and returns a modified DistortionBuilder, so the return value should be used"] + pub fn kind(self, kind: DistortionKind) -> Self { + Self { kind, ..self } + } + + /// Sets the factor to multiply the signal by before applying + /// the distortion. + #[must_use = "This method consumes self and returns a modified DistortionBuilder, so the return value should be used"] + pub fn drive(self, drive: impl Into>) -> Self { + Self { + drive: drive.into(), + ..self + } + } + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. `0.0` means only the dry + /// signal will be heard. `1.0` means only the wet signal will + /// be heard. + #[must_use = "This method consumes self and returns a modified DistortionBuilder, so the return value should be used"] + pub fn mix(self, mix: impl Into>) -> Self { + Self { + mix: mix.into(), + ..self + } + } +} + +impl Default for DistortionBuilder { + fn default() -> Self { + Self { + kind: Default::default(), + drive: Value::Fixed(Decibels::IDENTITY), + mix: Value::Fixed(Mix::WET), + } + } +} + +impl EffectBuilder for DistortionBuilder { + type Handle = DistortionHandle; + + fn build(self) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Box::new(Distortion { + command_readers, + kind: self.kind, + drive: Parameter::new(self.drive, Decibels::IDENTITY), + mix: Parameter::new(self.mix, Mix::WET), + }), + DistortionHandle { command_writers }, + ) + } +} diff --git a/third_party/kira/src/effect/distortion/handle.rs b/third_party/kira/src/effect/distortion/handle.rs new file mode 100644 index 00000000..9da34128 --- /dev/null +++ b/third_party/kira/src/effect/distortion/handle.rs @@ -0,0 +1,25 @@ +use crate::{Decibels, Mix, command::handle_param_setters}; + +use super::{CommandWriters, DistortionKind}; + +/// Controls a distortion effect. +#[derive(Debug)] +pub struct DistortionHandle { + pub(super) command_writers: CommandWriters, +} + +impl DistortionHandle { + /// Sets the kind of distortion to use. + pub fn set_kind(&mut self, kind: DistortionKind) { + self.command_writers.set_kind.write(kind) + } + + handle_param_setters! { + /// Sets how much distortion should be applied. + drive: Decibels, + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + mix: Mix, + } +} diff --git a/third_party/kira/src/effect/eq_filter.rs b/third_party/kira/src/effect/eq_filter.rs new file mode 100644 index 00000000..aebdd164 --- /dev/null +++ b/third_party/kira/src/effect/eq_filter.rs @@ -0,0 +1,183 @@ +//! Adjusts the volume of a frequency range of a sound. + +// Code is based on https://cytomic.com/files/dsp/SvfLinearTrapOptimised2.pdf + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use std::f64::consts::PI; + +use crate::{ + Decibels, Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + frame::Frame, + info::Info, +}; + +use super::Effect; + +const MIN_Q: f64 = 0.01; + +struct EqFilter { + command_readers: CommandReaders, + kind: EqFilterKind, + frequency: Parameter, + gain: Parameter, + q: Parameter, + ic1eq: Frame, + ic2eq: Frame, +} + +impl EqFilter { + #[must_use] + fn new(builder: EqFilterBuilder, command_readers: CommandReaders) -> Self { + Self { + command_readers, + kind: builder.kind, + frequency: Parameter::new(builder.frequency, 500.0), + gain: Parameter::new(builder.gain, Decibels::IDENTITY), + q: Parameter::new(builder.q, 1.0), + ic1eq: Frame::ZERO, + ic2eq: Frame::ZERO, + } + } +} + +impl Effect for EqFilter { + fn on_start_processing(&mut self) { + if let Some(kind) = self.command_readers.set_kind.read() { + self.kind = kind; + } + read_commands_into_parameters!(self, frequency, gain, q); + } + + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) { + self.frequency.update(dt * input.len() as f64, info); + self.gain.update(dt * input.len() as f64, info); + self.q.update(dt * input.len() as f64, info); + + let num_frames = input.len(); + for (i, frame) in input.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let frequency = self.frequency.interpolated_value(time_in_chunk); + let q = self.q.interpolated_value(time_in_chunk); + let gain = self.gain.interpolated_value(time_in_chunk); + + let Coefficients { + a1, + a2, + a3, + m0, + m1, + m2, + } = Coefficients::calculate(self.kind, frequency, q, gain, dt); + let v3 = *frame - self.ic2eq; + let v1 = self.ic1eq * (a1 as f32) + v3 * (a2 as f32); + let v2 = self.ic2eq + self.ic1eq * (a2 as f32) + v3 * (a3 as f32); + self.ic1eq = v1 * 2.0 - self.ic1eq; + self.ic2eq = v2 * 2.0 - self.ic2eq; + *frame = *frame * (m0 as f32) + v1 * (m1 as f32) + v2 * (m2 as f32) + } + } +} + +/// The shape of the frequency adjustment curve. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum EqFilterKind { + /// Frequencies around the user-defined frequency are adjusted. + Bell, + /// Frequencies around and lower than the user-defined frequency are adjusted. + LowShelf, + /// Frequencies around and higher than the user-defined frequency are adjusted. + HighShelf, +} + +struct Coefficients { + a1: f64, + a2: f64, + a3: f64, + m0: f64, + m1: f64, + m2: f64, +} + +impl Coefficients { + #[must_use] + fn calculate(kind: EqFilterKind, frequency: f64, q: f64, gain: Decibels, dt: f64) -> Self { + // In my testing, the filter goes unstable when the frequency exceeds half the sample rate, + // so I'm clamping this value to 0.5 + let relative_frequency = (frequency * dt).clamp(0.0001, 0.5); + let q = q.max(MIN_Q); + match kind { + EqFilterKind::Bell => { + let a = 10.0f64.powf(gain.0 as f64 / 40.0); + let g = (PI * relative_frequency).tan(); + let k = 1.0 / (q * a); + let a1 = 1.0 / (1.0 + g * (g + k)); + let a2 = g * a1; + let a3 = g * a2; + let m0 = 1.0; + let m1 = k * (a * a - 1.0); + let m2 = 0.0; + Self { + a1, + a2, + a3, + m0, + m1, + m2, + } + } + EqFilterKind::LowShelf => { + let a = 10.0f64.powf(gain.0 as f64 / 40.0); + let g = (PI * relative_frequency).tan() / a.sqrt(); + let k = 1.0 / q; + let a1 = 1.0 / (1.0 + g * (g + k)); + let a2 = g * a1; + let a3 = g * a2; + let m0 = 1.0; + let m1 = k * (a - 1.0); + let m2 = a * a - 1.0; + Self { + a1, + a2, + a3, + m0, + m1, + m2, + } + } + EqFilterKind::HighShelf => { + let a = 10.0f64.powf(gain.0 as f64 / 40.0); + let g = (PI * relative_frequency).tan() * a.sqrt(); + let k = 1.0 / q; + let a1 = 1.0 / (1.0 + g * (g + k)); + let a2 = g * a1; + let a3 = g * a2; + let m0 = a * a; + let m1 = k * (1.0 - a) * a; + let m2 = 1.0 - a * a; + Self { + a1, + a2, + a3, + m0, + m1, + m2, + } + } + } + } +} + +command_writers_and_readers! { + set_kind: EqFilterKind, + set_frequency: ValueChangeCommand, + set_gain: ValueChangeCommand, + set_q: ValueChangeCommand, +} diff --git a/third_party/kira/src/effect/eq_filter/builder.rs b/third_party/kira/src/effect/eq_filter/builder.rs new file mode 100644 index 00000000..2ebfac57 --- /dev/null +++ b/third_party/kira/src/effect/eq_filter/builder.rs @@ -0,0 +1,49 @@ +use crate::{Decibels, Value, effect::EffectBuilder}; + +use super::{EqFilter, EqFilterHandle, EqFilterKind, command_writers_and_readers}; + +/// Configures an EQ filter. +pub struct EqFilterBuilder { + /// The shape of the frequency adjustment curve. + pub kind: EqFilterKind, + /// The "center" or "corner" of the frequency range to adjust in Hz + /// (for bell or shelf curves, respectively). + pub frequency: Value, + /// The volume adjustment for frequencies in the specified range (in decibels). + pub gain: Value, + /// The width of the frequency range to adjust. + /// + /// A higher Q value results in a narrower range of frequencies being adjusted. + /// The value should be greater than `0.0`. + pub q: Value, +} + +impl EqFilterBuilder { + /// Creates a new `EqFilterBuilder`. + #[must_use] + pub fn new( + kind: EqFilterKind, + frequency: impl Into>, + gain: impl Into>, + q: impl Into>, + ) -> Self { + Self { + kind, + frequency: frequency.into(), + gain: gain.into(), + q: q.into(), + } + } +} + +impl EffectBuilder for EqFilterBuilder { + type Handle = EqFilterHandle; + + fn build(self) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Box::new(EqFilter::new(self, command_readers)), + EqFilterHandle { command_writers }, + ) + } +} diff --git a/third_party/kira/src/effect/eq_filter/handle.rs b/third_party/kira/src/effect/eq_filter/handle.rs new file mode 100644 index 00000000..adea0452 --- /dev/null +++ b/third_party/kira/src/effect/eq_filter/handle.rs @@ -0,0 +1,31 @@ +use crate::{Decibels, command::handle_param_setters}; + +use super::{CommandWriters, EqFilterKind}; + +/// Controls an EQ filter. +#[derive(Debug)] +pub struct EqFilterHandle { + pub(super) command_writers: CommandWriters, +} + +impl EqFilterHandle { + /// Sets the shape of the frequency adjustment curve. + pub fn set_kind(&mut self, kind: EqFilterKind) { + self.command_writers.set_kind.write(kind) + } + + handle_param_setters! { + /// Sets the "center" or "corner" of the frequency range to adjust in Hz + /// (for bell or shelf curves, respectively). + frequency: f64, + + /// Sets the volume adjustment for frequencies in the specified range (in decibels). + gain: Decibels, + + /// Sets the width of the frequency range to adjust. + /// + /// A higher Q value results in a narrower range of frequencies being adjusted. + /// The value should be greater than `0.0`. + q: f64, + } +} diff --git a/third_party/kira/src/effect/filter.rs b/third_party/kira/src/effect/filter.rs new file mode 100644 index 00000000..bee0c67a --- /dev/null +++ b/third_party/kira/src/effect/filter.rs @@ -0,0 +1,113 @@ +//! Removes frequencies from a sound. + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use std::f64::consts::PI; + +use crate::{ + Mix, Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + effect::Effect, + frame::Frame, + info::Info, +}; + +// This filter code is based on the filter code from baseplug: +// https://github.com/wrl/baseplug/blob/trunk/examples/svf/svf_simper.rs + +/// The frequencies that the filter will remove. +#[derive(Debug, Copy, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum FilterMode { + /// Removes frequencies above the cutoff frequency. + LowPass, + /// Removes frequencies above and below the cutoff frequency. + BandPass, + /// Removes frequencies below the cutoff frequency. + HighPass, + /// Removes frequencies around the cutoff frequency. + Notch, +} + +struct Filter { + command_readers: CommandReaders, + mode: FilterMode, + cutoff: Parameter, + resonance: Parameter, + mix: Parameter, + ic1eq: Frame, + ic2eq: Frame, +} + +impl Filter { + /// Creates a new filter. + #[must_use] + fn new(builder: FilterBuilder, command_readers: CommandReaders) -> Self { + Self { + command_readers, + mode: builder.mode, + cutoff: Parameter::new(builder.cutoff, 1000.0), + resonance: Parameter::new(builder.resonance, 0.0), + mix: Parameter::new(builder.mix, Mix(1.0)), + ic1eq: Frame::ZERO, + ic2eq: Frame::ZERO, + } + } +} + +impl Effect for Filter { + fn on_start_processing(&mut self) { + if let Some(mode) = self.command_readers.set_mode.read() { + self.mode = mode; + } + read_commands_into_parameters!(self, cutoff, resonance, mix); + } + + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) { + self.cutoff.update(dt * input.len() as f64, info); + self.resonance.update(dt * input.len() as f64, info); + self.mix.update(dt * input.len() as f64, info); + + let num_frames = input.len(); + for (i, frame) in input.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let cutoff = self.cutoff.interpolated_value(time_in_chunk); + let resonance = self + .resonance + .interpolated_value(time_in_chunk) + .clamp(0.0, 1.0); + let mix = self.mix.interpolated_value(time_in_chunk).0.clamp(0.0, 1.0); + + let sample_rate = 1.0 / dt; + let g = (PI * (cutoff / sample_rate).clamp(0.0001, 0.5)).tan(); + let k = 2.0 - (1.9 * resonance); + let a1 = 1.0 / (1.0 + (g * (g + k))); + let a2 = g * a1; + let a3 = g * a2; + let v3 = *frame - self.ic2eq; + let v1 = (self.ic1eq * (a1 as f32)) + (v3 * (a2 as f32)); + let v2 = self.ic2eq + (self.ic1eq * (a2 as f32)) + (v3 * (a3 as f32)); + self.ic1eq = (v1 * 2.0) - self.ic1eq; + self.ic2eq = (v2 * 2.0) - self.ic2eq; + let output = match self.mode { + FilterMode::LowPass => v2, + FilterMode::BandPass => v1, + FilterMode::HighPass => *frame - v1 * (k as f32) - v2, + FilterMode::Notch => *frame - v1 * (k as f32), + }; + *frame = output * mix.sqrt() + *frame * (1.0 - mix).sqrt() + } + } +} + +command_writers_and_readers!( + set_mode: FilterMode, + set_cutoff: ValueChangeCommand, + set_resonance: ValueChangeCommand, + set_mix: ValueChangeCommand, +); diff --git a/third_party/kira/src/effect/filter/builder.rs b/third_party/kira/src/effect/filter/builder.rs new file mode 100644 index 00000000..b40866a2 --- /dev/null +++ b/third_party/kira/src/effect/filter/builder.rs @@ -0,0 +1,90 @@ +use crate::{ + Mix, Value, + effect::{Effect, EffectBuilder}, +}; + +use super::{Filter, FilterHandle, FilterMode, command_writers_and_readers}; + +/// Configures a filter effect. +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct FilterBuilder { + /// The frequencies that the filter will remove. + pub mode: FilterMode, + /// The cutoff frequency of the filter (in hertz). + pub cutoff: Value, + /// The resonance of the filter. + /// + /// The resonance is a feedback effect that produces + /// a distinctive "ringing" sound. + pub resonance: Value, + /// How much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + pub mix: Value, +} + +impl FilterBuilder { + /// Creates a new [`FilterBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets the frequencies that the filter will remove. + #[must_use = "This method consumes self and returns a modified FilterBuilder, so the return value should be used"] + pub fn mode(self, mode: FilterMode) -> Self { + Self { mode, ..self } + } + + /// Sets the cutoff frequency of the filter (in hertz). + #[must_use = "This method consumes self and returns a modified FilterBuilder, so the return value should be used"] + pub fn cutoff(self, cutoff: impl Into>) -> Self { + Self { + cutoff: cutoff.into(), + ..self + } + } + + /// Sets the resonance of the filter. + #[must_use = "This method consumes self and returns a modified FilterBuilder, so the return value should be used"] + pub fn resonance(self, resonance: impl Into>) -> Self { + Self { + resonance: resonance.into(), + ..self + } + } + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. `0.0` means only the dry + /// signal will be heard. `1.0` means only the wet signal will + /// be heard. + #[must_use = "This method consumes self and returns a modified FilterBuilder, so the return value should be used"] + pub fn mix(self, mix: impl Into>) -> Self { + Self { + mix: mix.into(), + ..self + } + } +} + +impl Default for FilterBuilder { + fn default() -> Self { + Self { + mode: FilterMode::LowPass, + cutoff: Value::Fixed(1000.0), + resonance: Value::Fixed(0.0), + mix: Value::Fixed(Mix::WET), + } + } +} + +impl EffectBuilder for FilterBuilder { + type Handle = FilterHandle; + + fn build(self) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Box::new(Filter::new(self, command_readers)), + FilterHandle { command_writers }, + ) + } +} diff --git a/third_party/kira/src/effect/filter/handle.rs b/third_party/kira/src/effect/filter/handle.rs new file mode 100644 index 00000000..f3cc0cb9 --- /dev/null +++ b/third_party/kira/src/effect/filter/handle.rs @@ -0,0 +1,28 @@ +use crate::{Mix, command::handle_param_setters}; + +use super::{CommandWriters, FilterMode}; + +/// Controls a filter effect. +#[derive(Debug)] +pub struct FilterHandle { + pub(super) command_writers: CommandWriters, +} + +impl FilterHandle { + /// Sets the frequencies that the filter will remove. + pub fn set_mode(&mut self, mode: FilterMode) { + self.command_writers.set_mode.write(mode) + } + + handle_param_setters! { + /// Sets the cutoff frequency of the filter (in hertz). + cutoff: f64, + + /// Sets the resonance of the filter. + resonance: f64, + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + mix: Mix, + } +} diff --git a/third_party/kira/src/effect/panning_control.rs b/third_party/kira/src/effect/panning_control.rs new file mode 100644 index 00000000..2d4daa98 --- /dev/null +++ b/third_party/kira/src/effect/panning_control.rs @@ -0,0 +1,51 @@ +//! Adjusts the panning of audio. + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use crate::{ + Panning, Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + frame::Frame, + info::Info, +}; + +use super::Effect; + +struct PanningControl { + command_readers: CommandReaders, + panning: Parameter, +} + +impl PanningControl { + #[must_use] + fn new(builder: PanningControlBuilder, command_readers: CommandReaders) -> Self { + Self { + command_readers, + panning: Parameter::new(builder.0, Panning::CENTER), + } + } +} + +impl Effect for PanningControl { + fn on_start_processing(&mut self) { + read_commands_into_parameters!(self, panning); + } + + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) { + self.panning.update(dt * input.len() as f64, info); + let num_frames = input.len(); + for (i, frame) in input.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + *frame = frame.panned(self.panning.interpolated_value(time_in_chunk)) + } + } +} + +command_writers_and_readers! { + set_panning: ValueChangeCommand, +} diff --git a/third_party/kira/src/effect/panning_control/builder.rs b/third_party/kira/src/effect/panning_control/builder.rs new file mode 100644 index 00000000..9c1a5caa --- /dev/null +++ b/third_party/kira/src/effect/panning_control/builder.rs @@ -0,0 +1,25 @@ +use crate::{Panning, Value, effect::EffectBuilder}; + +use super::{PanningControl, PanningControlHandle, command_writers_and_readers}; + +/// Configures a panning control effect. +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct PanningControlBuilder(pub Value); + +impl Default for PanningControlBuilder { + fn default() -> Self { + Self(Value::Fixed(Panning::CENTER)) + } +} + +impl EffectBuilder for PanningControlBuilder { + type Handle = PanningControlHandle; + + fn build(self) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Box::new(PanningControl::new(self, command_readers)), + PanningControlHandle { command_writers }, + ) + } +} diff --git a/third_party/kira/src/effect/panning_control/handle.rs b/third_party/kira/src/effect/panning_control/handle.rs new file mode 100644 index 00000000..e06b98e7 --- /dev/null +++ b/third_party/kira/src/effect/panning_control/handle.rs @@ -0,0 +1,16 @@ +use crate::{Panning, command::handle_param_setters}; + +use super::CommandWriters; + +/// Controls a panning control effect. +#[derive(Debug)] +pub struct PanningControlHandle { + pub(super) command_writers: CommandWriters, +} + +impl PanningControlHandle { + handle_param_setters! { + /// Sets the panning adjustment to apply to input audio. + panning: Panning, + } +} diff --git a/third_party/kira/src/effect/reverb.rs b/third_party/kira/src/effect/reverb.rs new file mode 100644 index 00000000..612ddb3e --- /dev/null +++ b/third_party/kira/src/effect/reverb.rs @@ -0,0 +1,191 @@ +//! Adds reverberations to a sound. Useful for simulating room tones. + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use crate::{ + Mix, Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + effect::Effect, + frame::Frame, + info::Info, +}; +use all_pass::AllPassFilter; +use comb::CombFilter; + +mod all_pass; +mod comb; + +const NUM_COMB_FILTERS: usize = 8; +const NUM_ALL_PASS_FILTERS: usize = 4; +const GAIN: f32 = 0.015; +const STEREO_SPREAD: usize = 23; + +#[derive(Debug)] +enum ReverbState { + Uninitialized, + Initialized { + comb_filters: [(CombFilter, CombFilter); NUM_COMB_FILTERS], + all_pass_filters: [(AllPassFilter, AllPassFilter); NUM_ALL_PASS_FILTERS], + }, +} + +// This code is based on Freeverb by Jezar at Dreampoint, found here: +// http://blog.bjornroche.com/2012/06/freeverb-original-public-domain-code-by.html +struct Reverb { + command_readers: CommandReaders, + feedback: Parameter, + damping: Parameter, + stereo_width: Parameter, + mix: Parameter, + state: ReverbState, +} + +impl Reverb { + /// Creates a new `Reverb` effect. + #[must_use] + fn new(settings: ReverbBuilder, command_readers: CommandReaders) -> Self { + Self { + command_readers, + feedback: Parameter::new(settings.feedback, 0.9), + damping: Parameter::new(settings.damping, 0.1), + stereo_width: Parameter::new(settings.stereo_width, 1.0), + mix: Parameter::new(settings.mix, Mix(0.5)), + state: ReverbState::Uninitialized, + } + } + + fn init_filters(&mut self, sample_rate: u32) { + const REFERENCE_SAMPLE_RATE: u32 = 44100; + + let adjust_buffer_size = |buffer_size: usize| -> usize { + let sample_rate_factor = (sample_rate as f64) / (REFERENCE_SAMPLE_RATE as f64); + ((buffer_size as f64) * sample_rate_factor) as usize + }; + + self.state = ReverbState::Initialized { + comb_filters: [ + ( + CombFilter::new(adjust_buffer_size(1116)), + CombFilter::new(adjust_buffer_size(1116 + STEREO_SPREAD)), + ), + ( + CombFilter::new(adjust_buffer_size(1188)), + CombFilter::new(adjust_buffer_size(1188 + STEREO_SPREAD)), + ), + ( + CombFilter::new(adjust_buffer_size(1277)), + CombFilter::new(adjust_buffer_size(1277 + STEREO_SPREAD)), + ), + ( + CombFilter::new(adjust_buffer_size(1356)), + CombFilter::new(adjust_buffer_size(1356 + STEREO_SPREAD)), + ), + ( + CombFilter::new(adjust_buffer_size(1422)), + CombFilter::new(adjust_buffer_size(1422 + STEREO_SPREAD)), + ), + ( + CombFilter::new(adjust_buffer_size(1491)), + CombFilter::new(adjust_buffer_size(1491 + STEREO_SPREAD)), + ), + ( + CombFilter::new(adjust_buffer_size(1557)), + CombFilter::new(adjust_buffer_size(1557 + STEREO_SPREAD)), + ), + ( + CombFilter::new(adjust_buffer_size(1617)), + CombFilter::new(adjust_buffer_size(1617 + STEREO_SPREAD)), + ), + ], + all_pass_filters: [ + ( + AllPassFilter::new(adjust_buffer_size(556)), + AllPassFilter::new(adjust_buffer_size(556 + STEREO_SPREAD)), + ), + ( + AllPassFilter::new(adjust_buffer_size(441)), + AllPassFilter::new(adjust_buffer_size(441 + STEREO_SPREAD)), + ), + ( + AllPassFilter::new(adjust_buffer_size(341)), + AllPassFilter::new(adjust_buffer_size(341 + STEREO_SPREAD)), + ), + ( + AllPassFilter::new(adjust_buffer_size(225)), + AllPassFilter::new(adjust_buffer_size(225 + STEREO_SPREAD)), + ), + ], + } + } +} + +impl Effect for Reverb { + fn init(&mut self, sample_rate: u32, _internal_buffer_size: usize) { + self.init_filters(sample_rate); + } + + fn on_change_sample_rate(&mut self, sample_rate: u32) { + self.init_filters(sample_rate); + } + + fn on_start_processing(&mut self) { + read_commands_into_parameters!(self, feedback, damping, stereo_width, mix); + } + + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) { + if let ReverbState::Initialized { + comb_filters, + all_pass_filters, + } = &mut self.state + { + self.feedback.update(dt * input.len() as f64, info); + self.damping.update(dt * input.len() as f64, info); + self.stereo_width.update(dt * input.len() as f64, info); + self.mix.update(dt * input.len() as f64, info); + + let feedback = self.feedback.value() as f32; + let damping = self.damping.value() as f32; + + let num_frames = input.len(); + for (i, frame) in input.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let stereo_width = self.stereo_width.interpolated_value(time_in_chunk) as f32; + let mix = self.mix.interpolated_value(time_in_chunk).0.clamp(0.0, 1.0); + + let mut output = Frame::ZERO; + let mono_input = (frame.left + frame.right) * GAIN; + // accumulate comb filters in parallel + for comb_filter in comb_filters.iter_mut() { + output.left += comb_filter.0.process(mono_input, feedback, damping); + output.right += comb_filter.1.process(mono_input, feedback, damping); + } + // feed through all-pass filters in series + for all_pass_filter in all_pass_filters.iter_mut() { + output.left = all_pass_filter.0.process(output.left); + output.right = all_pass_filter.1.process(output.right); + } + let wet_1 = stereo_width / 2.0 + 0.5; + let wet_2 = (1.0 - stereo_width) / 2.0; + let output = Frame::new( + output.left * wet_1 + output.right * wet_2, + output.right * wet_1 + output.left * wet_2, + ); + *frame = output * mix.sqrt() + *frame * (1.0 - mix).sqrt() + } + } else { + panic!("Reverb should be initialized before the first process call") + } + } +} + +command_writers_and_readers! { + set_feedback: ValueChangeCommand, + set_damping: ValueChangeCommand, + set_stereo_width: ValueChangeCommand, + set_mix: ValueChangeCommand, +} diff --git a/third_party/kira/src/effect/reverb/all_pass.rs b/third_party/kira/src/effect/reverb/all_pass.rs new file mode 100644 index 00000000..4463b1c0 --- /dev/null +++ b/third_party/kira/src/effect/reverb/all_pass.rs @@ -0,0 +1,27 @@ +const FEEDBACK: f32 = 0.5; + +#[derive(Debug)] +pub struct AllPassFilter { + buffer: Vec, + current_index: usize, +} + +impl AllPassFilter { + #[must_use] + pub fn new(buffer_size: usize) -> Self { + Self { + buffer: vec![0.0; buffer_size], + current_index: 0, + } + } + + #[must_use] + pub fn process(&mut self, input: f32) -> f32 { + let buffer_output = self.buffer[self.current_index]; + let output = -input + buffer_output; + self.buffer[self.current_index] = input + buffer_output * FEEDBACK; + self.current_index += 1; + self.current_index %= self.buffer.len(); + output + } +} diff --git a/third_party/kira/src/effect/reverb/builder.rs b/third_party/kira/src/effect/reverb/builder.rs new file mode 100644 index 00000000..21daf2a4 --- /dev/null +++ b/third_party/kira/src/effect/reverb/builder.rs @@ -0,0 +1,96 @@ +use crate::{ + Mix, Value, + effect::{Effect, EffectBuilder}, +}; + +use super::{Reverb, ReverbHandle, command_writers_and_readers}; + +/// Configures a reverb effect. +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct ReverbBuilder { + /// How much the room reverberates. A higher value will + /// result in a bigger sounding room. 1.0 gives an infinitely + /// reverberating room. + pub feedback: Value, + /// How quickly high frequencies disappear from the reverberation. + pub damping: Value, + /// The stereo width of the reverb effect (0.0 being fully mono, + /// 1.0 being fully stereo). + pub stereo_width: Value, + /// How much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + pub mix: Value, +} + +impl ReverbBuilder { + /// Creates a new [`ReverbBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets how much the room reverberates. A higher value will + /// result in a bigger sounding room. 1.0 gives an infinitely + /// reverberating room. + #[must_use = "This method consumes self and returns a modified ReverbBuilder, so the return value should be used"] + pub fn feedback(self, feedback: impl Into>) -> Self { + Self { + feedback: feedback.into(), + ..self + } + } + + /// Sets how quickly high frequencies disappear from the reverberation. + #[must_use = "This method consumes self and returns a modified ReverbBuilder, so the return value should be used"] + pub fn damping(self, damping: impl Into>) -> Self { + Self { + damping: damping.into(), + ..self + } + } + + /// Sets the stereo width of the reverb effect (0.0 being fully mono, + /// 1.0 being fully stereo). + #[must_use = "This method consumes self and returns a modified ReverbBuilder, so the return value should be used"] + pub fn stereo_width(self, stereo_width: impl Into>) -> Self { + Self { + stereo_width: stereo_width.into(), + ..self + } + } + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. `0.0` means only the dry + /// signal will be heard. `1.0` means only the wet signal will + /// be heard. + #[must_use = "This method consumes self and returns a modified ReverbBuilder, so the return value should be used"] + pub fn mix(self, mix: impl Into>) -> Self { + Self { + mix: mix.into(), + ..self + } + } +} + +impl Default for ReverbBuilder { + fn default() -> Self { + Self { + feedback: Value::Fixed(0.9), + damping: Value::Fixed(0.1), + stereo_width: Value::Fixed(1.0), + mix: Value::Fixed(Mix(0.5)), + } + } +} + +impl EffectBuilder for ReverbBuilder { + type Handle = ReverbHandle; + + fn build(self) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Box::new(Reverb::new(self, command_readers)), + ReverbHandle { command_writers }, + ) + } +} diff --git a/third_party/kira/src/effect/reverb/comb.rs b/third_party/kira/src/effect/reverb/comb.rs new file mode 100644 index 00000000..3cff1e61 --- /dev/null +++ b/third_party/kira/src/effect/reverb/comb.rs @@ -0,0 +1,27 @@ +#[derive(Debug)] +pub struct CombFilter { + filter_store: f32, + buffer: Vec, + current_index: usize, +} + +impl CombFilter { + #[must_use] + pub fn new(buffer_size: usize) -> Self { + Self { + filter_store: 0.0, + buffer: vec![0.0; buffer_size], + current_index: 0, + } + } + + #[must_use] + pub fn process(&mut self, input: f32, feedback: f32, damp: f32) -> f32 { + let output = self.buffer[self.current_index]; + self.filter_store = output * (1.0 - damp) + self.filter_store * damp; + self.buffer[self.current_index] = input + self.filter_store * feedback; + self.current_index += 1; + self.current_index %= self.buffer.len(); + output + } +} diff --git a/third_party/kira/src/effect/reverb/handle.rs b/third_party/kira/src/effect/reverb/handle.rs new file mode 100644 index 00000000..2d966e76 --- /dev/null +++ b/third_party/kira/src/effect/reverb/handle.rs @@ -0,0 +1,29 @@ +use crate::{Mix, command::handle_param_setters}; + +use super::CommandWriters; + +/// Controls a reverb effect. +#[derive(Debug)] +pub struct ReverbHandle { + pub(super) command_writers: CommandWriters, +} + +impl ReverbHandle { + handle_param_setters! { + /// Sets how much the room reverberates. A higher value will + /// result in a bigger sounding room. 1.0 gives an infinitely + /// reverberating room. + feedback: f64, + + /// Sets how quickly high frequencies disappear from the reverberation. + damping: f64, + + /// Sets the stereo width of the reverb effect (0.0 being fully mono, + /// 1.0 being fully stereo). + stereo_width: f64, + + /// Sets how much dry (unprocessed) signal should be blended + /// with the wet (processed) signal. + mix: Mix, + } +} diff --git a/third_party/kira/src/effect/volume_control.rs b/third_party/kira/src/effect/volume_control.rs new file mode 100644 index 00000000..aabd8f42 --- /dev/null +++ b/third_party/kira/src/effect/volume_control.rs @@ -0,0 +1,51 @@ +//! Adjusts the volume of audio. + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use crate::{ + Decibels, Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + frame::Frame, + info::Info, +}; + +use super::Effect; + +struct VolumeControl { + command_readers: CommandReaders, + volume: Parameter, +} + +impl VolumeControl { + #[must_use] + fn new(builder: VolumeControlBuilder, command_readers: CommandReaders) -> Self { + Self { + command_readers, + volume: Parameter::new(builder.0, Decibels::IDENTITY), + } + } +} + +impl Effect for VolumeControl { + fn on_start_processing(&mut self) { + read_commands_into_parameters!(self, volume); + } + + fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) { + self.volume.update(dt * input.len() as f64, info); + let num_frames = input.len(); + for (i, frame) in input.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + *frame *= self.volume.interpolated_value(time_in_chunk).as_amplitude(); + } + } +} + +command_writers_and_readers! { + set_volume: ValueChangeCommand, +} diff --git a/third_party/kira/src/effect/volume_control/builder.rs b/third_party/kira/src/effect/volume_control/builder.rs new file mode 100644 index 00000000..d1d9e7ea --- /dev/null +++ b/third_party/kira/src/effect/volume_control/builder.rs @@ -0,0 +1,33 @@ +use crate::{Decibels, Value, effect::EffectBuilder}; + +use super::{VolumeControl, VolumeControlHandle, command_writers_and_readers}; + +/// Configures a volume control effect. +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct VolumeControlBuilder(pub Value); + +impl VolumeControlBuilder { + /// Creates a new [`VolumeControlBuilder`]. + #[must_use] + pub fn new(volume: impl Into>) -> Self { + Self(volume.into()) + } +} + +impl Default for VolumeControlBuilder { + fn default() -> Self { + Self(Value::Fixed(Decibels::IDENTITY)) + } +} + +impl EffectBuilder for VolumeControlBuilder { + type Handle = VolumeControlHandle; + + fn build(self) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Box::new(VolumeControl::new(self, command_readers)), + VolumeControlHandle { command_writers }, + ) + } +} diff --git a/third_party/kira/src/effect/volume_control/handle.rs b/third_party/kira/src/effect/volume_control/handle.rs new file mode 100644 index 00000000..4d2673ee --- /dev/null +++ b/third_party/kira/src/effect/volume_control/handle.rs @@ -0,0 +1,16 @@ +use crate::{Decibels, command::handle_param_setters}; + +use super::CommandWriters; + +/// Controls a volume control effect. +#[derive(Debug)] +pub struct VolumeControlHandle { + pub(super) command_writers: CommandWriters, +} + +impl VolumeControlHandle { + handle_param_setters! { + /// Sets the volume adjustment to apply to input audio. + volume: Decibels, + } +} diff --git a/third_party/kira/src/error.rs b/third_party/kira/src/error.rs new file mode 100644 index 00000000..4041c8f9 --- /dev/null +++ b/third_party/kira/src/error.rs @@ -0,0 +1,45 @@ +use std::{ + error::Error, + fmt::{Display, Formatter}, +}; + +/// Errors that can occur when playing a sound. +#[derive(Debug)] +pub enum PlaySoundError { + /// Could not play a sound because the maximum number of sounds has been reached. + SoundLimitReached, + /// An error occurred when initializing the sound. + IntoSoundError(E), +} + +impl Display for PlaySoundError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + PlaySoundError::SoundLimitReached => f.write_str( + "Could not play a sound because the maximum number of sounds has been reached.", + ), + PlaySoundError::IntoSoundError(_) => { + f.write_str("An error occurred when initializing the sound.") + } + } + } +} + +impl Error for PlaySoundError {} + +/// An error that is returned when a resource cannot be added because the +/// maximum capacity for that resource has been reached. +/// +/// You can adjust these capacities using [`Capacities`](crate::Capacities). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ResourceLimitReached; + +impl Display for ResourceLimitReached { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str( + "Could not add a resource because the maximum capacity for that resource has been reached", + ) + } +} + +impl Error for ResourceLimitReached {} diff --git a/third_party/kira/src/frame.rs b/third_party/kira/src/frame.rs new file mode 100644 index 00000000..941cf505 --- /dev/null +++ b/third_party/kira/src/frame.rs @@ -0,0 +1,155 @@ +use std::{ + f32::consts::SQRT_2, + ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Sub, SubAssign}, +}; + +use crate::Panning; + +/// A stereo audio sample. +#[derive(Debug, Copy, Clone, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Frame { + /// The sample for the left channel. + pub left: f32, + /// The sample for the right channel. + pub right: f32, +} + +impl Frame { + /// A [`Frame`] with both the left and right samples + /// set to `0.0`. + pub const ZERO: Frame = Frame { + left: 0.0, + right: 0.0, + }; + + /// Creates a frame with the given left and right values. + #[must_use] + pub fn new(left: f32, right: f32) -> Self { + Self { left, right } + } + + /// Creates a frame with both the left and right channels set + /// to the same value. + #[must_use] + pub fn from_mono(value: f32) -> Self { + Self::new(value, value) + } + + /// Pans a frame to the left or right. + /// + /// A panning of -1.0 represents a hard left panning, a panning of 1.0 + /// represents a hard right panning. + #[allow(clippy::float_cmp)] + #[must_use = "This method returns a new Frame and does not mutate the original value"] + pub fn panned(self, panning: Panning) -> Self { + // adding a special case for center panning improves + // performance in the sound playback benchmarks by + // about 3% + if panning == Panning::CENTER { + return self; + } + let panning = panning.0.clamp(-1.0, 1.0); + let left_right_mix = (panning + 1.0) * 0.5; + Self::new( + self.left * (1.0 - left_right_mix).sqrt(), + self.right * left_right_mix.sqrt(), + ) * SQRT_2 + } + + /// Returns the frame mixed down to mono. + #[must_use = "This method returns a new Frame and does not mutate the original value"] + pub fn as_mono(self) -> Self { + Self::from_mono((self.left + self.right) / 2.0) + } +} + +impl Add for Frame { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self::new(self.left + rhs.left, self.right + rhs.right) + } +} + +impl AddAssign for Frame { + fn add_assign(&mut self, rhs: Self) { + self.left += rhs.left; + self.right += rhs.right; + } +} + +impl Sub for Frame { + type Output = Self; + + fn sub(self, rhs: Self) -> Self::Output { + Self::new(self.left - rhs.left, self.right - rhs.right) + } +} + +impl SubAssign for Frame { + fn sub_assign(&mut self, rhs: Self) { + self.left -= rhs.left; + self.right -= rhs.right; + } +} + +impl Mul for Frame { + type Output = Self; + + fn mul(self, rhs: f32) -> Self::Output { + Self::new(self.left * rhs, self.right * rhs) + } +} + +impl MulAssign for Frame { + fn mul_assign(&mut self, rhs: f32) { + self.left *= rhs; + self.right *= rhs; + } +} + +impl Div for Frame { + type Output = Self; + + fn div(self, rhs: f32) -> Self::Output { + Self::new(self.left / rhs, self.right / rhs) + } +} + +impl DivAssign for Frame { + fn div_assign(&mut self, rhs: f32) { + self.left /= rhs; + self.right /= rhs; + } +} + +impl Neg for Frame { + type Output = Self; + + fn neg(self) -> Self::Output { + Self::new(-self.left, -self.right) + } +} + +/// Given a previous frame, a current frame, the two next frames, +/// and a position `x` from 0.0 to 1.0 between the current frame +/// and next frame, get an approximated frame. +// This is the 4-point, 3rd-order Hermite interpolation x-form +// algorithm from "Polynomial Interpolators for High-Quality +// Resampling of Oversampled Audio" by Olli Niemitalo, p. 43: +// http://yehar.com/blog/wp-content/uploads/2009/08/deip.pdf +#[must_use] +pub fn interpolate_frame( + previous: Frame, + current: Frame, + next_1: Frame, + next_2: Frame, + fraction: f32, +) -> Frame { + let c0 = current; + let c1 = (next_1 - previous) * 0.5; + let c2 = previous - current * 2.5 + next_1 * 2.0 - next_2 * 0.5; + let c3 = (next_2 - previous) * 0.5 + (current - next_1) * 1.5; + ((c3 * fraction + c2) * fraction + c1) * fraction + c0 +} diff --git a/third_party/kira/src/info.rs b/third_party/kira/src/info.rs new file mode 100644 index 00000000..4b2f7348 --- /dev/null +++ b/third_party/kira/src/info.rs @@ -0,0 +1,294 @@ +/*! + * Types for providing info about resources to trait implementors. + * + * You'll only need this if you're implementing one of Kira's traits, + * like [`Sound`](crate::sound::Sound) or [`Effect`](crate::effect::Effect). + */ + +use atomic_arena::Arena; +use glam::{Quat, Vec3}; + +use crate::{ + clock::{Clock, ClockId, ClockTime, State as ClockState}, + listener::{Listener, ListenerId}, + modulator::{Modulator, ModulatorId}, +}; + +/// Provides info about resources on the audio thread. +/// +/// You'll only need this if you're implementing one of Kira's traits, +/// like [`Sound`](crate::sound::Sound) or [`Effect`](crate::effect::Effect). +pub struct Info<'a> { + kind: InfoKind<'a>, + spatial_track_info: Option, +} + +impl<'a> Info<'a> { + pub(crate) fn new( + clocks: &'a Arena, + modulators: &'a Arena>, + listeners: &'a Arena, + spatial_track_info: Option, + ) -> Self { + Self { + kind: InfoKind::Real { + clocks, + modulators, + listeners, + }, + spatial_track_info, + } + } + + /// Gets information about the clock with the given ID if it + /// exists, returns `None` otherwise. + #[must_use] + pub fn clock_info(&self, id: ClockId) -> Option { + match &self.kind { + InfoKind::Real { clocks, .. } => clocks.get(id.0).map(|clock| ClockInfo { + ticking: clock.ticking(), + time: ClockTime { + clock: id, + ticks: match clock.state() { + ClockState::NotStarted => 0, + ClockState::Started { ticks, .. } => ticks, + }, + fraction: match clock.state() { + ClockState::NotStarted => 0.0, + ClockState::Started { + fractional_position, + .. + } => fractional_position, + }, + }, + }), + InfoKind::Mock { clock_info, .. } => clock_info.get(id.0).copied(), + } + } + + /// Returns whether something with the given start time should + /// start now, later, or never given the current state of the clocks. + #[must_use] + pub fn when_to_start(&self, time: ClockTime) -> WhenToStart { + if let Some(clock_info) = self.clock_info(time.clock) { + if clock_info.ticking && clock_info.time >= time { + WhenToStart::Now + } else { + WhenToStart::Later + } + } else { + WhenToStart::Never + } + } + + /// Gets the value of the modulator with the given ID if it + /// exists, returns `None` otherwise. + #[must_use] + pub fn modulator_value(&self, id: ModulatorId) -> Option { + match &self.kind { + InfoKind::Real { modulators, .. } => { + modulators.get(id.0).map(|modulator| modulator.value()) + } + InfoKind::Mock { + modulator_values, .. + } => modulator_values.get(id.0).copied(), + } + } + + /// Gets information about the listener linked to the current spatial track + /// if there is one. + #[must_use] + pub fn listener_info(&self) -> Option { + self.spatial_track_info.and_then(|spatial_track_info| { + let listener_id = spatial_track_info.listener_id; + match &self.kind { + InfoKind::Real { listeners, .. } => { + listeners.get(listener_id.0).map(|listener| ListenerInfo { + position: listener.position.value().into(), + orientation: listener.orientation.value().into(), + previous_position: listener.position.previous_value().into(), + previous_orientation: listener.orientation.previous_value().into(), + }) + } + InfoKind::Mock { listener_info, .. } => listener_info.get(listener_id.0).copied(), + } + }) + } + + /// If this is called from an effect on a spatial track, returns the distance + /// of the spatial track's listener from the spatial track. Otherwise, returns + /// `None`. + pub fn listener_distance(&self) -> Option { + self.spatial_track_info.zip(self.listener_info()).map( + |(spatial_track_info, listener_info)| { + Vec3::from(listener_info.position).distance(spatial_track_info.position) + }, + ) + } +} + +/// Information about the current state of a [clock](super::clock). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ClockInfo { + /// Whether the clock is currently running. + pub ticking: bool, + /// The current time of the clock. + pub time: ClockTime, +} + +/// When something should start given the current state +/// of the clocks. +/// +/// The "something" in question can be anything that +/// would start at a given [`StartTime`](crate::StartTime). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WhenToStart { + /// The thing should start now. + Now, + /// The thing should start later because the appropriate + /// clock isn't ticking or hasn't reached the target tick + /// yet. + Later, + /// The thing will never start because the clock it depends + /// on no longer exists. + Never, +} + +/// Information about a listener. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ListenerInfo { + /// The position of the listener. + pub position: mint::Vector3, + /// The rotation of the listener. + pub orientation: mint::Quaternion, + /// The position of the listener prior to the last update. + pub previous_position: mint::Vector3, + /// The rotation of the listener prior to the last update. + pub previous_orientation: mint::Quaternion, +} + +impl ListenerInfo { + /// Returns the interpolated position between the previous and current + /// position of the listener. + pub fn interpolated_position(self, amount: f32) -> mint::Vector3 { + let position: Vec3 = self.position.into(); + let previous_position: Vec3 = self.previous_position.into(); + previous_position.lerp(position, amount).into() + } + + /// Returns the interpolated orientation between the previous and current + /// orientation of the listener. + pub fn interpolated_orientation(self, amount: f32) -> mint::Quaternion { + let orientation: Quat = self.orientation.into(); + let previous_orientation: Quat = self.previous_orientation.into(); + previous_orientation.lerp(orientation, amount).into() + } +} + +/// Generates a fake `Info` with arbitrary data. Useful for writing unit tests. +pub struct MockInfoBuilder { + clock_info: Arena, + modulator_values: Arena, + listener_info: Arena, + spatial_track_info: Option, +} + +impl MockInfoBuilder { + /// Creates a new `MockInfoBuilder`. + pub fn new() -> Self { + Self { + clock_info: Arena::new(100), + modulator_values: Arena::new(100), + listener_info: Arena::new(100), + spatial_track_info: None, + } + } + + /// Adds a fake clock with the given ticking state and time. Returns a fake + /// `ClockId`. + pub fn add_clock(&mut self, ticking: bool, ticks: u64, fraction: f64) -> ClockId { + let id = ClockId( + self.clock_info + .controller() + .try_reserve() + .expect("clock info arena is full"), + ); + self.clock_info + .insert_with_key( + id.0, + ClockInfo { + ticking, + time: ClockTime { + clock: id, + ticks, + fraction, + }, + }, + ) + .unwrap(); + id + } + + /// Adds a fake modulator outputting the given value. Returns a fake `ModulatorId`. + pub fn add_modulator(&mut self, value: f64) -> ModulatorId { + let id = ModulatorId( + self.modulator_values + .controller() + .try_reserve() + .expect("modulator info arena is full"), + ); + self.modulator_values.insert_with_key(id.0, value).unwrap(); + id + } + + /// Adds a fake listener at the given position and orientation. Returns a fake `ListenerId`. + pub fn add_listener(&mut self, listener_info: ListenerInfo) -> ListenerId { + let id = ListenerId( + self.listener_info + .controller() + .try_reserve() + .expect("listener info arena is full"), + ); + self.listener_info + .insert_with_key(id.0, listener_info) + .unwrap(); + id + } + + /// Consumes the `MockInfoProvider` and returns a fake `Info`. + pub fn build(self) -> Info<'static> { + Info { + kind: InfoKind::Mock { + clock_info: self.clock_info, + modulator_values: self.modulator_values, + listener_info: self.listener_info, + }, + spatial_track_info: self.spatial_track_info, + } + } +} + +impl Default for MockInfoBuilder { + fn default() -> Self { + Self::new() + } +} + +enum InfoKind<'a> { + Real { + clocks: &'a Arena, + modulators: &'a Arena>, + listeners: &'a Arena, + }, + Mock { + clock_info: Arena, + modulator_values: Arena, + listener_info: Arena, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct SpatialTrackInfo { + pub position: Vec3, + pub listener_id: ListenerId, +} diff --git a/third_party/kira/src/lib.rs b/third_party/kira/src/lib.rs new file mode 100644 index 00000000..cd054d39 --- /dev/null +++ b/third_party/kira/src/lib.rs @@ -0,0 +1,256 @@ +/*! +# Kira + +Kira is a backend-agnostic library to create expressive audio for games. It provides +tweens for smoothly adjusting properties of sounds, a flexible [mixer](track) +for applying [effects](effect) to audio, a [clock] system for precisely timing audio events, +and [spatial audio](crate::track#spatial-tracks) support. + +To get started, create an [`AudioManager`] and use it to [play](AudioManager::play) a +[`StaticSoundData`](crate::sound::static_sound::StaticSoundData) or +[`StreamingSoundData`](crate::sound::streaming::StreamingSoundData). + +## Examples + +Playing a sound multiple times simultaneously: + +```rust, no_run +# extern crate kira; +# +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, +}; + +// Create an audio manager. This plays sounds and manages resources. +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let sound_data = StaticSoundData::from_file("sound.ogg")?; +manager.play(sound_data.clone())?; +// After a couple seconds... +manager.play(sound_data.clone())?; +// Cloning the sound data will not use any extra memory. +# Result::<(), Box>::Ok(()) +``` + +Gradually speeding up a sound over time: + +```rust, no_run +# extern crate kira; +# +use std::time::Duration; + +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + Tween, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let sound_data = StaticSoundData::from_file("sound.ogg")?; +let mut sound = manager.play(sound_data)?; +// Start smoothly adjusting the playback rate parameter. +sound.set_playback_rate( + 2.0, + Tween { + duration: Duration::from_secs(3), + ..Default::default() + }, +); +# Result::<(), Box>::Ok(()) +``` + +Playing a sound with a low-pass filter applied (this makes the +audio sound muffled): + +```rust, no_run +# extern crate kira; +# +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + track::TrackBuilder, + effect::filter::FilterBuilder, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +// Create a mixer sub-track with a filter. +let mut track = manager.add_sub_track({ + let mut builder = TrackBuilder::new(); + builder.add_effect(FilterBuilder::new().cutoff(1000.0)); + builder +})?; +// Play the sound on the track. +let sound_data = StaticSoundData::from_file("sound.ogg")?; +track.play(sound_data)?; +# Result::<(), Box>::Ok(()) +``` + +Playing sounds in time with a musical beat: + +```rust, no_run +# extern crate kira; +# +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + clock::ClockSpeed, +}; + +const TEMPO: f64 = 120.0; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +// Create a clock that ticks 120 times per second. In this case, +// each tick is one musical beat. We can use a tick to represent any +// arbitrary amount of time. +let mut clock = manager.add_clock(ClockSpeed::TicksPerMinute(TEMPO))?; +// Play a sound 2 ticks (beats) from now. +let sound_data_1 = StaticSoundData::from_file("sound1.ogg")? + .start_time(clock.time() + 2); +manager.play(sound_data_1)?; +// Play a different sound 4 ticks (beats) from now. +let sound_data_2 = StaticSoundData::from_file("sound2.ogg")? + .start_time(clock.time() + 4); +manager.play(sound_data_2)?; +// Start the clock. +clock.start(); +# Result::<(), Box>::Ok(()) +``` + +## Features + +The Kira crate has the following feature flags: + +- `cpal` (enabled by default) - enables the cpal backend and makes it the default for + audio managers. This allows Kira to talk to the operating system to output audio. Most + users should leave this enabled. +- `symphonia` (enabled by default) - allows loading and streaming audio from common + audio formats, like MP3 and WAV. + - Audio formats: `aiff`, `caf`, `isomp4`, `mkv`, `ogg`, `wav` - these correspond to the + Symphonia features of the same name + - Audio codecs: `aac`, `adpcm`, `alac`, `he-aac`, `he-aac-v2`, `flac`, `mp1`, `mp2`, `mp3`, + `mpa`, `opus', `pcm`, `vorbis`, `wavpack` - these correspond to the Symphonia features + of the same name + - `mp3`, `ogg`, `vorbis`, `flac`, `wav`, and `pcm` are enabled by default. + - [See Symphonia's documentation for more information about these flags.](https://github.com/pdeljanov/Symphonia?tab=readme-ov-file#formats-demuxers) +- `serde` - adds `Serialize` and `Deserialize` implementations for the following types: + - [`Capacities`] + - [`ClockSpeed`](crate::clock::ClockSpeed) + - [`DistortionKind`](crate::effect::distortion::DistortionKind) + - [`Easing`] + - [`EndPosition`](crate::sound::EndPosition) + - [`EqFilterKind`](crate::effect::eq_filter::EqFilterKind) + - [`FilterMode`](crate::effect::filter::FilterMode) + - [`Frame`] + - [`Mapping`] + - [`PlaybackPosition`](crate::sound::PlaybackPosition) + - [`PlaybackRate`] + - [`PlaybackState`](crate::sound::PlaybackState) + - [`Region`](crate::sound::Region) + - [`Decibels`] + - [`Waveform`](crate::modulator::lfo::Waveform) +- `assert_no_alloc` - uses the [`assert_no_alloc`](https://crates.io/crates/assert_no_alloc) crate + to cause panics if memory is allocated or deallocated on the audio thread. This is mainly useful + for people developing Kira itself. + +## Performance using the `dev` profile + +By default, Rust programs run with the `dev` profile are not optimized. This can +lead to poor performance of audio playback and long loading times for audio +files. You can alleviate this by building Kira and its audio-related +dependencies with a higher optimization level. Add the following to your +Cargo.toml: + +```toml +[profile.dev.package.kira] +opt-level = 3 + +[profile.dev.package.cpal] +opt-level = 3 + +[profile.dev.package.symphonia] +opt-level = 3 + +[profile.dev.package.symphonia-bundle-mp3] +opt-level = 3 + +[profile.dev.package.symphonia-format-ogg] +opt-level = 3 + +[profile.dev.package.symphonia-codec-vorbis] +opt-level = 3 + +[profile.dev.package.symphonia-bundle-flac] +opt-level = 3 + +[profile.dev.package.symphonia-format-wav] +opt-level = 3 + +[profile.dev.package.symphonia-codec-pcm] +opt-level = 3 +``` + +You can also build all of your projects with a higher optimization level by +using this snippet instead: + +```toml +[profile.dev.package."*"] +opt-level = 3 +``` + +Building dependencies with a higher optimization level does increase compile +times, but only when compiling your project from scratch. If you only make +changes to your crate, you're not recompiling the dependencies, so you don't +suffer from the longer compilation step in that case. Building dependencies +optimized and the main crate unoptimized can be a good balance of performance +and compile times for games. +*/ + +#![cfg_attr(docsrs, feature(doc_cfg))] +#![allow(clippy::collapsible_if)] +#![allow(clippy::collapsible_else_if)] +#![allow(clippy::doc_lazy_continuation)] +#![allow(clippy::large_enum_variant)] +#![allow(clippy::enum_variant_names)] +#![warn(clippy::todo)] +#![warn(missing_docs)] +#![allow(clippy::tabs_in_doc_comments)] + +pub mod backend; +pub mod clock; +pub mod command; +mod decibels; +pub mod effect; +mod error; +mod frame; +pub mod info; +pub mod listener; +mod manager; +mod mix; +pub mod modulator; +mod panning; +mod parameter; +mod playback_rate; +mod playback_state_manager; +mod semitones; +pub mod sound; +mod start_time; +#[cfg(test)] +mod test_helpers; +pub mod track; +mod tween; +mod value; + +pub use backend::DefaultBackend; +pub use decibels::*; +pub use error::*; +pub use frame::*; +pub use manager::*; +pub use mix::*; +pub use panning::*; +pub use parameter::*; +pub use pastey; +pub use playback_rate::*; +pub use semitones::*; +pub use start_time::*; +pub use tween::*; +pub use value::*; diff --git a/third_party/kira/src/listener.rs b/third_party/kira/src/listener.rs new file mode 100644 index 00000000..6041fa7f --- /dev/null +++ b/third_party/kira/src/listener.rs @@ -0,0 +1,109 @@ +/*! +Types related to spatial listeners. + +To create a listener, use [`AudioManager::add_listener`](crate::AudioManager::add_listener). + +For more information, see the documentation on [spatial mixer tracks](crate::track#spatial-tracks). +*/ + +mod handle; + +use atomic_arena::Key; +pub use handle::*; + +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +use glam::{Quat, Vec3}; + +use crate::{ + Parameter, Value, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + info::Info, +}; + +/// A unique identifier for a listener. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ListenerId(pub(crate) Key); + +pub(crate) struct Listener { + pub shared: Arc, + pub position: Parameter, + pub orientation: Parameter, + pub command_readers: CommandReaders, +} + +impl Listener { + pub fn new( + id: ListenerId, + position: Value, + orientation: Value, + ) -> (Self, ListenerHandle) { + let shared = Arc::new(ListenerShared::new()); + let (command_writers, command_readers) = command_writers_and_readers(); + ( + Self { + shared: shared.clone(), + position: Parameter::new(position, Vec3::ZERO), + orientation: Parameter::new(orientation, Quat::IDENTITY), + command_readers, + }, + ListenerHandle { + id, + shared, + command_writers, + }, + ) + } + + pub fn on_start_processing(&mut self) { + read_commands_into_parameters!(self, position, orientation); + } + + pub(crate) fn update(&mut self, dt: f64, info: &Info) { + self.position.update(dt, info); + self.orientation.update(dt, info); + } +} + +impl Default for Listener { + fn default() -> Self { + let (_, command_readers) = command_writers_and_readers(); + Self { + shared: Arc::new(ListenerShared::new()), + position: Parameter::new(Value::Fixed(Vec3::ZERO), Vec3::ZERO), + orientation: Parameter::new(Value::Fixed(Quat::IDENTITY), Quat::IDENTITY), + command_readers, + } + } +} + +#[derive(Debug)] +pub(crate) struct ListenerShared { + removed: AtomicBool, +} + +impl ListenerShared { + pub fn new() -> Self { + Self { + removed: AtomicBool::new(false), + } + } + + #[must_use] + pub fn is_marked_for_removal(&self) -> bool { + self.removed.load(Ordering::SeqCst) + } + + pub fn mark_for_removal(&self) { + self.removed.store(true, Ordering::SeqCst); + } +} + +command_writers_and_readers! { + set_position: ValueChangeCommand, + set_orientation: ValueChangeCommand, +} diff --git a/third_party/kira/src/listener/handle.rs b/third_party/kira/src/listener/handle.rs new file mode 100644 index 00000000..fb55a418 --- /dev/null +++ b/third_party/kira/src/listener/handle.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use crate::{Tween, Value, command::ValueChangeCommand}; + +use super::{CommandWriters, ListenerId, ListenerShared}; + +/// Controls a listener. +/// +/// When a [`ListenerHandle`] is dropped, the corresponding +/// listener will be removed. +#[derive(Debug)] +pub struct ListenerHandle { + pub(crate) id: ListenerId, + pub(crate) shared: Arc, + pub(crate) command_writers: CommandWriters, +} + +impl ListenerHandle { + /// Gets the unique identifier for this listener. + pub fn id(&self) -> ListenerId { + self.id + } + + /// Sets the location of the listener in the spatial scene. + pub fn set_position(&mut self, position: impl Into>>, tween: Tween) { + let position: Value> = position.into(); + self.command_writers.set_position.write(ValueChangeCommand { + target: position.to_(), + tween, + }) + } + + /// Sets the rotation of the listener. + /// + /// An unrotated listener should face in the negative Z direction with + /// positive X to the right and positive Y up. + pub fn set_orientation( + &mut self, + orientation: impl Into>>, + tween: Tween, + ) { + let orientation: Value> = orientation.into(); + self.command_writers + .set_orientation + .write(ValueChangeCommand { + target: orientation.to_(), + tween, + }) + } +} + +impl Drop for ListenerHandle { + fn drop(&mut self) { + self.shared.mark_for_removal(); + } +} + +impl From<&ListenerHandle> for ListenerId { + fn from(value: &ListenerHandle) -> Self { + value.id() + } +} diff --git a/third_party/kira/src/manager.rs b/third_party/kira/src/manager.rs new file mode 100644 index 00000000..0a63ffe1 --- /dev/null +++ b/third_party/kira/src/manager.rs @@ -0,0 +1,330 @@ +//! The main entrypoint for controlling audio from gameplay code. +//! +//! In order to play audio, you'll need to create an [`AudioManager`]. +//! The [`AudioManager`] keeps track of playing sounds and manages other +//! resources like clocks, mixer tracks, and spatial scenes. Once the +//! [`AudioManager`] is dropped, its audio output will be stopped. + +mod settings; + +pub use settings::*; + +use std::sync::{Arc, atomic::Ordering}; + +use crate::{ + PlaySoundError, ResourceLimitReached, Value, + backend::{ + Backend, DefaultBackend, Renderer, RendererShared, + resources::{ResourceControllers, create_resources}, + }, + clock::{Clock, ClockHandle, ClockId, ClockSpeed}, + listener::{Listener, ListenerHandle, ListenerId}, + modulator::{ModulatorBuilder, ModulatorId}, + sound::SoundData, + track::{ + MainTrackHandle, SendTrackBuilder, SendTrackHandle, SendTrackId, SpatialTrackBuilder, + SpatialTrackHandle, TrackBuilder, TrackHandle, + }, +}; + +/// Controls audio from gameplay code. +pub struct AudioManager { + backend: B, + resource_controllers: ResourceControllers, + renderer_shared: Arc, + internal_buffer_size: usize, +} + +impl AudioManager { + /** + Creates a new [`AudioManager`]. + + # Examples + + Create an [`AudioManager`] using the [`DefaultBackend`] and the + default settings: + + ```no_run + use kira::{AudioManager, AudioManagerSettings, DefaultBackend}; + + let audio_manager = AudioManager::::new(AudioManagerSettings::default())?; + # Result::<(), Box>::Ok(()) + ``` + + Create an [`AudioManager`] with a reverb effect on the main mixer track: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + track::MainTrackBuilder, + effect::reverb::ReverbBuilder, + }; + + let audio_manager = AudioManager::::new(AudioManagerSettings { + main_track_builder: MainTrackBuilder::new().with_effect(ReverbBuilder::new()), + ..Default::default() + })?; + # Result::<(), Box>::Ok(()) + ``` + */ + pub fn new(settings: AudioManagerSettings) -> Result { + let (mut backend, sample_rate) = + B::setup(settings.backend_settings, settings.internal_buffer_size)?; + let renderer_shared = Arc::new(RendererShared::new(sample_rate)); + let (resources, resource_controllers) = create_resources( + settings.capacities, + settings.main_track_builder, + sample_rate, + settings.internal_buffer_size, + ); + let renderer = Renderer::new( + renderer_shared.clone(), + settings.internal_buffer_size, + resources, + ); + backend.start(renderer)?; + Ok(Self { + backend, + resource_controllers, + renderer_shared, + internal_buffer_size: settings.internal_buffer_size, + }) + } + + /** + Plays a sound. + + # Examples + + ```no_run + # use kira::{AudioManager, AudioManagerSettings, DefaultBackend}; + use kira::sound::static_sound::{StaticSoundData, StaticSoundSettings}; + + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let sound_data = StaticSoundData::from_file("sound.ogg")?; + manager.play(sound_data)?; + # Result::<(), Box>::Ok(()) + ``` + */ + pub fn play( + &mut self, + sound_data: D, + ) -> Result> { + self.main_track().play(sound_data) + } + + /// Creates a mixer sub-track. + pub fn add_sub_track( + &mut self, + builder: TrackBuilder, + ) -> Result { + let (mut track, handle) = + builder.build(self.renderer_shared.clone(), self.internal_buffer_size); + track.init_effects(self.renderer_shared.sample_rate.load(Ordering::SeqCst)); + self.resource_controllers + .sub_track_controller + .insert(track)?; + Ok(handle) + } + + /// Adds a spatial mixer sub-track. + pub fn add_spatial_sub_track( + &mut self, + listener: impl Into, + position: impl Into>>, + builder: SpatialTrackBuilder, + ) -> Result { + let (mut track, handle) = builder.build( + self.renderer_shared.clone(), + self.internal_buffer_size, + listener.into(), + position.into().to_(), + ); + track.init_effects(self.renderer_shared.sample_rate.load(Ordering::SeqCst)); + self.resource_controllers + .sub_track_controller + .insert(track)?; + Ok(handle) + } + + /// Creates a mixer send track. + pub fn add_send_track( + &mut self, + builder: SendTrackBuilder, + ) -> Result { + let key = self + .resource_controllers + .send_track_controller + .try_reserve()?; + let id = SendTrackId(key); + let (mut track, handle) = builder.build(id, self.internal_buffer_size); + track.init_effects(self.renderer_shared.sample_rate.load(Ordering::SeqCst)); + self.resource_controllers + .send_track_controller + .insert_with_key(key, track); + Ok(handle) + } + + /** + Creates a clock. + + # Examples + + ```no_run + # use kira::{AudioManager, AudioManagerSettings, DefaultBackend}; + use kira::clock::ClockSpeed; + + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let clock = manager.add_clock(ClockSpeed::TicksPerMinute(120.0))?; + # Result::<(), Box>::Ok(()) + ``` + */ + pub fn add_clock( + &mut self, + speed: impl Into>, + ) -> Result { + let key = self.resource_controllers.clock_controller.try_reserve()?; + let id = ClockId(key); + let (clock, handle) = Clock::new(speed.into(), id); + self.resource_controllers + .clock_controller + .insert_with_key(key, clock); + Ok(handle) + } + + /** + Creates a modulator. + + # Examples + + ```no_run + # use kira::{AudioManager, AudioManagerSettings, DefaultBackend}; + use kira::modulator::lfo::LfoBuilder; + + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let modulator = manager.add_modulator(LfoBuilder::new())?; + # Result::<(), Box>::Ok(()) + ``` + */ + pub fn add_modulator( + &mut self, + builder: Builder, + ) -> Result { + let key = self + .resource_controllers + .modulator_controller + .try_reserve()?; + let id = ModulatorId(key); + let (modulator, handle) = builder.build(id); + self.resource_controllers + .modulator_controller + .insert_with_key(key, modulator); + Ok(handle) + } + + /** + Creates a listener. + + # Examples + + ```no_run + # use kira::{AudioManager, AudioManagerSettings, DefaultBackend}; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + // This example uses `glam`, but you can use any math library that has interoperability + // with `mint`. + let listener = manager.add_listener(glam::vec3(0.0, 0.0, 0.0), glam::Quat::IDENTITY)?; + # Result::<(), Box>::Ok(()) + ``` + */ + pub fn add_listener( + &mut self, + position: impl Into>>, + orientation: impl Into>>, + ) -> Result { + let key = self + .resource_controllers + .listener_controller + .try_reserve()?; + let id = ListenerId(key); + let (listener, handle) = Listener::new(id, position.into().to_(), orientation.into().to_()); + self.resource_controllers + .listener_controller + .insert_with_key(key, listener); + Ok(handle) + } + + /** + Returns a handle to the main mixer track. + + # Examples + + Use the main track handle to adjust the volume of all audio: + + ```no_run + # use kira::{AudioManager, AudioManagerSettings, DefaultBackend}; + use kira::Tween; + + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + manager.main_track().set_volume(-6.0, Tween::default()); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use] + pub fn main_track(&mut self) -> &mut MainTrackHandle { + &mut self.resource_controllers.main_track_handle + } + + /// Returns the number of mixer sub-tracks that can exist at a time. + #[must_use] + pub fn sub_track_capacity(&self) -> usize { + self.resource_controllers.sub_track_controller.capacity() + } + + /// Returns the number of mixer send tracks that can exist at a time. + #[must_use] + pub fn send_track_capacity(&self) -> usize { + self.resource_controllers.send_track_controller.capacity() + } + + /// Returns the number of clocks that can exist at a time. + #[must_use] + pub fn clock_capacity(&self) -> usize { + self.resource_controllers.clock_controller.capacity() + } + + /// Returns the number of modulators that can exist at a time. + #[must_use] + pub fn modulator_capacity(&self) -> usize { + self.resource_controllers.modulator_controller.capacity() + } + + /// Returns the number of mixer sub-tracks that currently exist. + #[must_use] + pub fn num_sub_tracks(&self) -> usize { + self.resource_controllers.sub_track_controller.len() + } + + /// Returns the number of mixer send tracks that currently exist. + #[must_use] + pub fn num_send_tracks(&self) -> usize { + self.resource_controllers.send_track_controller.len() + } + + /// Returns the number of clocks that currently exist. + #[must_use] + pub fn num_clocks(&self) -> usize { + self.resource_controllers.clock_controller.len() + } + + /// Returns the number of modulators that currently exist. + #[must_use] + pub fn num_modulators(&self) -> usize { + self.resource_controllers.modulator_controller.len() + } + + /// Returns a mutable reference to this manager's backend. + #[must_use] + pub fn backend_mut(&mut self) -> &mut B { + &mut self.backend + } +} diff --git a/third_party/kira/src/manager/settings.rs b/third_party/kira/src/manager/settings.rs new file mode 100644 index 00000000..1352ee31 --- /dev/null +++ b/third_party/kira/src/manager/settings.rs @@ -0,0 +1,65 @@ +use crate::track::MainTrackBuilder; + +use crate::backend::Backend; + +/// Specifies how many of each resource type an audio context +/// can have. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Capacities { + /// The maximum number of mixer sub-tracks that can exist at a time. + pub sub_track_capacity: usize, + /// The maximum number of mixer send tracks that can exist at a time. + pub send_track_capacity: usize, + /// The maximum number of clocks that can exist at a time. + pub clock_capacity: usize, + /// The maximum number of modulators that can exist at a time. + pub modulator_capacity: usize, + /// The maximum number of listeners that can exist at a time. + pub listener_capacity: usize, +} + +impl Default for Capacities { + fn default() -> Self { + Self { + sub_track_capacity: 128, + send_track_capacity: 16, + clock_capacity: 8, + modulator_capacity: 16, + listener_capacity: 8, + } + } +} + +/// Settings for an [`AudioManager`](super::AudioManager). +pub struct AudioManagerSettings { + /// Specifies how many of each resource type an audio context + /// can have. + pub capacities: Capacities, + /// Configures the main mixer track. + pub main_track_builder: MainTrackBuilder, + /// Determines how often modulators and clocks will be updated (in samples). + /// + /// At the default size of 128 samples, at a sample rate of 44100hz, + /// modulators and clocks will update about every 3 milliseconds. + /// + /// Decreasing this value increases the precision of clocks and modulators + /// at the expense of higher CPU usage. + pub internal_buffer_size: usize, + /// Configures the backend. + pub backend_settings: B::Settings, +} + +impl Default for AudioManagerSettings +where + B::Settings: Default, +{ + fn default() -> Self { + Self { + capacities: Capacities::default(), + main_track_builder: MainTrackBuilder::default(), + internal_buffer_size: 128, + backend_settings: B::Settings::default(), + } + } +} diff --git a/third_party/kira/src/mix.rs b/third_party/kira/src/mix.rs new file mode 100644 index 00000000..c59c813c --- /dev/null +++ b/third_party/kira/src/mix.rs @@ -0,0 +1,129 @@ +use std::ops::{ + Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Rem, RemAssign, Sub, SubAssign, +}; + +use crate::{Value, tween::Tweenable}; + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +/** +An amount to blend the "dry" and "wet" outputs from an effect. + +The "dry" signal is the audio before the effect is applied. +The "wet" signal is the audio after the effect is applied. + +Valid mix values range from `0.0` to `1.0`, where `0.0` is +the dry signal only, `1.0` is the wet signal only, and `0.5` +is an equal mix of both. +*/ +pub struct Mix(pub f32); + +impl Mix { + /// Only output the dry signal. + pub const DRY: Self = Self(0.0); + /// Only output the wet signal. + pub const WET: Self = Self(1.0); +} + +impl Tweenable for Mix { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + Self(Tweenable::interpolate(a.0, b.0, amount)) + } +} + +impl From for Mix { + fn from(value: f32) -> Self { + Self(value) + } +} + +impl From for Value { + fn from(value: f32) -> Self { + Self::Fixed(Mix(value)) + } +} + +impl From for Value { + fn from(value: Mix) -> Self { + Self::Fixed(value) + } +} + +impl Add for Mix { + type Output = Mix; + + fn add(self, rhs: Mix) -> Self::Output { + Self(self.0 + rhs.0) + } +} + +impl AddAssign for Mix { + fn add_assign(&mut self, rhs: Mix) { + self.0 += rhs.0; + } +} + +impl Sub for Mix { + type Output = Mix; + + fn sub(self, rhs: Mix) -> Self::Output { + Self(self.0 - rhs.0) + } +} + +impl SubAssign for Mix { + fn sub_assign(&mut self, rhs: Mix) { + self.0 -= rhs.0; + } +} + +impl Mul for Mix { + type Output = Mix; + + fn mul(self, rhs: f32) -> Self::Output { + Self(self.0 * rhs) + } +} + +impl MulAssign for Mix { + fn mul_assign(&mut self, rhs: f32) { + self.0 *= rhs; + } +} + +impl Div for Mix { + type Output = Mix; + + fn div(self, rhs: f32) -> Self::Output { + Self(self.0 / rhs) + } +} + +impl DivAssign for Mix { + fn div_assign(&mut self, rhs: f32) { + self.0 /= rhs; + } +} + +impl Neg for Mix { + type Output = Mix; + + fn neg(self) -> Self::Output { + Self(-self.0) + } +} + +impl Rem for Mix { + type Output = Mix; + + fn rem(self, rhs: f32) -> Self::Output { + Self(self.0 % rhs) + } +} + +impl RemAssign for Mix { + fn rem_assign(&mut self, rhs: f32) { + self.0 %= rhs; + } +} diff --git a/third_party/kira/src/modulator.rs b/third_party/kira/src/modulator.rs new file mode 100644 index 00000000..aa1071ee --- /dev/null +++ b/third_party/kira/src/modulator.rs @@ -0,0 +1,205 @@ +/*! +Global values that parameters (like volume and playback rate) can be linked to. + +Any type that implements [`ModulatorBuilder`] can be added to an audio manager by +using [`AudioManager::add_modulator`](crate::AudioManager::add_modulator). + +If needed, you can create custom modulators by implementing the [`ModulatorBuilder`] +and [`Modulator`] traits. + +# Why modulators? + +Many properties of things in Kira, like the volumes of sounds, can be smoothly +transitioned from one value to another without the use of modulators. Modulators +become handy when: + +- You want to control multiple properties of objects in lockstep +- You need to change a property in a way that's more complicated than a simple + transition + +# Tweener example + +Let's say we have a music track with two layers that play simultaneously: +drums and piano. When the player character enters water, we want the music +to sound "underwater", so we'll fade out the drums and make the piano sound +more muffled using a low-pass filter. + +For this situation, a [`tweener`] is appropriate. Tweeners hold a value +that doesn't change until we tell it to, and the value can be smoothly +transitioned. + +The tweener is an input value that will generate multiple output values: +the drums volume and piano filter frequency. When the tweener is set to +`0.0`, that represents that the player is not underwater, and when it's +`1.0`, the player is submerged. (These are arbitrary values.) + +| Tweener value | Drums volume | Piano filter frequency | +|---------------|--------------|------------------------| +| 0.0 | 1.0 | 20,000 Hz | +| 1.0 | 0.0 | 500 Hz | + +First, let's create the tweener: + +```no_run +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let mut tweener = manager.add_modulator(TweenerBuilder { initial_value: 0.0 })?; +# Result::<(), Box>::Ok(()) +``` + +Next, we'll create a mixer track with a low-pass filter effect. The piano will play +on this track so we can make it sound more or less muffled. + +```no_run +# use kira::{ +# AudioManager, AudioManagerSettings, DefaultBackend, +# modulator::tweener::TweenerBuilder, +# }; +use kira::{ + effect::filter::FilterBuilder, + Mapping, Value, Easing, + track::TrackBuilder, +}; + +# let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +# let mut tweener = manager.add_modulator(TweenerBuilder { initial_value: 0.0 })?; +let filter_builder = FilterBuilder::new() + .cutoff(Value::from_modulator(&tweener, Mapping { + input_range: (0.0, 1.0), + output_range: (20_000.0, 500.0), + easing: Easing::Linear, + })); +let mut piano_track = manager.add_sub_track(TrackBuilder::new().with_effect(filter_builder))?; +# Result::<(), Box>::Ok(()) +``` + +We use a `Mapping` to map the input values of the tweener to the output values +of the filter cutoff frequency. + +Finally, we'll play the sounds: + +```no_run +# use kira::{ +# AudioManager, AudioManagerSettings, DefaultBackend, +# modulator::tweener::TweenerBuilder, +# effect::filter::FilterBuilder, +# track::TrackBuilder, +# Mapping, Value, Easing, +# Decibels, +# }; +use kira::sound::static_sound::StaticSoundData; + +# let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +# let mut tweener = manager.add_modulator(TweenerBuilder { initial_value: 0.0 })?; +# let filter_builder = FilterBuilder::new() +# .cutoff(Value::from_modulator(&tweener, Mapping { +# input_range: (0.0, 1.0), +# output_range: (20_000.0, 500.0), +# easing: Easing::Linear, +# })); +# let mut piano_track = manager.add_sub_track(TrackBuilder::new().with_effect(filter_builder))?; +piano_track.play(StaticSoundData::from_file("piano.ogg")?)?; +manager.play( + StaticSoundData::from_file("drums.ogg")? + .volume(Value::from_modulator(&tweener, Mapping { + input_range: (0.0, 1.0), + output_range: (Decibels::IDENTITY, Decibels::SILENCE), + easing: Easing::Linear, + })) +)?; +# Result::<(), Box>::Ok(()) +``` + +Notice how we also use a `Mapping` to map the input range of the tweener to the +output values of the sound volume. + +Once the player goes underwater, we can smoothly transition the tweener's value from +`0.0` to `1.0`, which will automatically change the drum volume and piano filter frequency. + +```no_run +# use kira::{ +# AudioManager, AudioManagerSettings, DefaultBackend, +# modulator::tweener::TweenerBuilder, +# track::TrackBuilder, +# effect::filter::FilterBuilder, +# sound::static_sound::StaticSoundData, +# Mapping, Value, Easing, +# Decibels, +# }; +use kira::Tween; +use std::time::Duration; + +# let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +# let mut tweener = manager.add_modulator(TweenerBuilder { initial_value: 0.0 })?; +# let filter_builder = FilterBuilder::new() +# .cutoff(Value::from_modulator(&tweener, Mapping { +# input_range: (0.0, 1.0), +# output_range: (20_000.0, 500.0), +# easing: Easing::Linear, +# })); +# let mut piano_track = manager.add_sub_track(TrackBuilder::new().with_effect(filter_builder))?; +# piano_track.play(StaticSoundData::from_file("piano.ogg")?)?; +# manager.play( +# StaticSoundData::from_file("drums.ogg")? +# .volume(Value::from_modulator(&tweener, Mapping { +# input_range: (0.0, 1.0), +# output_range: (Decibels::IDENTITY, Decibels::SILENCE), +# easing: Easing::Linear, +# })) +# )?; +tweener.set(1.0, Tween { + duration: Duration::from_secs(3), + ..Default::default() +}); +# Result::<(), Box>::Ok(()) +``` + +*/ + +pub mod lfo; +pub mod tweener; + +use atomic_arena::Key; + +use crate::info::Info; + +/// Configures a modulator. +pub trait ModulatorBuilder { + /// Allows the user to control the modulator from gameplay code. + type Handle; + + /// Creates the modulator and a handle to the modulator. + #[must_use] + fn build(self, id: ModulatorId) -> (Box, Self::Handle); +} + +/// Produces a stream of values that a parameter can be linked to. +pub trait Modulator: Send { + /// Called whenever a new batch of audio samples is requested by the backend. + /// + /// This is a good place to put code that needs to run fairly frequently, + /// but not for every single audio sample. + fn on_start_processing(&mut self) {} + + /// Updates the modulator. + /// + /// `dt` is the time that's elapsed since the previous round of + /// processing (in seconds). + fn update(&mut self, dt: f64, info: &Info); + + /// Returns the current output of the modulator. + #[must_use] + fn value(&self) -> f64; + + /// Whether the modulator can be removed from the audio context. + #[must_use] + fn finished(&self) -> bool; +} + +/// A unique identifier for a modulator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ModulatorId(pub(crate) Key); diff --git a/third_party/kira/src/modulator/lfo.rs b/third_party/kira/src/modulator/lfo.rs new file mode 100644 index 00000000..9ff715d9 --- /dev/null +++ b/third_party/kira/src/modulator/lfo.rs @@ -0,0 +1,142 @@ +//! Oscillates back and forth. + +#[cfg(test)] +mod test; + +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use std::{ + f64::consts::TAU, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + +use crate::{ + Parameter, + command::{ValueChangeCommand, read_commands_into_parameters}, + command_writers_and_readers, + info::Info, +}; + +use super::Modulator; + +struct Lfo { + waveform: Waveform, + frequency: Parameter, + amplitude: Parameter, + offset: Parameter, + command_readers: CommandReaders, + shared: Arc, + phase: f64, + value: f64, +} + +impl Lfo { + #[must_use] + fn new(builder: &LfoBuilder, command_readers: CommandReaders, shared: Arc) -> Self { + Self { + waveform: builder.waveform, + frequency: Parameter::new(builder.frequency, 2.0), + amplitude: Parameter::new(builder.amplitude, 1.0), + offset: Parameter::new(builder.offset, 0.0), + command_readers, + shared, + phase: builder.starting_phase / TAU, + value: 0.0, + } + } +} + +impl Modulator for Lfo { + fn on_start_processing(&mut self) { + read_commands_into_parameters!(self, frequency, amplitude, offset); + if let Some(waveform) = self.command_readers.set_waveform.read() { + self.waveform = waveform; + } + if let Some(phase) = self.command_readers.set_phase.read() { + self.phase = phase / TAU; + } + } + + fn update(&mut self, dt: f64, info: &Info) { + self.frequency.update(dt, info); + self.amplitude.update(dt, info); + self.offset.update(dt, info); + self.phase += dt * self.frequency.value(); + self.phase %= 1.0; + self.value = self.offset.value() + self.amplitude.value() * self.waveform.value(self.phase); + } + + fn value(&self) -> f64 { + self.value + } + + fn finished(&self) -> bool { + self.shared.removed.load(Ordering::SeqCst) + } +} + +/// Describes an oscillation pattern. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Waveform { + /// The value moves back and forth smoothly. + Sine, + /// The value moves back and forth at a constant speed. + Triangle, + /// The value moves gradually in one direction, then abruptly jumps in the other. + Saw, + /// The value jumps back and forth between two values. + Pulse { + /// The ratio between how much time the oscillator spends on one value vs. the other. + /// + /// This should be a number between `0.0` and `1.0`. A value of `0.5` means the oscillator + /// spends an equal amount of time at both values. + width: f64, + }, +} + +impl Waveform { + #[must_use] + fn value(self, phase: f64) -> f64 { + match self { + Waveform::Sine => (phase * TAU).sin(), + Waveform::Triangle => ((phase + 0.75).fract() - 0.5).abs() * 4.0 - 1.0, + Waveform::Saw => (phase + 0.5).fract() * 2.0 - 1.0, + Waveform::Pulse { width } => { + if phase < width { + 1.0 + } else { + -1.0 + } + } + } + } +} + +#[derive(Debug)] +struct LfoShared { + removed: AtomicBool, +} + +impl LfoShared { + fn new() -> Self { + Self { + removed: AtomicBool::new(false), + } + } +} + +command_writers_and_readers! { + set_waveform: Waveform, + set_frequency: ValueChangeCommand, + set_amplitude: ValueChangeCommand, + set_offset: ValueChangeCommand, + set_phase: f64, +} diff --git a/third_party/kira/src/modulator/lfo/builder.rs b/third_party/kira/src/modulator/lfo/builder.rs new file mode 100644 index 00000000..0e1ecacd --- /dev/null +++ b/third_party/kira/src/modulator/lfo/builder.rs @@ -0,0 +1,117 @@ +use std::sync::Arc; + +use crate::{ + Value, + modulator::{Modulator, ModulatorBuilder, ModulatorId}, +}; + +use super::{Lfo, LfoShared, Waveform, command_writers_and_readers, handle::LfoHandle}; + +/// Configures an LFO modulator. +pub struct LfoBuilder { + /// The oscillation pattern. + pub waveform: Waveform, + /// How quickly the value oscillates. + pub frequency: Value, + /// How much the value oscillates. + /// + /// An amplitude of `2.0` means the modulator will reach a maximum + /// value of `2.0` and a minimum value of `-2.0`. + pub amplitude: Value, + /// The constant value the modulator is offset by. + /// + /// An LFO with an offset of `1.0` and an amplitude of `0.5` will reach + /// a maximum value of `1.5` and a minimum value of `0.5`. + pub offset: Value, + /// The phase the LFO should start at (in radians). + /// + /// This determines when in the oscillation the modulator will start. + pub starting_phase: f64, +} + +impl LfoBuilder { + /// Creates a new [`LfoBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets the oscillation pattern. + #[must_use = "This method consumes self and returns a modified LfoBuilder, so the return value should be used"] + pub fn waveform(self, waveform: Waveform) -> Self { + Self { waveform, ..self } + } + + /// Sets how quickly the value oscillates. + #[must_use = "This method consumes self and returns a modified LfoBuilder, so the return value should be used"] + pub fn frequency(self, frequency: impl Into>) -> Self { + Self { + frequency: frequency.into(), + ..self + } + } + + /// Sets how much the value oscillates. + /// + /// An amplitude of `2.0` means the modulator will reach a maximum + /// value of `2.0` and a minimum value of `-2.0`. + #[must_use = "This method consumes self and returns a modified LfoBuilder, so the return value should be used"] + pub fn amplitude(self, amplitude: impl Into>) -> Self { + Self { + amplitude: amplitude.into(), + ..self + } + } + + /// Sets a constant value that the modulator is offset by. + /// + /// An LFO with an offset of `1.0` and an amplitude of `0.5` will reach + /// a maximum value of `1.5` and a minimum value of `0.5`. + #[must_use = "This method consumes self and returns a modified LfoBuilder, so the return value should be used"] + pub fn offset(self, offset: impl Into>) -> Self { + Self { + offset: offset.into(), + ..self + } + } + + /// Sets the phase the LFO should start at (in radians). + /// + /// This determines when in the oscillation the modulator will start. + #[must_use = "This method consumes self and returns a modified LfoBuilder, so the return value should be used"] + pub fn starting_phase(self, starting_phase: f64) -> Self { + Self { + starting_phase, + ..self + } + } +} + +impl Default for LfoBuilder { + fn default() -> Self { + Self { + waveform: Waveform::Sine, + frequency: Value::Fixed(2.0), + amplitude: Value::Fixed(1.0), + offset: Value::Fixed(0.0), + starting_phase: 0.0, + } + } +} + +impl ModulatorBuilder for LfoBuilder { + type Handle = LfoHandle; + + fn build(self, id: ModulatorId) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + let shared = Arc::new(LfoShared::new()); + ( + Box::new(Lfo::new(&self, command_readers, shared.clone())), + LfoHandle { + id, + command_writers, + shared, + }, + ) + } +} diff --git a/third_party/kira/src/modulator/lfo/handle.rs b/third_party/kira/src/modulator/lfo/handle.rs new file mode 100644 index 00000000..89bf0305 --- /dev/null +++ b/third_party/kira/src/modulator/lfo/handle.rs @@ -0,0 +1,60 @@ +use std::sync::{Arc, atomic::Ordering}; + +use crate::{command::handle_param_setters, modulator::ModulatorId}; + +use super::{CommandWriters, LfoShared, Waveform}; + +/// Controls an LFO modulator. +#[derive(Debug)] +pub struct LfoHandle { + pub(super) id: ModulatorId, + pub(super) command_writers: CommandWriters, + pub(super) shared: Arc, +} + +impl LfoHandle { + /// Returns the unique identifier for the modulator. + #[must_use] + pub fn id(&self) -> ModulatorId { + self.id + } + + /// Sets the oscillation pattern. + pub fn set_waveform(&mut self, waveform: Waveform) { + self.command_writers.set_waveform.write(waveform) + } + + handle_param_setters! { + /// Sets how quickly the value oscillates. + frequency: f64, + + /// Sets how much the value oscillates. + /// + /// An amplitude of `2.0` means the modulator will reach a maximum + /// value of `2.0` and a minimum value of `-2.0`. + amplitude: f64, + + /// Sets a constant value that the modulator is offset by. + /// + /// An LFO with an offset of `1.0` and an amplitude of `0.5` will reach + /// a maximum value of `1.5` and a minimum value of `0.5`. + offset: f64, + } + + /// Sets the phase of the LFO (in radians). + pub fn set_phase(&mut self, phase: f64) { + self.command_writers.set_phase.write(phase) + } +} + +impl Drop for LfoHandle { + fn drop(&mut self) { + self.shared.removed.store(true, Ordering::SeqCst); + } +} + +impl From<&LfoHandle> for ModulatorId { + fn from(value: &LfoHandle) -> Self { + value.id + } +} diff --git a/third_party/kira/src/modulator/lfo/test.rs b/third_party/kira/src/modulator/lfo/test.rs new file mode 100644 index 00000000..4a588fc9 --- /dev/null +++ b/third_party/kira/src/modulator/lfo/test.rs @@ -0,0 +1,56 @@ +use std::f64::consts::FRAC_1_SQRT_2; + +use approx::assert_relative_eq; + +use crate::modulator::lfo::Waveform; + +#[test] +fn sine() { + test_waveform( + Waveform::Sine, + [ + 0.0, + FRAC_1_SQRT_2, + 1.0, + FRAC_1_SQRT_2, + 0.0, + -FRAC_1_SQRT_2, + -1.0, + -FRAC_1_SQRT_2, + ], + ); +} + +#[test] +fn triangle() { + test_waveform( + Waveform::Triangle, + [0.0, 0.5, 1.0, 0.5, 0.0, -0.5, -1.0, -0.5], + ); +} + +#[test] +fn saw() { + test_waveform( + Waveform::Saw, + [0.0, 0.25, 0.5, 0.75, -1.0, -0.75, -0.5, -0.25], + ); +} + +#[test] +fn pulse() { + test_waveform( + Waveform::Pulse { width: 0.25 }, + [1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0], + ); + test_waveform( + Waveform::Pulse { width: 0.75 }, + [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, -1.0], + ); +} + +fn test_waveform(waveform: Waveform, values: [f64; 8]) { + for (i, value) in values.iter().enumerate() { + assert_relative_eq!(waveform.value(i as f64 / 8.0), *value); + } +} diff --git a/third_party/kira/src/modulator/tweener.rs b/third_party/kira/src/modulator/tweener.rs new file mode 100644 index 00000000..438eab7f --- /dev/null +++ b/third_party/kira/src/modulator/tweener.rs @@ -0,0 +1,134 @@ +//! Smoothly transitions values to other values. + +mod builder; +mod handle; + +#[cfg(test)] +mod test; + +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +pub use builder::*; +pub use handle::*; + +use crate::{ + StartTime, Tween, Tweenable, command_writers_and_readers, + info::{Info, WhenToStart}, +}; + +use super::Modulator; + +struct Tweener { + state: State, + value: f64, + command_readers: CommandReaders, + shared: Arc, +} + +impl Tweener { + #[must_use] + fn new( + initial_value: f64, + command_readers: CommandReaders, + shared: Arc, + ) -> Self { + Self { + state: State::Idle, + value: initial_value, + command_readers, + shared, + } + } + + fn set(&mut self, target: f64, tween: Tween) { + self.state = State::Tweening { + values: (self.value, target), + time: 0.0, + tween, + } + } +} + +impl Modulator for Tweener { + fn on_start_processing(&mut self) { + if let Some((target, tween)) = self.command_readers.set.read() { + self.set(target, tween); + } + } + + fn update(&mut self, dt: f64, info: &Info) { + if let State::Tweening { + values, + time, + tween, + } = &mut self.state + { + let started = match &mut tween.start_time { + StartTime::Immediate => true, + StartTime::Delayed(time_remaining) => { + if time_remaining.is_zero() { + true + } else { + *time_remaining = + time_remaining.saturating_sub(Duration::from_secs_f64(dt)); + false + } + } + StartTime::ClockTime(clock_time) => { + info.when_to_start(*clock_time) == WhenToStart::Now + } + }; + if !started { + return; + } + *time += dt; + if *time >= tween.duration.as_secs_f64() { + self.value = values.1; + self.state = State::Idle; + } else { + self.value = Tweenable::interpolate(values.0, values.1, tween.value(*time)); + } + } + } + + fn value(&self) -> f64 { + self.value + } + + fn finished(&self) -> bool { + self.shared.removed.load(Ordering::SeqCst) + } +} + +enum State { + Idle, + Tweening { + values: (f64, f64), + time: f64, + tween: Tween, + }, +} + +#[derive(Debug)] +struct TweenerShared { + removed: AtomicBool, +} + +impl TweenerShared { + #[must_use] + fn new() -> Self { + Self { + removed: AtomicBool::new(false), + } + } +} + +command_writers_and_readers! { + set: (f64, Tween), +} diff --git a/third_party/kira/src/modulator/tweener/builder.rs b/third_party/kira/src/modulator/tweener/builder.rs new file mode 100644 index 00000000..4d2d6fc4 --- /dev/null +++ b/third_party/kira/src/modulator/tweener/builder.rs @@ -0,0 +1,32 @@ +use std::sync::Arc; + +use crate::modulator::{Modulator, ModulatorBuilder, ModulatorId}; + +use super::{Tweener, TweenerHandle, TweenerShared, command_writers_and_readers}; + +/// Configures a tweener. +pub struct TweenerBuilder { + /// The initial value of the tweener. + pub initial_value: f64, +} + +impl ModulatorBuilder for TweenerBuilder { + type Handle = TweenerHandle; + + fn build(self, id: ModulatorId) -> (Box, Self::Handle) { + let (command_writers, command_readers) = command_writers_and_readers(); + let shared = Arc::new(TweenerShared::new()); + ( + Box::new(Tweener::new( + self.initial_value, + command_readers, + shared.clone(), + )), + TweenerHandle { + id, + command_writers, + shared, + }, + ) + } +} diff --git a/third_party/kira/src/modulator/tweener/handle.rs b/third_party/kira/src/modulator/tweener/handle.rs new file mode 100644 index 00000000..ce80aaa8 --- /dev/null +++ b/third_party/kira/src/modulator/tweener/handle.rs @@ -0,0 +1,38 @@ +use std::sync::{Arc, atomic::Ordering}; + +use crate::{modulator::ModulatorId, tween::Tween}; + +use super::{CommandWriters, TweenerShared}; + +/// Controls a tweener. +#[derive(Debug)] +pub struct TweenerHandle { + pub(super) id: ModulatorId, + pub(super) command_writers: CommandWriters, + pub(super) shared: Arc, +} + +impl TweenerHandle { + /// Returns the unique identifier for the modulator. + pub fn id(&self) -> ModulatorId { + self.id + } + + /// Starts a transition from the current value to a target value with + /// the given tween. + pub fn set(&mut self, target: f64, tween: Tween) { + self.command_writers.set.write((target, tween)) + } +} + +impl From<&TweenerHandle> for ModulatorId { + fn from(handle: &TweenerHandle) -> Self { + handle.id + } +} + +impl Drop for TweenerHandle { + fn drop(&mut self) { + self.shared.removed.store(true, Ordering::SeqCst); + } +} diff --git a/third_party/kira/src/modulator/tweener/test.rs b/third_party/kira/src/modulator/tweener/test.rs new file mode 100644 index 00000000..280d35cc --- /dev/null +++ b/third_party/kira/src/modulator/tweener/test.rs @@ -0,0 +1,149 @@ +use std::time::Duration; + +use atomic_arena::Arena; + +use crate::info::MockInfoBuilder; +use crate::{ + StartTime, Tween, + clock::ClockTime, + modulator::{ModulatorBuilder, ModulatorId, tweener::TweenerBuilder}, +}; + +/// Tests that the basic tweening behavior of a `Tweener` +/// works properly. +#[test] +#[allow(clippy::float_cmp)] +fn tweening() { + let (mut tweener, mut handle) = + TweenerBuilder { initial_value: 0.0 }.build(generate_fake_modulator_id()); + let info = MockInfoBuilder::new().build(); + + // value should not be changing yet + for _ in 0..3 { + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 0.0); + } + + handle.set( + 1.0, + Tween { + duration: Duration::from_secs(2), + ..Default::default() + }, + ); + tweener.on_start_processing(); + + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 0.5); + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 1.0); + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 1.0); +} + +/// Tests that a `Tweener` with a delayed start time waits for +/// that time before it begins tweening. +#[test] +#[allow(clippy::float_cmp)] +fn waits_for_delay() { + let info = MockInfoBuilder::new().build(); + + let (mut tweener, mut handle) = + TweenerBuilder { initial_value: 0.0 }.build(generate_fake_modulator_id()); + handle.set( + 1.0, + Tween { + start_time: StartTime::Delayed(Duration::from_secs(2)), + duration: Duration::from_secs(1), + ..Default::default() + }, + ); + tweener.on_start_processing(); + + // value should not be changing yet + for _ in 0..2 { + assert_eq!(tweener.value(), 0.0); + tweener.update(1.0, &info); + } + + // the tween should start now + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 1.0); +} + +/// Tests that a `Tweener` with a clock time set as +/// the start time waits for that time before it +/// begins tweening. +#[test] +#[allow(clippy::float_cmp)] +fn waits_for_start_time() { + let (mut tweener, mut handle) = + TweenerBuilder { initial_value: 0.0 }.build(generate_fake_modulator_id()); + let mut info_builder = MockInfoBuilder::new(); + let clock_id_1 = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + handle.set( + 1.0, + Tween { + start_time: StartTime::ClockTime(ClockTime { + clock: clock_id_1, + ticks: 2, + fraction: 0.0, + }), + duration: Duration::from_secs(1), + ..Default::default() + }, + ); + tweener.on_start_processing(); + + // value should not be changing yet + for _ in 0..3 { + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 0.0); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.add_clock(true, 0, 0.0); + builder.build() + }; + + // the tween is set to start at tick 2, so it should not + // start yet + for _ in 0..3 { + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 0.0); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.add_clock(true, 2, 0.0); + builder.build() + }; + + // a different clock reached tick 2, so the tween should not + // start yet + for _ in 0..3 { + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 0.0); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 2, 0.0); + builder.add_clock(true, 2, 0.0); + builder.build() + }; + + // the tween should start now + tweener.update(1.0, &info); + assert_eq!(tweener.value(), 1.0); +} + +fn generate_fake_modulator_id() -> ModulatorId { + let arena = Arena::<()>::new(1); + ModulatorId(arena.controller().try_reserve().unwrap()) +} diff --git a/third_party/kira/src/panning.rs b/third_party/kira/src/panning.rs new file mode 100644 index 00000000..203b81bc --- /dev/null +++ b/third_party/kira/src/panning.rs @@ -0,0 +1,134 @@ +use std::ops::{ + Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Rem, RemAssign, Sub, SubAssign, +}; + +use crate::{Value, tween::Tweenable}; + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +/// The stereo positioning of a sound. +/// +/// Valid panning values range from `-1.0` to `1.0`. A value of `-1.0` +/// will cause a sound to only be output from the left speaker. A value +/// of `1.0` will cause a sound to only be output from the right speaker. +/// A value of `0.0` will cause a sound to be played at an equal volume +/// from both speakers. +pub struct Panning(pub f32); + +impl Panning { + /// Play the sound from the left speaker only. + pub const LEFT: Self = Self(-1.0); + /// Play the sound from both speakers at the same volume. + pub const CENTER: Self = Self(0.0); + /// Play the sound from the right speaker only. + pub const RIGHT: Self = Self(1.0); +} + +impl Default for Panning { + fn default() -> Self { + Self::CENTER + } +} + +impl Tweenable for Panning { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + Self(Tweenable::interpolate(a.0, b.0, amount)) + } +} + +impl From for Panning { + fn from(value: f32) -> Self { + Self(value) + } +} + +impl From for Value { + fn from(value: f32) -> Self { + Self::Fixed(Panning(value)) + } +} + +impl From for Value { + fn from(value: Panning) -> Self { + Self::Fixed(value) + } +} + +impl Add for Panning { + type Output = Panning; + + fn add(self, rhs: Panning) -> Self::Output { + Self(self.0 + rhs.0) + } +} + +impl AddAssign for Panning { + fn add_assign(&mut self, rhs: Panning) { + self.0 += rhs.0; + } +} + +impl Sub for Panning { + type Output = Panning; + + fn sub(self, rhs: Panning) -> Self::Output { + Self(self.0 - rhs.0) + } +} + +impl SubAssign for Panning { + fn sub_assign(&mut self, rhs: Panning) { + self.0 -= rhs.0; + } +} + +impl Mul for Panning { + type Output = Panning; + + fn mul(self, rhs: f32) -> Self::Output { + Self(self.0 * rhs) + } +} + +impl MulAssign for Panning { + fn mul_assign(&mut self, rhs: f32) { + self.0 *= rhs; + } +} + +impl Div for Panning { + type Output = Panning; + + fn div(self, rhs: f32) -> Self::Output { + Self(self.0 / rhs) + } +} + +impl DivAssign for Panning { + fn div_assign(&mut self, rhs: f32) { + self.0 /= rhs; + } +} + +impl Neg for Panning { + type Output = Panning; + + fn neg(self) -> Self::Output { + Self(-self.0) + } +} + +impl Rem for Panning { + type Output = Panning; + + fn rem(self, rhs: f32) -> Self::Output { + Self(self.0 % rhs) + } +} + +impl RemAssign for Panning { + fn rem_assign(&mut self, rhs: f32) { + self.0 %= rhs; + } +} diff --git a/third_party/kira/src/parameter.rs b/third_party/kira/src/parameter.rs new file mode 100644 index 00000000..789acb6c --- /dev/null +++ b/third_party/kira/src/parameter.rs @@ -0,0 +1,179 @@ +#[cfg(test)] +mod test; + +use std::time::Duration; + +use crate::{ + StartTime, Tween, Tweenable, Value, + command::{CommandReader, ValueChangeCommand}, + info::{Info, WhenToStart}, +}; + +/// Manages and updates a value that can be smoothly transitioned +/// and linked to modulators. +/// +/// You'll only need to use this if you're creating your own +/// [`Sound`](crate::sound::Sound), [`Effect`](crate::effect::Effect), +/// or [`Modulator`](crate::modulator::Modulator) implementations. If you +/// want to adjust a parameter of something from gameplay code (such as the +/// volume of a sound or the speed of a clock), use the functions on that +/// object's handle. +#[derive(Clone)] +pub struct Parameter { + state: State, + raw_value: T, + previous_raw_value: T, + stagnant: bool, +} + +impl Parameter { + /// Creates a new [`Parameter`] with an initial [`Value`]. + /// + /// The `default_raw_value` is used if the parameter is linked to a modulator + /// that doesn't exist. + #[must_use] + pub fn new(initial_value: Value, default_raw_value: T) -> Self { + let raw_value = match initial_value { + Value::Fixed(value) => value, + Value::FromModulator { .. } => default_raw_value, + Value::FromListenerDistance { .. } => default_raw_value, + }; + Self { + state: State::Idle { + value: initial_value, + }, + raw_value, + previous_raw_value: raw_value, + stagnant: matches!(initial_value, Value::Fixed(_)), + } + } + + /// Returns the current actual value of the parameter. + #[must_use] + pub fn value(&self) -> T { + self.raw_value + } + + /// Returns the previous actual value of the parameter. + #[must_use] + pub fn previous_value(&self) -> T { + self.previous_raw_value + } + + /// Returns the interpolated value between the previous and current + /// actual value of the parameter. + #[must_use] + pub fn interpolated_value(&self, amount: f64) -> T { + T::interpolate(self.previous_raw_value, self.raw_value, amount) + } + + /// Starts a transition from the current value to the target value. + pub fn set(&mut self, target: Value, tween: Tween) { + self.stagnant = false; + self.state = State::Tweening { + start: self.value(), + target, + time: 0.0, + tween, + }; + } + + /// Reads a [`ValueChangeCommand`] from a [`CommandReader`], and if there is one, + /// sets the parameter with the value and tween. + pub fn read_command(&mut self, command_reader: &mut CommandReader>) + where + T: Send, + { + if let Some(ValueChangeCommand { target, tween }) = command_reader.read() { + self.set(target, tween); + } + } + + /// Updates any in-progress transitions and keeps the value up-to-date + /// with any linked modulators. + /// + /// Returns `true` if a transition just finished after this update. + pub fn update(&mut self, dt: f64, info: &Info) -> JustFinishedTween { + self.previous_raw_value = self.raw_value; + if self.stagnant { + return false; + } + let just_finished_tween = self.update_tween(dt, info); + if let Some(raw_value) = self.calculate_new_raw_value(info) { + self.raw_value = raw_value; + } + just_finished_tween + } + + fn update_tween(&mut self, dt: f64, info: &Info) -> JustFinishedTween { + if let State::Tweening { + target, + time, + tween, + .. + } = &mut self.state + { + let started = match &mut tween.start_time { + StartTime::Immediate => true, + StartTime::Delayed(time_remaining) => { + if time_remaining.is_zero() { + true + } else { + *time_remaining = + time_remaining.saturating_sub(Duration::from_secs_f64(dt)); + false + } + } + StartTime::ClockTime(clock_time) => { + info.when_to_start(*clock_time) == WhenToStart::Now + } + }; + if !started { + return false; + } + *time += dt; + if *time >= tween.duration.as_secs_f64() { + if matches!(target, Value::Fixed(_)) { + self.stagnant = true; + } + self.state = State::Idle { value: *target }; + return true; + } + } + false + } + + fn calculate_new_raw_value(&self, info: &Info) -> Option { + match &self.state { + State::Idle { value } => value.raw_value(info), + State::Tweening { + start, + target, + time, + tween, + } => { + if tween.duration.is_zero() { + return None; + } + target + .raw_value(info) + .map(|target| T::interpolate(*start, target, tween.value(*time))) + } + } + } +} + +#[derive(Clone)] +enum State { + Idle { + value: Value, + }, + Tweening { + start: T, + target: Value, + time: f64, + tween: Tween, + }, +} + +type JustFinishedTween = bool; diff --git a/third_party/kira/src/parameter/test.rs b/third_party/kira/src/parameter/test.rs new file mode 100644 index 00000000..2d694cba --- /dev/null +++ b/third_party/kira/src/parameter/test.rs @@ -0,0 +1,168 @@ +use std::time::Duration; + +use crate::{Easing, Mapping, StartTime, Tween, Value, clock::ClockTime, info::MockInfoBuilder}; + +use super::Parameter; + +/// Tests that the basic tweening behavior of a `Parameter` +/// works properly. +#[test] +#[allow(clippy::float_cmp)] +fn tweening() { + let mut parameter = Parameter::new(Value::Fixed(0.0), 0.0); + let info = MockInfoBuilder::new().build(); + + // value should not be changing yet + for _ in 0..3 { + assert_eq!(parameter.value(), 0.0); + assert!(!parameter.update(1.0, &info)); + } + + parameter.set( + Value::Fixed(1.0), + Tween { + duration: Duration::from_secs(2), + ..Default::default() + }, + ); + + assert!(!parameter.update(1.0, &info)); + assert_eq!(parameter.value(), 0.5); + assert!(parameter.update(1.0, &info)); + assert_eq!(parameter.value(), 1.0); + assert!(!parameter.update(1.0, &info)); + assert_eq!(parameter.value(), 1.0); +} + +/// Tests that a `Parameter` with a delayed start time waits for +/// that time before it begins tweening. +#[test] +#[allow(clippy::float_cmp)] +fn waits_for_delay() { + let info = MockInfoBuilder::new().build(); + + let mut parameter = Parameter::new(Value::Fixed(0.0), 0.0); + parameter.set( + Value::Fixed(1.0), + Tween { + start_time: StartTime::Delayed(Duration::from_secs(2)), + duration: Duration::from_secs(1), + ..Default::default() + }, + ); + + // value should not be changing yet + for _ in 0..2 { + assert_eq!(parameter.value(), 0.0); + assert!(!parameter.update(1.0, &info)); + } + + // the tween should start now + assert!(parameter.update(1.0, &info)); + assert_eq!(parameter.value(), 1.0); +} + +/// Tests that a `Parameter` with a clock time set as +/// the start time waits for that time before it +/// begins tweening. +#[test] +#[allow(clippy::float_cmp)] +fn waits_for_start_time() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id_1 = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let mut parameter = Parameter::new(Value::Fixed(0.0), 0.0); + parameter.set( + Value::Fixed(1.0), + Tween { + start_time: StartTime::ClockTime(ClockTime { + clock: clock_id_1, + ticks: 2, + fraction: 0.0, + }), + duration: Duration::from_secs(1), + ..Default::default() + }, + ); + + // value should not be changing yet + for _ in 0..3 { + assert_eq!(parameter.value(), 0.0); + assert!(!parameter.update(1.0, &info)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.add_clock(true, 0, 0.0); + builder.build() + }; + + // the tween is set to start at tick 2, so it should not + // start yet + for _ in 0..3 { + assert_eq!(parameter.value(), 0.0); + assert!(!parameter.update(1.0, &info)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.add_clock(true, 2, 0.0); + builder.build() + }; + + // a different clock reached tick 2, so the tween should not + // start yet + for _ in 0..3 { + assert_eq!(parameter.value(), 0.0); + assert!(!parameter.update(1.0, &info)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 2, 0.0); + builder.add_clock(true, 2, 0.0); + builder.build() + }; + + // the tween should start now + assert!(parameter.update(1.0, &info)); + assert_eq!(parameter.value(), 1.0); +} + +/// Tests that a parameter can smoothly tween from a fixed value +/// to the value of a modulator (while the modulator value is changing). +#[test] +#[allow(clippy::float_cmp)] +fn tweens_to_modulator_values() { + let mut info_builder = MockInfoBuilder::new(); + let modulator_id = info_builder.add_modulator(0.0); + let mut parameter = Parameter::new(Value::Fixed(0.0), 0.0); + parameter.set( + Value::FromModulator { + id: modulator_id, + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (0.0, 1.0), + easing: Easing::Linear, + }, + }, + Tween { + duration: Duration::from_secs(1), + ..Default::default() + }, + ); + + for i in 1..=4 { + let time = i as f64 / 4.0; + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_modulator(time); + builder.build() + }; + parameter.update(0.25, &info); + assert_eq!(parameter.value(), time * time); + } +} diff --git a/third_party/kira/src/playback_rate.rs b/third_party/kira/src/playback_rate.rs new file mode 100644 index 00000000..01a219cb --- /dev/null +++ b/third_party/kira/src/playback_rate.rs @@ -0,0 +1,122 @@ +use std::ops::{ + Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Rem, RemAssign, Sub, SubAssign, +}; + +use crate::{Value, tween::Tweenable}; + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +/// How quickly a sound should be played, where `1.0` is the default +/// playback rate. +/// +/// Changing the playback rate of a sound will affect both the speed and pitch. +pub struct PlaybackRate(pub f64); + +impl Default for PlaybackRate { + fn default() -> Self { + Self(1.0) + } +} + +impl Tweenable for PlaybackRate { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + Self(Tweenable::interpolate(a.0, b.0, amount)) + } +} + +impl From for PlaybackRate { + fn from(value: f64) -> Self { + Self(value) + } +} + +impl From for Value { + fn from(value: f64) -> Self { + Self::Fixed(PlaybackRate(value)) + } +} + +impl From for Value { + fn from(value: PlaybackRate) -> Self { + Self::Fixed(value) + } +} + +impl Add for PlaybackRate { + type Output = PlaybackRate; + + fn add(self, rhs: PlaybackRate) -> Self::Output { + Self(self.0 + rhs.0) + } +} + +impl AddAssign for PlaybackRate { + fn add_assign(&mut self, rhs: PlaybackRate) { + self.0 += rhs.0; + } +} + +impl Sub for PlaybackRate { + type Output = PlaybackRate; + + fn sub(self, rhs: PlaybackRate) -> Self::Output { + Self(self.0 - rhs.0) + } +} + +impl SubAssign for PlaybackRate { + fn sub_assign(&mut self, rhs: PlaybackRate) { + self.0 -= rhs.0; + } +} + +impl Mul for PlaybackRate { + type Output = PlaybackRate; + + fn mul(self, rhs: f64) -> Self::Output { + Self(self.0 * rhs) + } +} + +impl MulAssign for PlaybackRate { + fn mul_assign(&mut self, rhs: f64) { + self.0 *= rhs; + } +} + +impl Div for PlaybackRate { + type Output = PlaybackRate; + + fn div(self, rhs: f64) -> Self::Output { + Self(self.0 / rhs) + } +} + +impl DivAssign for PlaybackRate { + fn div_assign(&mut self, rhs: f64) { + self.0 /= rhs; + } +} + +impl Neg for PlaybackRate { + type Output = PlaybackRate; + + fn neg(self) -> Self::Output { + Self(-self.0) + } +} + +impl Rem for PlaybackRate { + type Output = PlaybackRate; + + fn rem(self, rhs: f64) -> Self::Output { + Self(self.0 % rhs) + } +} + +impl RemAssign for PlaybackRate { + fn rem_assign(&mut self, rhs: f64) { + self.0 %= rhs; + } +} diff --git a/third_party/kira/src/playback_state_manager.rs b/third_party/kira/src/playback_state_manager.rs new file mode 100644 index 00000000..465b37b9 --- /dev/null +++ b/third_party/kira/src/playback_state_manager.rs @@ -0,0 +1,138 @@ +use crate::{ + Decibels, StartTime, Tween, Value, info::Info, parameter::Parameter, sound::PlaybackState, +}; + +pub(crate) struct PlaybackStateManager { + state: State, + volume_fade: Parameter, +} + +impl PlaybackStateManager { + pub fn new(fade_in_tween: Option) -> Self { + Self { + state: State::Playing, + volume_fade: fade_in_tween + .map(|tween| { + let mut parameter = + Parameter::new(Value::Fixed(Decibels::SILENCE), Decibels::SILENCE); + parameter.set(Value::Fixed(Decibels::IDENTITY), tween); + parameter + }) + .unwrap_or_else(|| { + Parameter::new(Value::Fixed(Decibels::IDENTITY), Decibels::IDENTITY) + }), + } + } + + pub fn interpolated_fade_volume(&self, amount: f64) -> Decibels { + self.volume_fade.interpolated_value(amount) + } + + pub fn playback_state(&self) -> PlaybackState { + match self.state { + State::Playing => PlaybackState::Playing, + State::Pausing => PlaybackState::Pausing, + State::Paused => PlaybackState::Paused, + State::WaitingToResume { .. } => PlaybackState::WaitingToResume, + State::Resuming => PlaybackState::Resuming, + State::Stopping => PlaybackState::Stopping, + State::Stopped => PlaybackState::Stopped, + } + } + + pub fn pause(&mut self, fade_out_tween: Tween) { + if let State::Stopped = &self.state { + return; + } + self.state = State::Pausing; + self.volume_fade + .set(Value::Fixed(Decibels::SILENCE), fade_out_tween); + } + + pub fn resume(&mut self, start_time: StartTime, fade_in_tween: Tween) { + if let State::Stopped = &self.state { + return; + } + if let StartTime::Immediate = start_time { + self.state = State::Resuming; + self.volume_fade + .set(Value::Fixed(Decibels::IDENTITY), fade_in_tween); + } else { + self.state = State::WaitingToResume { + start_time, + fade_in_tween, + }; + } + } + + pub fn stop(&mut self, fade_out_tween: Tween) { + if let State::Stopped = &self.state { + return; + } + self.state = State::Stopping; + self.volume_fade + .set(Value::Fixed(Decibels::SILENCE), fade_out_tween); + } + + pub fn mark_as_stopped(&mut self) { + self.state = State::Stopped; + } + + pub fn update(&mut self, dt: f64, info: &Info) -> ChangedPlaybackState { + let finished = self.volume_fade.update(dt, info); + match &mut self.state { + State::Playing => {} + State::Pausing => { + if finished { + self.state = State::Paused; + return true; + } + } + State::Paused => {} + State::WaitingToResume { + start_time, + fade_in_tween, + } => { + let will_never_start = start_time.update(dt, info); + if will_never_start { + self.state = State::Stopped; + return true; + } + if *start_time == StartTime::Immediate { + let fade_in_tween = *fade_in_tween; + self.resume(StartTime::Immediate, fade_in_tween); + return true; + } + } + State::Resuming => { + if finished { + self.state = State::Playing; + return true; + } + } + State::Stopping => { + if finished { + self.state = State::Stopped; + return true; + } + } + State::Stopped => {} + } + false + } +} + +pub type ChangedPlaybackState = bool; + +enum State { + Playing, + Pausing, + Paused, + WaitingToResume { + start_time: StartTime, + fade_in_tween: Tween, + }, + Resuming, + Stopping, + Stopped, +} diff --git a/third_party/kira/src/semitones.rs b/third_party/kira/src/semitones.rs new file mode 100644 index 00000000..b195271f --- /dev/null +++ b/third_party/kira/src/semitones.rs @@ -0,0 +1,135 @@ +use std::ops::{ + Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Rem, RemAssign, Sub, SubAssign, +}; + +use crate::{PlaybackRate, Value, tween::Tweenable}; + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +/// A change in pitch in semitones. +/// +/// This can be used where [`PlaybackRate`](crate::PlaybackRate)s are expected to control +/// the pitch of a sound. +pub struct Semitones(pub f64); + +impl Tweenable for Semitones { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + Self(Tweenable::interpolate(a.0, b.0, amount)) + } +} + +impl From for Semitones { + fn from(value: f64) -> Self { + Self(value) + } +} + +impl From for PlaybackRate { + fn from(value: Semitones) -> Self { + PlaybackRate(2.0f64.powf(value.0 / 12.0)) + } +} + +impl From for Value { + fn from(value: Semitones) -> Self { + Self::Fixed(value.into()) + } +} + +impl Add for Semitones { + type Output = Semitones; + + fn add(self, rhs: Semitones) -> Self::Output { + Self(self.0 + rhs.0) + } +} + +impl AddAssign for Semitones { + fn add_assign(&mut self, rhs: Semitones) { + self.0 += rhs.0; + } +} + +impl Sub for Semitones { + type Output = Semitones; + + fn sub(self, rhs: Semitones) -> Self::Output { + Self(self.0 - rhs.0) + } +} + +impl SubAssign for Semitones { + fn sub_assign(&mut self, rhs: Semitones) { + self.0 -= rhs.0; + } +} + +impl Mul for Semitones { + type Output = Semitones; + + fn mul(self, rhs: f64) -> Self::Output { + Self(self.0 * rhs) + } +} + +impl MulAssign for Semitones { + fn mul_assign(&mut self, rhs: f64) { + self.0 *= rhs; + } +} + +impl Div for Semitones { + type Output = Semitones; + + fn div(self, rhs: f64) -> Self::Output { + Self(self.0 / rhs) + } +} + +impl DivAssign for Semitones { + fn div_assign(&mut self, rhs: f64) { + self.0 /= rhs; + } +} + +impl Neg for Semitones { + type Output = Semitones; + + fn neg(self) -> Self::Output { + Self(-self.0) + } +} + +impl Rem for Semitones { + type Output = Semitones; + + fn rem(self, rhs: f64) -> Self::Output { + Self(self.0 % rhs) + } +} + +impl RemAssign for Semitones { + fn rem_assign(&mut self, rhs: f64) { + self.0 %= rhs; + } +} + +#[cfg(test)] +#[test] +#[allow(clippy::float_cmp)] +fn test() { + /// A table of semitone differences to pitch factors. + /// Values calculated from http://www.sengpielaudio.com/calculator-centsratio.htm + const TEST_CALCULATIONS: [(Semitones, PlaybackRate); 5] = [ + (Semitones(0.0), PlaybackRate(1.0)), + (Semitones(1.0), PlaybackRate(1.059463)), + (Semitones(2.0), PlaybackRate(1.122462)), + (Semitones(-1.0), PlaybackRate(0.943874)), + (Semitones(-2.0), PlaybackRate(0.890899)), + ]; + + for (semitones, playback_rate) in TEST_CALCULATIONS { + assert!((PlaybackRate::from(semitones).0 - playback_rate.0).abs() < 0.00001); + } +} diff --git a/third_party/kira/src/sound.rs b/third_party/kira/src/sound.rs new file mode 100644 index 00000000..d212d2a0 --- /dev/null +++ b/third_party/kira/src/sound.rs @@ -0,0 +1,204 @@ +/*! +Sources of audio. + +Any type that implements [`SoundData`] can be played using +[`AudioManager::play`](crate::AudioManager::play). Kira comes with two +[`SoundData`] implementations: + +- [`StaticSoundData`](static_sound::StaticSoundData), which loads an entire chunk of audio + into memory. This is more appropriate for short sounds, sounds you want to play multiple times, + or sounds where consistent start times are important. +- [`StreamingSoundData`](streaming::StreamingSoundData), which streams audio from a file or cursor + (only available on desktop platforms). This is more appropriate for long sounds that you only + play once at a time, like background music. Streaming sounds use less memory than static sounds. + +These two sound types should cover most use cases, but if you need something else, you can +create your own types that implement the [`SoundData`] and [`Sound`] traits. +*/ + +#[cfg(feature = "symphonia")] +mod error; +mod playback_position; +pub mod static_sound; +#[cfg(not(target_arch = "wasm32"))] +pub mod streaming; +#[cfg(feature = "symphonia")] +mod symphonia; +mod transport; + +use std::ops::{Range, RangeFrom, RangeFull, RangeTo}; + +#[cfg(feature = "symphonia")] +pub use error::*; +pub use playback_position::*; + +use crate::{frame::Frame, info::Info}; + +/// A source of audio that is loaded, but not yet playing. +pub trait SoundData { + /// Errors that can occur when starting the sound. + type Error; + + /// The type that can be used to control the sound once + /// it has started. + type Handle; + + /// Converts the loaded sound into a live, playing sound + /// and a handle to control it. + /// + /// The [`Sound`] implementation will be sent to the audio renderer + /// for playback, and the handle will be returned to the user by + /// [`AudioManager::play`](crate::AudioManager::play). + #[allow(clippy::type_complexity)] + fn into_sound(self) -> Result<(Box, Self::Handle), Self::Error>; +} + +/// An actively playing sound. +/// +/// For performance reasons, the methods of this trait should not allocate +/// or deallocate memory. +#[allow(unused_variables)] +pub trait Sound: Send { + /// Called whenever a new batch of audio samples is requested by the backend. + /// + /// This is a good place to put code that needs to run fairly frequently, + /// but not for every single audio sample. + fn on_start_processing(&mut self) {} + + /// Produces the next [`Frame`]s of audio. This should overwrite + /// the entire `out` slice with new audio. + /// + /// `dt` is the time between each frame (in seconds). + fn process(&mut self, out: &mut [Frame], dt: f64, info: &Info); + + /// Processes a single [`Frame`]. Mostly useful for testing. + /// + /// `dt` is the time elapsed since the previous frame (in seconds). + fn process_one(&mut self, dt: f64, info: &Info) -> Frame { + let mut out = [Frame::ZERO]; + self.process(&mut out, dt, info); + out[0] + } + + /// Returns `true` if the sound is finished and can be unloaded. + /// + /// For finite sounds, this will typically be when playback has reached the + /// end of the sound. For infinite sounds, this will typically be when the + /// handle for the sound is dropped. + #[must_use] + fn finished(&self) -> bool; +} + +/// The playback state of a sound. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum PlaybackState { + /// The sound is playing normally. + Playing, + /// The sound is fading out, and when the fade-out + /// is finished, playback will pause. + Pausing, + /// Playback is paused. + Paused, + /// The sound is paused, but is schedule to resume in the future. + WaitingToResume, + /// The sound is fading back in after being previously paused. + Resuming, + /// The sound is fading out, and when the fade-out + /// is finished, playback will stop. + Stopping, + /// The sound has stopped and can no longer be resumed. + Stopped, +} + +impl PlaybackState { + /// Whether the sound is advancing and outputting audio given + /// its current playback state. + pub fn is_advancing(self) -> bool { + match self { + PlaybackState::Playing => true, + PlaybackState::Pausing => true, + PlaybackState::Paused => false, + PlaybackState::WaitingToResume => false, + PlaybackState::Resuming => true, + PlaybackState::Stopping => true, + PlaybackState::Stopped => false, + } + } +} + +/// A portion of audio. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Region { + /// The starting time of the region (in seconds). + pub start: PlaybackPosition, + /// The (exclusive) ending time of the region. + pub end: EndPosition, +} + +impl> From> for Region { + fn from(range: RangeFrom) -> Self { + Self { + start: range.start.into(), + end: EndPosition::EndOfAudio, + } + } +} + +impl> From> for Region { + fn from(range: Range) -> Self { + Self { + start: range.start.into(), + end: EndPosition::Custom(range.end.into()), + } + } +} + +impl> From> for Region { + fn from(range: RangeTo) -> Self { + Self { + start: PlaybackPosition::Samples(0), + end: EndPosition::Custom(range.end.into()), + } + } +} + +impl From for Region { + fn from(_: RangeFull) -> Self { + Self { + start: PlaybackPosition::Samples(0), + end: EndPosition::EndOfAudio, + } + } +} + +/// A trait for types that can be converted into an `Option`. +pub trait IntoOptionalRegion { + /// Converts the type into an `Option`. + #[must_use] + fn into_optional_region(self) -> Option; +} + +impl> IntoOptionalRegion for T { + fn into_optional_region(self) -> Option { + Some(self.into()) + } +} + +impl IntoOptionalRegion for Option { + fn into_optional_region(self) -> Option { + self + } +} + +/// The ending time of a region of audio. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum EndPosition { + /// The end of the audio data. + #[default] + EndOfAudio, + /// A user-defined time. + Custom(PlaybackPosition), +} diff --git a/third_party/kira/src/sound/error.rs b/third_party/kira/src/sound/error.rs new file mode 100644 index 00000000..9185293a --- /dev/null +++ b/third_party/kira/src/sound/error.rs @@ -0,0 +1,63 @@ +use std::fmt::Display; + +/// Errors that can occur when loading or streaming an audio file. +#[derive(Debug)] +#[cfg_attr(docsrs, doc(cfg(feature = "symphonia")))] +pub enum FromFileError { + /// Could not determine the default audio track in the file. + NoDefaultTrack, + /// Could not determine the sample rate of the audio. + UnknownSampleRate, + /// Could not determine the duration of the audio. + UnknownDuration, + /// The audio uses an unsupported channel configuration. Only + /// mono and stereo audio is supported. + UnsupportedChannelConfiguration, + /// An error occurred while reading the file from the filesystem. + IoError(std::io::Error), + /// An error occurred when parsing the file. + SymphoniaError(symphonia::core::errors::Error), +} + +impl Display for FromFileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FromFileError::NoDefaultTrack => { + f.write_str("Could not determine the default audio track") + } + FromFileError::UnknownSampleRate => { + f.write_str("Could not detect the sample rate of the audio") + } + FromFileError::UnknownDuration => { + f.write_str("Could not detect the duration of the audio") + } + FromFileError::UnsupportedChannelConfiguration => { + f.write_str("Only mono and stereo audio is supported") + } + FromFileError::IoError(error) => error.fmt(f), + FromFileError::SymphoniaError(error) => error.fmt(f), + } + } +} + +impl std::error::Error for FromFileError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + FromFileError::IoError(error) => Some(error), + FromFileError::SymphoniaError(error) => Some(error), + _ => None, + } + } +} + +impl From for FromFileError { + fn from(v: std::io::Error) -> Self { + Self::IoError(v) + } +} + +impl From for FromFileError { + fn from(v: symphonia::core::errors::Error) -> Self { + Self::SymphoniaError(v) + } +} diff --git a/third_party/kira/src/sound/playback_position.rs b/third_party/kira/src/sound/playback_position.rs new file mode 100644 index 00000000..2835e2fe --- /dev/null +++ b/third_party/kira/src/sound/playback_position.rs @@ -0,0 +1,34 @@ +#[cfg(test)] +mod test; + +/// A point in time in a piece of audio. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum PlaybackPosition { + /// The time in seconds. + Seconds(f64), + /// The time in samples (individual audio data points). + Samples(usize), +} + +impl PlaybackPosition { + #[must_use] + pub(crate) fn into_samples(self, sample_rate: u32) -> usize { + match self { + PlaybackPosition::Seconds(seconds) => (seconds * sample_rate as f64).round() as usize, + PlaybackPosition::Samples(samples) => samples, + } + } +} + +impl From for PlaybackPosition { + fn from(v: f64) -> Self { + Self::Seconds(v) + } +} + +impl Default for PlaybackPosition { + fn default() -> Self { + Self::Seconds(0.0) + } +} diff --git a/third_party/kira/src/sound/playback_position/test.rs b/third_party/kira/src/sound/playback_position/test.rs new file mode 100644 index 00000000..20176d3e --- /dev/null +++ b/third_party/kira/src/sound/playback_position/test.rs @@ -0,0 +1,13 @@ +use super::PlaybackPosition; + +#[test] +fn into_samples() { + const SAMPLE_RATE: u32 = 44100; + const SECONDS_TO_SAMPLES: &[(f64, usize)] = &[(8.786054, 387465), (0.061519, 2713)]; + for (seconds, samples) in SECONDS_TO_SAMPLES { + assert_eq!( + PlaybackPosition::Seconds(*seconds).into_samples(SAMPLE_RATE), + *samples + ); + } +} diff --git a/third_party/kira/src/sound/static_sound.rs b/third_party/kira/src/sound/static_sound.rs new file mode 100644 index 00000000..c068714e --- /dev/null +++ b/third_party/kira/src/sound/static_sound.rs @@ -0,0 +1,49 @@ +/*! +Playable chunks of audio that are loaded into memory all at once. + +To play a static sound, pass a [`StaticSoundData`] to +[`AudioManager::play`](crate::AudioManager::play). + +```no_run +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let sound_data = StaticSoundData::from_file("sound.ogg")?; +manager.play(sound_data)?; +# Result::<(), Box>::Ok(()) +``` + +Compared to streaming sounds, static sounds have lower CPU usage and shorter delays +when starting and seeking, but they use a lot more memory. +*/ + +mod data; +mod handle; +mod settings; +mod sound; + +pub use data::*; +pub use handle::*; +pub use settings::*; + +use crate::{ + Decibels, Panning, PlaybackRate, StartTime, command::ValueChangeCommand, + command_writers_and_readers, tween::Tween, +}; + +use super::Region; + +command_writers_and_readers! { + set_volume: ValueChangeCommand, + set_playback_rate: ValueChangeCommand, + set_panning: ValueChangeCommand, + set_loop_region: Option, + pause: Tween, + resume: (StartTime, Tween), + stop: Tween, + seek_by: f64, + seek_to: f64, +} diff --git a/third_party/kira/src/sound/static_sound/data.rs b/third_party/kira/src/sound/static_sound/data.rs new file mode 100644 index 00000000..f73e5ee3 --- /dev/null +++ b/third_party/kira/src/sound/static_sound/data.rs @@ -0,0 +1,442 @@ +#[cfg(feature = "symphonia")] +mod from_file; + +#[cfg(test)] +mod test; + +use std::{ + fmt::{Debug, Formatter}, + sync::Arc, + time::Duration, +}; + +use crate::{ + Decibels, Panning, PlaybackRate, StartTime, Tween, Value, + frame::Frame, + sound::{EndPosition, IntoOptionalRegion, PlaybackPosition, Region, Sound, SoundData}, +}; + +use super::{ + StaticSoundSettings, command_writers_and_readers, handle::StaticSoundHandle, sound::StaticSound, +}; + +/// A piece of audio loaded into memory all at once. +/// +/// These can be cheaply cloned, as the audio data is shared +/// among all clones. +#[derive(Clone, PartialEq)] +pub struct StaticSoundData { + /// The sample rate of the audio (in Hz). + pub sample_rate: u32, + /// The raw samples that make up the audio. + pub frames: Arc<[Frame]>, + /// Settings for the sound. + pub settings: StaticSoundSettings, + /** + The portion of the sound this [`StaticSoundData`] represents. + + Note that the [`StaticSoundData`] holds the entire piece of audio + it was originally given regardless of the value of `slice`, but + [`StaticSoundData::num_frames`], [`StaticSoundData::duration`], + and [`StaticSoundData::frame_at_index`] will all behave as if + this [`StaticSoundData`] only contained the specified portion of + audio. + */ + pub slice: Option<(usize, usize)>, +} + +impl StaticSoundData { + /** + Sets when the sound should start playing. + + This returns a cheap clone of the [`StaticSoundData`] with the modified start time. + + # Examples + + Configuring a sound to start 4 ticks after a clock's current time: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + clock::ClockSpeed, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let clock_handle = manager.add_clock(ClockSpeed::TicksPerMinute(120.0))?; + let sound = StaticSoundData::from_file("sound.ogg")? + .start_time(clock_handle.time() + 4); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn start_time(&self, start_time: impl Into) -> Self { + let mut new = self.clone(); + new.settings.start_time = start_time.into(); + new + } + + /// Sets where in the sound playback should start. + /// + /// This returns a cheap clone of the [`StaticSoundData`] with the modified start position. + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn start_position(&self, start_position: impl Into) -> Self { + let mut new = self.clone(); + new.settings.start_position = start_position.into(); + new + } + + /// Sets whether the sound should be played in reverse. + /// + /// This returns a cheap clone of the [`StaticSoundData`] with the modified setting. + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn reverse(&self, reverse: bool) -> Self { + let mut new = self.clone(); + new.settings.reverse = reverse; + new + } + + /** + Sets the portion of the sound that should be looped. + + This returns a cheap clone of the [`StaticSoundData`] with the modified loop region. + + # Examples + + Configure a sound to loop the portion from 3 seconds in to the end: + + ``` + # use kira::sound::static_sound::StaticSoundSettings; + let settings = StaticSoundSettings::new().loop_region(3.0..); + ``` + + Configure a sound to loop the portion from 2 to 4 seconds: + + ``` + # use kira::sound::static_sound::StaticSoundSettings; + let settings = StaticSoundSettings::new().loop_region(2.0..4.0); + ``` + */ + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn loop_region(&self, loop_region: impl IntoOptionalRegion) -> Self { + let mut new = self.clone(); + new.settings.loop_region = loop_region.into_optional_region(); + new + } + + /** + Sets the volume of the sound. + + # Examples + + Set the volume to a fixed value: + + ```no_run + # use kira::sound::static_sound::StaticSoundData; + let sound = StaticSoundData::from_file("sound.ogg")?.volume(-6.0); + # Result::<(), Box>::Ok(()) + ``` + + Link the volume to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + sound::static_sound::StaticSoundData, + Value, Mapping, Easing, + Decibels, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let sound = StaticSoundData::from_file("sound.ogg")?.volume(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (Decibels::SILENCE, Decibels::IDENTITY), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn volume(&self, volume: impl Into>) -> Self { + let mut new = self.clone(); + new.settings.volume = volume.into(); + new + } + + /** + Sets the playback rate of the sound. + + Changing the playback rate will change both the speed + and the pitch of the sound. + + # Examples + + Set the playback rate as a factor: + + ```no_run + # use kira::sound::static_sound::StaticSoundData; + let sound = StaticSoundData::from_file("sound.ogg")?.playback_rate(0.5); + # Result::<(), Box>::Ok(()) + ``` + + Set the playback rate as a change in semitones: + + ```no_run + # use kira::sound::static_sound::StaticSoundData; + use kira::Semitones; + let sound = StaticSoundData::from_file("sound.ogg")?.playback_rate(Semitones(-2.0)); + # Result::<(), Box>::Ok(()) + ``` + + Link the playback rate to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + sound::static_sound::StaticSoundData, + Value, Easing, Mapping, + PlaybackRate, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let sound = StaticSoundData::from_file("sound.ogg")?.playback_rate(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (PlaybackRate(0.0), PlaybackRate(1.0)), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn playback_rate(&self, playback_rate: impl Into>) -> Self { + let mut new = self.clone(); + new.settings.playback_rate = playback_rate.into(); + new + } + + /** + Sets the panning of the sound, where 0 is hard left + and 1 is hard right. + + # Examples + + Set the panning to a fixed value: + + ``` no_run + # use kira::sound::static_sound::StaticSoundData; + let sound = StaticSoundData::from_file("sound.ogg")?.panning(-0.5); + # Result::<(), Box>::Ok(()) + ``` + + Link the panning to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + sound::static_sound::StaticSoundData, + Value, Easing, Mapping, + Panning, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: -0.5, + })?; + let sound = StaticSoundData::from_file("sound.ogg")?.panning(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (-1.0, 1.0), + output_range: (Panning::LEFT, Panning::RIGHT), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn panning(&self, panning: impl Into>) -> Self { + let mut new = self.clone(); + new.settings.panning = panning.into(); + new + } + + /// Sets the tween used to fade in the sound from silence. + /// + /// This returns a cheap clone of the [`StaticSoundData`] with the modified fade in tween. + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn fade_in_tween(&self, fade_in_tween: impl Into>) -> Self { + let mut new = self.clone(); + new.settings.fade_in_tween = fade_in_tween.into(); + new + } + + /// Returns a cheap clone of the `StaticSoundData` with the specified settings. + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn with_settings(&self, settings: StaticSoundSettings) -> Self { + Self { + settings, + ..self.clone() + } + } + + /// Returns the number of frames in the [`StaticSoundData`]. + /// + /// If [`StaticSoundData::slice`] is `Some`, this will be the number + /// of frames in the slice. + #[must_use] + pub fn num_frames(&self) -> usize { + num_frames(&self.frames, self.slice) + } + + /// Returns the duration of the audio. + /// + /// If [`StaticSoundData::slice`] is `Some`, this will be the duration + /// of the slice. + #[must_use] + pub fn duration(&self) -> Duration { + Duration::from_secs_f64(self.num_frames() as f64 / self.sample_rate as f64) + } + + /// Returns the total duration of the audio, regardless of its slice. + #[must_use] + pub fn unsliced_duration(&self) -> Duration { + Duration::from_secs_f64(self.frames.len() as f64 / self.sample_rate as f64) + } + + /// Returns the nth [`Frame`] of audio in the [`StaticSoundData`]. + /// + /// If [`StaticSoundData::slice`] is `Some`, this will behave as if the [`StaticSoundData`] + /// only contained that portion of the audio. + #[must_use] + pub fn frame_at_index(&self, index: usize) -> Option { + frame_at_index(index, &self.frames, self.slice) + } + + /** + Sets the portion of the audio this [`StaticSoundData`] represents. + + This returns a cheap clone of the [`StaticSoundData`] with the modified slice. + + Note that the [`StaticSoundData`] holds the entire piece of audio it was originally + given regardless of the value of `slice`, but [`StaticSoundData::num_frames`], + [`StaticSoundData::duration`], and [`StaticSoundData::frame_at_index`] will all behave + as if this [`StaticSoundData`] only contained the specified portion of audio. + + # Example + + ``` + use kira::{ + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + Frame, + }; + let sound = StaticSoundData { + sample_rate: 1, + frames: (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::default(), + slice: None, + }; + let sliced = sound.slice(3.0..6.0); + assert_eq!(sliced.num_frames(), 3); + assert_eq!(sliced.frame_at_index(0), Some(Frame::from_mono(3.0))); + assert_eq!(sliced.frame_at_index(1), Some(Frame::from_mono(4.0))); + ``` + */ + #[must_use = "This method returns a modified StaticSoundData and does not mutate the original value"] + pub fn slice(&self, region: impl IntoOptionalRegion) -> Self { + let mut new = self.clone(); + new.slice = region.into_optional_region().map(|Region { start, end }| { + let start = start.into_samples(self.sample_rate); + let end = match end { + EndPosition::EndOfAudio => self.frames.len(), + EndPosition::Custom(end) => end.into_samples(self.sample_rate), + }; + (start, end) + }); + new + } + + pub(super) fn split(self) -> (StaticSound, StaticSoundHandle) { + let (command_writers, command_readers) = command_writers_and_readers(); + let sound = StaticSound::new(self, command_readers); + let shared = sound.shared(); + ( + sound, + StaticSoundHandle { + command_writers, + shared, + }, + ) + } +} + +impl SoundData for StaticSoundData { + type Error = (); + + type Handle = StaticSoundHandle; + + #[allow(clippy::type_complexity)] + fn into_sound(self) -> Result<(Box, Self::Handle), Self::Error> { + let (sound, handle) = self.split(); + Ok((Box::new(sound), handle)) + } +} + +impl Debug for StaticSoundData { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StaticSoundData") + .field("sample_rate", &self.sample_rate) + .field( + "frames", + &FramesDebug { + len: self.frames.len(), + }, + ) + .field("settings", &self.settings) + .finish() + } +} + +struct FramesDebug { + len: usize, +} + +impl Debug for FramesDebug { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("[{} frames]", self.len)) + } +} + +pub(crate) fn num_frames(frames: &[Frame], slice: Option<(usize, usize)>) -> usize { + if let Some((start, end)) = slice { + end - start + } else { + frames.len() + } +} + +pub(crate) fn frame_at_index( + index: usize, + frames: &[Frame], + slice: Option<(usize, usize)>, +) -> Option { + if index >= num_frames(frames, slice) { + return None; + } + let start = slice.map(|(start, _)| start).unwrap_or_default(); + Some(frames[index + start]) +} diff --git a/third_party/kira/src/sound/static_sound/data/from_file.rs b/third_party/kira/src/sound/static_sound/data/from_file.rs new file mode 100644 index 00000000..9f67951f --- /dev/null +++ b/third_party/kira/src/sound/static_sound/data/from_file.rs @@ -0,0 +1,84 @@ +use std::io::Cursor; + +use symphonia::core::io::{MediaSource, MediaSourceStream}; + +use crate::sound::{ + FromFileError, static_sound::StaticSoundSettings, symphonia::load_frames_from_buffer_ref, +}; + +use super::StaticSoundData; + +impl StaticSoundData { + /// Loads an audio file into a [`StaticSoundData`]. + #[cfg(not(target_arch = "wasm32"))] + #[cfg_attr(docsrs, doc(cfg(all(feature = "symphonia", not(wasm32)))))] + pub fn from_file(path: impl AsRef) -> Result { + Self::from_media_source(std::fs::File::open(path)?) + } + + /// Loads a cursor wrapping audio file data into a [`StaticSoundData`]. + #[cfg_attr(docsrs, doc(cfg(feature = "symphonia")))] + pub fn from_cursor + Send + Sync + 'static>( + cursor: Cursor, + ) -> Result { + Self::from_media_source(cursor) + } + + /// Loads an audio file from a type that implements Symphonia's [`MediaSource`] + /// trait. + #[cfg_attr(docsrs, doc(cfg(feature = "symphonia")))] + pub fn from_media_source( + media_source: impl MediaSource + 'static, + ) -> Result { + Self::from_boxed_media_source(Box::new(media_source)) + } + + fn from_boxed_media_source(media_source: Box) -> Result { + let codecs = symphonia::default::get_codecs(); + let probe = symphonia::default::get_probe(); + let mss = MediaSourceStream::new(media_source, Default::default()); + let mut format_reader = probe + .format( + &Default::default(), + mss, + &Default::default(), + &Default::default(), + )? + .format; + let default_track = format_reader + .default_track() + .ok_or(FromFileError::NoDefaultTrack)?; + let default_track_id = default_track.id; + let codec_params = &default_track.codec_params; + let sample_rate = codec_params + .sample_rate + .ok_or(FromFileError::UnknownSampleRate)?; + let mut decoder = codecs.make(codec_params, &Default::default())?; + let mut frames = vec![]; + loop { + match format_reader.next_packet() { + Ok(packet) => { + if default_track_id == packet.track_id() { + let buffer = decoder.decode(&packet)?; + frames.append(&mut load_frames_from_buffer_ref(&buffer)?); + } + } + Err(error) => match error { + symphonia::core::errors::Error::IoError(error) => { + if error.kind() == std::io::ErrorKind::UnexpectedEof { + break; + } + return Err(symphonia::core::errors::Error::IoError(error).into()); + } + error => return Err(error.into()), + }, + } + } + Ok(Self { + sample_rate, + frames: frames.into(), + settings: StaticSoundSettings::default(), + slice: None, + }) + } +} diff --git a/third_party/kira/src/sound/static_sound/data/test.rs b/third_party/kira/src/sound/static_sound/data/test.rs new file mode 100644 index 00000000..98cf9409 --- /dev/null +++ b/third_party/kira/src/sound/static_sound/data/test.rs @@ -0,0 +1,64 @@ +use std::{sync::Arc, time::Duration}; + +use crate::frame::Frame; + +use super::StaticSoundData; + +#[test] +fn duration() { + let static_sound = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(0.0); 4]), + settings: Default::default(), + slice: None, + }; + assert_eq!(static_sound.duration(), Duration::from_secs(4)); +} + +#[test] +fn unsliced_duration() { + let static_sound = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(0.0); 4]), + settings: Default::default(), + slice: Some((2, 3)), + }; + assert_eq!(static_sound.unsliced_duration(), Duration::from_secs(4)); +} + +#[test] +fn sliced_duration() { + let static_sound = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(0.0); 4]), + settings: Default::default(), + slice: None, + }; + assert_eq!(static_sound.duration(), Duration::from_secs(4)); + + let static_sound = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(0.0); 4]), + settings: Default::default(), + slice: Some((2, 3)), + }; + assert_eq!(static_sound.duration(), Duration::from_secs(1)); +} + +#[test] +fn slice() { + let static_sound = StaticSoundData { + sample_rate: 1, + frames: (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + settings: Default::default(), + slice: None, + } + .slice(3.0..6.0); + for i in 0..3 { + assert_eq!( + static_sound.frame_at_index(i), + Some(Frame::from_mono(i as f32 + 3.0)) + ); + } + assert!(static_sound.frame_at_index(3).is_none()); +} diff --git a/third_party/kira/src/sound/static_sound/handle.rs b/third_party/kira/src/sound/static_sound/handle.rs new file mode 100644 index 00000000..9488bf60 --- /dev/null +++ b/third_party/kira/src/sound/static_sound/handle.rs @@ -0,0 +1,323 @@ +use std::sync::Arc; + +use crate::{ + Decibels, Panning, PlaybackRate, StartTime, Tween, + command::handle_param_setters, + sound::{IntoOptionalRegion, PlaybackState}, +}; + +use super::{CommandWriters, sound::Shared}; + +/// Controls a static sound. +#[derive(Debug)] +pub struct StaticSoundHandle { + pub(super) command_writers: CommandWriters, + pub(super) shared: Arc, +} + +impl StaticSoundHandle { + /// Returns the current playback state of the sound. + #[must_use] + pub fn state(&self) -> PlaybackState { + self.shared.state() + } + + /// Returns the current playback position of the sound (in seconds). + #[must_use] + pub fn position(&self) -> f64 { + self.shared.position() + } + + handle_param_setters! { + /** + Sets the volume of the sound. + + # Examples + + Set the volume of the sound immediately: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::static_sound::{StaticSoundData, StaticSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + use kira::Tween; + + sound.set_volume(-6.0, Tween::default()); + # Result::<(), Box>::Ok(()) + ``` + + Smoothly transition the volume to a target volume: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::static_sound::{StaticSoundData, StaticSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + use kira::Tween; + use std::time::Duration; + + sound.set_volume(-6.0, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + + Link the volume to a modulator, smoothly transitioning from the current value: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + modulator::tweener::TweenerBuilder, + Value, Tween, Mapping, Easing, + Decibels, + }; + use std::time::Duration; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + sound.set_volume(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (Decibels::SILENCE, Decibels::IDENTITY), + easing: Easing::Linear, + } + }, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + */ + volume: Decibels, + + /** + Sets the playback rate of the sound. + + Changing the playback rate will change both the speed + and pitch of the sound. + + # Examples + + Set the playback rate of the sound immediately: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::static_sound::{StaticSoundData, StaticSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + use kira::Tween; + + sound.set_playback_rate(0.5, Tween::default()); + # Result::<(), Box>::Ok(()) + ``` + + Smoothly transition the playback rate to a target value in semitones: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::static_sound::{StaticSoundData, StaticSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + use kira::{Tween, Semitones}; + use std::time::Duration; + + sound.set_playback_rate(Semitones(-2.0), Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + + Link the playback rate to a modulator, smoothly transitioning from the current value: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + modulator::tweener::TweenerBuilder, + Value, Easing, Mapping, Tween, + PlaybackRate, + }; + use std::time::Duration; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + sound.set_playback_rate(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (PlaybackRate(0.0), PlaybackRate(1.0)), + easing: Easing::Linear, + }, + }, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + */ + playback_rate: PlaybackRate, + + /** + Sets the panning of the sound, where `-1.0` is hard left, + `0.0` is center, and `1.0` is hard right. + + # Examples + + Smoothly transition the panning to a target value: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::static_sound::{StaticSoundData, StaticSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + use kira::Tween; + use std::time::Duration; + + sound.set_panning(-0.5, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + + Link the panning to a modulator, smoothly transitioning from the current value: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + modulator::tweener::TweenerBuilder, + Value, Easing, Mapping, Tween, + Panning, + }; + use std::time::Duration; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: -0.5, + })?; + let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + sound.set_panning(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (-1.0, 1.0), + output_range: (Panning::LEFT, Panning::RIGHT), + easing: Easing::Linear, + }, + }, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + */ + panning: Panning, + } + + /** + Sets the portion of the sound that will play in a loop. + + # Examples + + Set the sound to loop the portion from 3 seconds in to the end: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::static_sound::{StaticSoundData, StaticSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + sound.set_loop_region(3.0..); + # Result::<(), Box>::Ok(()) + ``` + + Set the sound to loop the portion from 2 to 4 seconds: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::static_sound::{StaticSoundData, StaticSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + sound.set_loop_region(2.0..4.0); + # Result::<(), Box>::Ok(()) + ``` + + Set a sound that was previously looping to stop looping: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::static_sound::{StaticSoundData, StaticSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?; + sound.set_loop_region(None); + # Result::<(), Box>::Ok(()) + ``` + */ + pub fn set_loop_region(&mut self, loop_region: impl IntoOptionalRegion) { + self.command_writers + .set_loop_region + .write(loop_region.into_optional_region()) + } + + /// Fades out the sound to silence with the given tween and then + /// pauses playback. + pub fn pause(&mut self, tween: Tween) { + self.command_writers.pause.write(tween) + } + + /// Resumes playback and fades in the sound from silence + /// with the given tween. + pub fn resume(&mut self, tween: Tween) { + self.resume_at(StartTime::Immediate, tween) + } + + /// Resumes playback at the given start time and fades in + /// the sound from silence with the given tween. + pub fn resume_at(&mut self, start_time: StartTime, tween: Tween) { + self.command_writers.resume.write((start_time, tween)) + } + + /// Fades out the sound to silence with the given tween and then + /// stops playback. + /// + /// Once the sound is stopped, it cannot be restarted. + pub fn stop(&mut self, tween: Tween) { + self.command_writers.stop.write(tween) + } + + /// Sets the playback position to the specified time in seconds. + pub fn seek_to(&mut self, position: f64) { + self.command_writers.seek_to.write(position) + } + + /// Moves the playback position by the specified amount of time in seconds. + pub fn seek_by(&mut self, amount: f64) { + self.command_writers.seek_by.write(amount) + } +} diff --git a/third_party/kira/src/sound/static_sound/settings.rs b/third_party/kira/src/sound/static_sound/settings.rs new file mode 100644 index 00000000..afc998a7 --- /dev/null +++ b/third_party/kira/src/sound/static_sound/settings.rs @@ -0,0 +1,129 @@ +use crate::{ + Decibels, Panning, PlaybackRate, StartTime, Tween, Value, + sound::{IntoOptionalRegion, PlaybackPosition, Region}, +}; + +/// Settings for a static sound. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StaticSoundSettings { + /// When the sound should start playing. + pub start_time: StartTime, + /// Where in the sound playback should start. + pub start_position: PlaybackPosition, + /// The portion of the sound that should be looped. + pub loop_region: Option, + /// Whether the sound should be played in reverse. + pub reverse: bool, + /// The volume of the sound. + pub volume: Value, + /// The playback rate of the sound. + /// + /// Changing the playback rate will change both the speed + /// and the pitch of the sound. + pub playback_rate: Value, + /// The panning of the sound, where 0 is hard left + /// and 1 is hard right. + pub panning: Value, + /// An optional fade-in from silence. + pub fade_in_tween: Option, +} + +impl StaticSoundSettings { + /// Creates a new [`StaticSoundSettings`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self { + start_time: StartTime::default(), + start_position: PlaybackPosition::Seconds(0.0), + reverse: false, + loop_region: None, + volume: Value::Fixed(Decibels::IDENTITY), + playback_rate: Value::Fixed(PlaybackRate(1.0)), + panning: Value::Fixed(Panning::CENTER), + fade_in_tween: None, + } + } + + /** Sets when the sound should start playing. */ + #[must_use = "This method consumes self and returns a modified StaticSoundSettings, so the return value should be used"] + pub fn start_time(self, start_time: impl Into) -> Self { + Self { + start_time: start_time.into(), + ..self + } + } + + /// Sets where in the sound playback should start. + #[must_use = "This method consumes self and returns a modified StaticSoundSettings, so the return value should be used"] + pub fn start_position(self, start_position: impl Into) -> Self { + Self { + start_position: start_position.into(), + ..self + } + } + + /// Sets whether the sound should be played in reverse. + #[must_use = "This method consumes self and returns a modified StaticSoundSettings, so the return value should be used"] + pub fn reverse(self, reverse: bool) -> Self { + Self { reverse, ..self } + } + + /** Sets the portion of the sound that should be looped. */ + #[must_use = "This method consumes self and returns a modified StaticSoundSettings, so the return value should be used"] + pub fn loop_region(self, loop_region: impl IntoOptionalRegion) -> Self { + Self { + loop_region: loop_region.into_optional_region(), + ..self + } + } + + /** Sets the volume of the sound. */ + #[must_use = "This method consumes self and returns a modified StaticSoundSettings, so the return value should be used"] + pub fn volume(self, volume: impl Into>) -> Self { + Self { + volume: volume.into(), + ..self + } + } + + /** + Sets the playback rate of the sound. + + Changing the playback rate will change both the speed + and the pitch of the sound. + */ + #[must_use = "This method consumes self and returns a modified StaticSoundSettings, so the return value should be used"] + pub fn playback_rate(self, playback_rate: impl Into>) -> Self { + Self { + playback_rate: playback_rate.into(), + ..self + } + } + + /** + Sets the panning of the sound, where 0 is hard left + and 1 is hard right. + */ + #[must_use = "This method consumes self and returns a modified StaticSoundSettings, so the return value should be used"] + pub fn panning(self, panning: impl Into>) -> Self { + Self { + panning: panning.into(), + ..self + } + } + + /// Sets the tween used to fade in the sound from silence. + #[must_use = "This method consumes self and returns a modified StaticSoundSettings, so the return value should be used"] + pub fn fade_in_tween(self, fade_in_tween: impl Into>) -> Self { + Self { + fade_in_tween: fade_in_tween.into(), + ..self + } + } +} + +impl Default for StaticSoundSettings { + fn default() -> Self { + Self::new() + } +} diff --git a/third_party/kira/src/sound/static_sound/sound.rs b/third_party/kira/src/sound/static_sound/sound.rs new file mode 100644 index 00000000..b25bd16f --- /dev/null +++ b/third_party/kira/src/sound/static_sound/sound.rs @@ -0,0 +1,276 @@ +mod resampler; + +#[cfg(test)] +mod test; + +use std::sync::{ + Arc, + atomic::{AtomicU8, AtomicU64, Ordering}, +}; + +use crate::{ + Decibels, Panning, Parameter, PlaybackRate, StartTime, Tween, + command::read_commands_into_parameters, + frame::Frame, + info::Info, + playback_state_manager::PlaybackStateManager, + sound::{PlaybackState, Sound, transport::Transport}, +}; + +use self::resampler::Resampler; + +use super::{CommandReaders, data::StaticSoundData, frame_at_index, num_frames}; + +pub(super) struct StaticSound { + command_readers: CommandReaders, + sample_rate: u32, + frames: Arc<[Frame]>, + slice: Option<(usize, usize)>, + reverse: bool, + playback_state_manager: PlaybackStateManager, + start_time: StartTime, + resampler: Resampler, + transport: Transport, + fractional_position: f64, + volume: Parameter, + playback_rate: Parameter, + panning: Parameter, + shared: Arc, +} + +impl StaticSound { + #[must_use] + pub fn new(data: StaticSoundData, command_readers: CommandReaders) -> Self { + let settings = data.settings; + let transport = Transport::new( + data.settings.start_position.into_samples(data.sample_rate), + data.settings.loop_region, + data.settings.reverse, + data.sample_rate, + data.num_frames(), + ); + let starting_frame_index = transport.position; + let position = starting_frame_index as f64 / data.sample_rate as f64; + let mut sound = Self { + command_readers, + sample_rate: data.sample_rate, + frames: data.frames, + slice: data.slice, + reverse: data.settings.reverse, + playback_state_manager: PlaybackStateManager::new(settings.fade_in_tween), + start_time: settings.start_time, + resampler: Resampler::new(starting_frame_index), + transport, + fractional_position: 0.0, + volume: Parameter::new(settings.volume, Decibels::IDENTITY), + playback_rate: Parameter::new(settings.playback_rate, PlaybackRate(1.0)), + panning: Parameter::new(settings.panning, Panning::CENTER), + shared: Arc::new(Shared { + state: AtomicU8::new(PlaybackState::Playing as u8), + position: AtomicU64::new(position.to_bits()), + }), + }; + // fill the resample buffer with 3 samples so playback can + // start immediately + for _ in 0..3 { + sound.update_position(); + } + sound + } + + pub(super) fn shared(&self) -> Arc { + self.shared.clone() + } + + fn update_shared_playback_state(&mut self) { + self.shared + .set_state(self.playback_state_manager.playback_state()); + } + + fn pause(&mut self, fade_out_tween: Tween) { + self.playback_state_manager.pause(fade_out_tween); + self.update_shared_playback_state(); + } + + fn resume(&mut self, start_time: StartTime, fade_in_tween: Tween) { + self.playback_state_manager + .resume(start_time, fade_in_tween); + self.update_shared_playback_state(); + } + + fn stop(&mut self, fade_out_tween: Tween) { + self.playback_state_manager.stop(fade_out_tween); + self.update_shared_playback_state(); + } + + #[must_use] + fn is_playing_backwards(&self) -> bool { + let mut is_playing_backwards = self.playback_rate.value().0.is_sign_negative(); + if self.reverse { + is_playing_backwards = !is_playing_backwards + } + is_playing_backwards + } + + /// Updates the current frame index by 1 and pushes a new sample to the resampler. + fn update_position(&mut self) { + self.push_frame_to_resampler(); + if self.is_playing_backwards() { + self.transport.decrement_position(); + } else { + self.transport + .increment_position(num_frames(&self.frames, self.slice)); + } + if !self.transport.playing && self.resampler.empty() { + self.playback_state_manager.mark_as_stopped(); + self.update_shared_playback_state(); + } + } + + fn seek_to_index(&mut self, index: usize) { + self.transport + .seek_to(index, num_frames(&self.frames, self.slice)); + // if the sound is playing, push a frame to the resample buffer + // to make sure it doesn't get skipped + if self.playback_state_manager.playback_state().is_advancing() { + self.push_frame_to_resampler(); + } + } + + fn push_frame_to_resampler(&mut self) { + let frame = self.transport.playing.then(|| { + frame_at_index(self.transport.position, &self.frames, self.slice).unwrap_or_default() + }); + self.resampler.push_frame(frame, self.transport.position); + } + + fn seek_by(&mut self, amount: f64) { + let current_position = self.transport.position as f64 / self.sample_rate as f64; + let position = current_position + amount; + let index = (position * self.sample_rate as f64) as usize; + self.seek_to_index(index); + } + + fn seek_to(&mut self, position: f64) { + let index = (position * self.sample_rate as f64) as usize; + self.seek_to_index(index); + } + + fn read_commands(&mut self) { + read_commands_into_parameters!(self, volume, playback_rate, panning); + if let Some(loop_region) = self.command_readers.set_loop_region.read() { + self.transport.set_loop_region( + loop_region, + self.sample_rate, + num_frames(&self.frames, self.slice), + ); + } + if let Some(tween) = self.command_readers.pause.read() { + self.pause(tween); + } + if let Some((start_time, tween)) = self.command_readers.resume.read() { + self.resume(start_time, tween); + } + if let Some(tween) = self.command_readers.stop.read() { + self.stop(tween); + } + if let Some(amount) = self.command_readers.seek_by.read() { + self.seek_by(amount); + } + if let Some(position) = self.command_readers.seek_to.read() { + self.seek_to(position); + } + } +} + +impl Sound for StaticSound { + fn on_start_processing(&mut self) { + let last_played_frame_position = self.resampler.current_frame_index(); + self.shared.position.store( + (last_played_frame_position as f64 / self.sample_rate as f64).to_bits(), + Ordering::SeqCst, + ); + self.read_commands(); + } + + fn process(&mut self, out: &mut [Frame], dt: f64, info: &Info) { + // update parameters + self.volume.update(dt * out.len() as f64, info); + self.playback_rate.update(dt * out.len() as f64, info); + self.panning.update(dt * out.len() as f64, info); + let changed_playback_state = self + .playback_state_manager + .update(dt * out.len() as f64, info); + if changed_playback_state { + self.update_shared_playback_state(); + } + + let will_never_start = self.start_time.update(dt * out.len() as f64, info); + if will_never_start { + self.playback_state_manager.mark_as_stopped(); + self.update_shared_playback_state(); + } + if self.start_time != StartTime::Immediate { + out.fill(Frame::ZERO); + return; + } + + if !self.playback_state_manager.playback_state().is_advancing() { + out.fill(Frame::ZERO); + return; + } + + // play back audio + let num_frames = out.len(); + for (i, frame) in out.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let volume = self.volume.interpolated_value(time_in_chunk).as_amplitude(); + let fade_volume = self + .playback_state_manager + .interpolated_fade_volume(time_in_chunk) + .as_amplitude(); + let panning = self.panning.interpolated_value(time_in_chunk); + let playback_rate = self.playback_rate.interpolated_value(time_in_chunk); + let resampler_out = self.resampler.get(self.fractional_position as f32); + self.fractional_position += self.sample_rate as f64 * playback_rate.0.abs() * dt; + while self.fractional_position >= 1.0 { + self.fractional_position -= 1.0; + self.update_position(); + } + *frame = (resampler_out * fade_volume * volume).panned(panning); + } + } + + fn finished(&self) -> bool { + self.playback_state_manager.playback_state() == PlaybackState::Stopped + } +} + +#[derive(Debug)] +pub(super) struct Shared { + state: AtomicU8, + position: AtomicU64, +} + +impl Shared { + pub fn state(&self) -> PlaybackState { + match self.state.load(Ordering::SeqCst) { + 0 => PlaybackState::Playing, + 1 => PlaybackState::Pausing, + 2 => PlaybackState::Paused, + 3 => PlaybackState::WaitingToResume, + 4 => PlaybackState::Resuming, + 5 => PlaybackState::Stopping, + 6 => PlaybackState::Stopped, + _ => panic!("Invalid playback state"), + } + } + + pub fn set_state(&self, state: PlaybackState) { + self.state.store(state as u8, Ordering::SeqCst); + } + + pub fn position(&self) -> f64 { + f64::from_bits(self.position.load(Ordering::SeqCst)) + } +} diff --git a/third_party/kira/src/sound/static_sound/sound/resampler.rs b/third_party/kira/src/sound/static_sound/sound/resampler.rs new file mode 100644 index 00000000..3ba936c3 --- /dev/null +++ b/third_party/kira/src/sound/static_sound/sound/resampler.rs @@ -0,0 +1,71 @@ +use crate::frame::{Frame, interpolate_frame}; + +#[derive(Debug, Clone, Copy, PartialEq)] +struct RecentFrame { + /// A frame of audio. + frame: Frame, + /// The current frame index of the source sound at the + /// time this frame was pushed to the resampler. + frame_index: usize, +} + +pub(super) struct Resampler { + frames: [RecentFrame; 4], + time_until_empty: usize, +} + +impl Resampler { + #[must_use] + pub fn new(starting_frame_index: usize) -> Self { + Self { + frames: [RecentFrame { + frame: Frame::ZERO, + frame_index: starting_frame_index, + }; 4], + time_until_empty: 0, + } + } + + pub fn push_frame(&mut self, frame: Option, sample_index: usize) { + if frame.is_some() { + self.time_until_empty = 4; + } else { + self.time_until_empty = self.time_until_empty.saturating_sub(1); + } + let frame = frame.unwrap_or_default(); + self.frames.copy_within(1.., 0); + self.frames[self.frames.len() - 1] = RecentFrame { + frame, + frame_index: sample_index, + }; + } + + #[must_use] + pub fn get(&self, fractional_position: f32) -> Frame { + interpolate_frame( + self.frames[0].frame, + self.frames[1].frame, + self.frames[2].frame, + self.frames[3].frame, + fractional_position, + ) + } + + /// Returns the index of the frame in the source sound + /// that the user is currently hearing from this resampler. + /// + /// This is not the same as the most recently pushed frame. + /// The user mainly hears a frame between `self.frames[1]` and + /// `self.frames[2]`. `self.frames[0]` and `self.frames[3]` + /// are used to provide additional information to the interpolation + /// algorithm to get a smoother result. + #[must_use] + pub fn current_frame_index(&self) -> usize { + self.frames[1].frame_index + } + + #[must_use] + pub fn empty(&self) -> bool { + self.time_until_empty == 0 + } +} diff --git a/third_party/kira/src/sound/static_sound/sound/test.rs b/third_party/kira/src/sound/static_sound/sound/test.rs new file mode 100644 index 00000000..76824fb5 --- /dev/null +++ b/third_party/kira/src/sound/static_sound/sound/test.rs @@ -0,0 +1,841 @@ +use std::{sync::Arc, time::Duration}; + +use crate::{ + Decibels, Panning, StartTime, Tween, + clock::ClockTime, + frame::Frame, + info::MockInfoBuilder, + sound::{ + PlaybackState, Sound, + static_sound::{StaticSoundData, StaticSoundSettings}, + }, + test_helpers::expect_frame_soon, +}; + +/// Tests that a `StaticSound` will play all of its samples before finishing. +#[test] +fn plays_all_samples() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([ + Frame::from_mono(1.0), + Frame::from_mono(2.0), + Frame::from_mono(3.0), + ]), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, _) = data.split(); + + assert!(!sound.finished()); + + for i in 1..=3 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + assert!(!sound.finished()); + } + + // give some time for the resample buffer to empty. in the meantime we should + // get silent output. + for _ in 0..10 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + } + + // the sound should be finished and stopped by now + assert!(sound.finished()); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Stopped + ); +} + +/// Tests that a `StaticSound` correctly reports its playback state +/// to be queried by StaticSoundHandle::state. +#[test] +fn reports_playback_state() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(0.0); 10]), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, handle) = data.split(); + + for _ in 0..20 { + assert_eq!( + handle.state(), + sound.playback_state_manager.playback_state() + ); + sound.process_one(1.0, &MockInfoBuilder::new().build()); + } +} + +/// Tests that a `StaticSound` correctly reports its playback state +/// to be queried by StaticSoundHandle::state. +#[test] +#[allow(clippy::float_cmp)] +fn reports_playback_position() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(0.0); 10]), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, handle) = data.split(); + + for i in 0..20 { + assert_eq!( + handle.position(), + i.clamp(0, 10) as f64 / sound.sample_rate as f64 + ); + sound.process_one(1.0, &MockInfoBuilder::new().build()); + sound.on_start_processing(); + } +} + +/// Tests that a `StaticSound` fades out fully before pausing +/// and fades back in when resuming. +#[test] +#[allow(clippy::float_cmp)] +fn pauses_and_resumes_with_fades() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(1.0); 100]), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + + sound.process_one(1.0, &MockInfoBuilder::new().build()); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Playing + ); + + handle.pause(Tween { + duration: Duration::from_secs(4), + ..Default::default() + }); + sound.on_start_processing(); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Pausing + ); + + // allow for a few samples of delay because of the resampling, but the + // sound should fade out soon. + expect_frame_soon( + Frame::from_mono(Decibels(-15.0).as_amplitude()).panned(Panning::CENTER), + &mut sound, + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-30.0).as_amplitude()).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-45.0).as_amplitude()).panned(Panning::CENTER) + ); + + sound.on_start_processing(); + let position = handle.position(); + for _ in 0..10 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + sound.on_start_processing(); + assert_eq!(handle.position(), position); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Paused + ); + } + + handle.resume(Tween { + duration: Duration::from_secs(4), + ..Default::default() + }); + sound.on_start_processing(); + + // allow for a few samples of delay because of the resampling, but the + // sound should fade back in soon. + expect_frame_soon( + Frame::from_mono(Decibels(-45.0).as_amplitude()).panned(Panning::CENTER), + &mut sound, + ); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Resuming + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-30.0).as_amplitude()).panned(Panning::CENTER) + ); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Resuming + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-15.0).as_amplitude()).panned(Panning::CENTER) + ); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Resuming + ); + + for _ in 0..3 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Playing + ); + } + + let mut frames = vec![Frame::ZERO; 3]; + sound.process(&mut frames, 1.0, &MockInfoBuilder::new().build()); + assert_eq!( + frames, + vec![Frame::from_mono(1.0).panned(Panning::CENTER); 3] + ); +} + +/// Tests that a `StaticSound` stops and finishes after a fade-out. +#[test] +#[allow(clippy::float_cmp)] +fn stops_with_fade_out() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(1.0); 100]), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + + sound.process_one(1.0, &MockInfoBuilder::new().build()); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Playing + ); + + handle.stop(Tween { + duration: Duration::from_secs(4), + ..Default::default() + }); + sound.on_start_processing(); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Stopping + ); + + // allow for a few samples of delay because of the resampling, but the + // sound should fade out soon. + expect_frame_soon( + Frame::from_mono(Decibels(-15.0).as_amplitude()).panned(Panning::CENTER), + &mut sound, + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-30.0).as_amplitude()).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-45.0).as_amplitude()).panned(Panning::CENTER) + ); + + sound.on_start_processing(); + let position = handle.position(); + for _ in 0..3 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + sound.on_start_processing(); + assert_eq!(handle.position(), position); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Stopped + ); + assert!(sound.finished()); + } +} + +/// Tests that a `StaticSound` will wait for its start clock time +/// when appropriate. +#[test] +#[allow(clippy::float_cmp)] +fn waits_for_start_time() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id_1 = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StaticSoundData { + sample_rate: 1, + frames: (1..100).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new().start_time(ClockTime { + clock: clock_id_1, + ticks: 2, + fraction: 0.0, + }), + slice: None, + }; + let (mut sound, _) = data.split(); + + // the sound should not be playing yet + for _ in 0..3 { + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(0.0)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.add_clock(true, 0, 0.0); + builder.build() + }; + + // the sound is set to start at tick 2, so it should not + // play yet + for _ in 0..3 { + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(0.0)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.add_clock(true, 2, 0.0); + builder.build() + }; + + // a different clock reached tick 2, so the sound should + // not play yet + for _ in 0..3 { + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(0.0)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 2, 0.0); + builder.add_clock(true, 2, 0.0); + builder.build() + }; + + // the sound should start playing now + for i in 1..10 { + assert_eq!( + sound.process_one(1.0, &info), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + } +} + +/// Tests that a `StaticSound` will stop (allowing it to be removed) +/// if it's waiting on a clock that no longer exists before it can +/// start. +/// +/// Without this behavior, a sound in this situation would be in limbo +/// forever with no way to free up its resources. +#[test] +fn stops_if_waiting_on_missing_clock() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StaticSoundData { + sample_rate: 1, + frames: (1..100).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new().start_time(ClockTime { + clock: clock_id, + ticks: 2, + fraction: 0.0, + }), + slice: None, + }; + let (mut sound, handle) = data.split(); + + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Playing); + + // the clock is removed + let info = MockInfoBuilder::new().build(); + + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Stopped); + assert!(sound.finished()); +} + +/// Tests that a `StaticSound` that had its start time set to a clock time and already +/// started will not stop if the clock stops. +#[test] +fn continues_when_clock_stops() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StaticSoundData { + sample_rate: 1, + frames: (1..100).map(|_| Frame::from_mono(1.0)).collect(), + settings: StaticSoundSettings::new().start_time(ClockTime { + clock: clock_id, + ticks: 0, + fraction: 0.0, + }), + slice: None, + }; + let (mut sound, _) = data.split(); + + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(1.0),); + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(false, 0, 0.0); + builder.build() + }; + + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(1.0),); +} + +/// Tests that a `StaticSound` can be paused, resumed, and stopped immediately +/// even if it's waiting for its start time. +#[test] +fn immediate_playback_state_change_with_start_time() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StaticSoundData { + sample_rate: 1, + frames: (1..100).map(|_| Frame::from_mono(1.0)).collect(), + settings: StaticSoundSettings::new().start_time(ClockTime { + clock: clock_id, + ticks: 1, + fraction: 0.0, + }), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + + handle.pause(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Paused); + + handle.resume(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Playing); + + handle.stop(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Stopped); +} + +/// Tests that a `StaticSound` can be set to resume at a certain start time. +#[test] +fn resume_at() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StaticSoundData { + sample_rate: 1, + frames: (1..100).map(|_| Frame::from_mono(1.0)).collect(), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + + handle.pause(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Paused); + + handle.resume_at( + StartTime::ClockTime(ClockTime { + clock: clock_id, + ticks: 1, + fraction: 0.0, + }), + Tween { + duration: Duration::from_secs(1), + ..Default::default() + }, + ); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::WaitingToResume); + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.build() + }; + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Resuming); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Playing); +} + +/// Tests that a `StaticSound` can be played partially. +#[test] +#[allow(clippy::float_cmp)] +fn start_position() { + let data = StaticSoundData { + sample_rate: 1, + frames: (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new().start_position(3.0), + slice: None, + }; + let (mut sound, handle) = data.split(); + + for i in 3..=6 { + assert_eq!(handle.position(), i as f64); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + sound.on_start_processing(); + } +} + +/// Tests that starting a `StaticSound` past the end of the sound +/// will not cause a panic. +#[test] +#[allow(clippy::float_cmp)] +fn out_of_bounds_start_position() { + let data = StaticSoundData { + sample_rate: 1, + frames: (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new().start_position(15.0), + slice: None, + }; + let (mut sound, _) = data.split(); + sound.process_one(1.0, &MockInfoBuilder::new().build()); +} + +/// Tests that a `StaticSound` properly obeys looping behavior when +/// playing forward. +#[test] +#[allow(clippy::float_cmp)] +fn loops_forward() { + let data = StaticSoundData { + sample_rate: 1, + frames: (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new().loop_region(Some((3.0..6.0).into())), + slice: None, + }; + let (mut sound, _) = data.split(); + + for i in 0..6 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + } + + assert_eq!( + sound.process_one(2.0, &MockInfoBuilder::new().build()), + Frame::from_mono(3.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(2.0, &MockInfoBuilder::new().build()), + Frame::from_mono(5.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(2.0, &MockInfoBuilder::new().build()), + Frame::from_mono(4.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(2.0, &MockInfoBuilder::new().build()), + Frame::from_mono(3.0).panned(Panning::CENTER) + ); +} + +/// Tests that the volume of a `StaticSound` can be adjusted. +#[test] +#[allow(clippy::float_cmp)] +fn volume() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(1.0); 10]), + settings: StaticSoundSettings::new().volume(-6.0), + slice: None, + }; + let (mut sound, _) = data.split(); + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-6.0).as_amplitude()).panned(Panning::CENTER) + ); +} + +/// Tests that the volume of a `StaticSound` can be changed +/// after the sound is started. +#[test] +fn set_volume() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(1.0); 10]), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::CENTER) + ); + handle.set_volume( + -6.0, + Tween { + duration: Duration::ZERO, + ..Default::default() + }, + ); + sound.on_start_processing(); + expect_frame_soon( + Frame::from_mono(Decibels(-6.0).as_amplitude()).panned(Panning::CENTER), + &mut sound, + ); +} + +/// Tests that the panning of a `StaticSound` can be adjusted. +#[test] +#[allow(clippy::float_cmp)] +fn panning() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(1.0); 10]), + settings: StaticSoundSettings::new().panning(Panning::LEFT), + slice: None, + }; + let (mut sound, _) = data.split(); + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::LEFT) + ); +} + +/// Tests that the panning of a `StaticSound` can be changed +/// after the sound is started. +#[test] +fn set_panning() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(1.0); 10]), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::CENTER) + ); + handle.set_panning( + Panning::LEFT, + Tween { + duration: Duration::ZERO, + ..Default::default() + }, + ); + sound.on_start_processing(); + expect_frame_soon(Frame::from_mono(1.0).panned(Panning::LEFT), &mut sound); +} + +/// Tests that the playback rate of a `StaticSound` can be adjusted. +#[test] +#[allow(clippy::float_cmp)] +fn playback_rate() { + let data = StaticSoundData { + sample_rate: 1, + frames: (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new().playback_rate(2.0), + slice: None, + }; + let (mut sound, _) = data.split(); + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(2.0).panned(Panning::CENTER) + ); +} + +/// Tests that the playback rate of a `StaticSound` can be adjusted after +/// it's started. +#[test] +#[allow(clippy::float_cmp)] +fn set_playback_rate() { + let data = StaticSoundData { + sample_rate: 1, + frames: (0..100).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::CENTER) + ); + + handle.set_playback_rate( + 2.0, + Tween { + duration: Duration::ZERO, + ..Default::default() + }, + ); + sound.on_start_processing(); + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(2.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(4.0).panned(Panning::CENTER) + ); +} + +/// Tests that a `StaticSound` outputs interpolated samples when +/// its playback position is between samples. +#[test] +fn interpolates_samples() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([ + Frame::from_mono(0.0), + Frame::from_mono(1.0), + Frame::from_mono(1.0), + Frame::from_mono(1.0), + Frame::from_mono(1.0), + Frame::from_mono(1.0), + Frame::from_mono(-10.0), + ]), + settings: Default::default(), + slice: None, + }; + let (mut sound, _) = data.split(); + + assert_eq!( + sound.process_one(0.5, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + // at sample 0.5, the output should be somewhere between 0 and 1. + // i don't care what exactly, that's up the to the interpolation algorithm. + let frame = sound.process_one(5.0, &MockInfoBuilder::new().build()); + assert!(frame.left > 0.0 && frame.left < 1.0); + // at sample 5.5, the output should be between 1 and -10. + let frame = sound.process_one(1.0, &MockInfoBuilder::new().build()); + assert!(frame.left < 0.0 && frame.left > -10.0); +} + +/// Tests that a `StaticSound` outputs interpolated samples correctly +/// when looping. +#[test] +fn interpolates_samples_when_looping() { + let data = StaticSoundData { + sample_rate: 1, + frames: Arc::new([Frame::from_mono(10.0), Frame::from_mono(9.0)]), + settings: StaticSoundSettings::new().loop_region(Some((..).into())), + slice: None, + }; + let (mut sound, _) = data.split(); + sound.process_one(1.5, &MockInfoBuilder::new().build()); + // because we're looping back to the first sample, which is 10.0, + // the interpolated sample should be be tween 9.0 and 10.0 + let frame = sound.process_one(1.0, &MockInfoBuilder::new().build()); + assert!(frame.left > 9.0 && frame.left < 10.0); +} + +/// Tests that a `StaticSound` can seek to a position. +#[test] +fn seek_to() { + let data = StaticSoundData { + sample_rate: 1, + frames: (0..100).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + handle.seek_to(15.0); + sound.on_start_processing(); + expect_frame_soon(Frame::from_mono(15.0).panned(Panning::CENTER), &mut sound); +} + +/// Tests that a `StaticSound` can seek by an amount of time. +#[test] +fn seek_by() { + let data = StaticSoundData { + sample_rate: 1, + frames: (0..100).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new().start_position(10.0), + slice: None, + }; + let (mut sound, mut handle) = data.split(); + handle.seek_by(5.0); + sound.on_start_processing(); + // we wouldn't actually expect the position to be 10.0 seconds right at + // this moment - the sound probably ran ahead a few samples to fill + // the resample buffer. so let's just say it should reach 20.0 soon. + expect_frame_soon(Frame::from_mono(20.0).panned(Panning::CENTER), &mut sound); +} + +/// Tests that a `StaticSound` can play in reverse. +#[test] +fn reverse() { + let data = StaticSoundData { + sample_rate: 1, + frames: (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + settings: StaticSoundSettings::new().reverse(true), + slice: None, + }; + let (mut sound, _) = data.split(); + + for i in (4..=9).rev() { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + } +} diff --git a/third_party/kira/src/sound/streaming.rs b/third_party/kira/src/sound/streaming.rs new file mode 100644 index 00000000..f76ee177 --- /dev/null +++ b/third_party/kira/src/sound/streaming.rs @@ -0,0 +1,113 @@ +/*! +Decodes data gradually from an audio file. + +To play a streaming sound, pass a [`StreamingSoundData`] to +[`AudioManager::play`](crate::AudioManager::play). + +```no_run +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::streaming::{StreamingSoundData, StreamingSoundSettings}, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let sound_data = StreamingSoundData::from_file("sound.ogg")?; +manager.play(sound_data)?; +# Result::<(), Box>::Ok(()) +``` + +Streaming sounds use less memory than static sounds, but they use more +CPU, and they can have delays when starting or seeking. +*/ + +#![cfg_attr(docsrs, doc(cfg(not(wasm32))))] + +mod data; +mod decoder; +mod handle; +mod settings; +mod sound; + +pub use data::*; +pub use decoder::*; +pub use handle::*; +pub use settings::*; + +use crate::{ + Decibels, Panning, PlaybackRate, StartTime, Tween, + command::{CommandReader, CommandWriter, ValueChangeCommand, command_writer_and_reader}, +}; + +use super::Region; + +#[derive(Debug)] +pub(crate) struct CommandWriters { + set_volume: CommandWriter>, + set_playback_rate: CommandWriter>, + set_panning: CommandWriter>, + set_loop_region: CommandWriter>, + pause: CommandWriter, + resume: CommandWriter<(StartTime, Tween)>, + stop: CommandWriter, + seek_by: CommandWriter, + seek_to: CommandWriter, +} + +pub(crate) struct CommandReaders { + set_volume: CommandReader>, + set_playback_rate: CommandReader>, + set_panning: CommandReader>, + pause: CommandReader, + resume: CommandReader<(StartTime, Tween)>, + stop: CommandReader, +} + +#[derive(Debug)] +pub(crate) struct DecodeSchedulerCommandReaders { + set_loop_region: CommandReader>, + seek_by: CommandReader, + seek_to: CommandReader, +} + +#[must_use] +fn command_writers_and_readers() -> ( + CommandWriters, + CommandReaders, + DecodeSchedulerCommandReaders, +) { + let (set_volume_writer, set_volume_reader) = command_writer_and_reader(); + let (set_playback_rate_writer, set_playback_rate_reader) = command_writer_and_reader(); + let (set_panning_writer, set_panning_reader) = command_writer_and_reader(); + let (set_loop_region_writer, set_loop_region_reader) = command_writer_and_reader(); + let (pause_writer, pause_reader) = command_writer_and_reader(); + let (resume_writer, resume_reader) = command_writer_and_reader(); + let (stop_writer, stop_reader) = command_writer_and_reader(); + let (seek_by_writer, seek_by_reader) = command_writer_and_reader(); + let (seek_to_writer, seek_to_reader) = command_writer_and_reader(); + ( + CommandWriters { + set_volume: set_volume_writer, + set_playback_rate: set_playback_rate_writer, + set_panning: set_panning_writer, + set_loop_region: set_loop_region_writer, + pause: pause_writer, + resume: resume_writer, + stop: stop_writer, + seek_by: seek_by_writer, + seek_to: seek_to_writer, + }, + CommandReaders { + set_volume: set_volume_reader, + set_playback_rate: set_playback_rate_reader, + set_panning: set_panning_reader, + pause: pause_reader, + resume: resume_reader, + stop: stop_reader, + }, + DecodeSchedulerCommandReaders { + set_loop_region: set_loop_region_reader, + seek_by: seek_by_reader, + seek_to: seek_to_reader, + }, + ) +} diff --git a/third_party/kira/src/sound/streaming/data.rs b/third_party/kira/src/sound/streaming/data.rs new file mode 100644 index 00000000..803aed18 --- /dev/null +++ b/third_party/kira/src/sound/streaming/data.rs @@ -0,0 +1,411 @@ +#[cfg(test)] +mod test; + +use std::sync::Mutex; +use std::{sync::Arc, time::Duration}; + +use crate::sound::{EndPosition, IntoOptionalRegion, PlaybackPosition, Region, SoundData}; +use crate::{Decibels, Panning, PlaybackRate, StartTime}; +use crate::{Tween, Value}; +use rtrb::RingBuffer; + +use super::sound::Shared; +use super::{StreamingSoundHandle, StreamingSoundSettings, command_writers_and_readers}; + +use super::{ + decoder::Decoder, + sound::{StreamingSound, decode_scheduler::DecodeScheduler}, +}; + +const ERROR_BUFFER_CAPACITY: usize = 1; + +/// A streaming sound that is not playing yet. +pub struct StreamingSoundData { + pub(crate) decoder: Box>, + /// Settings for the streaming sound. + pub settings: StreamingSoundSettings, + /** + The portion of the sound this [`StreamingSoundData`] represents. + + Note that the [`StreamingSoundData`] holds the entire piece of audio + it was originally given regardless of the value of `slice`, but + [`StreamingSoundData::num_frames`] and [`StreamingSoundData::duration`] + will behave as if this [`StreamingSoundData`] only contained the specified + portion of audio. + */ + pub slice: Option<(usize, usize)>, +} + +impl StreamingSoundData { + /// Creates a [`StreamingSoundData`] for a [`Decoder`]. + #[must_use] + pub fn from_decoder(decoder: impl Decoder + 'static) -> Self { + Self { + decoder: Box::new(decoder), + settings: StreamingSoundSettings::default(), + slice: None, + } + } + + /** + Sets when the sound should start playing. + + # Examples + + Configuring a sound to start 4 ticks after a clock's current time: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + clock::ClockSpeed, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let clock_handle = manager.add_clock(ClockSpeed::TicksPerMinute(120.0))?; + let sound = StreamingSoundData::from_file("sound.ogg")? + .start_time(clock_handle.time() + 4); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn start_time(mut self, start_time: impl Into) -> Self { + self.settings.start_time = start_time.into(); + self + } + + /// Sets where in the sound playback should start. + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn start_position(mut self, start_position: impl Into) -> Self { + self.settings.start_position = start_position.into(); + self + } + + /** + Sets the portion of the sound that should be looped. + + # Examples + + Configure a sound to loop the portion from 3 seconds in to the end: + + ```no_run + # use kira::sound::streaming::StreamingSoundData; + let sound = StreamingSoundData::from_file("sound.ogg")?.loop_region(3.0..); + # Result::<(), Box>::Ok(()) + ``` + + Configure a sound to loop the portion from 2 to 4 seconds: + + ```no_run + # use kira::sound::streaming::StreamingSoundData; + let sound = StreamingSoundData::from_file("sound.ogg")?.loop_region(2.0..4.0); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn loop_region(mut self, loop_region: impl IntoOptionalRegion) -> Self { + self.settings.loop_region = loop_region.into_optional_region(); + self + } + + /** + Sets the volume of the sound. + + # Examples + + Set the volume to a fixed value: + + ```no_run + # use kira::sound::streaming::StreamingSoundData; + let sound = StreamingSoundData::from_file("sound.ogg")?.volume(-6.0); + # Result::<(), Box>::Ok(()) + ``` + + Link the volume to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + sound::streaming::StreamingSoundData, + Value, Mapping, Easing, + Decibels, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let sound = StreamingSoundData::from_file("sound.ogg")?.volume(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (Decibels::SILENCE, Decibels::IDENTITY), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn volume(mut self, volume: impl Into>) -> Self { + self.settings.volume = volume.into(); + self + } + + /** + Sets the playback rate of the sound. + + Changing the playback rate will change both the speed + and the pitch of the sound. + + # Examples + + Set the playback rate as a factor: + + ```no_run + # use kira::sound::streaming::StreamingSoundData; + let sound = StreamingSoundData::from_file("sound.ogg")?.playback_rate(0.5); + # Result::<(), Box>::Ok(()) + ``` + + Set the playback rate as a change in semitones: + + ```no_run + # use kira::sound::streaming::StreamingSoundData; + use kira::Semitones; + let sound = StreamingSoundData::from_file("sound.ogg")?.playback_rate(Semitones(-2.0)); + # Result::<(), Box>::Ok(()) + ``` + + Link the playback rate to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + sound::streaming::StreamingSoundData, + Value, Easing, Mapping, + PlaybackRate, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let sound = StreamingSoundData::from_file("sound.ogg")?.playback_rate(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (PlaybackRate(0.0), PlaybackRate(1.0)), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn playback_rate(mut self, playback_rate: impl Into>) -> Self { + self.settings.playback_rate = playback_rate.into(); + self + } + + /** + Sets the panning of the sound, where 0 is hard left + and 1 is hard right. + + # Examples + + Set the panning to a fixed value: + + ``` no_run + # use kira::sound::streaming::StreamingSoundData; + let sound = StreamingSoundData::from_file("sound.ogg")?.panning(-0.5); + # Result::<(), Box>::Ok(()) + ``` + + Link the panning to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + sound::streaming::StreamingSoundData, + Value, Easing, Mapping, + Panning, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: -0.5, + })?; + let sound = StreamingSoundData::from_file("sound.ogg")?.panning(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (-1.0, 1.0), + output_range: (Panning::LEFT, Panning::RIGHT), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn panning(mut self, panning: impl Into>) -> Self { + self.settings.panning = panning.into(); + self + } + + /// Sets the tween used to fade in the instance from silence. + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn fade_in_tween(mut self, fade_in_tween: impl Into>) -> Self { + self.settings.fade_in_tween = fade_in_tween.into(); + self + } + + /// Returns the `StreamingSoundData` with the specified settings. + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn with_settings(mut self, settings: StreamingSoundSettings) -> Self { + self.settings = settings; + self + } + + /// Returns the number of frames in the [`StreamingSoundData`]. + /// + /// If [`StreamingSoundData::slice`] is `Some`, this will be the number + /// of frames in the slice. + #[must_use] + pub fn num_frames(&self) -> usize { + if let Some((start, end)) = self.slice { + end - start + } else { + self.decoder.num_frames() + } + } + + /// Returns the duration of the audio. + /// + /// If [`StreamingSoundData::slice`] is `Some`, this will be the duration + /// of the slice. + #[must_use] + pub fn duration(&self) -> Duration { + Duration::from_secs_f64(self.num_frames() as f64 / self.decoder.sample_rate() as f64) + } + + /// Returns the total duration of the audio, regardless of its slice. + #[must_use] + pub fn unsliced_duration(&self) -> Duration { + Duration::from_secs_f64( + self.decoder.num_frames() as f64 / self.decoder.sample_rate() as f64, + ) + } + + /** + Sets the portion of the audio this [`StreamingSoundData`] represents. + */ + #[must_use = "This method consumes self and returns a modified StreamingSoundData, so the return value should be used"] + pub fn slice(mut self, region: impl IntoOptionalRegion) -> Self { + self.slice = region.into_optional_region().map(|Region { start, end }| { + let start = start.into_samples(self.decoder.sample_rate()); + let end = match end { + EndPosition::EndOfAudio => self.decoder.num_frames(), + EndPosition::Custom(end) => end.into_samples(self.decoder.sample_rate()), + }; + (start, end) + }); + self + } +} + +impl StreamingSoundData {} + +#[cfg(feature = "symphonia")] +impl StreamingSoundData { + /// Creates a [`StreamingSoundData`] for an audio file. + pub fn from_file( + path: impl AsRef, + ) -> Result, crate::sound::FromFileError> { + use std::fs::File; + + use super::symphonia::SymphoniaDecoder; + + Ok(Self::from_decoder(SymphoniaDecoder::new(Box::new( + File::open(path)?, + ))?)) + } + + /// Creates a [`StreamingSoundData`] for a cursor wrapping audio file data. + pub fn from_cursor + Send + Sync + 'static>( + cursor: std::io::Cursor, + ) -> Result, crate::sound::FromFileError> { + use super::symphonia::SymphoniaDecoder; + + Ok(Self::from_decoder(SymphoniaDecoder::new(Box::new(cursor))?)) + } + + /// Creates a [`StreamingSoundData`] for a type that implements Symphonia's + /// [`MediaSource`](symphonia::core::io::MediaSource) trait. + pub fn from_media_source( + media_source: impl symphonia::core::io::MediaSource + 'static, + ) -> Result, crate::sound::FromFileError> { + use super::symphonia::SymphoniaDecoder; + + Ok(Self::from_decoder(SymphoniaDecoder::new(Box::new( + media_source, + ))?)) + } +} + +impl StreamingSoundData { + pub(crate) fn split( + self, + ) -> Result< + ( + StreamingSound, + StreamingSoundHandle, + DecodeScheduler, + ), + Error, + > { + let (command_writers, command_readers, decode_scheduler_command_readers) = + command_writers_and_readers(); + let (error_producer, error_consumer) = RingBuffer::new(ERROR_BUFFER_CAPACITY); + let sample_rate = self.decoder.sample_rate(); + let shared = Arc::new(Shared::new()); + let (scheduler, frame_consumer) = DecodeScheduler::new( + self.decoder, + self.slice, + self.settings, + shared.clone(), + decode_scheduler_command_readers, + error_producer, + )?; + let sound = StreamingSound::new( + sample_rate, + self.settings, + shared.clone(), + frame_consumer, + command_readers, + &scheduler, + ); + let handle = StreamingSoundHandle { + shared, + command_writers, + error_consumer: Mutex::new(error_consumer), + }; + Ok((sound, handle, scheduler)) + } +} + +impl SoundData for StreamingSoundData { + type Error = Error; + + type Handle = StreamingSoundHandle; + + #[allow(clippy::type_complexity)] + fn into_sound(self) -> Result<(Box, Self::Handle), Self::Error> { + let (sound, handle, scheduler) = self.split()?; + scheduler.start(); + Ok((Box::new(sound), handle)) + } +} diff --git a/third_party/kira/src/sound/streaming/data/test.rs b/third_party/kira/src/sound/streaming/data/test.rs new file mode 100644 index 00000000..12c1f5fe --- /dev/null +++ b/third_party/kira/src/sound/streaming/data/test.rs @@ -0,0 +1,36 @@ +use std::time::Duration; + +use crate::{ + frame::Frame, + sound::streaming::{StreamingSoundData, mock::MockDecoder}, +}; + +#[test] +fn duration() { + let sound = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(0.5); 4])), + settings: Default::default(), + slice: None, + }; + assert_eq!(sound.duration(), Duration::from_secs(4)); +} + +#[test] +fn unsliced_duration() { + let sound = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(0.5); 4])), + settings: Default::default(), + slice: Some((2, 3)), + }; + assert_eq!(sound.unsliced_duration(), Duration::from_secs(4)); +} + +#[test] +fn sliced_duration() { + let sound = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(0.5); 4])), + settings: Default::default(), + slice: Some((2, 3)), + }; + assert_eq!(sound.duration(), Duration::from_secs(1)); +} diff --git a/third_party/kira/src/sound/streaming/decoder.rs b/third_party/kira/src/sound/streaming/decoder.rs new file mode 100644 index 00000000..71fa0286 --- /dev/null +++ b/third_party/kira/src/sound/streaming/decoder.rs @@ -0,0 +1,33 @@ +#[cfg(test)] +pub(crate) mod mock; +#[cfg(feature = "symphonia")] +pub(crate) mod symphonia; + +use crate::frame::Frame; + +/// Decodes chunks of audio. +pub trait Decoder: Send { + /// Errors that can occur when decoding audio. + type Error; + + /// Returns the sample rate of the audio (in Hz). + #[must_use] + fn sample_rate(&self) -> u32; + + /// Returns the total number of samples of audio. + #[must_use] + fn num_frames(&self) -> usize; + + /// Decodes the next chunk of audio. + fn decode(&mut self) -> Result, Self::Error>; + + /// Seeks to an audio sample. + /// + /// The `index` is the _requested_ sample to seek to. It's OK if the decoder + /// seeks to an earlier sample than the one requested. + /// + /// This should return the sample index that was _actually_ seeked to. + fn seek(&mut self, index: usize) -> Result; +} + +type SeekedToIndex = usize; diff --git a/third_party/kira/src/sound/streaming/decoder/mock.rs b/third_party/kira/src/sound/streaming/decoder/mock.rs new file mode 100644 index 00000000..c7d4fd27 --- /dev/null +++ b/third_party/kira/src/sound/streaming/decoder/mock.rs @@ -0,0 +1,61 @@ +use crate::frame::Frame; + +use super::Decoder; + +const MOCK_DECODER_SAMPLE_RATE: u32 = 1; +const MOCK_DECODER_PACKET_SIZE: usize = 3; + +pub(crate) struct MockDecoder { + frames: Vec, + current_frame_index: usize, +} + +impl MockDecoder { + #[must_use] + pub(crate) fn new(frames: Vec) -> Self { + Self { + frames, + current_frame_index: 0, + } + } +} + +impl Decoder for MockDecoder { + type Error = MockDecoderError; + + fn sample_rate(&self) -> u32 { + MOCK_DECODER_SAMPLE_RATE + } + + fn num_frames(&self) -> usize { + self.frames.len() + } + + fn decode(&mut self) -> Result, Self::Error> { + let mut frames = vec![]; + for _ in 0..MOCK_DECODER_PACKET_SIZE { + let frame = self.frames[self.current_frame_index]; + if frame.left.is_nan() || frame.right.is_nan() { + return Err(MockDecoderError); + } + frames.push(frame); + self.current_frame_index += 1; + if self.current_frame_index >= self.frames.len() { + break; + } + } + Ok(frames) + } + + fn seek(&mut self, index: usize) -> Result { + // seek to the beginning of the "packet" to simulate + // seeking behavior with real decoders + let index = + (index as f64 / MOCK_DECODER_PACKET_SIZE as f64) as usize * MOCK_DECODER_PACKET_SIZE; + self.current_frame_index = index; + Ok(index) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct MockDecoderError; diff --git a/third_party/kira/src/sound/streaming/decoder/symphonia.rs b/third_party/kira/src/sound/streaming/decoder/symphonia.rs new file mode 100644 index 00000000..9350f6f7 --- /dev/null +++ b/third_party/kira/src/sound/streaming/decoder/symphonia.rs @@ -0,0 +1,95 @@ +use std::convert::TryInto; + +use crate::{ + frame::Frame, + sound::{FromFileError, symphonia::load_frames_from_buffer_ref}, +}; +use symphonia::core::{ + codecs::Decoder, + formats::{FormatReader, SeekMode, SeekTo}, + io::{MediaSource, MediaSourceStream}, + probe::Hint, +}; + +pub(crate) struct SymphoniaDecoder { + format_reader: Box, + decoder: Box, + sample_rate: u32, + num_frames: usize, + track_id: u32, +} + +impl SymphoniaDecoder { + pub(crate) fn new(media_source: Box) -> Result { + let codecs = symphonia::default::get_codecs(); + let probe = symphonia::default::get_probe(); + let mss = MediaSourceStream::new(media_source, Default::default()); + let format_reader = probe + .format( + &Hint::default(), + mss, + &Default::default(), + &Default::default(), + )? + .format; + let default_track = format_reader + .default_track() + .ok_or(FromFileError::NoDefaultTrack)?; + let sample_rate = default_track + .codec_params + .sample_rate + .ok_or(FromFileError::UnknownSampleRate)?; + let num_frames = default_track + .codec_params + .n_frames + .ok_or(FromFileError::UnknownSampleRate)? + .try_into() + .expect("could not convert u64 into usize"); + let decoder = codecs.make(&default_track.codec_params, &Default::default())?; + let track_id = default_track.id; + Ok(Self { + format_reader, + decoder, + sample_rate, + num_frames, + track_id, + }) + } +} + +impl super::Decoder for SymphoniaDecoder { + type Error = FromFileError; + + fn sample_rate(&self) -> u32 { + self.sample_rate + } + + fn num_frames(&self) -> usize { + self.num_frames + } + + fn decode(&mut self) -> Result, Self::Error> { + let packet = loop { + let packet = self.format_reader.next_packet()?; + if self.track_id == packet.track_id() { + break packet; + } + }; + let buffer = self.decoder.decode(&packet)?; + load_frames_from_buffer_ref(&buffer) + } + + fn seek(&mut self, index: usize) -> Result { + let seeked_to = self.format_reader.seek( + SeekMode::Accurate, + SeekTo::TimeStamp { + ts: index.try_into().expect("could not convert usize into u64"), + track_id: self.track_id, + }, + )?; + Ok(seeked_to + .actual_ts + .try_into() + .expect("could not convert u64 into usize")) + } +} diff --git a/third_party/kira/src/sound/streaming/handle.rs b/third_party/kira/src/sound/streaming/handle.rs new file mode 100644 index 00000000..803f34a1 --- /dev/null +++ b/third_party/kira/src/sound/streaming/handle.rs @@ -0,0 +1,355 @@ +use std::{ + fmt::{Debug, Formatter}, + sync::{Arc, Mutex}, +}; + +use crate::{ + Decibels, Panning, PlaybackRate, StartTime, Tween, + command::handle_param_setters, + sound::{IntoOptionalRegion, PlaybackState}, +}; +use rtrb::Consumer; + +use super::{CommandWriters, sound::Shared}; + +/// Controls a streaming sound. +pub struct StreamingSoundHandle { + pub(super) shared: Arc, + pub(super) command_writers: CommandWriters, + pub(super) error_consumer: Mutex>, +} + +impl StreamingSoundHandle { + /// Returns the current playback state of the sound. + #[must_use] + pub fn state(&self) -> PlaybackState { + self.shared.state() + } + + /// Returns the current playback position of the sound (in seconds). + #[must_use] + pub fn position(&self) -> f64 { + self.shared.position() + } + + handle_param_setters! { + /** + Sets the volume of the sound. + + # Examples + + Set the volume of the sound immediately: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + use kira::Tween; + + sound.set_volume(-6.0, Tween::default()); + # Result::<(), Box>::Ok(()) + ``` + + Smoothly transition the volume to a target volume: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + use kira::Tween; + use std::time::Duration; + + sound.set_volume(-6.0, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + + Link the volume to a modulator, smoothly transitioning from the current value: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + modulator::tweener::TweenerBuilder, + Value, Tween, Mapping, Easing, + Decibels, + }; + use std::time::Duration; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + sound.set_volume(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (Decibels::SILENCE, Decibels::IDENTITY), + easing: Easing::Linear, + } + }, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + */ + volume: Decibels, + + /** + Sets the playback rate of the sound. + + Changing the playback rate will change both the speed + and pitch of the sound. + + # Examples + + Set the playback rate of the sound immediately: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + use kira::Tween; + + sound.set_playback_rate(0.5, Tween::default()); + # Result::<(), Box>::Ok(()) + ``` + + Smoothly transition the playback rate to a target value in semitones: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + use kira::{Tween, Semitones}; + use std::time::Duration; + + sound.set_playback_rate(Semitones(-2.0), Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + + Link the playback rate to a modulator, smoothly transitioning from the current value: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + modulator::tweener::TweenerBuilder, + Value, Easing, Mapping, Tween, + PlaybackRate, + }; + use std::time::Duration; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + sound.set_playback_rate(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (PlaybackRate(0.0), PlaybackRate(1.0)), + easing: Easing::Linear, + }, + }, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + */ + playback_rate: PlaybackRate, + + /** + Sets the panning of the sound, where `-1.0` is hard left, + `0.0` is center, and `1.0` is hard right. + + # Examples + + Smoothly transition the panning to a target value: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + use kira::Tween; + use std::time::Duration; + + sound.set_panning(-0.5, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + + Link the panning to a modulator, smoothly transitioning from the current value: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + modulator::tweener::TweenerBuilder, + Value, Easing, Mapping, Tween, + Panning, + }; + use std::time::Duration; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: -0.5, + })?; + let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + sound.set_panning(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (-1.0, 1.0), + output_range: (Panning::LEFT, Panning::RIGHT), + easing: Easing::Linear, + }, + }, Tween { + duration: Duration::from_secs(3), + ..Default::default() + }); + # Result::<(), Box>::Ok(()) + ``` + */ + panning: Panning, + } + + /** + Sets the portion of the sound that will play in a loop. + + # Examples + + Set the sound to loop the portion from 3 seconds in to the end: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + sound.set_loop_region(3.0..); + # Result::<(), Box>::Ok(()) + ``` + + Set the sound to loop the portion from 2 to 4 seconds: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + sound.set_loop_region(2.0..4.0); + # Result::<(), Box>::Ok(()) + ``` + + Set a sound that was previously looping to stop looping: + + ```no_run + # use kira::{ + # AudioManager, AudioManagerSettings, DefaultBackend, + # sound::streaming::{StreamingSoundData, StreamingSoundSettings}, + # }; + # let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + # let mut sound = manager.play(StreamingSoundData::from_file("sound.ogg")?)?; + sound.set_loop_region(None); + # Result::<(), Box>::Ok(()) + ``` + */ + pub fn set_loop_region(&mut self, loop_region: impl IntoOptionalRegion) { + self.command_writers + .set_loop_region + .write(loop_region.into_optional_region()) + } + + /// Fades out the sound to silence with the given tween and then + /// pauses playback. + pub fn pause(&mut self, tween: Tween) { + self.command_writers.pause.write(tween) + } + + /// Resumes playback and fades in the sound from silence + /// with the given tween. + pub fn resume(&mut self, tween: Tween) { + self.resume_at(StartTime::Immediate, tween) + } + + /// Resumes playback at the given start time and fades in + /// the sound from silence with the given tween. + pub fn resume_at(&mut self, start_time: StartTime, tween: Tween) { + self.command_writers.resume.write((start_time, tween)) + } + + /// Fades out the sound to silence with the given tween and then + /// stops playback. + /// + /// Once the sound is stopped, it cannot be restarted. + pub fn stop(&mut self, tween: Tween) { + self.command_writers.stop.write(tween) + } + + /// Sets the playback position to the specified time in seconds. + pub fn seek_to(&mut self, position: f64) { + self.command_writers.seek_to.write(position) + } + + /// Moves the playback position by the specified amount of time in seconds. + pub fn seek_by(&mut self, amount: f64) { + self.command_writers.seek_by.write(amount) + } + + /// Returns an error that occurred while decoding audio, if any. + #[must_use] + pub fn pop_error(&mut self) -> Option { + self.error_consumer + .lock() + .expect("error consumer mutex poisoned") + .pop() + .ok() + } +} + +impl Debug for StreamingSoundHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StreamingSoundHandle") + .field("shared", &self.shared) + .field("command_writers", &self.command_writers) + .field("error_consumer", &ConsumerDebug) + .finish() + } +} + +struct ConsumerDebug; + +impl Debug for ConsumerDebug { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Consumer").finish() + } +} diff --git a/third_party/kira/src/sound/streaming/settings.rs b/third_party/kira/src/sound/streaming/settings.rs new file mode 100644 index 00000000..b672742e --- /dev/null +++ b/third_party/kira/src/sound/streaming/settings.rs @@ -0,0 +1,120 @@ +use crate::{ + Decibels, Panning, PlaybackRate, StartTime, Tween, Value, + sound::{IntoOptionalRegion, PlaybackPosition, Region}, +}; + +/// Settings for a streaming sound. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StreamingSoundSettings { + /// When the sound should start playing. + pub start_time: StartTime, + /// Where in the sound playback should start. + pub start_position: PlaybackPosition, + /// The portion of the sound that should be looped. + pub loop_region: Option, + /// The volume of the sound. + pub volume: Value, + /// The playback rate of the sound. + /// + /// Changing the playback rate will change both the speed + /// and the pitch of the sound. + pub playback_rate: Value, + /// The panning of the sound, where 0 is hard left + /// and 1 is hard right. + pub panning: Value, + /// An optional fade-in from silence. + pub fade_in_tween: Option, +} + +impl StreamingSoundSettings { + /// Creates a new [`StreamingSoundSettings`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self { + start_time: StartTime::Immediate, + start_position: PlaybackPosition::Seconds(0.0), + loop_region: None, + volume: Value::Fixed(Decibels::IDENTITY), + playback_rate: Value::Fixed(PlaybackRate(1.0)), + panning: Value::Fixed(Panning::CENTER), + fade_in_tween: None, + } + } + + /** Sets when the sound should start playing. */ + #[must_use = "This method consumes self and returns a modified StreamingSoundSettings, so the return value should be used"] + pub fn start_time(self, start_time: impl Into) -> Self { + Self { + start_time: start_time.into(), + ..self + } + } + + /// Sets where in the sound playback should start. + #[must_use = "This method consumes self and returns a modified StreamingSoundSettings, so the return value should be used"] + pub fn start_position(self, start_position: impl Into) -> Self { + Self { + start_position: start_position.into(), + ..self + } + } + + /** Sets the portion of the sound that should be looped. */ + #[must_use = "This method consumes self and returns a modified StreamingSoundSettings, so the return value should be used"] + pub fn loop_region(self, loop_region: impl IntoOptionalRegion) -> Self { + Self { + loop_region: loop_region.into_optional_region(), + ..self + } + } + + /** Sets the volume of the sound. */ + #[must_use = "This method consumes self and returns a modified StreamingSoundSettings, so the return value should be used"] + pub fn volume(self, volume: impl Into>) -> Self { + Self { + volume: volume.into(), + ..self + } + } + + /** + Sets the playback rate of the sound. + + Changing the playback rate will change both the speed + and the pitch of the sound. + */ + #[must_use = "This method consumes self and returns a modified StreamingSoundSettings, so the return value should be used"] + pub fn playback_rate(self, playback_rate: impl Into>) -> Self { + Self { + playback_rate: playback_rate.into(), + ..self + } + } + + /** + Sets the panning of the sound, where -1.0 is hard left + and 1.0 is hard right. + */ + #[must_use = "This method consumes self and returns a modified StreamingSoundSettings, so the return value should be used"] + pub fn panning(self, panning: impl Into>) -> Self { + Self { + panning: panning.into(), + ..self + } + } + + /// Sets the tween used to fade in the instance from silence. + #[must_use = "This method consumes self and returns a modified StreamingSoundSettings, so the return value should be used"] + pub fn fade_in_tween(self, fade_in_tween: impl Into>) -> Self { + Self { + fade_in_tween: fade_in_tween.into(), + ..self + } + } +} + +impl Default for StreamingSoundSettings { + fn default() -> Self { + Self::new() + } +} diff --git a/third_party/kira/src/sound/streaming/sound.rs b/third_party/kira/src/sound/streaming/sound.rs new file mode 100644 index 00000000..6f0423b2 --- /dev/null +++ b/third_party/kira/src/sound/streaming/sound.rs @@ -0,0 +1,283 @@ +pub(crate) mod decode_scheduler; + +#[cfg(test)] +mod test; + +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}, +}; + +use crate::{ + Decibels, Panning, Parameter, PlaybackRate, StartTime, Tween, + command::read_commands_into_parameters, + frame::{Frame, interpolate_frame}, + info::Info, + playback_state_manager::PlaybackStateManager, + sound::{PlaybackState, Sound}, +}; +use rtrb::Consumer; + +use super::{CommandReaders, StreamingSoundSettings}; + +use self::decode_scheduler::DecodeScheduler; + +#[derive(Debug)] +pub(crate) struct Shared { + state: AtomicU8, + position: AtomicU64, + reached_end: AtomicBool, + encountered_error: AtomicBool, +} + +impl Shared { + #[must_use] + pub fn new() -> Self { + Self { + position: AtomicU64::new(0.0f64.to_bits()), + state: AtomicU8::new(PlaybackState::Playing as u8), + reached_end: AtomicBool::new(false), + encountered_error: AtomicBool::new(false), + } + } + + #[must_use] + pub fn state(&self) -> PlaybackState { + match self.state.load(Ordering::SeqCst) { + 0 => PlaybackState::Playing, + 1 => PlaybackState::Pausing, + 2 => PlaybackState::Paused, + 3 => PlaybackState::WaitingToResume, + 4 => PlaybackState::Resuming, + 5 => PlaybackState::Stopping, + 6 => PlaybackState::Stopped, + _ => panic!("Invalid playback state"), + } + } + + pub fn set_state(&self, state: PlaybackState) { + self.state.store(state as u8, Ordering::SeqCst); + } + + #[must_use] + pub fn position(&self) -> f64 { + f64::from_bits(self.position.load(Ordering::SeqCst)) + } + + #[must_use] + pub fn reached_end(&self) -> bool { + self.reached_end.load(Ordering::SeqCst) + } + + #[must_use] + pub fn encountered_error(&self) -> bool { + self.encountered_error.load(Ordering::SeqCst) + } +} + +pub(crate) struct StreamingSound { + command_readers: CommandReaders, + sample_rate: u32, + frame_consumer: Consumer, + start_time: StartTime, + playback_state_manager: PlaybackStateManager, + current_frame: usize, + fractional_position: f64, + volume: Parameter, + playback_rate: Parameter, + panning: Parameter, + shared: Arc, +} + +impl StreamingSound { + #[must_use] + pub(super) fn new( + sample_rate: u32, + settings: StreamingSoundSettings, + shared: Arc, + frame_consumer: Consumer, + command_readers: CommandReaders, + scheduler: &DecodeScheduler, + ) -> Self { + let current_frame = scheduler.current_frame(); + let start_position = current_frame as f64 / sample_rate as f64; + shared + .position + .store(start_position.to_bits(), Ordering::SeqCst); + Self { + command_readers, + sample_rate, + frame_consumer, + start_time: settings.start_time, + playback_state_manager: PlaybackStateManager::new(settings.fade_in_tween), + current_frame, + fractional_position: 0.0, + volume: Parameter::new(settings.volume, Decibels::IDENTITY), + playback_rate: Parameter::new(settings.playback_rate, PlaybackRate(1.0)), + panning: Parameter::new(settings.panning, Panning::CENTER), + shared, + } + } + + fn update_shared_playback_state(&mut self) { + self.shared + .set_state(self.playback_state_manager.playback_state()); + } + + fn update_current_frame(&mut self) { + let chunk = self + .frame_consumer + .read_chunk(self.frame_consumer.slots().min(4)) + .unwrap(); + let (a, b) = chunk.as_slices(); + let mut iter = a.iter().chain(b.iter()); + if let Some(TimestampedFrame { index, .. }) = iter.nth(1) { + self.current_frame = *index; + } + } + + #[must_use] + fn next_frames(&mut self) -> [Frame; 4] { + let mut frames = [Frame::ZERO; 4]; + let chunk = self + .frame_consumer + .read_chunk(self.frame_consumer.slots().min(4)) + .unwrap(); + let (a, b) = chunk.as_slices(); + let mut iter = a.iter().chain(b.iter()); + for frame in &mut frames { + *frame = iter + .next() + .copied() + .map(|TimestampedFrame { frame, .. }| frame) + .unwrap_or(Frame::ZERO); + } + frames + } + + #[must_use] + fn position(&self) -> f64 { + (self.current_frame as f64 + self.fractional_position) / self.sample_rate as f64 + } + + fn pause(&mut self, fade_out_tween: Tween) { + self.playback_state_manager.pause(fade_out_tween); + self.update_shared_playback_state(); + } + + fn resume(&mut self, start_time: StartTime, fade_in_tween: Tween) { + self.playback_state_manager + .resume(start_time, fade_in_tween); + self.update_shared_playback_state(); + } + + fn stop(&mut self, fade_out_tween: Tween) { + self.playback_state_manager.stop(fade_out_tween); + self.update_shared_playback_state(); + } + + fn read_commands(&mut self) { + read_commands_into_parameters!(self, volume, playback_rate, panning); + if let Some(tween) = self.command_readers.pause.read() { + self.pause(tween); + } + if let Some((start_time, tween)) = self.command_readers.resume.read() { + self.resume(start_time, tween); + } + if let Some(tween) = self.command_readers.stop.read() { + self.stop(tween); + } + } +} + +impl Sound for StreamingSound { + fn on_start_processing(&mut self) { + self.update_current_frame(); + self.shared + .position + .store(self.position().to_bits(), Ordering::SeqCst); + self.read_commands(); + } + + fn process(&mut self, out: &mut [Frame], dt: f64, info: &Info) { + if self.shared.encountered_error() { + self.playback_state_manager.mark_as_stopped(); + self.update_shared_playback_state(); + out.fill(Frame::ZERO); + return; + } + + // update parameters + self.volume.update(dt * out.len() as f64, info); + self.playback_rate.update(dt * out.len() as f64, info); + self.panning.update(dt * out.len() as f64, info); + let changed_playback_state = self + .playback_state_manager + .update(dt * out.len() as f64, info); + if changed_playback_state { + self.update_shared_playback_state(); + } + + let will_never_start = self.start_time.update(dt * out.len() as f64, info); + if will_never_start { + self.playback_state_manager.mark_as_stopped(); + self.update_shared_playback_state(); + } + if self.start_time != StartTime::Immediate { + out.fill(Frame::ZERO); + return; + } + + if !self.playback_state_manager.playback_state().is_advancing() { + out.fill(Frame::ZERO); + return; + } + // pause playback while waiting for audio data. the first frame + // in the ringbuffer is the previous frame, so we need to make + // sure there's at least 2 before we continue playing. + if self.frame_consumer.slots() < 2 && !self.shared.reached_end() { + out.fill(Frame::ZERO); + return; + } + + let num_frames = out.len(); + for (i, frame) in out.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let volume = self.volume.interpolated_value(time_in_chunk).as_amplitude(); + let fade_volume = self + .playback_state_manager + .interpolated_fade_volume(time_in_chunk) + .as_amplitude(); + let panning = self.panning.interpolated_value(time_in_chunk); + let playback_rate = self.playback_rate.interpolated_value(time_in_chunk); + let next_frames = self.next_frames(); + let interpolated_out = interpolate_frame( + next_frames[0], + next_frames[1], + next_frames[2], + next_frames[3], + self.fractional_position as f32, + ); + self.fractional_position += self.sample_rate as f64 * playback_rate.0.max(0.0) * dt; + while self.fractional_position >= 1.0 { + self.fractional_position -= 1.0; + self.frame_consumer.pop().ok(); + } + if self.shared.reached_end() && self.frame_consumer.is_empty() { + self.playback_state_manager.mark_as_stopped(); + self.update_shared_playback_state(); + } + *frame = (interpolated_out * fade_volume * volume).panned(panning); + } + } + + fn finished(&self) -> bool { + self.playback_state_manager.playback_state() == PlaybackState::Stopped + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct TimestampedFrame { + frame: Frame, + index: usize, +} diff --git a/third_party/kira/src/sound/streaming/sound/decode_scheduler.rs b/third_party/kira/src/sound/streaming/sound/decode_scheduler.rs new file mode 100644 index 00000000..58dae449 --- /dev/null +++ b/third_party/kira/src/sound/streaming/sound/decode_scheduler.rs @@ -0,0 +1,216 @@ +use std::{ + sync::{Arc, atomic::Ordering}, + time::Duration, +}; + +use crate::{ + frame::Frame, + sound::{ + PlaybackState, + streaming::{DecodeSchedulerCommandReaders, StreamingSoundSettings, decoder::Decoder}, + transport::Transport, + }, +}; +use rtrb::{Consumer, Producer, RingBuffer}; + +use super::{Shared, TimestampedFrame}; + +const BUFFER_SIZE: usize = 16_384; +const DECODER_THREAD_SLEEP_DURATION: Duration = Duration::from_millis(1); + +pub(crate) enum NextStep { + Continue, + Wait, + End, +} + +pub(crate) struct DecodeScheduler { + decoder: Box>, + sample_rate: u32, + slice: Option<(usize, usize)>, + num_frames: usize, + transport: Transport, + decoder_current_frame_index: usize, + decoded_chunk: Option, + command_readers: DecodeSchedulerCommandReaders, + frame_producer: Producer, + error_producer: Producer, + shared: Arc, +} + +impl DecodeScheduler { + pub(crate) fn new( + mut decoder: Box>, + slice: Option<(usize, usize)>, + settings: StreamingSoundSettings, + shared: Arc, + command_readers: DecodeSchedulerCommandReaders, + error_producer: Producer, + ) -> Result<(Self, Consumer), Error> { + let (mut frame_producer, frame_consumer) = RingBuffer::new(BUFFER_SIZE); + // pre-seed the frame ringbuffer with a zero frame. this is the "previous" frame + // when the sound just started. + frame_producer + .push(TimestampedFrame { + frame: Frame::ZERO, + index: 0, + }) + .expect("The frame producer shouldn't be full because we just created it"); + let sample_rate = decoder.sample_rate(); + let num_frames = if let Some((start, end)) = slice { + end - start + } else { + decoder.num_frames() + }; + let start_position = settings.start_position.into_samples(sample_rate); + let decoder_current_frame_index = decoder.seek(start_position)?; + let scheduler = Self { + decoder, + sample_rate, + slice, + num_frames, + transport: Transport::new( + start_position, + settings.loop_region, + false, + sample_rate, + num_frames, + ), + decoder_current_frame_index, + decoded_chunk: None, + command_readers, + frame_producer, + error_producer, + shared, + }; + Ok((scheduler, frame_consumer)) + } + + #[must_use] + pub fn current_frame(&self) -> usize { + self.transport.position + } + + pub fn start(mut self) { + std::thread::spawn(move || { + loop { + match self.run() { + Ok(result) => match result { + NextStep::Continue => {} + NextStep::Wait => std::thread::sleep(DECODER_THREAD_SLEEP_DURATION), + NextStep::End => break, + }, + Err(error) => { + self.error_producer.push(error).ok(); + self.shared.encountered_error.store(true, Ordering::SeqCst); + } + } + } + }); + } + + pub fn run(&mut self) -> Result { + // if the sound was manually stopped, end the thread + if self.shared.state() == PlaybackState::Stopped { + return Ok(NextStep::End); + } + // if the frame ringbuffer is full, sleep for a bit + if self.frame_producer.is_full() { + return Ok(NextStep::Wait); + } + // check for commands + if let Some(loop_region) = self.command_readers.set_loop_region.read() { + self.transport + .set_loop_region(loop_region, self.sample_rate, self.num_frames); + } + if let Some(amount) = self.command_readers.seek_by.read() { + self.seek_by(amount)?; + } + if let Some(position) = self.command_readers.seek_to.read() { + self.seek_to(position)?; + } + let frame = self.frame_at_index(self.transport.position)?; + self.frame_producer + .push(TimestampedFrame { + frame, + index: self.transport.position, + }) + .expect("could not push frame to frame producer"); + self.transport.increment_position(self.num_frames); + if !self.transport.playing { + self.shared.reached_end.store(true, Ordering::SeqCst); + return Ok(NextStep::End); + } + Ok(NextStep::Continue) + } + + fn frame_at_index(&mut self, index: usize) -> Result { + let start = self.slice.map(|(start, _)| start).unwrap_or(0); + let end = self.slice.map(|(_, end)| end).unwrap_or(self.num_frames); + if index >= end - start { + return Ok(Frame::ZERO); + } + let index = start + index; + // if the requested frame is already loaded, return it + if let Some(chunk) = &self.decoded_chunk { + if let Some(frame) = chunk.frame_at_index(index) { + return Ok(frame); + } + } + /* + otherwise, seek to the requested index and decode chunks sequentially + until we get the frame we want. just because we seek to an index does + not mean the next decoded chunk will have the frame we want (or any frame + at all, for that matter), so we may need to decode multiple chunks to + get the frame we care about. + */ + if index < self.decoder_current_frame_index { + self.decoder_current_frame_index = self.decoder.seek(index)?; + } + loop { + let decoded_chunk = DecodedChunk { + start_index: self.decoder_current_frame_index, + frames: self.decoder.decode()?, + }; + self.decoder_current_frame_index += decoded_chunk.frames.len(); + self.decoded_chunk = Some(decoded_chunk); + if let Some(chunk) = &self.decoded_chunk { + if let Some(frame) = chunk.frame_at_index(index) { + return Ok(frame); + } + } + } + } + + fn seek_to(&mut self, position: f64) -> Result<(), Error> { + let index = (position * self.sample_rate as f64).round() as usize; + self.seek_to_index(index)?; + Ok(()) + } + + fn seek_by(&mut self, amount: f64) -> Result<(), Error> { + let position = self.shared.position() + amount; + self.seek_to(position)?; + Ok(()) + } + + fn seek_to_index(&mut self, index: usize) -> Result<(), Error> { + self.transport.seek_to(index, self.num_frames); + self.decoder_current_frame_index = self.decoder.seek(index)?; + Ok(()) + } +} + +struct DecodedChunk { + pub start_index: usize, + pub frames: Vec, +} + +impl DecodedChunk { + fn frame_at_index(&self, index: usize) -> Option { + if index < self.start_index { + return None; + } + self.frames.get(index - self.start_index).copied() + } +} diff --git a/third_party/kira/src/sound/streaming/sound/test.rs b/third_party/kira/src/sound/streaming/sound/test.rs new file mode 100644 index 00000000..81293fc8 --- /dev/null +++ b/third_party/kira/src/sound/streaming/sound/test.rs @@ -0,0 +1,878 @@ +use std::time::Duration; + +use crate::{ + Decibels, Panning, StartTime, Tween, + clock::ClockTime, + frame::Frame, + info::MockInfoBuilder, + sound::{ + PlaybackState, Sound, + streaming::{StreamingSoundData, StreamingSoundSettings, decoder::mock::MockDecoder}, + }, + test_helpers::expect_frame_soon, +}; + +use super::decode_scheduler::NextStep; + +/// Tests that a `StreamingSound` will play all of its samples before finishing. +#[test] +fn plays_all_samples() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![ + Frame::from_mono(1.0), + Frame::from_mono(2.0), + Frame::from_mono(3.0), + ])), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert!(!sound.finished()); + + for i in 1..=3 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + assert!(!sound.finished()); + } + + // give some time for the resample buffer to empty. in the meantime we should + // get silent output. + for _ in 0..10 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + } + + // the sound should be finished and stopped by now + assert!(sound.finished()); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Stopped + ); +} + +/// Tests that a `StreamingSound` will pause playback while waiting +/// for samples from the decoder. +#[test] +#[allow(clippy::float_cmp)] +fn waits_for_samples() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (1..=10).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::default(), + slice: None, + }; + let (mut sound, handle, mut scheduler) = data.split().unwrap(); + + for _ in 0..3 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::ZERO.panned(Panning::CENTER) + ); + sound.on_start_processing(); + assert_eq!(handle.position(), 0.0); + } + + for _ in 0..3 { + scheduler.run().unwrap(); + } + + for i in 1..=3 { + assert_eq!(handle.position(), (i - 1) as f64); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + sound.on_start_processing(); + } + + for _ in 0..3 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::ZERO.panned(Panning::CENTER) + ); + sound.on_start_processing(); + assert_eq!(handle.position(), 2.0); + } +} + +/// Tests that a `StreamingSound` correctly reports its playback state +/// to be queried by StreamingSoundHandle::state. +#[test] +fn reports_playback_state() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(0.0); 10])), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + for _ in 0..20 { + assert_eq!( + handle.state(), + sound.playback_state_manager.playback_state() + ); + sound.process_one(1.0, &MockInfoBuilder::new().build()); + } +} + +/// Tests that a `StreamingSound` correctly reports its playback state +/// to be queried by StreamingSoundHandle::state. +#[test] +#[allow(clippy::float_cmp)] +fn reports_playback_position() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(0.0); 10])), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + for i in 0..20 { + assert_eq!(handle.position(), i.clamp(0, 9) as f64); + sound.process_one(1.0, &MockInfoBuilder::new().build()); + sound.on_start_processing(); + } +} + +/// Tests that a `StreamingSound` fades out fully before pausing +/// and fades back in when resuming. +#[test] +#[allow(clippy::float_cmp)] +fn pauses_and_resumes_with_fades() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(1.0); 100])), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + sound.process_one(1.0, &MockInfoBuilder::new().build()); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Playing + ); + + handle.pause(Tween { + duration: Duration::from_secs(4), + ..Default::default() + }); + sound.on_start_processing(); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Pausing + ); + + // allow for a few samples of delay because of the resampling, but the + // sound should fade out soon. + expect_frame_soon( + Frame::from_mono(Decibels(-15.0).as_amplitude()).panned(Panning::CENTER), + &mut sound, + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-30.0).as_amplitude()).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-45.0).as_amplitude()).panned(Panning::CENTER) + ); + sound.process_one(1.0, &MockInfoBuilder::new().build()); + + sound.on_start_processing(); + let position = handle.position(); + for _ in 0..10 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + sound.on_start_processing(); + assert_eq!(handle.position(), position); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Paused + ); + } + + handle.resume(Tween { + duration: Duration::from_secs(4), + ..Default::default() + }); + sound.on_start_processing(); + + // allow for a few samples of delay because of the resampling, but the + // sound should fade back in soon. + expect_frame_soon( + Frame::from_mono(Decibels(-45.0).as_amplitude()).panned(Panning::CENTER), + &mut sound, + ); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Resuming + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-30.0).as_amplitude()).panned(Panning::CENTER) + ); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Resuming + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-15.0).as_amplitude()).panned(Panning::CENTER) + ); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Resuming + ); + + for _ in 0..3 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Playing + ); + } + + let mut frames = vec![Frame::ZERO; 3]; + sound.process(&mut frames, 1.0, &MockInfoBuilder::new().build()); + assert_eq!( + frames, + vec![Frame::from_mono(1.0).panned(Panning::CENTER); 3] + ); +} + +/// Tests that a `StreamingSound` stops and finishes after a fade-out. +#[test] +#[allow(clippy::float_cmp)] +fn stops_with_fade_out() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(1.0); 100])), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + sound.process_one(1.0, &MockInfoBuilder::new().build()); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Playing + ); + + handle.stop(Tween { + duration: Duration::from_secs(4), + ..Default::default() + }); + sound.on_start_processing(); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Stopping + ); + + // allow for a few samples of delay because of the resampling, but the + // sound should fade out soon. + expect_frame_soon( + Frame::from_mono(Decibels(-15.0).as_amplitude()).panned(Panning::CENTER), + &mut sound, + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-30.0).as_amplitude()).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-45.0).as_amplitude()).panned(Panning::CENTER) + ); + sound.process_one(1.0, &MockInfoBuilder::new().build()); + + sound.on_start_processing(); + let position = handle.position(); + for _ in 0..3 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + sound.on_start_processing(); + assert_eq!(handle.position(), position); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Stopped + ); + assert!(sound.finished()); + } +} + +/// Tests that a `StreamingSound` will wait for its start clock time +/// when appropriate. +#[test] +#[allow(clippy::float_cmp)] +fn waits_for_start_time() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id_1 = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (1..100).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new().start_time(ClockTime { + clock: clock_id_1, + ticks: 2, + fraction: 0.0, + }), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + // the sound should not be playing yet + for _ in 0..3 { + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(0.0)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.add_clock(true, 0, 0.0); + builder.build() + }; + + // the sound is set to start at tick 2, so it should not + // play yet + for _ in 0..3 { + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(0.0)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.add_clock(true, 2, 0.0); + builder.build() + }; + + // a different clock reached tick 2, so the sound should + // not play yet + for _ in 0..3 { + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(0.0)); + } + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 2, 0.0); + builder.add_clock(true, 2, 0.0); + builder.build() + }; + + // the sound should start playing now + for i in 1..10 { + assert_eq!( + sound.process_one(1.0, &info), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + } +} + +/// Tests that a `StreamingSound` will stop (allowing it to be removed) +/// if it's waiting on a clock that no longer exists before it can +/// start. +/// +/// Without this behavior, a sound in this situation would be in limbo +/// forever with no way to free up its resources. +#[test] +fn stops_if_waiting_on_missing_clock() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (1..100).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new().start_time(ClockTime { + clock: clock_id, + ticks: 2, + fraction: 0.0, + }), + slice: None, + }; + let (mut sound, handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Playing); + + // the clock is removed + let info = MockInfoBuilder::new().build(); + + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Stopped); + assert!(sound.finished()); +} + +/// Tests that a `StreamingSound` that had its start time set to a clock time and already +/// started will not stop if the clock stops. +#[test] +fn continues_when_clock_stops() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (1..100).map(|_| Frame::from_mono(1.0)).collect(), + )), + settings: StreamingSoundSettings::new().start_time(ClockTime { + clock: clock_id, + ticks: 0, + fraction: 0.0, + }), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(1.0),); + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(false, 0, 0.0); + builder.build() + }; + + assert_eq!(sound.process_one(1.0, &info), Frame::from_mono(1.0),); +} + +/// Tests that a `StreamingSound` can be paused, resumed, and stopped immediately +/// even if it's waiting for its start time. +#[test] +fn immediate_playback_state_change_with_start_time() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (1..100).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new().start_time(ClockTime { + clock: clock_id, + ticks: 2, + fraction: 0.0, + }), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + handle.pause(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Paused); + + handle.resume(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Playing); + + handle.stop(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Stopped); +} + +/// Tests that a `StreamingSound` can be set to resume at a certain start time. +#[test] +fn resume_at() { + let mut info_builder = MockInfoBuilder::new(); + let clock_id = info_builder.add_clock(true, 0, 0.0); + let info = info_builder.build(); + + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (1..100).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + handle.pause(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Paused); + + handle.resume_at( + StartTime::ClockTime(ClockTime { + clock: clock_id, + ticks: 1, + fraction: 0.0, + }), + Tween { + duration: Duration::from_secs(1), + ..Default::default() + }, + ); + sound.on_start_processing(); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::WaitingToResume); + + let info = { + let mut builder = MockInfoBuilder::new(); + builder.add_clock(true, 1, 0.0); + builder.build() + }; + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Resuming); + sound.process_one(1.0, &info); + sound.on_start_processing(); + assert_eq!(handle.state(), PlaybackState::Playing); +} + +/// Tests that a `StreamingSound` can be started partway through the sound. +#[test] +#[allow(clippy::float_cmp)] +fn start_position() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new().start_position(3.0), + slice: None, + }; + let (mut sound, handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + for i in 3..=6 { + assert_eq!(handle.position(), i as f64); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + sound.on_start_processing(); + } +} + +/// Tests that a `StreamingSound` properly obeys looping behavior when +/// playing forward. +#[test] +#[allow(clippy::float_cmp)] +fn loops_forward() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new().loop_region(Some((3.0..6.0).into())), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + for i in 0..6 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + } + + assert_eq!( + sound.process_one(2.0, &MockInfoBuilder::new().build()), + Frame::from_mono(3.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(2.0, &MockInfoBuilder::new().build()), + Frame::from_mono(5.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(2.0, &MockInfoBuilder::new().build()), + Frame::from_mono(4.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(2.0, &MockInfoBuilder::new().build()), + Frame::from_mono(3.0).panned(Panning::CENTER) + ); +} + +/// Tests that the volume of a `StreamingSound` can be adjusted. +#[test] +#[allow(clippy::float_cmp)] +fn volume() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(1.0); 10])), + settings: StreamingSoundSettings::new().volume(-6.0), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(Decibels(-6.0).as_amplitude()).panned(Panning::CENTER) + ); +} + +/// Tests that the volume of a `StreamingSound` can be changed +/// after the sound is started. +#[test] +fn set_volume() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(1.0); 100])), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::CENTER) + ); + handle.set_volume( + -6.0, + Tween { + duration: Duration::ZERO, + ..Default::default() + }, + ); + sound.on_start_processing(); + expect_frame_soon( + Frame::from_mono(Decibels(-6.0).as_amplitude()).panned(Panning::CENTER), + &mut sound, + ); +} + +/// Tests that the panning of a `StreamingSound` can be adjusted. +#[test] +#[allow(clippy::float_cmp)] +fn panning() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(1.0); 10])), + settings: StreamingSoundSettings::new().panning(Panning::LEFT), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::LEFT) + ); +} + +/// Tests that the panning of a `StreamingSound` can be changed +/// after the sound is started. +#[test] +fn set_panning() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![Frame::from_mono(1.0); 100])), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::CENTER) + ); + handle.set_panning( + Panning::LEFT, + Tween { + duration: Duration::ZERO, + ..Default::default() + }, + ); + sound.on_start_processing(); + expect_frame_soon(Frame::from_mono(1.0).panned(Panning::LEFT), &mut sound); +} + +/// Tests that the playback rate of a `StreamingSound` can be adjusted. +#[test] +#[allow(clippy::float_cmp)] +fn playback_rate() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (0..10).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new().playback_rate(2.0), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(2.0).panned(Panning::CENTER) + ); +} + +/// Tests that the playback rate of a `StreamingSound` can be adjusted after +/// it's started. +#[test] +#[allow(clippy::float_cmp)] +fn set_playback_rate() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (0..100).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(1.0).panned(Panning::CENTER) + ); + + handle.set_playback_rate( + 2.0, + Tween { + duration: Duration::ZERO, + ..Default::default() + }, + ); + sound.on_start_processing(); + + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(2.0).panned(Panning::CENTER) + ); + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(4.0).panned(Panning::CENTER) + ); +} + +/// Tests that a `StreamingSound` outputs interpolated samples when +/// its playback position is between samples. +#[test] +fn interpolates_samples() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![ + Frame::from_mono(0.0), + Frame::from_mono(1.0), + Frame::from_mono(1.0), + Frame::from_mono(1.0), + Frame::from_mono(1.0), + Frame::from_mono(1.0), + Frame::from_mono(-10.0), + ])), + settings: Default::default(), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + assert_eq!( + sound.process_one(0.5, &MockInfoBuilder::new().build()), + Frame::from_mono(0.0).panned(Panning::CENTER) + ); + // at sample 0.5, the output should be somewhere between 0 and 1. + // i don't care what exactly, that's up the to the interpolation algorithm. + let frame = sound.process_one(5.0, &MockInfoBuilder::new().build()); + assert!(frame.left > 0.0 && frame.left < 1.0); + // at sample 5.5, the output should be between 1 and -10. + let frame = sound.process_one(1.0, &MockInfoBuilder::new().build()); + assert!(frame.left < 0.0 && frame.left > -10.0); +} + +/// Tests that a `StreamingSound` outputs interpolated samples correctly +/// when looping. +#[test] +fn interpolates_samples_when_looping() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new(vec![ + Frame::from_mono(10.0), + Frame::from_mono(9.0), + ])), + settings: StreamingSoundSettings::new().loop_region(Some((..).into())), + slice: None, + }; + let (mut sound, _, mut scheduler) = data.split().unwrap(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + + sound.process_one(1.5, &MockInfoBuilder::new().build()); + // because we're looping back to the first sample, which is 10.0, + // the interpolated sample should be be tween 9.0 and 10.0 + let frame = sound.process_one(1.0, &MockInfoBuilder::new().build()); + assert!(frame.left > 9.0 && frame.left < 10.0); +} + +/// Tests that a `StreamingSound` can seek to a position. +#[test] +fn seek_to() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (0..100).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + + handle.seek_to(15.0); + sound.on_start_processing(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + expect_frame_soon(Frame::from_mono(15.0).panned(Panning::CENTER), &mut sound); +} + +/// Tests that a `StreamingSound` can seek by an amount of time. +#[test] +fn seek_by() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (0..100).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new().start_position(10.0), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + handle.seek_by(5.0); + sound.on_start_processing(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + expect_frame_soon(Frame::from_mono(20.0).panned(Panning::CENTER), &mut sound); +} diff --git a/third_party/kira/src/sound/symphonia.rs b/third_party/kira/src/sound/symphonia.rs new file mode 100644 index 00000000..f642a6b0 --- /dev/null +++ b/third_party/kira/src/sound/symphonia.rs @@ -0,0 +1,46 @@ +use symphonia::core::{ + audio::{AudioBuffer, AudioBufferRef, Signal}, + conv::{FromSample, IntoSample}, + sample::Sample, +}; + +use crate::frame::Frame; + +use super::FromFileError; + +pub fn load_frames_from_buffer_ref(buffer: &AudioBufferRef) -> Result, FromFileError> { + match buffer { + AudioBufferRef::U8(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::U16(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::U24(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::U32(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::S8(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::S16(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::S24(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::S32(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::F32(buffer) => load_frames_from_buffer(buffer), + AudioBufferRef::F64(buffer) => load_frames_from_buffer(buffer), + } +} + +pub fn load_frames_from_buffer( + buffer: &AudioBuffer, +) -> Result, FromFileError> +where + f32: FromSample, +{ + match buffer.spec().channels.count() { + 1 => Ok(buffer + .chan(0) + .iter() + .map(|sample| Frame::from_mono((*sample).into_sample())) + .collect()), + 2 => Ok(buffer + .chan(0) + .iter() + .zip(buffer.chan(1).iter()) + .map(|(left, right)| Frame::new((*left).into_sample(), (*right).into_sample())) + .collect()), + _ => Err(FromFileError::UnsupportedChannelConfiguration), + } +} diff --git a/third_party/kira/src/sound/transport.rs b/third_party/kira/src/sound/transport.rs new file mode 100644 index 00000000..f349b78e --- /dev/null +++ b/third_party/kira/src/sound/transport.rs @@ -0,0 +1,106 @@ +use super::{EndPosition, Region}; + +#[cfg(test)] +mod test; + +pub struct Transport { + pub position: usize, + /// The start and end frames of the sound that should be looped. The upper bound + /// is *exclusive*. + pub loop_region: Option<(usize, usize)>, + pub playing: bool, +} + +impl Transport { + #[must_use] + pub fn new( + start_position: usize, + loop_region: Option, + reverse: bool, + sample_rate: u32, + num_frames: usize, + ) -> Self { + let loop_region = loop_region.map(|loop_region| { + let loop_start = loop_region.start.into_samples(sample_rate); + let loop_end = match loop_region.end { + EndPosition::EndOfAudio => num_frames, + EndPosition::Custom(end_position) => end_position.into_samples(sample_rate), + }; + (loop_start, loop_end) + }); + Self { + position: if reverse { + num_frames - 1 - start_position + } else { + start_position + }, + loop_region, + playing: true, + } + } + + pub fn set_loop_region( + &mut self, + loop_region: Option, + sample_rate: u32, + num_frames: usize, + ) { + self.loop_region = loop_region.map(|loop_region| { + let loop_start = loop_region.start.into_samples(sample_rate); + let loop_end = match loop_region.end { + EndPosition::EndOfAudio => num_frames, + EndPosition::Custom(end_position) => end_position.into_samples(sample_rate), + }; + (loop_start, loop_end) + }); + } + + pub fn increment_position(&mut self, num_frames: usize) { + if !self.playing { + return; + } + self.position += 1; + if let Some((loop_start, loop_end)) = self.loop_region { + while self.position >= loop_end { + self.position -= loop_end - loop_start; + } + } + if self.position >= num_frames { + self.playing = false; + } + } + + pub fn decrement_position(&mut self) { + if !self.playing { + return; + } + if let Some((loop_start, loop_end)) = self.loop_region { + while self.position <= loop_start { + self.position += loop_end - loop_start; + } + } + if self.position == 0 { + self.playing = false; + } else { + self.position -= 1; + } + } + + pub fn seek_to(&mut self, mut position: usize, num_frames: usize) { + if let Some((loop_start, loop_end)) = self.loop_region { + if position > self.position { + while position >= loop_end { + position -= loop_end - loop_start; + } + } else { + while position < loop_start { + position += loop_end - loop_start; + } + } + } + self.position = position; + if self.position >= num_frames { + self.playing = false; + } + } +} diff --git a/third_party/kira/src/sound/transport/test.rs b/third_party/kira/src/sound/transport/test.rs new file mode 100644 index 00000000..b5edbf6a --- /dev/null +++ b/third_party/kira/src/sound/transport/test.rs @@ -0,0 +1,107 @@ +use super::Transport; + +#[test] +fn stops_at_end() { + let mut transport = Transport { + position: 2, + loop_region: None, + playing: true, + }; + for i in 2..4 { + assert_eq!(transport.position, i); + assert!(transport.playing); + transport.increment_position(4); + } + assert_eq!(transport.position, 4); + assert!(!transport.playing); +} + +#[test] +fn stops_at_start_when_playing_backwards() { + let mut transport = Transport { + position: 2, + loop_region: None, + playing: true, + }; + for i in (0..=2).rev() { + assert_eq!(transport.position, i); + assert!(transport.playing); + transport.decrement_position(); + } + assert_eq!(transport.position, 0); + assert!(!transport.playing); +} + +#[test] +fn loops() { + let mut transport = Transport { + position: 0, + loop_region: Some((2, 5)), + playing: true, + }; + for i in 0..5 { + assert_eq!(transport.position, i); + assert!(transport.playing); + transport.increment_position(10); + } + for i in 2..5 { + assert_eq!(transport.position, i); + assert!(transport.playing); + transport.increment_position(10); + } +} + +#[test] +fn loops_when_playing_backward() { + let mut transport = Transport { + position: 0, + loop_region: Some((2, 5)), + playing: true, + }; + transport.position = 10; + for i in (2..=10).rev() { + assert_eq!(transport.position, i); + assert!(transport.playing); + transport.decrement_position(); + } + for i in (2..5).rev() { + assert_eq!(transport.position, i); + assert!(transport.playing); + transport.decrement_position(); + } +} + +#[test] +fn loop_wrapping() { + let mut transport = Transport { + position: 0, + loop_region: Some((2, 5)), + playing: true, + }; + transport.position = 6; + transport.increment_position(10); + assert_eq!(transport.position, 4); + transport.position = 1; + transport.decrement_position(); + assert_eq!(transport.position, 3); +} + +#[test] +fn seek_loop_wrapping() { + let mut transport = Transport { + position: 0, + loop_region: Some((2, 5)), + playing: true, + }; + transport.seek_to(7, 10); + assert_eq!(transport.position, 4); + transport.seek_to(0, 10); + assert_eq!(transport.position, 3); +} + +#[test] +fn seek_out_of_bounds() { + let mut transport = Transport::new(5, None, false, 1, 10); + transport.seek_to(10, 10); + assert!(!transport.playing); +} diff --git a/third_party/kira/src/start_time.rs b/third_party/kira/src/start_time.rs new file mode 100644 index 00000000..faffe95f --- /dev/null +++ b/third_party/kira/src/start_time.rs @@ -0,0 +1,55 @@ +use std::time::Duration; + +use crate::{ + clock::ClockTime, + info::{Info, WhenToStart}, +}; + +/// Describes when an action should occur. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum StartTime { + /// The action should occur immediately. + #[default] + Immediate, + /// The action should occur a certain amount of time from now. + Delayed(Duration), + /// The action should occur when a clock reaches a + /// specific time. + ClockTime(ClockTime), +} + +impl StartTime { + pub(crate) fn update(&mut self, dt: f64, info: &Info) -> WillNeverStart { + match self { + StartTime::Immediate => {} + StartTime::Delayed(time_remaining) => { + *time_remaining = time_remaining.saturating_sub(Duration::from_secs_f64(dt)); + if time_remaining.is_zero() { + *self = StartTime::Immediate; + } + } + StartTime::ClockTime(clock_time) => match info.when_to_start(*clock_time) { + WhenToStart::Now => { + *self = StartTime::Immediate; + } + WhenToStart::Later => {} + WhenToStart::Never => return true, + }, + } + false + } +} + +pub(crate) type WillNeverStart = bool; + +impl From for StartTime { + fn from(v: Duration) -> Self { + Self::Delayed(v) + } +} + +impl From for StartTime { + fn from(v: ClockTime) -> Self { + Self::ClockTime(v) + } +} diff --git a/third_party/kira/src/test_helpers.rs b/third_party/kira/src/test_helpers.rs new file mode 100644 index 00000000..f19a7032 --- /dev/null +++ b/third_party/kira/src/test_helpers.rs @@ -0,0 +1,17 @@ +use crate::{Frame, info::MockInfoBuilder, sound::Sound}; + +pub fn expect_frame_soon(expected_frame: Frame, sound: &mut dyn Sound) { + const NUM_SAMPLES_TO_WAIT: usize = 10; + let mut collected_samples = vec![]; + for _ in 0..NUM_SAMPLES_TO_WAIT { + let frame = sound.process_one(1.0, &MockInfoBuilder::new().build()); + if frame == expected_frame { + return; + } + collected_samples.push(frame); + } + panic!( + "Sound did not output frame with value {:?} within {} samples. Recent samples: {:#?}", + expected_frame, NUM_SAMPLES_TO_WAIT, collected_samples + ); +} diff --git a/third_party/kira/src/track.rs b/third_party/kira/src/track.rs new file mode 100644 index 00000000..92c7a2b3 --- /dev/null +++ b/third_party/kira/src/track.rs @@ -0,0 +1,364 @@ +/*! +Organizes and applies effects to audio. + +Kira has an internal mixer which works like a real-life mixing console. Sounds +can be played on "tracks", which are individual streams of audio that can +optionally have effects that modify the audio. + +Tracks can also be spatialized, which gives them a position in a 3D space +relative to a [listener](crate::listener). The distance from the listener +can be used to drive settings on effects on that track. + +## Creating and using tracks + +The mixer has a "main" track by default, and you can add any number of +sub-tracks. To add a sub-track, use +[`AudioManager::add_sub_track`](crate::AudioManager::add_sub_track). + +```no_run +# use std::error::Error; +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + track::TrackBuilder, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let mut track = manager.add_sub_track(TrackBuilder::default())?; +# Result::<(), Box>::Ok(()) +``` + +To play a sound on the track, use [`TrackHandle::play`]. + +```no_run +# use std::error::Error; +# use kira::{ +# AudioManager, AudioManagerSettings, backend::DefaultBackend, +# track::TrackBuilder, +# }; +use kira::sound::static_sound::StaticSoundData; + +# let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +# let mut track = manager.add_sub_track(TrackBuilder::default())?; +track.play(StaticSoundData::from_file("sound.ogg")?)?; +# Result::<(), Box>::Ok(()) +``` + +Tracks can themselves have sub-tracks. You can create them using [`TrackHandle::add_sub_track`]. + +You can set the volume of a track using [`TrackHandle::set_volume`]. The volume +setting will affect all sounds being played on the track as well as all child tracks. + +You can pause all sounds (and child tracks) of a track using [`TrackHandle::pause`] +and resume them using [`TrackHandle::resume`] or [`TrackHandle::resume_at`]. + +## Effects + +You can add effects to the track when creating it using +[`TrackBuilder::with_effect`]. All sounds that are played on that track will have +the effects applied sequentially. + +In this example, we'll use the [filter](crate::effect::filter) effect, which in the +low pass mode will remove high frequencies from sounds, making them sound muffled. + +```no_run +# use std::error::Error; +use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + sound::static_sound::{StaticSoundData, StaticSoundSettings}, + track::TrackBuilder, + effect::filter::FilterBuilder, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let mut track = manager.add_sub_track( + TrackBuilder::new() + .with_effect(FilterBuilder::new().cutoff(1000.0)) +)?; +track.play(StaticSoundData::from_file("sound.ogg")?)?; +# Result::<(), Box>::Ok(()) +``` + +[`TrackBuilder::add_effect`] returns a handle that can be used to modify the effect +after the track has been created. + +```no_run +# use std::error::Error; +# use kira::{ +# AudioManager, AudioManagerSettings, DefaultBackend, +# sound::static_sound::{StaticSoundData, StaticSoundSettings}, +# track::TrackBuilder, +# effect::filter::FilterBuilder, +# Tween, +# }; +# let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let mut filter; +let track = manager.add_sub_track({ + let mut builder = TrackBuilder::new(); + filter = builder.add_effect(FilterBuilder::new().cutoff(1000.0)); + builder +})?; +filter.set_cutoff(4000.0, Tween::default()); +# Result::<(), Box>::Ok(()) +``` + +## Send tracks + +Sometimes, you may want to share effects across multiple tracks. For example, let's say +we have a game with a player and an enemy that both make sounds, so we have a "player" +track and an "enemy" track. We want sounds for both of these tracks to have reverb +applied. We could add a separate reverb effect to both the player and enemy track, +but there's a couple reasons this isn't an optimal solution: + +- Since the player and enemy are in the same space, they should be using the same + reverb settings. But if we have two reverb effects, we're duplicating the settings. +- Having more effects takes more CPU time, and it's wasteful in this case because + both reverb effects are doing the same thing. + +This is where send tracks come in handy. Send tracks are non-hierarchical mixer tracks +which you can't play sounds on directly - instead, regular mixer tracks can have their +output sent to the input of one or more send tracks. The output of the send tracks +is then sent to the main mixer track. + +In the following example, we'll set up mixer tracks to have the following flow of audio: + +```text +┌──────┐ ┌──────────┐ +│ ├─────────► │ +│Player├───────┐ │Main track│ +│ │ ┌─────┼─► │ +└──────┘ │ │ └─────▲────┘ +┌──────┐ │ ┌───▼──┐ │ +│ ├─┘ │ │ │ +│Enemy ├───►Reverb├────┘ +│ │ │(send)│ +└──────┘ └──────┘ +``` + +To start, we'll create the reverb send with +[`AudioManager::add_send_track`](crate::AudioManager::add_send_track): + +```no_run +# use std::error::Error; +use kira::{ + effect::reverb::ReverbBuilder, + AudioManager, AudioManagerSettings, DefaultBackend, + track::SendTrackBuilder, +}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let reverb_send = manager + .add_send_track(SendTrackBuilder::new().with_effect(ReverbBuilder::new().mix(1.0)))?; +# Result::<(), Box>::Ok(()) +``` + +Note that we set the mix of the reverb to `1.0`, meaning only the reverberations will be output, +not the dry signal. This is important because the player and enemy tracks will already +be outputting the dry signal. If the reverb effect was also outputting the dry signal, the +overall volume of the sound would be louder than we want. + +Next, we'll create the player and enemy tracks and route them to the reverb send using +[`TrackBuilder::with_send`]: + +```no_run +# use std::error::Error; +use kira::track::TrackBuilder; +# use kira::{ +# effect::reverb::ReverbBuilder, +# AudioManager, AudioManagerSettings, DefaultBackend, +# track::SendTrackBuilder, +# }; + +# let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +# let reverb_send = manager +# .add_send_track(SendTrackBuilder::new().with_effect(ReverbBuilder::new().mix(1.0)))?; +let player_track = manager.add_sub_track(TrackBuilder::new().with_send(&reverb_send, -6.0))?; +let enemy_track = manager.add_sub_track(TrackBuilder::new().with_send(&reverb_send, -12.0))?; +# Result::<(), Box>::Ok(()) +``` + +We can use the second argument of `with_send` to change the volume of the track before sending +it to the send track. This allows the player and enemy to have different amounts of reverb +without having to instantiate two separate effects. + +## Spatial tracks + +Oftentimes, it’s useful to give sounds a location in a 3D (or 2D) space and play back those sounds +from the perspective of a character’s ears located somewhere else in that space. For example, as a +player character gets closer to a waterfall, you may want the sound of the waterfall to get louder. + +We can use **spatial tracks** as the sound source and [listener](crate::listener)s for the +character's ears. + +First, let's create a listener using +[`AudioManager::add_listener`](crate::AudioManager::add_listener): + +```no_run +# use std::error::Error; +use kira::{AudioManager, AudioManagerSettings, DefaultBackend}; + +let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +let listener = manager.add_listener(glam::Vec3::ZERO, glam::Quat::IDENTITY)?; +# Result::<(), Box>::Ok(()) +``` + +This example uses `glam`, but you can use any math library that has interoperability +with `mint`. + +Next, we'll create a spatial track that's linked to the listener using +[`AudioManager::add_spatial_sub_track`](crate::AudioManager::add_spatial_sub_track): + +```no_run +# use std::error::Error; +# use kira::{ +# AudioManager, AudioManagerSettings, DefaultBackend, +# }; +use kira::track::SpatialTrackBuilder; + +# let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +# let listener = manager.add_listener(glam::Vec3::ZERO, glam::Quat::IDENTITY)?; +let spatial_track = manager.add_spatial_sub_track( + &listener, + glam::vec3(0.0, 0.0, 10.0), // track position + SpatialTrackBuilder::new(), +)?; +# Result::<(), Box>::Ok(()) +``` + +Now any sounds played on the spatial track will automatically have the following behaviors: + +- Attenuation: sounds will get quieter the farther away they are from the listener +- Spatialization: sounds will be panned left or right depending on their direction from + the listener + +We can customize or disable these behaviors using the methods on [`SpatialTrackBuilder`]. + +We can also map any effect setting that uses `Value` to the distance between the spatial +track and the listener using `Value::FromListenerDistance`. One common use for this is to +change the amount of reverb a sound has based on distance: + +```no_run +# use std::error::Error; +use kira::{ + effect::reverb::ReverbBuilder, + Easing, Mapping, Value, + Mix, +}; +# use kira::{ +# AudioManager, AudioManagerSettings, DefaultBackend, +# track::SpatialTrackBuilder, +# }; + +# let mut manager = AudioManager::::new(AudioManagerSettings::default())?; +# let listener = manager.add_listener(glam::Vec3::ZERO, glam::Quat::IDENTITY)?; +manager.add_spatial_sub_track( + &listener, + glam::vec3(0.0, 0.0, 10.0), + SpatialTrackBuilder::new().with_effect(ReverbBuilder::new().mix( + Value::FromListenerDistance( + Mapping { + input_range: (0.0, 100.0), + output_range: (Mix::DRY, Mix::WET), + easing: Easing::Linear, + }, + ), + )), +)?; +# Result::<(), Box>::Ok(()) +``` +*/ + +mod main; +mod send; +mod sub; + +pub use main::*; +pub use send::*; +pub use sub::*; + +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; + +use crate::sound::PlaybackState; + +#[derive(Debug)] +pub(crate) struct TrackShared { + state: AtomicU8, + removed: AtomicBool, +} + +impl TrackShared { + pub fn new() -> Self { + Self { + state: AtomicU8::new(TrackPlaybackState::Playing as u8), + removed: AtomicBool::new(false), + } + } + + pub fn state(&self) -> TrackPlaybackState { + match self.state.load(Ordering::SeqCst) { + 0 => TrackPlaybackState::Playing, + 1 => TrackPlaybackState::Pausing, + 2 => TrackPlaybackState::Paused, + 3 => TrackPlaybackState::WaitingToResume, + 4 => TrackPlaybackState::Resuming, + _ => panic!("Invalid playback state"), + } + } + + pub fn set_state(&self, playback_state: PlaybackState) { + self.state.store(playback_state as u8, Ordering::SeqCst); + } + + #[must_use] + pub fn is_marked_for_removal(&self) -> bool { + self.removed.load(Ordering::SeqCst) + } + + pub fn mark_for_removal(&self) { + self.removed.store(true, Ordering::SeqCst); + } +} + +/// The playback state of a mixer sub-track. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum TrackPlaybackState { + /// The track is playing normally. + Playing, + /// The track is fading out, and when the fade-out + /// is finished, playback will pause. + Pausing, + /// Playback is paused. + Paused, + /// The track is paused, but is schedule to resume in the future. + WaitingToResume, + /// The track is fading back in after being previously paused. + Resuming, +} + +impl TrackPlaybackState { + /// Whether the track is outputting audio given + /// its current playback state. + pub fn is_advancing(self) -> bool { + match self { + TrackPlaybackState::Playing => true, + TrackPlaybackState::Pausing => true, + TrackPlaybackState::Paused => false, + TrackPlaybackState::WaitingToResume => false, + TrackPlaybackState::Resuming => true, + } + } +} + +impl From for TrackPlaybackState { + fn from(value: PlaybackState) -> Self { + match value { + PlaybackState::Playing => Self::Playing, + PlaybackState::Pausing => Self::Pausing, + PlaybackState::Paused => Self::Paused, + PlaybackState::WaitingToResume => Self::WaitingToResume, + PlaybackState::Resuming => Self::Resuming, + PlaybackState::Stopping => unreachable!(), + PlaybackState::Stopped => unreachable!(), + } + } +} diff --git a/third_party/kira/src/track/main.rs b/third_party/kira/src/track/main.rs new file mode 100644 index 00000000..de0bc170 --- /dev/null +++ b/third_party/kira/src/track/main.rs @@ -0,0 +1,69 @@ +mod builder; +mod handle; + +pub use builder::*; +pub use handle::*; + +use crate::{ + Decibels, Frame, Parameter, + backend::resources::ResourceStorage, + command::{CommandReader, ValueChangeCommand}, + effect::Effect, + info::Info, + sound::Sound, +}; + +pub(crate) struct MainTrack { + volume: Parameter, + set_volume_command_reader: CommandReader>, + sounds: ResourceStorage>, + effects: Vec>, + temp_buffer: Vec, + internal_buffer_size: usize, +} + +impl MainTrack { + pub fn init_effects(&mut self, sample_rate: u32) { + for effect in &mut self.effects { + effect.init(sample_rate, self.internal_buffer_size); + } + } + + pub fn on_change_sample_rate(&mut self, sample_rate: u32) { + for effect in &mut self.effects { + effect.on_change_sample_rate(sample_rate); + } + } + + pub fn on_start_processing(&mut self) { + self.volume + .read_command(&mut self.set_volume_command_reader); + self.sounds.remove_and_add(|sound| sound.finished()); + for (_, sound) in &mut self.sounds { + sound.on_start_processing(); + } + for effect in &mut self.effects { + effect.on_start_processing(); + } + } + + pub fn process(&mut self, out: &mut [Frame], dt: f64, info: &Info) { + self.volume.update(dt * out.len() as f64, info); + for (_, sound) in &mut self.sounds { + sound.process(&mut self.temp_buffer[..out.len()], dt, info); + for (summed_out, sound_out) in out.iter_mut().zip(self.temp_buffer.iter().copied()) { + *summed_out += sound_out; + } + self.temp_buffer.fill(Frame::ZERO); + } + for effect in &mut self.effects { + effect.process(out, dt, info); + } + let num_frames = out.len(); + for (i, frame) in out.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let volume = self.volume.interpolated_value(time_in_chunk).as_amplitude(); + *frame *= volume; + } + } +} diff --git a/third_party/kira/src/track/main/builder.rs b/third_party/kira/src/track/main/builder.rs new file mode 100644 index 00000000..9b063f08 --- /dev/null +++ b/third_party/kira/src/track/main/builder.rs @@ -0,0 +1,168 @@ +use crate::{ + Decibels, Frame, Parameter, Value, backend::resources::ResourceStorage, + command::command_writer_and_reader, effect::EffectBuilder, +}; + +use super::{Effect, MainTrack, MainTrackHandle}; + +/// Configures the main mixer track. +pub struct MainTrackBuilder { + /// The volume of the track. + pub(crate) volume: Value, + /// The effects that should be applied to the input audio + /// for this track. + pub(crate) effects: Vec>, + /// The maximum number of sounds that can be played simultaneously on this track. + pub(crate) sound_capacity: usize, +} + +impl MainTrackBuilder { + /// Creates a new [`MainTrackBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self { + volume: Value::Fixed(Decibels::IDENTITY), + effects: vec![], + sound_capacity: 128, + } + } + + /// Sets the volume of the main mixer track. + #[must_use = "This method consumes self and returns a modified MainTrackBuilder, so the return value should be used"] + pub fn volume(self, volume: impl Into>) -> Self { + Self { + volume: volume.into(), + ..self + } + } + + /// Sets the maximum number of sounds that can be played simultaneously on this track. + #[must_use = "This method consumes self and returns a modified MainTrackBuilder, so the return value should be used"] + pub fn sound_capacity(self, capacity: usize) -> Self { + Self { + sound_capacity: capacity, + ..self + } + } + + /** + Adds an effect to the track. + + # Examples + + ``` + use kira::{track::MainTrackBuilder, effect::delay::DelayBuilder}; + + let mut builder = MainTrackBuilder::new(); + let delay_handle = builder.add_effect(DelayBuilder::new()); + ``` + */ + pub fn add_effect(&mut self, builder: B) -> B::Handle { + let (effect, handle) = builder.build(); + self.effects.push(effect); + handle + } + + /** + Adds an effect to the track and returns the [`MainTrackBuilder`]. + + If you need to modify the effect later, use [`add_effect`](Self::add_effect), + which returns the effect handle. + + # Examples + + ``` + use kira::{ + track::MainTrackBuilder, + effect::{filter::FilterBuilder, reverb::ReverbBuilder}, + }; + + let mut builder = MainTrackBuilder::new() + .with_effect(FilterBuilder::new()) + .with_effect(ReverbBuilder::new()); + ``` + */ + #[must_use = "This method consumes self and returns a modified MainTrackBuilder, so the return value should be used"] + pub fn with_effect(mut self, builder: B) -> Self { + self.add_effect(builder); + self + } + + /** Adds an already built effect into this track. + + `Box` values are created when calling `build` on an effect builder, which gives you + an effect handle, as well as this boxed effect, which is the actual audio effect. + + This is a lower-level method than [`Self::add_effect`], and you should probably use it rather + than this method, unless you have a reason to. + + # Examples + + ``` + use kira::track::MainTrackBuilder; + use kira::effect::{EffectBuilder, delay::DelayBuilder}; + + let mut builder = MainTrackBuilder::new(); + let delay_builder = DelayBuilder::new(); + let (effect, delay_handle) = delay_builder.build(); + let delay_handle = builder.add_built_effect(effect); + ``` + */ + pub fn add_built_effect(&mut self, effect: Box) { + self.effects.push(effect); + } + + /** Add an already-built effect and return the [`MainTrackBuilder`]. + + `Box` values are created when calling `build` on an effect builder, which gives you + an effect handle, as well as this boxed effect, which is the actual audio effect. + + This is a lower-level method than [`Self::with_effect`], and you should probably use it rather + than this method, unless you have a reason to. + + # Examples + + ``` + use kira::{ + track::MainTrackBuilder, + effect::{filter::FilterBuilder, reverb::ReverbBuilder, EffectBuilder}, + }; + + let (filter_effect, filter_handle) = FilterBuilder::new().build(); + let (reverb_effect, reverb_handle) = ReverbBuilder::new().build(); + let mut builder = MainTrackBuilder::new() + .with_built_effect(filter_effect) + .with_built_effect(reverb_effect); + ``` + */ + #[must_use = "This method consumes self and returns a modified MainTrackBuilder, so the return value should be used"] + pub fn with_built_effect(mut self, effect: Box) -> Self { + self.add_built_effect(effect); + self + } + + #[must_use] + pub(crate) fn build(self, internal_buffer_size: usize) -> (MainTrack, MainTrackHandle) { + let (set_volume_command_writer, set_volume_command_reader) = command_writer_and_reader(); + let (sounds, sound_controller) = ResourceStorage::new(self.sound_capacity); + let track = MainTrack { + volume: Parameter::new(self.volume, Decibels::IDENTITY), + set_volume_command_reader, + sounds, + effects: self.effects, + temp_buffer: vec![Frame::ZERO; internal_buffer_size], + internal_buffer_size, + }; + let handle = MainTrackHandle { + set_volume_command_writer, + sound_controller, + }; + (track, handle) + } +} + +impl Default for MainTrackBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/third_party/kira/src/track/main/handle.rs b/third_party/kira/src/track/main/handle.rs new file mode 100644 index 00000000..4185f05e --- /dev/null +++ b/third_party/kira/src/track/main/handle.rs @@ -0,0 +1,49 @@ +use crate::{ + Decibels, PlaySoundError, Tween, Value, + backend::resources::ResourceController, + command::{CommandWriter, ValueChangeCommand}, + sound::{Sound, SoundData}, +}; + +/// Controls the main mixer track. +#[derive(Debug)] +pub struct MainTrackHandle { + pub(crate) set_volume_command_writer: CommandWriter>, + pub(crate) sound_controller: ResourceController>, +} + +impl MainTrackHandle { + /// Plays a sound. + pub fn play( + &mut self, + sound_data: D, + ) -> Result> { + let (sound, handle) = sound_data + .into_sound() + .map_err(PlaySoundError::IntoSoundError)?; + self.sound_controller + .insert(sound) + .map_err(|_| PlaySoundError::SoundLimitReached)?; + Ok(handle) + } + + /// Sets the (post-effects) volume of the mixer track. + pub fn set_volume(&mut self, volume: impl Into>, tween: Tween) { + self.set_volume_command_writer.write(ValueChangeCommand { + target: volume.into(), + tween, + }) + } + + /// Returns the maximum number of sounds that can play simultaneously on this track. + #[must_use] + pub fn sound_capacity(&self) -> usize { + self.sound_controller.capacity() + } + + /// Returns the number of sounds currently playing on this track. + #[must_use] + pub fn num_sounds(&self) -> usize { + self.sound_controller.len() + } +} diff --git a/third_party/kira/src/track/send.rs b/third_party/kira/src/track/send.rs new file mode 100644 index 00000000..68e2ce1f --- /dev/null +++ b/third_party/kira/src/track/send.rs @@ -0,0 +1,98 @@ +mod builder; +mod handle; + +use std::sync::Arc; + +use atomic_arena::Key; +pub use builder::*; +pub use handle::*; + +use crate::{ + Decibels, Frame, Parameter, + command::{CommandReader, ValueChangeCommand}, + effect::Effect, + info::Info, +}; + +use super::TrackShared; + +/// A unique identifier for a mixer send track. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SendTrackId(pub(crate) Key); + +impl From<&SendTrackHandle> for SendTrackId { + fn from(handle: &SendTrackHandle) -> Self { + handle.id() + } +} + +pub(crate) struct SendTrack { + shared: Arc, + volume: Parameter, + set_volume_command_reader: CommandReader>, + effects: Vec>, + input: Vec, + internal_buffer_size: usize, +} + +impl SendTrack { + pub fn init_effects(&mut self, sample_rate: u32) { + for effect in &mut self.effects { + effect.init(sample_rate, self.internal_buffer_size); + } + } + + pub fn on_change_sample_rate(&mut self, sample_rate: u32) { + for effect in &mut self.effects { + effect.on_change_sample_rate(sample_rate); + } + } + + #[must_use] + pub fn shared(&self) -> Arc { + self.shared.clone() + } + + pub fn add_input(&mut self, input: &[Frame], volume: Decibels) { + for (input, added) in self.input.iter_mut().zip(input.iter().copied()) { + *input += added * volume.as_amplitude(); + } + } + + pub fn on_start_processing(&mut self) { + self.volume + .read_command(&mut self.set_volume_command_reader); + for effect in &mut self.effects { + effect.on_start_processing(); + } + } + + pub fn process(&mut self, out: &mut [Frame], dt: f64, info: &Info) { + self.volume.update(dt * out.len() as f64, info); + for (out_frame, input_frame) in out.iter_mut().zip(self.input.iter().copied()) { + *out_frame += input_frame; + } + self.input.fill(Frame::ZERO); + for effect in &mut self.effects { + effect.process(out, dt, info); + } + let num_frames = out.len(); + for (i, frame) in out.iter_mut().enumerate() { + let time_in_chunk = (i + 1) as f64 / num_frames as f64; + let volume = self.volume.interpolated_value(time_in_chunk).as_amplitude(); + *frame *= volume; + } + } +} + +pub(crate) struct SendTrackRoute { + pub(crate) volume: Parameter, + pub(crate) set_volume_command_reader: CommandReader>, +} + +impl SendTrackRoute { + pub fn read_commands(&mut self) { + self.volume + .read_command(&mut self.set_volume_command_reader); + } +} diff --git a/third_party/kira/src/track/send/builder.rs b/third_party/kira/src/track/send/builder.rs new file mode 100644 index 00000000..bc61bc89 --- /dev/null +++ b/third_party/kira/src/track/send/builder.rs @@ -0,0 +1,200 @@ +use std::sync::Arc; + +use crate::{ + Decibels, Parameter, Value, command::command_writer_and_reader, effect::EffectBuilder, + frame::Frame, +}; + +use super::{Effect, SendTrack, SendTrackHandle, SendTrackId, TrackShared}; + +/// Configures a mixer track. +pub struct SendTrackBuilder { + /// The volume of the send track. + pub(crate) volume: Value, + /// The effects that should be applied to the input audio + /// for this track. + pub(crate) effects: Vec>, +} + +impl SendTrackBuilder { + /// Creates a new [`SendTrackBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self { + volume: Value::Fixed(Decibels::IDENTITY), + effects: vec![], + } + } + + /** + Sets the volume of the send track. + + # Examples + + Set the volume to a fixed decibel value: + + ``` + # use kira::track::SendTrackBuilder; + let builder = SendTrackBuilder::new().volume(-6.0); + ``` + + Link the volume to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + track::SendTrackBuilder, + Easing, Value, Mapping, + Decibels, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let builder = SendTrackBuilder::new().volume(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (Decibels::SILENCE, Decibels::IDENTITY), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method consumes self and returns a modified SendTrackBuilder, so the return value should be used"] + pub fn volume(self, volume: impl Into>) -> Self { + Self { + volume: volume.into(), + ..self + } + } + + /** + Adds an effect to the send track. + + # Examples + + ``` + use kira::{track::SendTrackBuilder, effect::delay::DelayBuilder}; + + let mut builder = SendTrackBuilder::new(); + let delay_handle = builder.add_effect(DelayBuilder::new()); + ``` + */ + pub fn add_effect(&mut self, builder: B) -> B::Handle { + let (effect, handle) = builder.build(); + self.effects.push(effect); + handle + } + + /** + Adds an effect to the send track and returns the [`SendTrackBuilder`]. + + If you need to modify the effect later, use [`add_effect`](Self::add_effect), + which returns the effect handle. + + # Examples + + ``` + use kira::{ + track::SendTrackBuilder, + effect::{filter::FilterBuilder, reverb::ReverbBuilder}, + }; + + let mut builder = SendTrackBuilder::new() + .with_effect(FilterBuilder::new()) + .with_effect(ReverbBuilder::new()); + ``` + */ + #[must_use = "This method consumes self and returns a modified SendTrackBuilder, so the return value should be used"] + pub fn with_effect(mut self, builder: B) -> Self { + self.add_effect(builder); + self + } + + /** Adds an already built effect into this track. + + `Box` values are created when calling `build` on an effect builder, which gives you + an effect handle, as well as this boxed effect, which is the actual audio effect. + + This is a lower-level method than [`Self::add_effect`], and you should probably use it rather + than this method, unless you have a reason to. + + # Examples + + ``` + use kira::track::SendTrackBuilder; + use kira::effect::{EffectBuilder, delay::DelayBuilder}; + + let mut builder = SendTrackBuilder::new(); + let delay_builder = DelayBuilder::new(); + let (effect, delay_handle) = delay_builder.build(); + let delay_handle = builder.add_built_effect(effect); + ``` + */ + pub fn add_built_effect(&mut self, effect: Box) { + self.effects.push(effect); + } + + /** Add an already-built effect and return the [`SendTrackBuilder`]. + + `Box` values are created when calling `build` on an effect builder, which gives you + an effect handle, as well as this boxed effect, which is the actual audio effect. + + This is a lower-level method than [`Self::with_effect`], and you should probably use it rather + than this method, unless you have a reason to. + + # Examples + + ``` + use kira::{ + track::SendTrackBuilder, + effect::{filter::FilterBuilder, reverb::ReverbBuilder, EffectBuilder}, + }; + + let (filter_effect, filter_handle) = FilterBuilder::new().build(); + let (reverb_effect, reverb_handle) = ReverbBuilder::new().build(); + let mut builder = SendTrackBuilder::new() + .with_built_effect(filter_effect) + .with_built_effect(reverb_effect); + ``` + */ + #[must_use = "This method consumes self and returns a modified SendTrackBuilder, so the return value should be used"] + pub fn with_built_effect(mut self, effect: Box) -> Self { + self.add_built_effect(effect); + self + } + + #[must_use] + pub(crate) fn build( + self, + id: SendTrackId, + internal_buffer_size: usize, + ) -> (SendTrack, SendTrackHandle) { + let (set_volume_command_writer, set_volume_command_reader) = command_writer_and_reader(); + let shared = Arc::new(TrackShared::new()); + let track = SendTrack { + shared: shared.clone(), + volume: Parameter::new(self.volume, Decibels::IDENTITY), + set_volume_command_reader, + effects: self.effects, + input: vec![Frame::ZERO; internal_buffer_size], + internal_buffer_size, + }; + let handle = SendTrackHandle { + id, + shared, + set_volume_command_writer, + }; + (track, handle) + } +} + +impl Default for SendTrackBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/third_party/kira/src/track/send/handle.rs b/third_party/kira/src/track/send/handle.rs new file mode 100644 index 00000000..e731326f --- /dev/null +++ b/third_party/kira/src/track/send/handle.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use crate::{ + Decibels, Tween, Value, + command::{CommandWriter, ValueChangeCommand}, +}; + +use super::{SendTrackId, TrackShared}; + +/// Controls a mixer track. +/// +/// When a [`SendTrackHandle`] is dropped, the corresponding mixer +/// track will be removed. +#[derive(Debug)] +pub struct SendTrackHandle { + pub(crate) id: SendTrackId, + pub(crate) shared: Arc, + pub(crate) set_volume_command_writer: CommandWriter>, +} + +impl SendTrackHandle { + /// Returns a unique identifier for this send track. + pub fn id(&self) -> SendTrackId { + self.id + } + + /// Sets the (post-effects) volume of the send track. + pub fn set_volume(&mut self, volume: impl Into>, tween: Tween) { + self.set_volume_command_writer.write(ValueChangeCommand { + target: volume.into(), + tween, + }) + } +} + +impl Drop for SendTrackHandle { + fn drop(&mut self) { + self.shared.mark_for_removal(); + } +} diff --git a/third_party/kira/src/track/sub.rs b/third_party/kira/src/track/sub.rs new file mode 100644 index 00000000..d76dc9a2 --- /dev/null +++ b/third_party/kira/src/track/sub.rs @@ -0,0 +1,491 @@ +mod builder; +mod handle; +mod spatial_builder; +mod spatial_handle; + +pub use builder::*; +pub use handle::*; +pub use spatial_builder::*; +pub use spatial_handle::*; + +use std::{error::Error, f32::consts::FRAC_PI_8, fmt::Display, sync::Arc}; + +use glam::{Quat, Vec3}; + +use crate::{ + Decibels, Easing, Frame, Parameter, StartTime, Tween, Tweenable, + backend::resources::{ + ResourceStorage, clocks::Clocks, listeners::Listeners, modulators::Modulators, + }, + command::ValueChangeCommand, + command_writers_and_readers, + effect::Effect, + info::{Info, SpatialTrackInfo}, + listener::ListenerId, + playback_state_manager::PlaybackStateManager, + sound::Sound, +}; + +use super::{SendTrack, SendTrackId, SendTrackRoute, TrackShared}; + +/// An error that's returned when trying to change the volume of a track route +/// that did not exist originally. +#[derive(Debug)] +pub struct NonexistentRoute; + +impl Display for NonexistentRoute { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Cannot change the volume of a track route that did not exist originally") + } +} + +impl Error for NonexistentRoute {} + +pub(crate) struct Track { + shared: Arc, + command_readers: CommandReaders, + volume: Parameter, + sounds: ResourceStorage>, + sub_tracks: ResourceStorage, + effects: Vec>, + sends: Vec<(SendTrackId, SendTrackRoute)>, + persist_until_sounds_finish: bool, + spatial_data: Option, + playback_state_manager: PlaybackStateManager, + temp_buffer: Vec, + internal_buffer_size: usize, +} + +impl Track { + fn update_shared_playback_state(&mut self) { + self.shared + .set_state(self.playback_state_manager.playback_state()); + } + + fn pause(&mut self, fade_out_tween: Tween) { + self.playback_state_manager.pause(fade_out_tween); + self.update_shared_playback_state(); + } + + fn resume(&mut self, start_time: StartTime, fade_in_tween: Tween) { + self.playback_state_manager + .resume(start_time, fade_in_tween); + self.update_shared_playback_state(); + } + + pub fn init_effects(&mut self, sample_rate: u32) { + for effect in &mut self.effects { + effect.init(sample_rate, self.internal_buffer_size); + } + for (_, sub_track) in &mut self.sub_tracks { + sub_track.init_effects(sample_rate); + } + } + + pub fn on_change_sample_rate(&mut self, sample_rate: u32) { + for effect in &mut self.effects { + effect.on_change_sample_rate(sample_rate); + } + for (_, sub_track) in &mut self.sub_tracks { + sub_track.on_change_sample_rate(sample_rate); + } + } + + #[must_use] + pub fn shared(&self) -> Arc { + self.shared.clone() + } + + pub fn should_be_removed(&self) -> bool { + if self + .sub_tracks + .iter() + .any(|(_, sub_track)| !sub_track.should_be_removed()) + { + return false; + } + if self.persist_until_sounds_finish { + self.shared().is_marked_for_removal() && self.sounds.is_empty() + } else { + self.shared().is_marked_for_removal() + } + } + + pub fn on_start_processing(&mut self) { + self.read_commands(); + self.sounds.remove_and_add(|sound| sound.finished()); + for (_, sound) in &mut self.sounds { + sound.on_start_processing(); + } + self.sub_tracks + .remove_and_add(|sub_track| sub_track.should_be_removed()); + for (_, sub_track) in &mut self.sub_tracks { + sub_track.on_start_processing(); + } + for effect in &mut self.effects { + effect.on_start_processing(); + } + } + + #[allow(clippy::too_many_arguments)] + pub fn process( + &mut self, + out: &mut [Frame], + dt: f64, + clocks: &Clocks, + modulators: &Modulators, + listeners: &Listeners, + parent_spatial_track_info: Option, + send_tracks: &mut ResourceStorage, + ) { + // get info + let spatial_track_info = self + .spatial_data + .as_ref() + .map(|spatial_data| SpatialTrackInfo { + position: spatial_data.position.value(), + listener_id: spatial_data.listener_id, + }) + .or(parent_spatial_track_info); + let info = Info::new( + &clocks.0.resources, + &modulators.0.resources, + &listeners.0.resources, + spatial_track_info, + ); + + // update volume parameters + self.volume.update(dt * out.len() as f64, &info); + for (_, route) in &mut self.sends { + route.volume.update(dt * out.len() as f64, &info); + } + + // update playback state + let changed_playback_state = self + .playback_state_manager + .update(dt * out.len() as f64, &info); + if changed_playback_state { + self.update_shared_playback_state(); + } + if !self.playback_state_manager.playback_state().is_advancing() { + out.fill(Frame::ZERO); + return; + } + + let num_frames = out.len(); + + // process sub tracks + for (_, sub_track) in &mut self.sub_tracks { + sub_track.process( + &mut self.temp_buffer[..out.len()], + dt, + clocks, + modulators, + listeners, + spatial_track_info, + send_tracks, + ); + for (summed_out, track_out) in out.iter_mut().zip(self.temp_buffer.iter().copied()) { + *summed_out += track_out; + } + self.temp_buffer.fill(Frame::ZERO); + } + + // process sounds + for (_, sound) in &mut self.sounds { + sound.process(&mut self.temp_buffer[..out.len()], dt, &info); + for (summed_out, sound_out) in out.iter_mut().zip(self.temp_buffer.iter().copied()) { + *summed_out += sound_out; + } + self.temp_buffer.fill(Frame::ZERO); + } + + // apply effects + for effect in &mut self.effects { + effect.process(out, dt, &info); + } + + // apply spatialization, then the volume fade. + // + // benilla: the spatial gains and the volume are evaluated at the CHUNK's two ends and + // interpolated linearly across it, not recomputed per frame. Upstream spatialized every + // frame — two vector normalizes, a length, an attenuation curve and a dB→amplitude + // `powf` per frame per spatial track, and two more `powf`s for the volume tween — which + // made a dozen voices cost ~1 ms of every 60 Hz frame on the audio thread. A chunk is + // the output device's buffer (~10 ms); the pan and attenuation of a moving source change + // negligibly across it, and the mixer this backend stands in for (FMOD 3) updates them + // per mixer update, not per sample. See `third_party/kira/BENILLA.md`. + if let Some(spatial_data) = &mut self.spatial_data { + spatial_data.position.update(dt * out.len() as f64, &info); + spatial_data + .spatialization_strength + .update(dt * out.len() as f64, &info); + if let Some(listener_info) = info.listener_info() { + let gains_at = |t: f64| { + spatial_data.spatial_gains( + listener_info.interpolated_position(t as f32).into(), + listener_info.interpolated_orientation(t as f32).into(), + t, + ) + }; + let g0 = gains_at(0.0); + let g1 = gains_at(1.0); + let inv = 1.0 / num_frames as f32; + for (i, frame) in out.iter_mut().enumerate() { + let t = i as f32 * inv; + let g = g0.lerp(&g1, t); + *frame = g.apply(*frame); + } + } else { + out.fill(Frame::ZERO); + } + } + + { + let amplitude_at = |t: f64| { + self.volume.interpolated_value(t).as_amplitude() + * self + .playback_state_manager + .interpolated_fade_volume(t) + .as_amplitude() + }; + let a0 = amplitude_at(0.0); + let a1 = amplitude_at(1.0); + if a0 == a1 { + if a0 != 1.0 { + for frame in out.iter_mut() { + *frame *= a0; + } + } + } else { + let inv = 1.0 / num_frames as f32; + for (i, frame) in out.iter_mut().enumerate() { + let t = (i + 1) as f32 * inv; + *frame *= a0 + (a1 - a0) * t; + } + } + } + + // output to send tracks + for (send_track_id, SendTrackRoute { volume, .. }) in &self.sends { + let Some(send_track) = send_tracks.get_mut(send_track_id.0) else { + continue; + }; + send_track.add_input(out, volume.value()); + } + } + + fn read_commands(&mut self) { + self.volume + .read_command(&mut self.command_readers.set_volume); + for (_, route) in &mut self.sends { + route.read_commands(); + } + if let Some(SpatialData { + position, + spatialization_strength, + .. + }) = &mut self.spatial_data + { + position.read_command(&mut self.command_readers.set_position); + spatialization_strength + .read_command(&mut self.command_readers.set_spatialization_strength); + } + if let Some(tween) = self.command_readers.pause.read() { + self.pause(tween); + } + if let Some((start_time, tween)) = self.command_readers.resume.read() { + self.resume(start_time, tween); + } + } +} + +struct SpatialData { + listener_id: ListenerId, + position: Parameter, + /// The distances from a listener at which the track is loudest and quietest. + distances: SpatialTrackDistances, + /// How the track's volume will change with distance. + /// + /// If `None`, the track will output at a constant volume. + attenuation_function: Option, + /// How much the track's output should be panned left or right depending on its + /// direction from the listener. + /// + /// This value should be between `0.0` and `1.0`. `0.0` disables spatialization + /// entirely. + spatialization_strength: Parameter, +} + +impl SpatialData { + /// benilla: the gains [`Self::spatialize`] applies, as a value — so a chunk can evaluate + /// them twice and lerp instead of recomputing them per frame. + fn spatial_gains( + &self, + listener_position: Vec3, + listener_orientation: Quat, + time_in_chunk: f64, + ) -> SpatialGains { + let position = self.position.interpolated_value(time_in_chunk); + let spatialization_strength = self + .spatialization_strength + .interpolated_value(time_in_chunk) + .clamp(0.0, 1.0); + let min_ear_amplitude = 1.0 - spatialization_strength; + let attenuation = match self.attenuation_function { + Some(attenuation_function) => { + let distance = (listener_position - position).length(); + let relative_distance = self.distances.relative_distance(distance); + let relative_volume = + attenuation_function.apply((1.0 - relative_distance).into()) as f32; + Tweenable::interpolate( + Decibels::SILENCE, + Decibels::IDENTITY, + relative_volume.into(), + ) + .as_amplitude() + } + None => 1.0, + }; + let (left, right) = if spatialization_strength != 0.0 { + let (left_ear_position, right_ear_position) = + listener_ear_positions(listener_position, listener_orientation); + let (left_ear_direction, right_ear_direction) = + listener_ear_directions(listener_orientation); + let emitter_direction_relative_to_left_ear = + (position - left_ear_position).normalize_or_zero(); + let emitter_direction_relative_to_right_ear = + (position - right_ear_position).normalize_or_zero(); + let left_ear_volume = + (left_ear_direction.dot(emitter_direction_relative_to_left_ear) + 1.0) / 2.0; + let right_ear_volume = + (right_ear_direction.dot(emitter_direction_relative_to_right_ear) + 1.0) / 2.0; + ( + min_ear_amplitude + (1.0 - min_ear_amplitude) * left_ear_volume, + min_ear_amplitude + (1.0 - min_ear_amplitude) * right_ear_volume, + ) + } else { + (1.0, 1.0) + }; + SpatialGains { + attenuation, + left, + right, + mono: spatialization_strength != 0.0, + } + } + + #[allow(dead_code)] + fn spatialize( + &self, + input: Frame, + listener_position: Vec3, + listener_orientation: Quat, + time_in_chunk: f64, + ) -> Frame { + let position = self.position.interpolated_value(time_in_chunk); + let spatialization_strength = self + .spatialization_strength + .interpolated_value(time_in_chunk) + .clamp(0.0, 1.0); + let min_ear_amplitude = 1.0 - spatialization_strength; + + let mut output = input; + // attenuate volume + if let Some(attenuation_function) = self.attenuation_function { + let distance = (listener_position - position).length(); + let relative_distance = self.distances.relative_distance(distance); + let relative_volume = + attenuation_function.apply((1.0 - relative_distance).into()) as f32; + let amplitude = Tweenable::interpolate( + Decibels::SILENCE, + Decibels::IDENTITY, + relative_volume.into(), + ) + .as_amplitude(); + output *= amplitude; + } + // apply spatialization + if spatialization_strength != 0.0 { + output = output.as_mono(); + let (left_ear_position, right_ear_position) = + listener_ear_positions(listener_position, listener_orientation); + let (left_ear_direction, right_ear_direction) = + listener_ear_directions(listener_orientation); + let emitter_direction_relative_to_left_ear = + (position - left_ear_position).normalize_or_zero(); + let emitter_direction_relative_to_right_ear = + (position - right_ear_position).normalize_or_zero(); + let left_ear_volume = + (left_ear_direction.dot(emitter_direction_relative_to_left_ear) + 1.0) / 2.0; + let right_ear_volume = + (right_ear_direction.dot(emitter_direction_relative_to_right_ear) + 1.0) / 2.0; + output.left *= min_ear_amplitude + (1.0 - min_ear_amplitude) * left_ear_volume; + output.right *= min_ear_amplitude + (1.0 - min_ear_amplitude) * right_ear_volume; + } + output + } +} + +/// benilla: what [`SpatialData::spatialize`] does to a frame, factored out of the per-frame +/// loop — attenuation, then (for a spatialized track) mono-fold and per-ear gain. +#[derive(Clone, Copy)] +struct SpatialGains { + attenuation: f32, + left: f32, + right: f32, + mono: bool, +} + +impl SpatialGains { + fn lerp(&self, other: &Self, t: f32) -> Self { + Self { + attenuation: self.attenuation + (other.attenuation - self.attenuation) * t, + left: self.left + (other.left - self.left) * t, + right: self.right + (other.right - self.right) * t, + // The fold is a state, not a gain: a chunk whose strength crosses zero folds for + // the whole chunk, exactly as upstream would at every frame past the crossing. + mono: self.mono || other.mono, + } + } + + fn apply(&self, input: Frame) -> Frame { + let mut output = input * self.attenuation; + if self.mono { + output = output.as_mono(); + output.left *= self.left; + output.right *= self.right; + } + output + } +} + +#[must_use] +fn listener_ear_positions(listener_position: Vec3, listener_orientation: Quat) -> (Vec3, Vec3) { + const EAR_DISTANCE: f32 = 0.1; + let position = listener_position; + let orientation = listener_orientation; + let left = position + orientation * (Vec3::NEG_X * EAR_DISTANCE); + let right = position + orientation * (Vec3::X * EAR_DISTANCE); + (left, right) +} + +#[must_use] +fn listener_ear_directions(listener_orientation: Quat) -> (Vec3, Vec3) { + const EAR_ANGLE_FROM_HEAD: f32 = FRAC_PI_8; + let left_ear_direction_relative_to_head = + Quat::from_rotation_y(-EAR_ANGLE_FROM_HEAD) * Vec3::NEG_X; + let right_ear_direction_relative_to_head = Quat::from_rotation_y(EAR_ANGLE_FROM_HEAD) * Vec3::X; + let orientation = listener_orientation; + let left = orientation * left_ear_direction_relative_to_head; + let right = orientation * right_ear_direction_relative_to_head; + (left, right) +} + +command_writers_and_readers! { + set_volume: ValueChangeCommand, + set_position: ValueChangeCommand, + set_spatialization_strength: ValueChangeCommand, + pause: Tween, + resume: (StartTime, Tween), +} diff --git a/third_party/kira/src/track/sub/builder.rs b/third_party/kira/src/track/sub/builder.rs new file mode 100644 index 00000000..8ffbf090 --- /dev/null +++ b/third_party/kira/src/track/sub/builder.rs @@ -0,0 +1,282 @@ +use std::{collections::HashMap, sync::Arc}; + +use crate::{ + Decibels, Frame, Parameter, Value, + backend::{RendererShared, resources::ResourceStorage}, + command::command_writer_and_reader, + effect::EffectBuilder, + playback_state_manager::PlaybackStateManager, +}; + +use super::{ + Effect, SendTrackId, SendTrackRoute, Track, TrackHandle, TrackShared, + command_writers_and_readers, +}; + +/// Configures a mixer track. +pub struct TrackBuilder { + /// The volume of the track. + pub(crate) volume: Value, + /// The effects that should be applied to the input audio + /// for this track. + pub(crate) effects: Vec>, + /// The number of child tracks that can be added to this track. + pub(crate) sub_track_capacity: usize, + /// The maximum number of sounds that can be played simultaneously on this track. + pub(crate) sound_capacity: usize, + pub(crate) sends: HashMap>, + pub(crate) persist_until_sounds_finish: bool, +} + +impl TrackBuilder { + /// Creates a new [`TrackBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self { + volume: Value::Fixed(Decibels::IDENTITY), + effects: vec![], + sub_track_capacity: 128, + sound_capacity: 128, + sends: HashMap::new(), + persist_until_sounds_finish: false, + } + } + + /** + Sets the volume of the track. + + # Examples + + Set the volume to a fixed decibel value: + + ``` + # use kira::track::TrackBuilder; + let builder = TrackBuilder::new().volume(-6.0); + ``` + + Link the volume to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + track::TrackBuilder, + Easing, Value, Mapping, + Decibels, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let builder = TrackBuilder::new().volume(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (Decibels::SILENCE, Decibels::IDENTITY), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method consumes self and returns a modified TrackBuilder, so the return value should be used"] + pub fn volume(self, volume: impl Into>) -> Self { + Self { + volume: volume.into(), + ..self + } + } + + /// Sets the maximum number of sub-tracks this track can have. + #[must_use = "This method consumes self and returns a modified TrackBuilder, so the return value should be used"] + pub fn sub_track_capacity(self, capacity: usize) -> Self { + Self { + sub_track_capacity: capacity, + ..self + } + } + + /// Sets the maximum number of sounds that can be played simultaneously on this track. + #[must_use = "This method consumes self and returns a modified TrackBuilder, so the return value should be used"] + pub fn sound_capacity(self, capacity: usize) -> Self { + Self { + sound_capacity: capacity, + ..self + } + } + + /// Routes this track to the given send track with the given volume. + pub fn with_send( + mut self, + track: impl Into, + volume: impl Into>, + ) -> Self { + self.sends.insert(track.into(), volume.into()); + self + } + + /** + Adds an effect to the track. + + # Examples + + ``` + use kira::{track::TrackBuilder, effect::delay::DelayBuilder}; + + let mut builder = TrackBuilder::new(); + let delay_handle = builder.add_effect(DelayBuilder::new()); + ``` + */ + pub fn add_effect(&mut self, builder: B) -> B::Handle { + let (effect, handle) = builder.build(); + self.effects.push(effect); + handle + } + + /** + Adds an effect to the track and returns the [`TrackBuilder`]. + + If you need to modify the effect later, use [`add_effect`](Self::add_effect), + which returns the effect handle. + + # Examples + + ``` + use kira::{ + track::TrackBuilder, + effect::{filter::FilterBuilder, reverb::ReverbBuilder}, + }; + + let mut builder = TrackBuilder::new() + .with_effect(FilterBuilder::new()) + .with_effect(ReverbBuilder::new()); + ``` + */ + #[must_use = "This method consumes self and returns a modified TrackBuilder, so the return value should be used"] + pub fn with_effect(mut self, builder: B) -> Self { + self.add_effect(builder); + self + } + + /** Adds an already built effect into this track. + + `Box` values are created when calling `build` on an effect builder, which gives you + an effect handle, as well as this boxed effect, which is the actual audio effect. + + This is a lower-level method than [`Self::add_effect`], and you should probably use it rather + than this method, unless you have a reason to. + + # Examples + + ``` + use kira::track::TrackBuilder; + use kira::effect::{EffectBuilder, delay::DelayBuilder}; + + let mut builder = TrackBuilder::new(); + let delay_builder = DelayBuilder::new(); + let (effect, delay_handle) = delay_builder.build(); + let delay_handle = builder.add_built_effect(effect); + ``` + */ + pub fn add_built_effect(&mut self, effect: Box) { + self.effects.push(effect); + } + + /** Add an already-built effect and return the [`TrackBuilder`]. + + `Box` values are created when calling `build` on an effect builder, which gives you + an effect handle, as well as this boxed effect, which is the actual audio effect. + + This is a lower-level method than [`Self::with_effect`], and you should probably use it rather + than this method, unless you have a reason to. + + # Examples + + ``` + use kira::{ + track::TrackBuilder, + effect::{filter::FilterBuilder, reverb::ReverbBuilder, EffectBuilder}, + }; + + let (filter_effect, filter_handle) = FilterBuilder::new().build(); + let (reverb_effect, reverb_handle) = ReverbBuilder::new().build(); + let mut builder = TrackBuilder::new() + .with_built_effect(filter_effect) + .with_built_effect(reverb_effect); + ``` + */ + #[must_use = "This method consumes self and returns a modified TrackBuilder, so the return value should be used"] + pub fn with_built_effect(mut self, effect: Box) -> Self { + self.add_built_effect(effect); + self + } + + /// Sets whether the track should stay alive while sounds are playing on it. + /// + /// By default, as soon as a track's handle is dropped, the track is unloaded. + /// If this is set to `true`, the track will wait until all sounds on the track + /// are finished before unloading. + pub fn persist_until_sounds_finish(self, persist: bool) -> Self { + Self { + persist_until_sounds_finish: persist, + ..self + } + } + + #[must_use] + pub(crate) fn build( + self, + renderer_shared: Arc, + internal_buffer_size: usize, + ) -> (Track, TrackHandle) { + let (command_writers, command_readers) = command_writers_and_readers(); + let shared = Arc::new(TrackShared::new()); + let (sounds, sound_controller) = ResourceStorage::new(self.sound_capacity); + let (sub_tracks, sub_track_controller) = ResourceStorage::new(self.sub_track_capacity); + let mut sends = vec![]; + let mut send_volume_command_writers = HashMap::new(); + for (send_track_id, volume) in self.sends { + let (set_volume_command_writer, set_volume_command_reader) = + command_writer_and_reader(); + sends.push(( + send_track_id, + SendTrackRoute { + volume: Parameter::new(volume, Decibels::IDENTITY), + set_volume_command_reader, + }, + )); + send_volume_command_writers.insert(send_track_id, set_volume_command_writer); + } + let track = Track { + shared: shared.clone(), + command_readers, + volume: Parameter::new(self.volume, Decibels::IDENTITY), + sounds, + sub_tracks, + effects: self.effects, + sends, + persist_until_sounds_finish: self.persist_until_sounds_finish, + spatial_data: None, + playback_state_manager: PlaybackStateManager::new(None), + temp_buffer: vec![Frame::ZERO; internal_buffer_size], + internal_buffer_size, + }; + let handle = TrackHandle { + renderer_shared, + shared, + command_writers, + sound_controller, + sub_track_controller, + send_volume_command_writers, + internal_buffer_size, + }; + (track, handle) + } +} + +impl Default for TrackBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/third_party/kira/src/track/sub/handle.rs b/third_party/kira/src/track/sub/handle.rs new file mode 100644 index 00000000..b30afd6f --- /dev/null +++ b/third_party/kira/src/track/sub/handle.rs @@ -0,0 +1,165 @@ +use std::{ + collections::HashMap, + sync::{Arc, atomic::Ordering}, +}; + +use crate::{ + Decibels, PlaySoundError, ResourceLimitReached, StartTime, Tween, Value, + backend::{RendererShared, resources::ResourceController}, + command::{CommandWriter, ValueChangeCommand}, + listener::ListenerId, + sound::{Sound, SoundData}, + track::TrackPlaybackState, +}; + +use super::{ + CommandWriters, NonexistentRoute, SendTrackId, SpatialTrackBuilder, SpatialTrackHandle, Track, + TrackBuilder, TrackShared, +}; + +/// Controls a mixer track. +/// +/// When a [`TrackHandle`] is dropped, the corresponding mixer +/// track will be removed. +#[derive(Debug)] +pub struct TrackHandle { + pub(crate) renderer_shared: Arc, + pub(crate) shared: Arc, + pub(crate) command_writers: CommandWriters, + pub(crate) sound_controller: ResourceController>, + pub(crate) sub_track_controller: ResourceController, + pub(crate) send_volume_command_writers: + HashMap>>, + pub(crate) internal_buffer_size: usize, +} + +impl TrackHandle { + /// Returns the current playback state of the track. + #[must_use] + pub fn state(&self) -> TrackPlaybackState { + self.shared.state() + } + + /// Plays a sound. + pub fn play( + &mut self, + sound_data: D, + ) -> Result> { + let (sound, handle) = sound_data + .into_sound() + .map_err(PlaySoundError::IntoSoundError)?; + self.sound_controller + .insert(sound) + .map_err(|_| PlaySoundError::SoundLimitReached)?; + Ok(handle) + } + + /// Adds a child track to this track. + pub fn add_sub_track( + &mut self, + builder: TrackBuilder, + ) -> Result { + let (mut track, handle) = + builder.build(self.renderer_shared.clone(), self.internal_buffer_size); + track.init_effects(self.renderer_shared.sample_rate.load(Ordering::SeqCst)); + self.sub_track_controller.insert(track)?; + Ok(handle) + } + + /// Adds a spatial child track to this track. + pub fn add_spatial_sub_track( + &mut self, + listener: impl Into, + position: impl Into>>, + builder: SpatialTrackBuilder, + ) -> Result { + let (mut track, handle) = builder.build( + self.renderer_shared.clone(), + self.internal_buffer_size, + listener.into(), + position.into().to_(), + ); + track.init_effects(self.renderer_shared.sample_rate.load(Ordering::SeqCst)); + self.sub_track_controller.insert(track)?; + Ok(handle) + } + + /// Sets the (post-effects) volume of the mixer track. + pub fn set_volume(&mut self, volume: impl Into>, tween: Tween) { + self.command_writers.set_volume.write(ValueChangeCommand { + target: volume.into(), + tween, + }) + } + + /// Sets the volume of this track's route to a send track. + /// + /// This can only be used to change the volume of existing routes, + /// not to add new routes. + pub fn set_send( + &mut self, + to: impl Into, + volume: impl Into>, + tween: Tween, + ) -> Result<(), NonexistentRoute> { + let to = to.into(); + self.send_volume_command_writers + .get_mut(&to) + .ok_or(NonexistentRoute)? + .write(ValueChangeCommand { + target: volume.into(), + tween, + }); + Ok(()) + } + + /// Fades out the track to silence with the given tween and then + /// pauses playback, pausing all sounds and emitters playing on this + /// track. + pub fn pause(&mut self, tween: Tween) { + self.command_writers.pause.write(tween) + } + + /// Resumes playback and fades in the sound from silence + /// with the given tween, resuming all sounds and emitters + /// playing on this track. + pub fn resume(&mut self, tween: Tween) { + self.resume_at(StartTime::Immediate, tween) + } + + /// Resumes playback at the given start time and fades in + /// the sound from silence with the given tween. + pub fn resume_at(&mut self, start_time: StartTime, tween: Tween) { + self.command_writers.resume.write((start_time, tween)) + } + + /// Returns the maximum number of sounds that can play simultaneously on this track. + #[must_use] + pub fn sound_capacity(&self) -> usize { + self.sound_controller.capacity() + } + + /// Returns the number of sounds currently playing on this track. + #[must_use] + pub fn num_sounds(&self) -> usize { + self.sound_controller.len() + } + + /// Returns the maximum number of child tracks this track can have. + #[must_use] + pub fn sub_track_capacity(&self) -> usize { + self.sub_track_controller.capacity() + } + + /// Returns the number of child tracks this track has. + #[must_use] + pub fn num_sub_tracks(&self) -> usize { + self.sub_track_controller.len() + } +} + +impl Drop for TrackHandle { + fn drop(&mut self) { + self.shared.mark_for_removal(); + } +} diff --git a/third_party/kira/src/track/sub/spatial_builder.rs b/third_party/kira/src/track/sub/spatial_builder.rs new file mode 100644 index 00000000..124e1a5a --- /dev/null +++ b/third_party/kira/src/track/sub/spatial_builder.rs @@ -0,0 +1,395 @@ +use std::{collections::HashMap, ops::RangeInclusive, sync::Arc}; + +use glam::Vec3; + +use crate::{ + Decibels, Frame, Parameter, Value, + backend::{RendererShared, resources::ResourceStorage}, + command::command_writer_and_reader, + effect::EffectBuilder, + listener::ListenerId, + playback_state_manager::PlaybackStateManager, + tween::Easing, +}; + +use super::{ + Effect, SendTrackId, SendTrackRoute, SpatialData, SpatialTrackHandle, Track, TrackShared, + command_writers_and_readers, +}; + +/// Configures a spatial mixer track. +pub struct SpatialTrackBuilder { + /// The volume of the track. + pub(crate) volume: Value, + /// The effects that should be applied to the input audio + /// for this track. + pub(crate) effects: Vec>, + /// The number of child tracks that can be added to this track. + pub(crate) sub_track_capacity: usize, + /// The maximum number of sounds that can be played simultaneously on this track. + pub(crate) sound_capacity: usize, + pub(crate) sends: HashMap>, + pub(crate) persist_until_sounds_finish: bool, + /// The distances from a listener at which the track is loudest and quietest. + pub(crate) distances: SpatialTrackDistances, + /// How the track's volume will change with distance. + /// + /// If `None`, the track will output at a constant volume. + pub(crate) attenuation_function: Option, + /// How much the track's output should be panned left or right depending on its + /// direction from the listener. + /// + /// This value should be between `0.0` and `1.0`. `0.0` disables spatialization + /// entirely. + pub(crate) spatialization_strength: Value, +} + +impl SpatialTrackBuilder { + /// Creates a new [`SpatialTrackBuilder`] with the default settings. + #[must_use] + pub fn new() -> Self { + Self { + volume: Value::Fixed(Decibels::IDENTITY), + effects: vec![], + sub_track_capacity: 128, + sound_capacity: 128, + sends: HashMap::new(), + persist_until_sounds_finish: false, + distances: SpatialTrackDistances::default(), + attenuation_function: Some(Easing::Linear), + spatialization_strength: Value::Fixed(0.75), + } + } + + /** + Sets the volume of the track. + + # Examples + + Set the volume to a fixed decibel value: + + ``` + # use kira::track::SpatialTrackBuilder; + let builder = SpatialTrackBuilder::new().volume(-6.0); + ``` + + Link the volume to a modulator: + + ```no_run + use kira::{ + AudioManager, AudioManagerSettings, DefaultBackend, + modulator::tweener::TweenerBuilder, + track::SpatialTrackBuilder, + Easing, Value, Mapping, + Decibels, + }; + + let mut manager = AudioManager::::new(AudioManagerSettings::default())?; + let tweener = manager.add_modulator(TweenerBuilder { + initial_value: 0.5, + })?; + let builder = SpatialTrackBuilder::new().volume(Value::FromModulator { + id: tweener.id(), + mapping: Mapping { + input_range: (0.0, 1.0), + output_range: (Decibels::SILENCE, Decibels::IDENTITY), + easing: Easing::Linear, + }, + }); + # Result::<(), Box>::Ok(()) + ``` + */ + #[must_use = "This method consumes self and returns a modified SpatialTrackBuilder, so the return value should be used"] + pub fn volume(self, volume: impl Into>) -> Self { + Self { + volume: volume.into(), + ..self + } + } + + /// Sets the maximum number of sub-tracks this track can have. + #[must_use = "This method consumes self and returns a modified SpatialTrackBuilder, so the return value should be used"] + pub fn sub_track_capacity(self, capacity: usize) -> Self { + Self { + sub_track_capacity: capacity, + ..self + } + } + + /// Sets the maximum number of sounds that can be played simultaneously on this track. + #[must_use = "This method consumes self and returns a modified SpatialTrackBuilder, so the return value should be used"] + pub fn sound_capacity(self, capacity: usize) -> Self { + Self { + sound_capacity: capacity, + ..self + } + } + + /// Routes this track to the given send track with the given volume. + pub fn with_send( + mut self, + track: impl Into, + volume: impl Into>, + ) -> Self { + self.sends.insert(track.into(), volume.into()); + self + } + + /** + Adds an effect to the track. + + # Examples + + ``` + use kira::{track::SpatialTrackBuilder, effect::delay::DelayBuilder}; + + let mut builder = SpatialTrackBuilder::new(); + let delay_handle = builder.add_effect(DelayBuilder::new()); + ``` + */ + pub fn add_effect(&mut self, builder: B) -> B::Handle { + let (effect, handle) = builder.build(); + self.effects.push(effect); + handle + } + + /** + Adds an effect to the track and returns the [`SpatialTrackBuilder`]. + + If you need to modify the effect later, use [`add_effect`](Self::add_effect), + which returns the effect handle. + + # Examples + + ``` + use kira::{ + track::SpatialTrackBuilder, + effect::{filter::FilterBuilder, reverb::ReverbBuilder}, + }; + + let mut builder = SpatialTrackBuilder::new() + .with_effect(FilterBuilder::new()) + .with_effect(ReverbBuilder::new()); + ``` + */ + #[must_use = "This method consumes self and returns a modified SpatialTrackBuilder, so the return value should be used"] + pub fn with_effect(mut self, builder: B) -> Self { + self.add_effect(builder); + self + } + + /** Adds an already built effect into this track. + + `Box` values are created when calling `build` on an effect builder, which gives you + an effect handle, as well as this boxed effect, which is the actual audio effect. + + This is a lower-level method than [`Self::add_effect`], and you should probably use it rather + than this method, unless you have a reason to. + + # Examples + + ``` + use kira::track::SpatialTrackBuilder; + use kira::effect::{EffectBuilder, delay::DelayBuilder}; + + let mut builder = SpatialTrackBuilder::new(); + let delay_builder = DelayBuilder::new(); + let (effect, delay_handle) = delay_builder.build(); + let delay_handle = builder.add_built_effect(effect); + ``` + */ + pub fn add_built_effect(&mut self, effect: Box) { + self.effects.push(effect); + } + + /** Add an already-built effect and return the [`SpatialTrackBuilder`]. + + `Box` values are created when calling `build` on an effect builder, which gives you + an effect handle, as well as this boxed effect, which is the actual audio effect. + + This is a lower-level method than [`Self::with_effect`], and you should probably use it rather + than this method, unless you have a reason to. + + # Examples + + ``` + use kira::{ + track::SpatialTrackBuilder, + effect::{filter::FilterBuilder, reverb::ReverbBuilder, EffectBuilder}, + }; + + let (filter_effect, filter_handle) = FilterBuilder::new().build(); + let (reverb_effect, reverb_handle) = ReverbBuilder::new().build(); + let mut builder = SpatialTrackBuilder::new() + .with_built_effect(filter_effect) + .with_built_effect(reverb_effect); + ``` + */ + #[must_use = "This method consumes self and returns a modified SpatialTrackBuilder, so the return value should be used"] + pub fn with_built_effect(mut self, effect: Box) -> Self { + self.add_built_effect(effect); + self + } + + /// Sets whether the track should stay alive while sounds are playing on it. + /// + /// By default, as soon as a track's handle is dropped, the track is unloaded. + /// If this is set to `true`, the track will wait until all sounds on the track + /// are finished before unloading. + pub fn persist_until_sounds_finish(self, persist: bool) -> Self { + Self { + persist_until_sounds_finish: persist, + ..self + } + } + + /// Sets the distances from a listener at which the emitter is loudest and quietest. + #[must_use = "This method consumes self and returns a modified SpatialTrackBuilder, so the return value should be used"] + pub fn distances(self, distances: impl Into) -> Self { + Self { + distances: distances.into(), + ..self + } + } + + /// Sets how the emitter's volume will change with distance. + /// + /// If `None`, the emitter will output at a constant volume. + #[must_use = "This method consumes self and returns a modified SpatialTrackBuilder, so the return value should be used"] + pub fn attenuation_function(self, attenuation_function: impl Into>) -> Self { + Self { + attenuation_function: attenuation_function.into(), + ..self + } + } + + /// Sets how much the track's output should be panned left or right depending on its + /// direction from the listener. + /// + /// This value should be between `0.0` and `1.0`. `0.0` disables spatialization + /// entirely. + #[must_use = "This method consumes self and returns a modified SpatialTrackBuilder, so the return value should be used"] + pub fn spatialization_strength(self, spatialization_strength: impl Into>) -> Self { + Self { + spatialization_strength: spatialization_strength.into(), + ..self + } + } + + #[must_use] + pub(crate) fn build( + self, + renderer_shared: Arc, + internal_buffer_size: usize, + listener_id: ListenerId, + position: Value, + ) -> (Track, SpatialTrackHandle) { + let (command_writers, command_readers) = command_writers_and_readers(); + let shared = Arc::new(TrackShared::new()); + let (sounds, sound_controller) = ResourceStorage::new(self.sound_capacity); + let (sub_tracks, sub_track_controller) = ResourceStorage::new(self.sub_track_capacity); + let mut sends = vec![]; + let mut send_volume_command_writers = HashMap::new(); + for (send_track_id, volume) in self.sends { + let (set_volume_command_writer, set_volume_command_reader) = + command_writer_and_reader(); + sends.push(( + send_track_id, + SendTrackRoute { + volume: Parameter::new(volume, Decibels::IDENTITY), + set_volume_command_reader, + }, + )); + send_volume_command_writers.insert(send_track_id, set_volume_command_writer); + } + let track = Track { + shared: shared.clone(), + command_readers, + volume: Parameter::new(self.volume, Decibels::IDENTITY), + sounds, + sub_tracks, + effects: self.effects, + sends, + persist_until_sounds_finish: self.persist_until_sounds_finish, + spatial_data: Some(SpatialData { + listener_id, + position: Parameter::new(position, Vec3::ZERO), + distances: self.distances, + attenuation_function: self.attenuation_function, + spatialization_strength: Parameter::new(self.spatialization_strength, 0.75), + }), + playback_state_manager: PlaybackStateManager::new(None), + temp_buffer: vec![Frame::ZERO; internal_buffer_size], + internal_buffer_size, + }; + let handle = SpatialTrackHandle { + renderer_shared, + shared, + command_writers, + sound_controller, + sub_track_controller, + send_volume_command_writers, + internal_buffer_size, + }; + (track, handle) + } +} + +impl Default for SpatialTrackBuilder { + fn default() -> Self { + Self::new() + } +} + +/// The distances from a listener at which an emitter is loudest and quietest. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SpatialTrackDistances { + /// The distance from a listener at which an emitter outputs at full volume. + pub min_distance: f32, + /// The distance from a listener at which an emitter becomes inaudible. + pub max_distance: f32, +} + +impl SpatialTrackDistances { + #[must_use] + pub(crate) fn relative_distance(&self, distance: f32) -> f32 { + let distance = distance.clamp(self.min_distance, self.max_distance); + (distance - self.min_distance) / (self.max_distance - self.min_distance) + } +} + +impl Default for SpatialTrackDistances { + fn default() -> Self { + Self { + min_distance: 1.0, + max_distance: 100.0, + } + } +} + +impl From<(f32, f32)> for SpatialTrackDistances { + fn from((min_distance, max_distance): (f32, f32)) -> Self { + Self { + min_distance, + max_distance, + } + } +} + +impl From<[f32; 2]> for SpatialTrackDistances { + fn from([min_distance, max_distance]: [f32; 2]) -> Self { + Self { + min_distance, + max_distance, + } + } +} + +impl From> for SpatialTrackDistances { + fn from(range: RangeInclusive) -> Self { + Self { + min_distance: *range.start(), + max_distance: *range.end(), + } + } +} diff --git a/third_party/kira/src/track/sub/spatial_handle.rs b/third_party/kira/src/track/sub/spatial_handle.rs new file mode 100644 index 00000000..96c5c8b2 --- /dev/null +++ b/third_party/kira/src/track/sub/spatial_handle.rs @@ -0,0 +1,192 @@ +use std::{ + collections::HashMap, + sync::{Arc, atomic::Ordering}, +}; + +use crate::{ + Decibels, PlaySoundError, ResourceLimitReached, StartTime, Tween, Value, + backend::{RendererShared, resources::ResourceController}, + command::{CommandWriter, ValueChangeCommand}, + listener::ListenerId, + sound::{Sound, SoundData}, + track::TrackPlaybackState, +}; + +use super::{ + CommandWriters, NonexistentRoute, SendTrackId, SpatialTrackBuilder, Track, TrackBuilder, + TrackHandle, TrackShared, +}; + +/// Controls a mixer track. +/// +/// When a [`SpatialTrackHandle`] is dropped, the corresponding mixer +/// track will be removed. +#[derive(Debug)] +pub struct SpatialTrackHandle { + pub(crate) renderer_shared: Arc, + pub(crate) shared: Arc, + pub(crate) command_writers: CommandWriters, + pub(crate) sound_controller: ResourceController>, + pub(crate) sub_track_controller: ResourceController, + pub(crate) send_volume_command_writers: + HashMap>>, + pub(crate) internal_buffer_size: usize, +} + +impl SpatialTrackHandle { + /// Returns the current playback state of the track. + #[must_use] + pub fn state(&self) -> TrackPlaybackState { + self.shared.state() + } + + /// Plays a sound. + pub fn play( + &mut self, + sound_data: D, + ) -> Result> { + let (sound, handle) = sound_data + .into_sound() + .map_err(PlaySoundError::IntoSoundError)?; + self.sound_controller + .insert(sound) + .map_err(|_| PlaySoundError::SoundLimitReached)?; + Ok(handle) + } + + /// Adds a child track to this track. + pub fn add_sub_track( + &mut self, + builder: TrackBuilder, + ) -> Result { + let (mut track, handle) = + builder.build(self.renderer_shared.clone(), self.internal_buffer_size); + track.init_effects(self.renderer_shared.sample_rate.load(Ordering::SeqCst)); + self.sub_track_controller.insert(track)?; + Ok(handle) + } + + /// Adds a spatial child track to this track. + pub fn add_spatial_sub_track( + &mut self, + listener: impl Into, + position: impl Into>>, + builder: SpatialTrackBuilder, + ) -> Result { + let (mut track, handle) = builder.build( + self.renderer_shared.clone(), + self.internal_buffer_size, + listener.into(), + position.into().to_(), + ); + track.init_effects(self.renderer_shared.sample_rate.load(Ordering::SeqCst)); + self.sub_track_controller.insert(track)?; + Ok(handle) + } + + /// Sets the (post-effects) volume of the mixer track. + pub fn set_volume(&mut self, volume: impl Into>, tween: Tween) { + self.command_writers.set_volume.write(ValueChangeCommand { + target: volume.into(), + tween, + }) + } + + /// Sets the position that audio is produced from. + pub fn set_position(&mut self, position: impl Into>>, tween: Tween) { + let position: Value> = position.into(); + self.command_writers.set_position.write(ValueChangeCommand { + target: position.to_(), + tween, + }) + } + + /// Sets how much the track's output should be panned left or right depending on its + /// direction from the listener. + /// + /// This value should be between `0.0` and `1.0`. `0.0` disables spatialization + /// entirely. + pub fn set_spatialization_strength( + &mut self, + spatialization_strength: impl Into>, + tween: Tween, + ) { + self.command_writers + .set_spatialization_strength + .write(ValueChangeCommand { + target: spatialization_strength.into(), + tween, + }) + } + + /// Sets the volume of this track's route to a send track. + /// + /// This can only be used to change the volume of existing routes, + /// not to add new routes. + pub fn set_send( + &mut self, + to: impl Into, + volume: impl Into>, + tween: Tween, + ) -> Result<(), NonexistentRoute> { + let to = to.into(); + self.send_volume_command_writers + .get_mut(&to) + .ok_or(NonexistentRoute)? + .write(ValueChangeCommand { + target: volume.into(), + tween, + }); + Ok(()) + } + + /// Fades out the track to silence with the given tween and then + /// pauses playback, pausing all sounds and emitters playing on this + /// track. + pub fn pause(&mut self, tween: Tween) { + self.command_writers.pause.write(tween) + } + + /// Resumes playback and fades in the sound from silence + /// with the given tween, resuming all sounds and emitters + /// playing on this track. + pub fn resume(&mut self, tween: Tween) { + self.resume_at(StartTime::Immediate, tween) + } + + /// Resumes playback at the given start time and fades in + /// the sound from silence with the given tween. + pub fn resume_at(&mut self, start_time: StartTime, tween: Tween) { + self.command_writers.resume.write((start_time, tween)) + } + + /// Returns the maximum number of sounds that can play simultaneously on this track. + #[must_use] + pub fn sound_capacity(&self) -> usize { + self.sound_controller.capacity() + } + + /// Returns the number of sounds currently playing on this track. + #[must_use] + pub fn num_sounds(&self) -> usize { + self.sound_controller.len() + } + + /// Returns the maximum number of child tracks this track can have. + #[must_use] + pub fn sub_track_capacity(&self) -> usize { + self.sub_track_controller.capacity() + } + + /// Returns the number of child tracks this track has. + #[must_use] + pub fn num_sub_tracks(&self) -> usize { + self.sub_track_controller.len() + } +} + +impl Drop for SpatialTrackHandle { + fn drop(&mut self) { + self.shared.mark_for_removal(); + } +} diff --git a/third_party/kira/src/tween.rs b/third_party/kira/src/tween.rs new file mode 100644 index 00000000..eaf910d4 --- /dev/null +++ b/third_party/kira/src/tween.rs @@ -0,0 +1,112 @@ +//! Smooth interpolation between values. + +mod tweenable; + +pub use tweenable::*; + +use std::time::Duration; + +use crate::start_time::StartTime; + +/// Curves the motion of a [`Tween`]. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Easing { + /// Maintains a constant speed for the duration of the [`Tween`]. + Linear, + /// Causes the [`Tween`] to start slow and speed up. A higher + /// value causes the [`Tween`] to speed up more dramatically. + InPowi(i32), + /// Causes the [`Tween`] to start fast and slow down. A higher + /// value causes the [`Tween`] to slow down more dramatically. + OutPowi(i32), + /// Causes the [`Tween`] to start slow, speed up, and then slow + /// back down. A higher values causes the [`Tween`] to have more + /// dramatic speed changes. + InOutPowi(i32), + /// Causes the [`Tween`] to start slow and speed up. A higher + /// value causes the [`Tween`] to speed up more dramatically. + /// + /// This is similar to [`InPowi`](Easing::InPowi), but allows + /// for float intensity values at the cost of being more + /// CPU intensive. + InPowf(f64), + /// Causes the [`Tween`] to start fast and slow down. A higher + /// value causes the [`Tween`] to slow down more dramatically. + /// + /// This is similar to [`OutPowi`](Easing::InPowi), but allows + /// for float intensity values at the cost of being more + /// CPU intensive. + OutPowf(f64), + /// Causes the [`Tween`] to start slow, speed up, and then slow + /// back down. A higher values causes the [`Tween`] to have more + /// dramatic speed changes. + /// + /// This is similar to [`InOutPowi`](Easing::InPowi), but allows + /// for float intensity values at the cost of being more + /// CPU intensive. + InOutPowf(f64), +} + +impl Easing { + pub(crate) fn apply(&self, mut x: f64) -> f64 { + match self { + Easing::Linear => x, + Easing::InPowi(power) => x.powi(*power), + Easing::OutPowi(power) => 1.0 - Self::InPowi(*power).apply(1.0 - x), + Easing::InOutPowi(power) => { + x *= 2.0; + if x < 1.0 { + 0.5 * Self::InPowi(*power).apply(x) + } else { + x = 2.0 - x; + 0.5 * (1.0 - Self::InPowi(*power).apply(x)) + 0.5 + } + } + Easing::InPowf(power) => x.powf(*power), + Easing::OutPowf(power) => 1.0 - Self::InPowf(*power).apply(1.0 - x), + Easing::InOutPowf(power) => { + x *= 2.0; + if x < 1.0 { + 0.5 * Self::InPowf(*power).apply(x) + } else { + x = 2.0 - x; + 0.5 * (1.0 - Self::InPowf(*power).apply(x)) + 0.5 + } + } + } + } +} + +impl Default for Easing { + fn default() -> Self { + Self::Linear + } +} + +/// Describes a smooth transition between values. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Tween { + /// When the motion starts. + pub start_time: StartTime, + /// The duration of the motion. + pub duration: Duration, + /// The curve of the motion. + pub easing: Easing, +} + +impl Tween { + pub(super) fn value(&self, time: f64) -> f64 { + self.easing.apply(time / self.duration.as_secs_f64()) + } +} + +impl Default for Tween { + fn default() -> Self { + Self { + start_time: StartTime::default(), + duration: Duration::from_millis(10), + easing: Easing::Linear, + } + } +} diff --git a/third_party/kira/src/tween/tweenable.rs b/third_party/kira/src/tween/tweenable.rs new file mode 100644 index 00000000..f4ce25b1 --- /dev/null +++ b/third_party/kira/src/tween/tweenable.rs @@ -0,0 +1,46 @@ +use std::time::Duration; + +use glam::{Quat, Vec3}; + +/// A trait for types that can be smoothly interpolated. +pub trait Tweenable: Copy { + /// Returns an linearly interpolated value between `a` and `b`. + /// + /// An amount of `0.0` should yield `a`, an amount of `1.0` should + /// yield `b`, and an amount of `0.5` should yield a value halfway + /// between `a` and `b`. + #[must_use] + fn interpolate(a: Self, b: Self, amount: f64) -> Self; +} + +impl Tweenable for f32 { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + a + (b - a) * amount as f32 + } +} + +impl Tweenable for f64 { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + a + (b - a) * amount + } +} + +impl Tweenable for Vec3 { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + a + (b - a) * amount as f32 + } +} + +impl Tweenable for Quat { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + a.slerp(b, amount as f32) + } +} + +impl Tweenable for Duration { + fn interpolate(a: Self, b: Self, amount: f64) -> Self { + let a_secs = a.as_secs_f64(); + let b_secs = b.as_secs_f64(); + Duration::from_secs_f64(a_secs + (b_secs - a_secs) * amount) + } +} diff --git a/third_party/kira/src/value.rs b/third_party/kira/src/value.rs new file mode 100644 index 00000000..bb0827ca --- /dev/null +++ b/third_party/kira/src/value.rs @@ -0,0 +1,337 @@ +use std::{ + ops::{Add, Div, Mul, Neg, Rem, Sub}, + time::Duration, +}; + +use crate::{Easing, Tweenable, info::Info, modulator::ModulatorId}; + +/// A value that a parameter can be linked to. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Value { + /// A fixed value. + Fixed(T), + /// The value of a [`modulator`](crate::modulator). + FromModulator { + /// The modulator to link to. + id: ModulatorId, + /// How the modulator's value should be converted to the parameter's value. + mapping: Mapping, + }, + /// The distance of the current spatial track to a listener. + FromListenerDistance(Mapping), +} + +impl Value { + /// Creates a `Value::FromModulator` from a modulator ID or handle. + #[must_use] + pub fn from_modulator(id: impl Into, mapping: Mapping) -> Self { + Self::FromModulator { + id: id.into(), + mapping, + } + } + + /// Converts a `Value` to a `Value`. + #[must_use = "This method returns a new Value and does not mutate the original."] + pub fn to_>(self) -> Value { + match self { + Value::Fixed(value) => Value::Fixed(value.into()), + Value::FromModulator { id, mapping } => Value::FromModulator { + id, + mapping: mapping.to_(), + }, + Value::FromListenerDistance(mapping) => Value::FromListenerDistance(mapping.to_()), + } + } +} + +impl Value +where + T: Tweenable, +{ + pub(crate) fn raw_value(self, info: &Info) -> Option { + match self { + Value::Fixed(value) => Some(value), + Value::FromModulator { id, mapping } => { + info.modulator_value(id).map(|value| mapping.map(value)) + } + Value::FromListenerDistance(mapping) => info + .listener_distance() + .map(|value| mapping.map(value.into())), + } + } +} + +impl From for Value { + fn from(value: f32) -> Self { + Self::Fixed(value) + } +} + +impl From for Value { + fn from(value: f64) -> Self { + Self::Fixed(value) + } +} + +impl From for Value> +where + T: Into>, +{ + fn from(value: T) -> Self { + Self::Fixed(value.into()) + } +} + +impl From for Value> +where + T: Into>, +{ + fn from(value: T) -> Self { + Self::Fixed(value.into()) + } +} + +impl From for Value { + fn from(value: Duration) -> Self { + Self::Fixed(value) + } +} + +impl Default for Value { + fn default() -> Self { + Self::Fixed(T::default()) + } +} + +impl Add for Value +where + T: Add, + Rhs: Copy, +{ + type Output = Self; + + fn add(self, rhs: Rhs) -> Self::Output { + match self { + Value::Fixed(value) => Self::Fixed(value + rhs), + Value::FromModulator { id, mapping } => Value::FromModulator { + id, + mapping: mapping.add_output(rhs), + }, + Value::FromListenerDistance(mapping) => { + Value::FromListenerDistance(mapping.add_output(rhs)) + } + } + } +} + +impl Sub for Value +where + T: Sub, + Rhs: Copy, +{ + type Output = Self; + + fn sub(self, rhs: Rhs) -> Self::Output { + match self { + Value::Fixed(value) => Self::Fixed(value - rhs), + Value::FromModulator { id, mapping } => Value::FromModulator { + id, + mapping: mapping.sub_output(rhs), + }, + Value::FromListenerDistance(mapping) => { + Value::FromListenerDistance(mapping.sub_output(rhs)) + } + } + } +} + +impl Mul for Value +where + T: Mul, + Rhs: Copy, +{ + type Output = Self; + + fn mul(self, rhs: Rhs) -> Self::Output { + match self { + Value::Fixed(value) => Self::Fixed(value * rhs), + Value::FromModulator { id, mapping } => Value::FromModulator { + id, + mapping: mapping.mul_output(rhs), + }, + Value::FromListenerDistance(mapping) => { + Value::FromListenerDistance(mapping.mul_output(rhs)) + } + } + } +} + +impl Div for Value +where + T: Div, + Rhs: Copy, +{ + type Output = Self; + + fn div(self, rhs: Rhs) -> Self::Output { + match self { + Value::Fixed(value) => Self::Fixed(value / rhs), + Value::FromModulator { id, mapping } => Value::FromModulator { + id, + mapping: mapping.div_output(rhs), + }, + Value::FromListenerDistance(mapping) => { + Value::FromListenerDistance(mapping.div_output(rhs)) + } + } + } +} + +impl Rem for Value +where + T: Rem, + Rhs: Copy, +{ + type Output = Self; + + fn rem(self, rhs: Rhs) -> Self::Output { + match self { + Value::Fixed(value) => Self::Fixed(value % rhs), + Value::FromModulator { id, mapping } => Value::FromModulator { + id, + mapping: mapping.rem_output(rhs), + }, + Value::FromListenerDistance(mapping) => { + Value::FromListenerDistance(mapping.rem_output(rhs)) + } + } + } +} + +impl Neg for Value +where + T: Neg, +{ + type Output = Self; + + fn neg(self) -> Self::Output { + match self { + Value::Fixed(value) => Self::Fixed(-value), + Value::FromModulator { id, mapping } => Value::FromModulator { + id, + mapping: mapping.neg_output(), + }, + Value::FromListenerDistance(mapping) => { + Value::FromListenerDistance(mapping.neg_output()) + } + } + } +} + +/// A transformation from a modulator's value to a parameter value. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Mapping { + /// A range of values from a modulator. + pub input_range: (f64, f64), + /// The corresponding range of values of the parameter. + pub output_range: (T, T), + /// The curve to apply to the output. + pub easing: Easing, +} + +impl Mapping { + /// Converts a `Mapping` to a `Mapping`. + #[must_use = "This method returns a new Mapping and does not mutate the original."] + pub fn to_>(self) -> Mapping { + Mapping { + input_range: self.input_range, + output_range: (self.output_range.0.into(), self.output_range.1.into()), + easing: self.easing, + } + } + + /// Transforms an input value to an output value using this mapping. + #[must_use] + pub fn map(self, input: f64) -> T + where + T: Tweenable, + { + let mut amount = (input - self.input_range.0) / (self.input_range.1 - self.input_range.0); + amount = amount.clamp(0.0, 1.0); + amount = self.easing.apply(amount); + T::interpolate(self.output_range.0, self.output_range.1, amount) + } + + /// Adds `rhs` to the minimum and maximum values of the output range. + pub fn add_output(self, rhs: Rhs) -> Self + where + T: Add, + Rhs: Copy, + { + Self { + output_range: (self.output_range.0 + rhs, self.output_range.1 + rhs), + ..self + } + } + + /// Subtracts `rhs` from the minimum and maximum values of the output range. + pub fn sub_output(self, rhs: Rhs) -> Self + where + T: Sub, + Rhs: Copy, + { + Self { + output_range: (self.output_range.0 - rhs, self.output_range.1 - rhs), + ..self + } + } + + /// Multiplies the minimum and maximum values of the output range by `rhs`. + pub fn mul_output(self, rhs: Rhs) -> Self + where + T: Mul, + Rhs: Copy, + { + Self { + output_range: (self.output_range.0 * rhs, self.output_range.1 * rhs), + ..self + } + } + + /// Divides the minimum and maximum values of the output range by `rhs`. + pub fn div_output(self, rhs: Rhs) -> Self + where + T: Div, + Rhs: Copy, + { + Self { + output_range: (self.output_range.0 / rhs, self.output_range.1 / rhs), + ..self + } + } + + /// Sets the minimum and maximum values of the output range to `x % rhs`. + pub fn rem_output(self, rhs: Rhs) -> Self + where + T: Rem, + Rhs: Copy, + { + Self { + output_range: (self.output_range.0 % rhs, self.output_range.1 % rhs), + ..self + } + } + + /// Negates the minimum and maximum values of the output range. + pub fn neg_output(self) -> Self + where + T: Neg, + { + Self { + output_range: (-self.output_range.0, -self.output_range.1), + ..self + } + } +} From 2fed24925258f1193fe4e1ccf501e518bf167f14 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 4 Sep 2026 20:55:55 +0700 Subject: [PATCH 11/78] the chat and spellbook go stock, and hidden props park their meshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the chat window is the reference's own `ChatFrame.xml` and `FloatingChatFrame.xml`, run off the player's chain; our 2216-line transcription is deleted, the feed fires `CHAT_MSG_*` and the reference's `ChatFrame_OnEvent` prints — no app-side composition into the windows, no routing table, no whisper chime or flash of ours, and the transcribed edit-box machine (sticky type, tell ring, Tab cycle) retires with it; the verbs the two files call are built to the bytes — the whole `0x98` chat-window record and its eight verbs, the 94-entry chat-type colour registry, the language walk, the channel verbs as a command queue, the slash handlers' emote/roll/assist/uninvite/console/logging verbs; `chat-cache.txt` is written and read in the reference's whole grammar, the 68-entry message-group table in flag order and the no-file boot included, and the chat bindings and macro lines run through the reference's own arms (1948) - the spellbook is the reference's own `SpellBookFrame.xml`; our 965-line transcription is deleted, and two quirks it reproduced by hand (the pet page ignores its page number, its arrows write the spell book's counter) now come from the reference's lines; one adapter remains because the reference's autocast shine is a `` that draws no pixels in this engine — the script puts the token-texture shine beside each button's model and carries it with the model's shown state; the nine spell-slot bindings (`GetSpellName`, `CastSpell`, `PickupSpell` and their kin) now parse their arguments the way the reference's shared marshaller `0x4b3ec0` does — a numeric arg 1, a mandatory arg 2, the index gated to [0, 0x400) with "Invalid spell slot in " on a miss, "pet" matched without case — where ours raised mlua's own message on a negative id, answered nil for 0 and above 1024, and read the wrong book for "PET" (1952) - a long-hidden world model part parks its `Mesh3d`: the entity path keeps the batches the retained pass declines resident — 5.3k hidden submeshes at any instant at the Stormwind auction house — and Bevy's sweeps walk every `Mesh3d` whether it draws or not, so a part parks after 30 consecutive hidden frames and unparks the frame its verdict turns visible; every world model part takes part, units' bodies included, while a booth's mirror parts stay out; interleaved pairs read an alone Stormwind 0.1–0.4 ms lighter, and `WOW_NO_MESH_PARK=1` is the A/B; the audio thread's realtime class was the standing suspect for the raid's `cpu_p99` spikes with sound on, and `WOW_AUDIO_NO_RT=1` across two interleaved Blackrock pairs read no difference, so the lever stays as an instrument (1949); and a no-merge prop batch now says which rule declined it — animated (1551 at the auction house) or transparent, not order-free (340) (1951) - the probe reads CPU per thread across its window (user and system split, because a user-mode sampler never sees kernel time) and the main thread's CPU inside the app's frame beside its whole-frame CPU; together they place the vsync tax every player pays by default — cpu_ms 10.7 uncapped against 12.5 under vsync, alone in Stormwind, with no system growing and no thread spinning: it lands on the main thread, in user time, inside the schedules, the same systems each running slower, and a low-priority spinner holding one core busy erases it to the last decimal (vsync + spinner 10.18 against uncapped 10.17), which is frequency scaling — under vsync the cores idle deeper between frames and the next burst starts at a lower clock; nothing in the client is the cause and nothing in the client changes for it (1950) Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/assets/ui/ChatFrame.xml | 2216 ----------------- crates/benilla-app/assets/ui/OptionsFrame.xml | 24 + .../benilla-app/assets/ui/ScriptLogFrame.xml | 27 + .../assets/ui/SpellBookAdapters.xml | 55 + .../benilla-app/assets/ui/SpellBookFrame.xml | 965 ------- crates/benilla-app/assets/ui/UIParent.xml | 6 + crates/benilla-app/assets/ui/benilla.toc | 32 +- crates/benilla-app/src/bindings/commands.rs | 95 +- crates/benilla-app/src/capture/fixtures.rs | 6 +- .../src/capture/probes/live_fps.rs | 47 +- crates/benilla-app/src/local_state.rs | 6 + crates/benilla-app/src/net.rs | 54 + crates/benilla-app/src/net/io.rs | 27 + crates/benilla-app/src/perf/clock.rs | 85 + crates/benilla-app/src/perf/main_split.rs | 49 + crates/benilla-app/src/perf/mod.rs | 5 +- crates/benilla-app/src/sound/output/mod.rs | 13 +- crates/benilla-app/src/target/click.rs | 7 + crates/benilla-app/src/ui_chat.rs | 26 +- crates/benilla-app/src/ui_chat/channels.rs | 83 +- crates/benilla-app/src/ui_chat/edit.rs | 534 +--- crates/benilla-app/src/ui_chat/event.rs | 134 +- crates/benilla-app/src/ui_chat/feed.rs | 71 +- crates/benilla-app/src/ui_chat/frames.rs | 190 +- crates/benilla-app/src/ui_chat/input/mod.rs | 206 +- crates/benilla-app/src/ui_chat/input/parse.rs | 44 +- crates/benilla-app/src/ui_chat/logging.rs | 148 ++ crates/benilla-app/src/ui_chat/settings.rs | 790 ++++-- crates/benilla-app/src/ui_chat/tests.rs | 351 +-- crates/benilla-app/src/ui_macro/tests.rs | 72 +- .../benilla-app/src/ui_script/cast_tests.rs | 11 +- .../src/ui_script/chat_options_tests.rs | 182 +- .../src/ui_script/chat_resize_tests.rs | 289 ++- .../benilla-app/src/ui_script/chat_tests.rs | 205 +- .../src/ui_script/dressup_tests.rs | 21 +- .../benilla-app/src/ui_script/escape_tests.rs | 18 +- .../src/ui_script/frame_flag_gate.rs | 96 +- .../src/ui_script/group_loot_tests.rs | 9 +- .../benilla-app/src/ui_script/loot_tests.rs | 9 +- .../src/ui_script/merchant_tests.rs | 9 +- crates/benilla-app/src/ui_script/mod.rs | 27 + .../src/ui_script/options_tests.rs | 7 +- .../src/ui_script/petition_tests.rs | 5 +- .../benilla-app/src/ui_script/quest_tests.rs | 6 +- .../src/ui_script/questlog_tests.rs | 20 +- .../benilla-app/src/ui_script/reference_ui.rs | 180 +- .../src/ui_script/shipped_xml_tests.rs | 48 +- .../src/ui_script/spellbook_tests.rs | 163 +- crates/benilla-app/src/ui_script/test_ui.rs | 6 +- crates/benilla-app/src/ui_unit.rs | 105 +- crates/benilla-formats/src/languages.rs | 54 + crates/benilla-formats/src/lib.rs | 3 +- crates/benilla-ui/src/script/channel.rs | 395 +++ crates/benilla-ui/src/script/chat_misc.rs | 285 +++ crates/benilla-ui/src/script/chat_send.rs | 84 + crates/benilla-ui/src/script/chat_types.rs | 379 +++ crates/benilla-ui/src/script/chat_window.rs | 640 ++++- crates/benilla-ui/src/script/cvars.rs | 50 +- .../benilla-ui/src/script/editbox/methods.rs | 20 + .../src/script/messageframe/scrolling.rs | 68 +- crates/benilla-ui/src/script/mod.rs | 9 +- crates/benilla-ui/src/script/model.rs | 37 + crates/benilla-ui/src/script/spellbook.rs | 146 +- crates/benilla-ui/src/script/unit/mod.rs | 4 + .../src/widget/kinds/messageframe.rs | 31 + crates/benilla-world/src/model_render.rs | 6 + crates/benilla-world/src/model_render/park.rs | 157 ++ .../src/terrain_stream/spawn/assemble.rs | 15 +- 68 files changed, 4709 insertions(+), 5458 deletions(-) delete mode 100644 crates/benilla-app/assets/ui/ChatFrame.xml create mode 100644 crates/benilla-app/assets/ui/SpellBookAdapters.xml delete mode 100644 crates/benilla-app/assets/ui/SpellBookFrame.xml create mode 100644 crates/benilla-app/src/perf/main_split.rs create mode 100644 crates/benilla-app/src/ui_chat/logging.rs create mode 100644 crates/benilla-ui/src/script/chat_misc.rs create mode 100644 crates/benilla-ui/src/script/chat_types.rs create mode 100644 crates/benilla-world/src/model_render/park.rs diff --git a/crates/benilla-app/assets/ui/ChatFrame.xml b/crates/benilla-app/assets/ui/ChatFrame.xml deleted file mode 100644 index feb5d139..00000000 --- a/crates/benilla-app/assets/ui/ChatFrame.xml +++ /dev/null @@ -1,2216 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if this:GetID() == 1 then - this:RegisterEvent("EXECUTE_CHAT_LINE") - this:RegisterEvent("UPDATE_CHAT_WINDOWS") - end - -- `isDocked` is NOT set here, and that is the reference's own shape: the flag is - -- `FCF_DockFrame`'s to write (FloatingChatFrame.lua:1153), and the chat-cache - -- load is what calls it — this file's trailing seeding loop, which reads - -- `GetChatWindowInfo`'s `docked` position exactly as `FloatingChatFrame_Update` - -- (l.34-70) does. - -- - -- It briefly lived here (1712), gated to ids 1 and 2, while there was no dock - -- model to own it. There is one now (1714), and leaving the assignment here would - -- break it outright: `FCF_DockFrame` early-returns on an already-docked frame, so - -- a pre-set flag would keep both windows out of `DOCKED_CHAT_FRAMES` — docked - -- according to the flag, absent from the list every dock verb walks. - - - if event == "EXECUTE_CHAT_LINE" then - SubmitChatInput(arg1) - elseif event == "UPDATE_CHAT_WINDOWS" then - BenillaFCF_LoadWindowLooks() - end - - - ChatFrame_OnUpdate(arg1) - - - -- WoW wheel convention: +1 per notch up, -1 down. Up = older messages. - if arg1 > 0 then this:ScrollUp() else this:ScrollDown() end - - - BenillaChatFrame_DismissCursor(arg1) - - - SetItemRef(arg1, arg2, arg3) - - - - FCF_StartMove(this) - - - FCF_StopMove(this) - - - - - - - - - - - - - - - - - - - - - - - - FCF_OnUpdate(arg1) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - BenillaOnEmoteMenuLoad(BenillaEmoteMenuList) - - - - - - - BenillaOnEmoteMenuLoad(BenillaVoiceMacroList) - - - - - - - ChatMenu_OnLoad() - - - UIMenu_OnShow() - EmoteMenu:Hide() - VoiceMacroMenu:Hide() - this:SetBackdropBorderColor(TOOLTIP_DEFAULT_COLOR.r, TOOLTIP_DEFAULT_COLOR.g, TOOLTIP_DEFAULT_COLOR.b) - this:SetBackdropColor(TOOLTIP_DEFAULT_BACKGROUND_COLOR.r, TOOLTIP_DEFAULT_BACKGROUND_COLOR.g, TOOLTIP_DEFAULT_BACKGROUND_COLOR.b) - - - - - - - - - - - - diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 2de6e879..532d863f 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -220,6 +220,23 @@ RegisterForSave("QUEST_FADING_DISABLE") AUTO_QUEST_WATCH = "1" RegisterForSave("AUTO_QUEST_WATCH") + -- CHAT_LOCKED / REMOVE_CHAT_DELAY — the chat window's two uvars, the reference's own + -- UIOptionsFrame.lua l.96-99 defaults ("0" and "0"), read every frame by + -- FloatingChatFrame.lua's FCF_Get_ChatLocked and FCF_OnUpdate since the chat window went + -- stock (1948). Declared here because this is where the reference declares them. + CHAT_LOCKED = "0" + RegisterForSave("CHAT_LOCKED") + REMOVE_CHAT_DELAY = "0" + RegisterForSave("REMOVE_CHAT_DELAY") + + -- The Remove Chat Hover Delay row's applyFunc: the reference's own line + -- (UIOptionsFrame.lua l.216, `SetChatMouseOverDelay(REMOVE_CHAT_DELAY)`) under the name the + -- row resolves at apply time. `SetChatMouseOverDelay` is FloatingChatFrame.lua's. + function ChatFrame_ApplyMouseOverDelay() + if SetChatMouseOverDelay then -- a VM that loaded this file without the chat files — a test's + SetChatMouseOverDelay(REMOVE_CHAT_DELAY) + end + end UIOptionsFrameCheckButtons = { }; ------------------- Basic Options ------------------- @@ -3977,4 +3994,11 @@ + + diff --git a/crates/benilla-app/assets/ui/ScriptLogFrame.xml b/crates/benilla-app/assets/ui/ScriptLogFrame.xml index 21c9a628..89a0cab0 100644 --- a/crates/benilla-app/assets/ui/ScriptLogFrame.xml +++ b/crates/benilla-app/assets/ui/ScriptLogFrame.xml @@ -377,4 +377,31 @@ BenillaScriptLogFrame_OnUpdate() + + diff --git a/crates/benilla-app/assets/ui/SpellBookAdapters.xml b/crates/benilla-app/assets/ui/SpellBookAdapters.xml new file mode 100644 index 00000000..d37fa3be --- /dev/null +++ b/crates/benilla-app/assets/ui/SpellBookAdapters.xml @@ -0,0 +1,55 @@ + + + + diff --git a/crates/benilla-app/assets/ui/SpellBookFrame.xml b/crates/benilla-app/assets/ui/SpellBookFrame.xml deleted file mode 100644 index eb295a13..00000000 --- a/crates/benilla-app/assets/ui/SpellBookFrame.xml +++ /dev/null @@ -1,965 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - SpellBookSkillLineTab_OnClick() - - GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - if self.tooltip then - GameTooltip:SetText(self.tooltip) - GameTooltip:Show() - end - - GameTooltip:Hide() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - SpellButton_OnLoad() - SpellButton_OnEvent(event) - SpellButton_OnClick() - BenillaSpellButton_UpdateButton(self) - - SpellButton_OnClick(1) - SpellButton_OnClick(1) - BenillaSpellButton_OnEnter(self) - GameTooltip:Hide() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - SpellBookFrame_OnLoad() - SpellBookFrame_OnEvent() - SpellBookFrame_OnShow() - SpellBookFrame_OnHide() - - - diff --git a/crates/benilla-app/assets/ui/UIParent.xml b/crates/benilla-app/assets/ui/UIParent.xml index ebfead66..1ae63d3c 100644 --- a/crates/benilla-app/assets/ui/UIParent.xml +++ b/crates/benilla-app/assets/ui/UIParent.xml @@ -40,6 +40,12 @@ this:RegisterEvent("CONFIRM_LOOT_ROLL") UIParent_OnEvent(event) + + if FCF_OnUpdate then FCF_OnUpdate(arg1) end diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index 6f087c4c..3201901f 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -614,7 +614,7 @@ Interface\FrameXML\DurabilityFrame.xml # shadowed name with no caller is building against a guess" rule (1222). That rule is about names # WE would invent; this one is the reference's own declaration, so there is nothing left to guess. # -# `ChatTypeInfo["RAID_WARNING"]` comes from ChatFrame.xml far below — read at call time, so the +# `ChatTypeInfo["RAID_WARNING"]` comes from the reference's ChatFrame.xml far below — read at call time, so the # order does not bind. Interface\FrameXML\UIErrorsFrame.xml Interface\FrameXML\RaidWarning.xml @@ -798,11 +798,18 @@ Interface\AddOns\Blizzard_InspectUI\InspectHonorFrame.xml # is a global resolved at call time. Needs UiPanels (the panel manager, its left-slot row) and # Fonts; its booth pane needs nothing loaded at all. DressUpFrame.xml -# The spellbook window (decision 0216 §8, slice 5): the P-key window, the spell SOURCE -# for the cursor-payload arc. Loaded right after CharacterFrame (the same "player-state -# windows grouped" posture, same Fonts/UiPanels/GameTooltip-only dependency set); the -# 'P' binding (ui_script/input.rs) calls its bare ToggleSpellBook(BOOKTYPE_SPELL). -SpellBookFrame.xml +# ══ THE SPELLBOOK — the reference's own, off the player's chain (1751; decision 1952) ══ +# SpellBookFrame.xml + SpellBookFrame.lua: the book, the twelve page buttons, the skill-line tabs, +# the Spell/Pet toggle tabs. Its whole engine surface was already ours (1924's UpdateSpells, 1931's +# GetSpellTabInfo tuple); what stopped it in 1926 — MultiActionBar_ShowAllGrids/HideAllGrids on +# show/hide — has been the reference's MultiActionBars.lua since 1938. Loaded here, after the +# action-bar files it calls into and after MicroMenu (UpdateMicroButtons on show). The 'P' binding +# calls its own ToggleSpellBook(BOOKTYPE_SPELL). 0216's transcription is gone. +Interface\FrameXML\SpellBookFrame.xml +# The one adapter the reference's file needs from this engine: its pet-page autocast shine is a +# (UI-AutoCastButton.mdx) and this engine's draws no pixels; the shine benilla +# draws is its own token texture (1383/1392/1393). BENILLA_ALLOW_OWN_UI=1. +SpellBookAdapters.xml # ══ THE TALENT WINDOW — the reference's own, off the player's chain (1751, window 25) ══ # The second LoadOnDemand addon (1832 was the first, and its header carries the mechanics). # `Blizzard_TalentUI` is FIVE files behind one manifest line: the `.xml` sources its own `.lua` @@ -1020,7 +1027,18 @@ Interface\FrameXML\QuestLogFrame.xml # and SecondsToTime), GameTooltip (its row hover), and QuestLogFrame — whose window a row # click opens, and whose MAX_QUESTS it shares. Interface\FrameXML\QuestTimerFrame.xml -ChatFrame.xml +# ══ THE CHAT WINDOW — the reference's own, off the player's chain (1751; decision 1948) ══ +# ChatFrame.xml (ChatFrameEditBox, ChatMenu/EmoteMenu/LanguageMenu/VoiceMacroMenu, ChatFrameMenuButton, +# the frame/tab/edit-box templates, ChatFrame.lua) then FloatingChatFrame.xml (ChatFrame1..7, their tabs +# and dock regions, the tab dropdown, FloatingChatFrame.lua) — the reference's pair in its own order +# (FrameXML.toc l.48-49). What they call and we built for them: the window record verbs and the colour +# registry (benilla-ui script/chat_window.rs, chat_types.rs), the channel verbs (channel.rs), the +# language walk (chat_send.rs), the slash-handler verbs (chat_misc.rs), and UpdateColorByID. Our own +# transcription (0288's ChatFrame.xml) is gone; benilla's instrument commands (/chattest, /shot, …) +# register through SlashCmdList from ScriptLogFrame.xml's dev block, and the app's chat feed FIRES the +# CHAT_MSG_* events the reference's ChatFrame_OnEvent prints from — it no longer prints them itself. +Interface\FrameXML\ChatFrame.xml +Interface\FrameXML\FloatingChatFrame.xml # ══ THE MACRO WINDOW — the reference's own, off the player's chain (1751, window 32) ══ # The fourth LoadOnDemand addon (1832's header carries the mechanics). ONE manifest line: its toc # lists `Blizzard_MacroUI.xml`, which sources its own `.lua`, plus the empty `Localization.lua`. diff --git a/crates/benilla-app/src/bindings/commands.rs b/crates/benilla-app/src/bindings/commands.rs index 8e44e55f..253505d4 100644 --- a/crates/benilla-app/src/bindings/commands.rs +++ b/crates/benilla-app/src/bindings/commands.rs @@ -129,10 +129,6 @@ pub(crate) mod cmd { pub(crate) const TOGGLE_SHEATH: Cmd = by_name("TOGGLESHEATH"); pub(crate) const TOGGLE_AUTORUN: Cmd = by_name("TOGGLEAUTORUN"); pub(crate) const TOGGLE_RUN: Cmd = by_name("TOGGLERUN"); - pub(crate) const OPEN_CHAT: Cmd = by_name("OPENCHAT"); - pub(crate) const OPEN_CHAT_SLASH: Cmd = by_name("OPENCHATSLASH"); - pub(crate) const REPLY: Cmd = by_name("REPLY"); - pub(crate) const REPLY2: Cmd = by_name("REPLY2"); pub(crate) const TARGET_NEAREST_ENEMY: Cmd = by_name("TARGETNEARESTENEMY"); pub(crate) const TARGET_PREVIOUS_ENEMY: Cmd = by_name("TARGETPREVIOUSENEMY"); pub(crate) const NAMEPLATES: Cmd = by_name("NAMEPLATES"); @@ -191,34 +187,89 @@ const TABLE: &[Spec] = &[ None ), // ── Chat (BINDING_HEADER_CHAT) ────────────────────────────────────────────────────── - spec!("OPENCHAT", CHAT, Kind::Host, Some("ENTER"), None), - spec!("OPENCHATSLASH", CHAT, Kind::Host, Some("/"), None), + spec!( + "OPENCHAT", + CHAT, + Kind::Edge("ChatFrame_OpenChat(\"\")"), + Some("ENTER"), + None + ), + spec!( + "OPENCHATSLASH", + CHAT, + Kind::Edge("ChatFrame_OpenChat(\"/\")"), + Some("/"), + None + ), spec!( "CHATPAGEUP", CHAT, - Kind::Edge(r#"getglobal("ChatFrame" .. BenillaFCF.selected):PageUp()"#), + Kind::Edge("ChatFrame_ChatPageUp()"), Some("PAGEUP"), None ), spec!( "CHATPAGEDOWN", CHAT, - Kind::Edge(r#"getglobal("ChatFrame" .. BenillaFCF.selected):PageDown()"#), + Kind::Edge("ChatFrame_ChatPageDown()"), Some("PAGEDOWN"), None ), spec!( "CHATBOTTOM", CHAT, - Kind::Edge(r#"getglobal("ChatFrame" .. BenillaFCF.selected):ScrollToBottom()"#), + Kind::Edge("ChatFrame_ScrollToBottom()"), Some("SHIFT-PAGEDOWN"), None ), - spec!("REPLY", CHAT, Kind::Host, Some("R"), None), + spec!( + "REPLY", + CHAT, + Kind::Edge("ChatFrame_ReplyTell()"), + Some("R"), + None + ), // The other reply: the last person YOU told, not the last who told you // (`ChatEdit_GetLastToldTarget`, ChatFrame.lua l.1650). The memory was already being kept by // the send path and read by nothing — 1745. - spec!("REPLY2", CHAT, Kind::Host, Some("SHIFT-R"), None), + spec!( + "REPLY2", + CHAT, + Kind::Edge("ChatFrame_ReplyTell2()"), + Some("SHIFT-R"), + None + ), + // The combat-log four (Bindings.xml l.108-119): ChatFrame2's own paging and the reference's + // `ToggleCombatLog`, both FloatingChatFrame.lua's since the chat window became the + // reference's (1948). Chords from `bindings-cache.wtf`, account ONE. + spec!( + "COMBATLOGPAGEUP", + CHAT, + Kind::Edge("ChatFrame2:PageUp()"), + Some("CTRL-PAGEUP"), + None + ), + spec!( + "COMBATLOGPAGEDOWN", + CHAT, + Kind::Edge("ChatFrame2:PageDown()"), + Some("CTRL-PAGEDOWN"), + None + ), + spec!( + "COMBATLOGBOTTOM", + CHAT, + Kind::Edge("ChatFrame2:ScrollToBottom()"), + Some("CTRL-SHIFT-PAGEDOWN"), + None + ), + spec!( + "TOGGLECOMBATLOG", + CHAT, + Kind::Edge("ToggleCombatLog()"), + Some("SHIFT-C"), + None + ), // ── Action bar (BINDING_HEADER_ACTIONBAR) ─────────────────────────────────────────── // The ref's runOnUp pair (Bindings.xml:121: DOWN shows the pushed visual, UP fires) — // exactly what the old hardcoded number-row table sent. @@ -1776,28 +1827,6 @@ pub(crate) static ABSENT: &[Absent] = &[ ["PitchDownStart", "PitchDownStop"], "no keyboard pitch — see PITCHUP" ), - // ── Chat ──────────────────────────────────────────────────────────────────────────── - absent!( - "COMBATLOGPAGEUP", - ["ToggleCombatLog"], - "ChatFrame2 is a real frame that nothing writes into — the combat-log line pipeline does \ - not exist, which is why 1.12's own ToggleCombatLog has no home here" - ), - absent!( - "COMBATLOGPAGEDOWN", - ["ToggleCombatLog"], - "nothing writes the combat log — see COMBATLOGPAGEUP" - ), - absent!( - "COMBATLOGBOTTOM", - ["ToggleCombatLog"], - "nothing writes the combat log — see COMBATLOGPAGEUP" - ), - absent!( - "TOGGLECOMBATLOG", - ["ToggleCombatLog"], - "nothing writes the combat log — see COMBATLOGPAGEUP" - ), // ── Action bar ────────────────────────────────────────────────────────────────────── // ── Interface ─────────────────────────────────────────────────────────────────────── absent!( diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index 576bc935..7cc40392 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -1254,9 +1254,9 @@ pub(super) fn seed_ui_fixture( "ChatFrameEditBox:SetText(\"hello northshire\")\n\ ChatFrameEditBox:HighlightText(6, 16)\n\ ChatFrame1:SetScript('OnUpdate', function()\n\ - BenillaFCF_TabResize(ChatFrame1Tab)\n\ + PanelTemplates_TabResize(10, ChatFrame1Tab)\n\ ChatFrame1Tab:SetAlpha(1.0)\n\ - for _, t in ipairs(BenillaFCF_Textures(1)) do t:SetAlpha(0.25) end\n\ + FCF_SetWindowAlpha(ChatFrame1, 0.25, 1)\n\ end)", ) { warn!("capture: ui-chatedit seed failed: {e}"); @@ -1293,7 +1293,7 @@ pub(super) fn seed_ui_fixture( // a tab quad is measured against let mode = std::env::var("WOW_TABHOVER").unwrap_or_else(|_| "1".into()); let select = if mode == "3" { 1 } else { 2 }; - if let Err(e) = script.run(&format!("BenillaFCF_TabClick({select})")) { + if let Err(e) = script.run(&format!("FCF_SelectDockFrame(ChatFrame{select})")) { warn!("capture: ui-chat-tabhover select failed: {e}"); } script.resolve(); diff --git a/crates/benilla-app/src/capture/probes/live_fps.rs b/crates/benilla-app/src/capture/probes/live_fps.rs index a066fd3b..454a7a56 100644 --- a/crates/benilla-app/src/capture/probes/live_fps.rs +++ b/crates/benilla-app/src/capture/probes/live_fps.rs @@ -57,6 +57,7 @@ impl Plugin for LiveFpsPlugin { decodes_prev: 0, load_samples: Vec::new(), paced_samples: Vec::new(), + threads_at_start: None, cpu_at_start: None, sys_at_start: None, occluded_now: false, @@ -108,6 +109,9 @@ struct LiveFps { /// snaps a cadenced delta to whole refreshes; a paced delta of two periods is a frame the /// display actually skipped. paced_samples: Vec, + /// Per-thread CPU at the window's first frame (`perf::thread_cpu_table`), keyed by pthread + /// identity — subtracted at the window's end for `thr=[name:ms/frame,…]`. + threads_at_start: Option>, /// 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, @@ -169,6 +173,7 @@ type ScreenParams<'w, 's> = ( MessageReader<'w, 's, AssetEvent>, MessageReader<'w, 's, AssetEvent>, Res<'w, Time>, + ResMut<'w, crate::perf::MainThreadSplit>, ); #[derive(SystemParam)] @@ -223,6 +228,7 @@ fn drive_live_fps( ); let (mesh_events, mat_events) = (&mut screen.7, &mut screen.8); let paced_ms = screen.9.delta_secs() * 1000.0; + let main_split = &mut screen.10; // Read every frame (a reader that only reads inside the window would report the whole // backlog on its first sampled frame). let mesh_added = mesh_events @@ -295,6 +301,8 @@ fn drive_live_fps( LiveFpsPhase::Sampling => { if probe.samples.is_empty() { probe.cpu_at_start = crate::perf::process_cpu_secs(); + probe.threads_at_start = crate::perf::thread_cpu_table(); + main_split.restart(); probe.sys_at_start = crate::perf::system_cpu_ticks(); // The churn census restarts with the window — warmup noise (streaming, shader // warms) would otherwise read as steady-state ratchets. @@ -427,6 +435,7 @@ fn drive_live_fps( }; // 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 v_len = v.len(); 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; @@ -463,9 +472,45 @@ fn drive_live_fps( }) .collect::>() .join(","); + // Per thread, ms per frame over the window — the split that says which + // thread a tax landed on; threads sharing a name (a pool) are summed. + let threads = match (&probe.threads_at_start, crate::perf::thread_cpu_table()) { + (Some(t0), Some(t1)) => { + let start: std::collections::HashMap = + t0.iter().map(|(k, _, u, s)| (*k, (*u, *s))).collect(); + let mut by_name: std::collections::HashMap = + std::collections::HashMap::new(); + for (k, name, u1, s1) in &t1 { + let (u0, s0) = start.get(k).copied().unwrap_or((0.0, 0.0)); + let name = match name.rfind(" (") { + Some(i) if name.ends_with(')') => name[..i].to_string(), + _ => name.clone(), + }; + let e = by_name.entry(name).or_default(); + e.0 += u1 - u0; + e.1 += s1 - s0; + } + let mut v: Vec<(String, (f64, f64))> = by_name.into_iter().collect(); + v.sort_by(|a, b| (b.1 .0 + b.1 .1).total_cmp(&(a.1 .0 + a.1 .1))); + let per = |secs: f64| secs * 1000.0 / v_len as f64; + // `name:user+sys`, ms per frame. + let list = v + .iter() + .take(8) + .map(|(n, (u, s))| format!("{n}:{:.2}+{:.2}", per(*u), per(*s))) + .collect::>() + .join(","); + let inside = main_split + .inside_ms() + .map(|m| format!(" main_in={m:.2}")) + .unwrap_or_default(); + format!(" thr=[{list}]{inside}") + } + _ => String::new(), + }; format!( " cpu_ms={per_frame_ms:.2} cpu_pct={:.0} cpu_p95={:.2} cpu_p99={:.2} \ - frames_over={over} dropped={dropped} paced_max={paced_max:.1} worst=[{worst}]", + frames_over={over} dropped={dropped} paced_max={paced_max:.1} worst=[{worst}]{threads}", per_frame_ms / mean as f64 * 100.0, cat(0.95), cat(0.99), diff --git a/crates/benilla-app/src/local_state.rs b/crates/benilla-app/src/local_state.rs index 381cb650..7087fcfb 100644 --- a/crates/benilla-app/src/local_state.rs +++ b/crates/benilla-app/src/local_state.rs @@ -272,6 +272,12 @@ pub(crate) fn name_cache_path(realm: &str) -> Option { /// `benilla-config/shots.txt` — the framing instrument's appended camera poses (decision 0600). A dev /// affordance (`/shot`, compiled out by `--no-default-features` since 1179), but it persists on a /// real machine, so it resolves here like everything else rather than through a private path. +/// `Logs/` — `WoWChatLog.txt` and `WoWCombatLog.txt`, the reference's two names beside its +/// `WTF`, kept here beside ours ([`crate::ui_chat`]'s logging). +pub(crate) fn logs_dir() -> Option { + home().map(|h| h.join("Logs")) +} + pub(crate) fn shots_path() -> Option { home().map(|h| h.join("shots.txt")) } diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index b61c533a..1832a497 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -1762,6 +1762,60 @@ pub(crate) enum ClientCommand { min: u32, max: u32, }, + /// `DisplayChannelOwner` — `CMSG_CHANNEL_OWNER`. + ChannelOwner { + name: String, + }, + /// `SetChannelOwner` — `CMSG_CHANNEL_SET_OWNER`. + ChannelSetOwner { + name: String, + player: String, + }, + /// `SetChannelPassword` — `CMSG_CHANNEL_PASSWORD` (an empty password clears it). + ChannelPassword { + name: String, + password: String, + }, + ChannelModerator { + name: String, + player: String, + }, + ChannelUnmoderator { + name: String, + player: String, + }, + ChannelMute { + name: String, + player: String, + }, + ChannelUnmute { + name: String, + player: String, + }, + ChannelInvite { + name: String, + player: String, + }, + ChannelKick { + name: String, + player: String, + }, + ChannelBan { + name: String, + player: String, + }, + ChannelUnban { + name: String, + player: String, + }, + /// `ChannelToggleAnnouncements` — `CMSG_CHANNEL_ANNOUNCEMENTS`. + ChannelAnnouncements { + name: String, + }, + /// `ChannelModerate` — `CMSG_CHANNEL_MODERATE`. + ChannelModerate { + name: String, + }, /// `/played` (`CMSG_PLAYED_TIME`). PlayedTime, /// Acknowledge a triggered cinematic as finished (`CMSG_COMPLETE_CINEMATIC`) — sent by diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index c7ebd7de..d7cde2a1 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -908,6 +908,33 @@ fn writer_loop( ClientCommand::LeaveChannel { name } => w.leave_channel(&name), ClientCommand::ChannelList { name } => w.channel_list(&name), ClientCommand::RandomRoll { min, max } => w.random_roll(min, max), + ClientCommand::ChannelOwner { name } => w.channel_owner(&name), + ClientCommand::ChannelSetOwner { name, player } => { + w.channel_set_owner(&name, &player) + } + ClientCommand::ChannelPassword { name, password } => { + w.channel_password(&name, &password) + } + ClientCommand::ChannelModerator { name, player } => { + w.channel_moderator(&name, &player) + } + ClientCommand::ChannelUnmoderator { name, player } => { + w.channel_unmoderator(&name, &player) + } + ClientCommand::ChannelMute { name, player } => w.channel_mute(&name, &player), + ClientCommand::ChannelUnmute { name, player } => { + w.channel_unmute(&name, &player) + } + ClientCommand::ChannelInvite { name, player } => { + w.channel_invite(&name, &player) + } + ClientCommand::ChannelKick { name, player } => w.channel_kick(&name, &player), + ClientCommand::ChannelBan { name, player } => w.channel_ban(&name, &player), + ClientCommand::ChannelUnban { name, player } => { + w.channel_unban(&name, &player) + } + ClientCommand::ChannelAnnouncements { name } => w.channel_announcements(&name), + ClientCommand::ChannelModerate { name } => w.channel_moderate(&name), ClientCommand::PlayedTime => w.played_time(), ClientCommand::NameQuery { guid } => w.name_query(guid), ClientCommand::CreatureQuery { entry, guid } => w.creature_query(entry, guid), diff --git a/crates/benilla-app/src/perf/clock.rs b/crates/benilla-app/src/perf/clock.rs index ddfd2893..1eb5324e 100644 --- a/crates/benilla-app/src/perf/clock.rs +++ b/crates/benilla-app/src/perf/clock.rs @@ -130,3 +130,88 @@ pub(crate) fn system_cpu_ticks() -> Option<(u64, u64)> { None } } + +/// CPU seconds consumed so far by **every thread of this process, by name** — the split of +/// [`process_cpu_secs`] that says *which* thread a tax landed on. A frame's `cpu_ms` rose by +/// ~1.8 ms under vsync with no system growing on a sampled profile and no thread spinning +/// (decision 1947): the number that resolves that is per-thread CPU across a window, which +/// no sampler reports and this call does. +/// +/// macOS only (`task_threads` + `thread_info(THREAD_BASIC_INFO)`, names through +/// `pthread_getname_np`); elsewhere `None`, like the twins above. Threads are keyed by their +/// pthread identity so two snapshots subtract, user and system time apart — the vsync tax +/// turned out to be the main thread's kernel time, which a user-mode sampler never sees; a +/// thread without a name reports as `"?"`. +/// Cost: one kernel round-trip per thread — tens of microseconds, taken twice per probe +/// window, never per frame. +pub(crate) fn thread_cpu_table() -> Option> { + // `libc` deprecates its mach port bindings in favour of a crate this module does not need + // for two symbols; both are libSystem's, the import every mach caller links. + #[cfg(target_os = "macos")] + extern "C" { + static mach_task_self_: libc::mach_port_t; + fn mach_port_deallocate( + task: libc::mach_port_t, + name: libc::mach_port_t, + ) -> libc::kern_return_t; + } + #[cfg(target_os = "macos")] + { + use std::ffi::CStr; + let mut out = Vec::new(); + // SAFETY: mach's own out-param protocol — `task_threads` hands back a kernel-allocated + // array of thread ports and its count, each port is inspected with a stack-allocated, + // count-checked `thread_basic_info`, and every port and the array are released after. + unsafe { + let task = mach_task_self_; + let mut list: libc::thread_act_array_t = std::ptr::null_mut(); + let mut count: libc::mach_msg_type_number_t = 0; + if libc::task_threads(task, &mut list, &mut count) != libc::KERN_SUCCESS { + return None; + } + for i in 0..count as usize { + let port = *list.add(i); + let mut info: libc::thread_basic_info = std::mem::zeroed(); + let mut n = libc::THREAD_BASIC_INFO_COUNT; + let kr = libc::thread_info( + port, + libc::THREAD_BASIC_INFO as libc::thread_flavor_t, + (&mut info as *mut libc::thread_basic_info).cast(), + &mut n, + ); + if kr == libc::KERN_SUCCESS { + let secs = |t: libc::time_value_t| { + f64::from(t.seconds) + f64::from(t.microseconds) * 1e-6 + }; + let (user, system) = (secs(info.user_time), secs(info.system_time)); + let pthread = libc::pthread_from_mach_thread_np(port); + let mut name = [0 as libc::c_char; 64]; + let label = if pthread != 0 + && libc::pthread_getname_np(pthread, name.as_mut_ptr(), name.len()) == 0 + { + let s = CStr::from_ptr(name.as_ptr()).to_string_lossy(); + if s.is_empty() { + "?".to_string() + } else { + s.into_owned() + } + } else { + "?".to_string() + }; + out.push((pthread as usize, label, user, system)); + } + mach_port_deallocate(task, port); + } + libc::vm_deallocate( + task, + list as libc::vm_address_t, + (count as usize * std::mem::size_of::()) as libc::vm_size_t, + ); + } + Some(out) + } + #[cfg(not(target_os = "macos"))] + { + None + } +} diff --git a/crates/benilla-app/src/perf/main_split.rs b/crates/benilla-app/src/perf/main_split.rs new file mode 100644 index 00000000..af5eead9 --- /dev/null +++ b/crates/benilla-app/src/perf/main_split.rs @@ -0,0 +1,49 @@ +//! **The main thread's CPU, inside the app's frame** — `First` to `Last`, per frame, so the +//! probe can subtract it from the thread's whole-frame CPU (`thr=[main:…]`) and name what is +//! left: the event loop, AppKit's redraw path, the pipelined-render handshake. The vsync tax +//! (decision 1947/1949) is +1.2 ms of main-thread user time per frame that no system grew and +//! no sampler placed; whether it is spent inside the schedules or around them is this split. +//! +//! Two pinned systems, one `clock_gettime` each — nothing a frame can feel. + +use bevy::prelude::*; + +use super::clock::main_thread_cpu_secs; + +#[derive(Resource, Default)] +pub(crate) struct MainThreadSplit { + at_first: Option, + /// Sum of per-frame main-thread CPU inside `First..Last`, seconds, and the frames summed. + pub inside_secs: f64, + pub frames: u32, +} + +impl MainThreadSplit { + /// Restart the accumulation (the probe does this at its window's first frame). + pub fn restart(&mut self) { + self.inside_secs = 0.0; + self.frames = 0; + } + + /// Mean main-thread CPU inside the app's frame, ms per frame, over the accumulation. + pub fn inside_ms(&self) -> Option { + (self.frames > 0).then(|| self.inside_secs * 1000.0 / f64::from(self.frames)) + } +} + +fn stamp_first(_pin: bevy::ecs::system::NonSendMarker, mut split: ResMut) { + split.at_first = main_thread_cpu_secs(); +} + +fn stamp_last(_pin: bevy::ecs::system::NonSendMarker, mut split: ResMut) { + if let (Some(t0), Some(t1)) = (split.at_first, main_thread_cpu_secs()) { + split.inside_secs += t1 - t0; + split.frames += 1; + } +} + +pub(crate) fn plugin(app: &mut App) { + app.init_resource::() + .add_systems(First, stamp_first) + .add_systems(Last, stamp_last); +} diff --git a/crates/benilla-app/src/perf/mod.rs b/crates/benilla-app/src/perf/mod.rs index d1ae6c7d..23d8c7e4 100644 --- a/crates/benilla-app/src/perf/mod.rs +++ b/crates/benilla-app/src/perf/mod.rs @@ -44,6 +44,7 @@ mod clock; mod gpu; mod hud; mod journal; +mod main_split; mod phases; #[cfg(target_os = "macos")] mod stall; @@ -53,10 +54,11 @@ mod trace; use bevy::prelude::*; use bevy::render::diagnostic::RenderDiagnosticsPlugin; -pub(crate) use clock::{process_cpu_secs, system_cpu_ticks}; +pub(crate) use clock::{process_cpu_secs, system_cpu_ticks, thread_cpu_table}; pub(crate) use gpu::{GpuMsShared, WgpuCensusShared}; pub(crate) use hud::PerfHud; pub(crate) use journal::FpsJournalPlugin; +pub(crate) use main_split::MainThreadSplit; /// The frame budget: a 60 fps floor. No frame should exceed this. pub const FRAME_BUDGET_MS: f32 = 1000.0 / 60.0; @@ -67,6 +69,7 @@ impl Plugin for PerfPlugin { fn build(&self, app: &mut App) { // Render-pass timing (CPU-only on Apple Silicon — see the module header). Harmless if // the backend can't record it; also the hook Tracy GPU uses on Vulkan/DX12. + main_split::plugin(app); app.add_plugins(RenderDiagnosticsPlugin) .init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/sound/output/mod.rs b/crates/benilla-app/src/sound/output/mod.rs index 5a8b25af..db7be143 100644 --- a/crates/benilla-app/src/sound/output/mod.rs +++ b/crates/benilla-app/src/sound/output/mod.rs @@ -707,7 +707,18 @@ fn render_loop( ) { // Held for the thread's life and dropped with it: on Windows that hands the MMCSS // registration back, everywhere else it is inert. - let _realtime = match device::set_realtime(period_ns) { + // + // `WOW_AUDIO_NO_RT=1` — the A/B lever (decision 1947): a raid with sound on reads a + // `cpu_p99` several ms over its mean while the annotation names no churn, and a realtime + // render thread preempting the compute pools on four performance cores is the standing + // suspect. Off, the thread takes the user-interactive QoS fallback below. + let no_rt = std::env::var_os("WOW_AUDIO_NO_RT").is_some(); + let realtime = if no_rt { + Err(anyhow::anyhow!("WOW_AUDIO_NO_RT=1")) + } else { + device::set_realtime(period_ns) + }; + let _realtime = match realtime { Ok(realtime) => { debug!( "audio: render thread scheduled realtime, period {} µs", diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index 14554cb1..a1c786b0 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -1436,6 +1436,7 @@ pub(super) fn selection_requests( // the classification too, which is why this drain no longer states one: hand-stating it here // is exactly how a `false` that the binary refutes got written down. mut commit: super::by_name::SelectCommit, + mut assist_by_name: MessageWriter, ) { let Some(mut script) = script else { return; @@ -1461,6 +1462,11 @@ pub(super) fn selection_requests( Some((basis, _)) => commit.assist(basis, "AssistUnit"), None => info!("assist (AssistUnit): \"{token}\" names nothing; silent no-op"), }, + // `0x489c40` — the `/assist Bob` handler's verb: a player NAME, resolved by the + // app's name scan (prefix-ok, players only) the way the slash line always was. + SelectionRequest::AssistByName(name) => { + assist_by_name.write(super::by_name::AssistRequest { name: Some(name) }); + } // `0x489b45` reads the last-attackable pair and hands it to the same `0x489a40`. // **Empty memory is a no-op, and that is derived now**: the shim `0x489b40` is // thirteen bytes with no emptiness test at all, where `TargetLastTarget 0x489b00` @@ -2053,6 +2059,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::>(); world.insert_non_send_resource(UiScript::new().expect("a bare VM")); world.spawn((SelfPlayer, Guid(ME), store(&[(22, 100), (28, 100)]))); diff --git a/crates/benilla-app/src/ui_chat.rs b/crates/benilla-app/src/ui_chat.rs index 217e1143..efe07edd 100644 --- a/crates/benilla-app/src/ui_chat.rs +++ b/crates/benilla-app/src/ui_chat.rs @@ -31,6 +31,7 @@ mod input; mod language; /// The chat windows' saved look (B246, decision 1589) — where the tab menu's tint/alpha/font-size /// picks are read from at login and written back at logout. +mod logging; mod settings; #[cfg(test)] mod tests; @@ -54,7 +55,6 @@ impl Plugin for UiChatPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() - .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -127,14 +127,6 @@ impl Plugin for UiChatPlugin { .add_systems( Update, ( - edit::open_chat_keys, - edit::open_tell_requests, - // An addon's `ChatFrame_OpenChat` — before the live parse in the same chain, - // so a box opened prefilled `/w Bob ` has its type switched on the very next - // frame rather than showing the raw slash to the user first. - edit::open_chat_requests, - edit::chat_edit_live, - edit::chat_tab_cycle, input::drain_chat_input, // An addon's own line into the wire (decision 1199). AFTER the box's drain // and in the same chain, so a `SendChatMessage` fired from a slash handler @@ -171,6 +163,7 @@ impl Plugin for UiChatPlugin { ); // The per-character saved look (B246) — its own load/watch/save edges. settings::plugin(app); + logging::plugin(app); } } @@ -196,31 +189,18 @@ impl Plugin for UiChatPlugin { /// its scrollback is the whole point. fn end_session_chat( script: Option>, - mut windows: ResMut, - mut edit: ResMut, mut log: ResMut, ) { - end_chat_session( - script.map(NonSendMut::into_inner), - &mut windows, - &mut edit, - &mut log, - ); + end_chat_session(script.map(NonSendMut::into_inner), &mut log); } /// [`end_session_chat`]'s body, callable without a `World` — the clear is the law, the system is /// the wiring. pub(crate) fn end_chat_session( script: Option<&mut benilla_ui::script::UiScript>, - windows: &mut frames::ChatWindows, - edit: &mut edit::ChatEditState, log: &mut ChatLog, ) { *log = ChatLog::default(); - windows.tell_alert_left = 0.0; - // The channel target/number are [`channels::end_session_channels`]'s (1284); everything else - // the box remembers across an open is this session's too. - *edit = edit::ChatEditState::default(); if let Some(script) = script { for frame in ["ChatFrame1", "ChatFrame2"] { crate::ui_script::run_or_warn(script, &format!("{frame}:Clear()")); diff --git a/crates/benilla-app/src/ui_chat/channels.rs b/crates/benilla-app/src/ui_chat/channels.rs index baee9100..6a5e4dfa 100644 --- a/crates/benilla-app/src/ui_chat/channels.rs +++ b/crates/benilla-app/src/ui_chat/channels.rs @@ -125,12 +125,9 @@ fn end_channel_session( script: Option<&mut benilla_ui::script::UiScript>, channels: &mut ChannelState, walk: &mut ZoneChannelWalk, - edit: &mut super::edit::ChatEditState, ) { walk.clear_session(); channels.joined.clear(); - edit.channel_target.clear(); - edit.channel_number = 0; if let Some(script) = script { script.set_joined_channels(Vec::new()); } @@ -162,14 +159,8 @@ pub(super) fn end_session_channels( script: Option>, mut channels: ResMut, mut walk: ResMut, - mut edit: ResMut, ) { - end_channel_session( - script.map(NonSendMut::into_inner), - &mut channels, - &mut walk, - &mut edit, - ); + end_channel_session(script.map(NonSendMut::into_inner), &mut channels, &mut walk); } /// The other end: a socket that died. A **recoverable** drop never leaves `InWorld` (0065 keeps the @@ -181,18 +172,12 @@ pub(super) fn end_session_channels_on_disconnect( script: Option>, mut channels: ResMut, mut walk: ResMut, - mut edit: ResMut, mut disconnects: MessageReader, ) { if disconnects.read().next().is_none() { return; } - end_channel_session( - script.map(NonSendMut::into_inner), - &mut channels, - &mut walk, - &mut edit, - ); + end_channel_session(script.map(NonSendMut::into_inner), &mut channels, &mut walk); } /// Startup: read `ChatChannels.dbc` into [`ChannelState`], which owns it because the two things @@ -285,6 +270,35 @@ fn zone_is_settled(player: Option<&crate::player::Player>) -> bool { /// file** (`0x4997fc`, gated `0x4997e8`). We persist no chat cache, so every session is that /// fresh-character path and reading the DBC bit directly is exactly right. It stops being right /// the day per-window channel settings are persisted. +/// Every `ChatChannels.dbc` row as the VM needs it (decision 1908; wow-re chat-cache-grammar.md +/// §5-6): the id, the Shortcut the verbs compare a typed name against, the name composed for +/// `zone_name` — `None` when the composition has nothing to substitute (a zone-dependent row with +/// no zone, a city row with no city word), which is the verbs' nil leg — and whether +/// `EnumerateServerChannels` lists it here (a city-only row, `flags & 0x10`, only in a city). +pub(crate) fn zone_channel_catalog( + catalog: &ChatChannelsCatalog, + zone_name: &str, + in_city: bool, + city_word: Option<&str>, +) -> Vec { + catalog + .rows() + .iter() + .map(|r| { + let resolvable = (!r.is_zone_dependent() || !zone_name.is_empty()) + && (!r.takes_city_name() || city_word.is_some()); + benilla_ui::script::ZoneChannelRow { + id: r.id, + shortcut: r.shortcut.clone(), + resolved: resolvable + .then(|| r.joinable_name(zone_name, city_word.unwrap_or_default())), + listed: !r.is_city_only() || in_city, + } + }) + .collect() +} + +#[allow(clippy::too_many_arguments)] // a Bevy system's param list IS its dependency set pub(super) fn auto_join_zone_channels( commands: Res, channels: Res, @@ -299,6 +313,8 @@ pub(super) fn auto_join_zone_channels( ), mut walk: ResMut, mut entered: MessageReader, + script: Option>, + mut catalog_fed: Local>>, ) { let (player, cinematic) = (&body.0, &body.1); // World entry arms the walk; the session-end clears disarm it ([`end_session_channels`] and its @@ -343,6 +359,21 @@ pub(super) fn auto_join_zone_channels( zone_row.name.clone(), zone_row.flags & AREA_FLAG_TRADE_CHANNEL != 0, ); + // The VM's zone-channel catalog — what `JoinChannelByName` resolves a typed name against and + // `EnumerateServerChannels` lists — is a function of the same zone; refed on a zone change + // and on a fresh VM, keyed like every other feed (1290). + if let Some(mut script) = script { + let fed = catalog_fed.get(&script); + if fed.as_ref() != Some(&at) { + script.set_zone_channel_catalog(zone_channel_catalog( + &channels.channels, + &at.0, + at.1, + city_word(&areas.0), + )); + *fed = Some(at.clone()); + } + } if walk.at.as_ref() == Some(&at) { return; // same zone as last frame — the common case, and free } @@ -496,19 +527,17 @@ mod tests { #[test] fn leaving_the_world_ends_the_channel_session() { let mut channels = ChannelState::default(); - let mut walk = ZoneChannelWalk::default(); - let mut edit = super::super::edit::ChatEditState::default(); - - // A live session: two zone channels asked for, both confirmed, `/2` targeted. - walk.held = vec!["General - Tanaris".into(), "LocalDefense - Tanaris".into()]; - walk.at = Some(("Tanaris".into(), false)); + // A live session: two zone channels asked for, both confirmed. + let mut walk = ZoneChannelWalk { + held: vec!["General - Tanaris".into(), "LocalDefense - Tanaris".into()], + at: Some(("Tanaris".into(), false)), + ..Default::default() + }; channels.joined = walk.held.iter().cloned().map(Some).collect(); - edit.channel_target = "LocalDefense - Tanaris".into(); - edit.channel_number = 2; // No VM in a unit test; the mirror leg is the one line this cannot reach, and // `end_session_channels` is a two-line wrapper over exactly this call. - end_channel_session(None, &mut channels, &mut walk, &mut edit); + end_channel_session(None, &mut channels, &mut walk); assert!(walk.held.is_empty(), "nothing is held"); assert_eq!(walk.at, None, "and the next entry re-walks from scratch"); @@ -517,8 +546,6 @@ mod tests { "the confirmed list is the server's, and the server just destroyed it — a survivor \ here is what renumbers the next character's channels" ); - assert_eq!(edit.channel_target, ""); - assert_eq!(edit.channel_number, 0, "a `/2` now targets nothing"); } /// No city word (a locale whose sentinel row ships blank) ⇒ the city-named rows are skipped, diff --git a/crates/benilla-app/src/ui_chat/edit.rs b/crates/benilla-app/src/ui_chat/edit.rs index 6ef0c011..f55ae4ad 100644 --- a/crates/benilla-app/src/ui_chat/edit.rs +++ b/crates/benilla-app/src/ui_chat/edit.rs @@ -1,28 +1,18 @@ -//! The `ChatEdit_*` state machine, transcribed (decision 0288 P5; ref ChatFrame.lua l.1782-2242): -//! the sticky type law (SAY/PARTY/RAID/GUILD/BATTLEGROUND commit on send — `ChatTypeInfo`'s -//! sticky column), the **live parse** (typing `/g hi` or `/w Bob hi` converts the box in place, -//! remainder preserved — `ChatEdit_ParseText(send=0)` on every text change), the header -//! (`CHAT__SEND` text + the type's color on header AND typed text, insets past the header — -//! `ChatEdit_UpdateHeader`), the 10-deep `lastTell` ring with Tab cycling in whisper mode, the -//! invalid-type downgrade on open (PARTY with no party → SAY — `ChatEdit_OnShow`), and the R / -//! `/` bindings (OPENCHAT-family, Bindings.xml). +//! The chat SEND types and the joined-channel roster — what the app keeps beside the reference's +//! own `ChatEdit_*` machine (ChatFrame.lua l.1782-2242), which owns the edit box since the chat +//! window became the reference's (decision 1948): the sticky type, the live parse, the header, +//! the tell ring, the Tab cycle and the R/`/` bindings are all its Lua now. //! -//! Division of labor: this module owns the STATE + the live-parse/header systems; [`super::input`] -//! owns the submitted-line routing (send-by-current-type + the action-command grammar). The box -//! widget itself (focus, history, insets mechanics) is the engine's. - -use std::collections::VecDeque; +//! [`SendType`] names the wire kind an addon's `SendChatMessage` token maps to +//! ([`super::input::drain_addon_chat_sends`]); [`ChannelState`] is the client-side mirror of the +//! joined channels (the `/N` numbering the reference keeps C-side). use bevy::prelude::*; -use crate::names::NameCache; -use crate::net::{ChatKind, NetCommands, SelfGuid}; -use crate::ui_script::{run_or_warn, UiKeyboardCapture}; +use crate::net::ChatKind; -use super::event::{default_color, ChatEventKind}; - -/// The box's send type — `ChatTypeInfo`'s sendable keys ([`SendType::sticky`] marks the sticky -/// column's 1-entries). `Whisper`/`Channel` carry their target in [`ChatEditState`]. +/// The sendable chat types — `ChatTypeInfo`'s sendable keys, as the wire kind an addon's +/// `SendChatMessage` token maps to. `Whisper`/`Channel` carry their target in the call. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[allow(dead_code)] // RaidLeader/BgLeader have no slash of their own (server-promoted sends); // Channel is the P6 wiring's target — the enum is the full sendable law. @@ -43,19 +33,6 @@ pub(crate) enum SendType { } impl SendType { - /// `ChatTypeInfo[type].sticky == 1` (ref ChatFrame.lua l.10-114): SAY, PARTY, RAID, GUILD, - /// BATTLEGROUND only. - pub(crate) fn sticky(self) -> bool { - matches!( - self, - SendType::Say - | SendType::Party - | SendType::Raid - | SendType::Guild - | SendType::Battleground - ) - } - /// The chat-type TOKEN an addon passes to `SendChatMessage` (decision 1199) — the reference's /// own `ChatTypeInfo` keys, uppercase. /// @@ -100,59 +77,6 @@ impl SendType { SendType::Channel => ChatKind::Channel, } } - - /// The header's display color = the matching receive kind's table color - /// (`ChatEdit_UpdateHeader` reads `ChatTypeInfo[type]`). - /// - /// CHANNEL takes the same override the render path does — `info = ChatTypeInfo["CHANNEL".. - /// channel]` (l.1902), the number from `GetChannelName`. It lands on the identical FFC0C0 - /// while the extras carry CHANNEL's seed color (1275), so the CHANNEL arm below is right - /// today; a per-number recolor is what would make the box's number load-bearing here. - fn color(self) -> [u8; 3] { - use ChatEventKind as K; - default_color(match self { - SendType::Say => K::Say, - SendType::Yell => K::Yell, - SendType::Emote => K::Emote, - SendType::Whisper => K::Whisper, - SendType::Party => K::Party, - SendType::Raid => K::Raid, - SendType::RaidLeader => K::RaidLeader, - SendType::RaidWarning => K::RaidWarning, - SendType::Guild => K::Guild, - SendType::Officer => K::Officer, - SendType::Battleground => K::Battleground, - SendType::BattlegroundLeader => K::BattlegroundLeader, - SendType::Channel => K::Channel, - }) - } - - /// The primary slash alias (`SLASH_1`) — the canonical form history recall stores - /// (the ref's `ChatEdit_AddHistory` header). `None` for the two leader types 1.12 gives no - /// ChatEdit slash. - pub(super) fn canonical_slash(self) -> Option<&'static str> { - self.aliases().first().copied() - } - - /// The slash aliases that switch the box to this type (`SLASH_n`, GlobalStrings - /// 3490-3801 — the full quoted sets). - fn aliases(self) -> &'static [&'static str] { - match self { - SendType::Say => &["s", "say"], - SendType::Yell => &["y", "yell", "sh", "shout"], - SendType::Emote => &["e", "em", "emote", "me"], - SendType::Whisper => &["w", "whisper", "t", "tell", "send"], - SendType::Party => &["p", "party"], - SendType::Raid => &["raid", "ra", "rsay"], - SendType::RaidLeader => &[], // no ChatEdit slash in 1.12 (leader auto via /raid) - SendType::RaidWarning => &["rw"], - SendType::Guild => &["g", "gc", "gu", "guild"], - SendType::Officer => &["o", "osay"], - SendType::Battleground => &["bg", "battleground"], - SendType::BattlegroundLeader => &[], - SendType::Channel => &["c", "csay"], - } - } } /// How many channels the client can hold at once — its allocator refuses the eleventh @@ -201,13 +125,6 @@ impl ChannelState { .map(|i| i as u32 + 1) } - /// The channel occupying slot `number` (1-based), if any. - pub(crate) fn name_of(&self, number: u32) -> Option<&str> { - self.joined - .get(usize::try_from(number.checked_sub(1)?).ok()?)? - .as_deref() - } - /// Give `name` a slot: **the first free one**, else a new one while under [`MAX_CHANNELS`] — /// the reference's allocator `0x49b980` (see [`ChannelState`]). Already-joined answers its own /// number rather than taking a second slot. `None` = all ten are taken. @@ -272,434 +189,3 @@ impl ChannelState { event.channel = format!("{n}. {}", event.channel_base); } } - -/// The chat edit box's cross-open state (the fields `ChatEdit_OnLoad` seeds on the box). -#[derive(Resource)] -pub(crate) struct ChatEditState { - pub chat_type: SendType, - /// `stickyType` — what Escape/close reverts to and what the box opens as. - pub sticky: SendType, - /// The current whisper target (`editBox.tellTarget`). - pub tell_target: String, - /// The current channel's WIRE name (the ref's `editBox.channelTarget` is the number; the - /// name is what the send body carries either way). - pub channel_target: String, - /// The current channel's 1-based number (the joined-order slot), for the header. - pub channel_number: u32, - /// The `lastTell` ring (`NUM_REMEMBERED_TELLS = 10`), most recent first. - pub last_tell: VecDeque, - /// `toldTarget` — who WE last whispered (the ctrl-R / ReplyTell2 memory). - pub last_told: Option, - /// The header/insets need a repaint (type or target changed). - pub header_dirty: bool, - /// The last text the live parse saw (skip re-parsing an unchanged box). - last_text: String, -} - -impl Default for ChatEditState { - fn default() -> Self { - ChatEditState { - chat_type: SendType::Say, - sticky: SendType::Say, - tell_target: String::new(), - channel_target: String::new(), - channel_number: 0, - last_tell: VecDeque::new(), - last_told: None, - header_dirty: true, - last_text: String::new(), - } - } -} - -impl ChatEditState { - /// `ChatEdit_SetLastTellTarget`: move-to-front dedup (case-insensitive), cap 10. - pub(crate) fn remember_tell(&mut self, target: &str) { - self.last_tell.retain(|t| !t.eq_ignore_ascii_case(target)); - self.last_tell.push_front(target.to_string()); - self.last_tell.truncate(10); - } - - /// `ChatEdit_GetNextTellTarget`: the ring entry after `current` (wrapping to the most - /// recent), for Tab cycling in whisper mode. - pub(crate) fn next_tell(&self, current: &str) -> Option { - if self.last_tell.is_empty() { - return None; - } - if current.is_empty() { - return self.last_tell.front().cloned(); - } - let pos = self - .last_tell - .iter() - .position(|t| t.eq_ignore_ascii_case(current)); - match pos { - Some(i) if i + 1 < self.last_tell.len() => self.last_tell.get(i + 1).cloned(), - _ => self.last_tell.front().cloned(), - } - } - - /// The header's text for the current type (`ChatEdit_UpdateHeader`'s strings, quoted). - /// Channel shows the stripped name (the `[%d. %s]:` number form is the P6 wiring). - fn header_text(&self, own_name: &str) -> String { - match self.chat_type { - SendType::Say => "Say: ".into(), - SendType::Yell => "Yell: ".into(), - SendType::Emote => format!("{own_name} "), - SendType::Whisper => format!("Tell {}: ", self.tell_target), - SendType::Party => "Party: ".into(), - SendType::Raid => "Raid: ".into(), - SendType::RaidLeader => "Raid: ".into(), - SendType::RaidWarning => "Raid Warning: ".into(), - SendType::Guild => "Guild: ".into(), - SendType::Officer => "Officer: ".into(), - SendType::Battleground => "Battleground: ".into(), - SendType::BattlegroundLeader => "Battleground: ".into(), - SendType::Channel => { - // CHAT_CHANNEL_SEND = "[%d. %s]: " (the zone tail stripped like the display law). - let name = self.channel_target.split(" - ").next().unwrap_or(""); - format!("[{}. {name}]: ", self.channel_number) - } - } - } -} - -/// The live parse (`ChatEdit_ParseText(send=0)`, run per frame while the box is focused): a -/// leading `/ ` converts the box's TYPE in place, keeping the remainder as the draft — -/// typing `/g hi` flips to Guild with "hi" in the box. `/w`/`/t` wait for the target word to -/// complete (`ChatEdit_ExtractTellTarget` grabs the first word once a space follows it); `/r` -/// loads the last teller (`ChatEdit_GetLastTellTarget`). Action commands (`/join`, `/wave`, …) -/// are NOT consumed here — they execute on Enter ([`super::input`]). -pub(super) fn chat_edit_live( - script: Option>, - mut state: ResMut, - channels: Res, - ui_capture: Res, - mut names: ResMut, - self_guid: Res, - commands: Res, -) { - let Some(script) = script else { - return; - }; - if !ui_capture.typing { - return; // box not focused — nothing to live-parse - } - let text: String = script - .eval("return (ChatFrameEditBox and ChatFrameEditBox:GetText()) or ''") - .unwrap_or_default(); - if text != state.last_text { - state.last_text = text.clone(); - if let Some((new_type, remainder)) = parse_type_switch(&state, &channels, &text) { - match new_type { - TypeSwitch::Plain(t) => { - state.chat_type = t; - } - TypeSwitch::Whisper(target) => { - state.chat_type = SendType::Whisper; - state.tell_target = target; - } - TypeSwitch::Channel { name, number } => { - state.chat_type = SendType::Channel; - state.channel_target = name; - state.channel_number = number; - } - } - state.header_dirty = true; - state.last_text = remainder.clone(); - let lua_text = remainder.replace('\\', "\\\\").replace('"', "\\\""); - run_or_warn( - &script, - &format!("ChatFrameEditBox:SetText(\"{lua_text}\")"), - ); - } - } - if state.header_dirty { - // The Emote header is the player's own name (CHAT_EMOTE_SEND = "%s "), resolved through - // the same ask-once cache as everyone else's. - let own = self_guid - .0 - .and_then(|g| names.resolve(g, &commands).map(str::to_string)) - .unwrap_or_else(|| "You".to_string()); - let header = state.header_text(&own); - let [r, g, b] = state.chat_type.color(); - let (rf, gf, bf) = ( - f32::from(r) / 255.0, - f32::from(g) / 255.0, - f32::from(b) / 255.0, - ); - let lua_header = header.replace('\\', "\\\\").replace('"', "\\\""); - // Header text+color, typed-text color, and the insets past the measured header - // (`SetTextInsets(15 + header:GetWidth(), 13, 0, 0)` — ref ChatEdit_UpdateHeader l.1912). - // The width is the measure round-trip's, one frame late for a fresh string — so we re-run - // while dirty until a nonzero width lands. GetWidth serves ONLY a measure of the CURRENT - // text (region.rs key-checks it): after a type switch (Say → "Tell Alice:") the old - // header's width reads 0, not stale — the settle below waits for the RIGHT measure - // instead of latching the previous header's insets (the /w cursor-in-the-header bug). - run_or_warn( - &script, - &format!( - "ChatFrameEditBoxHeader:SetText(\"{lua_header}\")\n\ - ChatFrameEditBoxHeader:SetTextColor({rf:.4}, {gf:.4}, {bf:.4})\n\ - ChatFrameEditBox:SetTextColor({rf:.4}, {gf:.4}, {bf:.4})\n\ - local w = ChatFrameEditBoxHeader:GetWidth()\n\ - if w and w > 1 then\n\ - ChatFrameEditBox:SetTextInsets(15 + w, 13, 0, 0)\n\ - BenillaChatHeaderSettled = true\n\ - end" - ), - ); - // Stay dirty until the measured width landed (the round-trip is a frame late on a fresh - // header string). - if script - .eval::("local s = BenillaChatHeaderSettled; BenillaChatHeaderSettled = false; return s or false") - .unwrap_or(false) - { - state.header_dirty = false; - } - } -} - -/// A live type switch the parse found. -pub(super) enum TypeSwitch { - Plain(SendType), - Whisper(String), - Channel { name: String, number: u32 }, -} - -/// The `/N` / `/c ` channel switch against the joined list. `cmd_lower` is the -/// first word (no slash); returns the switch + remaining draft. -pub(super) fn channel_switch( - channels: &ChannelState, - cmd_lower: &str, - args: &str, -) -> Option<(TypeSwitch, String)> { - if let Ok(n) = cmd_lower.parse::() { - let name = channels.name_of(n)?; - return Some(( - TypeSwitch::Channel { - name: name.to_string(), - number: n, - }, - args.to_string(), - )); - } - if ["c", "csay"].contains(&cmd_lower) { - let (chan, remainder) = args.split_once(' ').unwrap_or((args, "")); - if chan.is_empty() { - return None; - } - let number = if let Ok(n) = chan.parse::() { - n - } else { - channels.number_of(chan)? - }; - let name = channels.name_of(number)?.to_string(); - return Some((TypeSwitch::Channel { name, number }, remainder.to_string())); - } - None -} - -/// Match `text` against the type-switch grammar. Returns the switch + the box's remaining draft. -pub(super) fn parse_type_switch( - state: &ChatEditState, - channels: &ChannelState, - text: &str, -) -> Option<(TypeSwitch, String)> { - let rest = text.strip_prefix('/')?; - let (cmd, args) = rest.split_once(' ').unwrap_or((rest, "")); - if cmd.is_empty() { - return None; - } - let lower = cmd.to_ascii_lowercase(); - // `/` — the numbered-channel switch (`ChatEdit_ParseText` l.2110-2121: a live - // GetChannelName hit converts immediately, remainder kept) and `/c ` - // (`ChatEdit_ExtractChannel`). Both wait for the delimiting space like every live switch. - if rest.contains(' ') { - if let Some(switch) = channel_switch(channels, &lower, args) { - return Some(switch); - } - } - // `/r` — reply: load the last teller (only when one exists; else leave the text alone). - if (lower == "r" || lower == "reply") && rest.contains(' ') { - let target = state.last_tell.front()?.clone(); - return Some((TypeSwitch::Whisper(target), args.to_string())); - } - // Whisper family: wait for the completed target word ("/w Bob " — the space after the name - // is the ref's extract trigger). - if SendType::Whisper.aliases().contains(&lower.as_str()) { - let (target, remainder) = args.split_once(' ')?; - if target.is_empty() || target.starts_with('|') { - return None; // the ref rejects a link-leading "name" - } - return Some(( - TypeSwitch::Whisper(target.to_string()), - remainder.to_string(), - )); - } - // The plain type families — the switch fires as soon as the command word is delimited - // (a trailing space), so "/g" alone doesn't convert while you're still typing "/gc". - if !rest.contains(' ') { - return None; - } - for t in [ - SendType::Say, - SendType::Yell, - SendType::Emote, - SendType::Party, - SendType::Raid, - SendType::RaidWarning, - SendType::Guild, - SendType::Officer, - SendType::Battleground, - ] { - if t.aliases().contains(&lower.as_str()) { - return Some((TypeSwitch::Plain(t), args.to_string())); - } - } - None -} - -/// The open commands through the binding table (0997; 1.12 defaults OPENCHAT = Enter, -/// OPENCHATSLASH = `/`, REPLY = R, REPLY2 = Shift-R): OPENCHAT opens with the sticky type, -/// downgrading an invalid PARTY/RAID to SAY against the live group state (the ref's -/// `ChatEdit_UpdateHeader` invalid-type law; the sticky itself keeps its value, so rejoining a -/// group restores it — GUILD/BG downgrades wait on their arcs' state); OPENCHATSLASH opens -/// pre-slashed; REPLY opens as a reply to the last teller, and **REPLY2 to the last person YOU -/// told** — the reference's own split, `ChatEdit_GetLastTellTarget` against -/// `ChatEdit_GetLastToldTarget` (ChatFrame.lua l.1627/1645), and the reason they are two -/// commands rather than one. The dispatch already gated on nothing owning the keyboard -/// (a focused box's Enter is the box's), and Shift-Enter no longer opens chat — `SHIFT-ENTER` -/// is a different chord than `ENTER`, the exact-modifier law the reference applies. -pub(super) fn open_chat_keys( - script: Option>, - binds: Res, - mut state: ResMut, - group: Res, -) { - let Some(mut script) = script else { - return; - }; - use crate::bindings::cmd; - let open_plain = binds.fired(cmd::OPEN_CHAT); - let open_slash = binds.fired(cmd::OPEN_CHAT_SLASH); - let open_reply = binds.fired(cmd::REPLY) && !state.last_tell.is_empty(); - // REPLY2 rides `last_told`, which the send path has been keeping all along - // ([`super::input::send`]) with nothing reading it. Same guard shape as REPLY's: with nobody - // told yet the reference falls into its own empty-string else-branch and opens nothing. - let open_reply2 = binds.fired(cmd::REPLY2) && state.last_told.is_some(); - if !(open_plain || open_slash || open_reply || open_reply2) { - return; - } - if open_reply || open_reply2 { - state.chat_type = SendType::Whisper; - // REPLY wins a same-frame tie, which is the order the reference's two bindings can only - // be pressed in anyway (they are different chords); stated rather than left to the `if`. - state.tell_target = if open_reply { - state.last_tell.front().cloned().unwrap_or_default() - } else { - state.last_told.clone().unwrap_or_default() - }; - } else { - state.chat_type = sticky_on_open(state.sticky, &group); - } - state.header_dirty = true; - state.last_text.clear(); - script.focus_editbox("ChatFrameEditBox"); - if open_slash { - run_or_warn(&script, "ChatFrameEditBox:SetText(\"/\")"); - state.last_text = "/".into(); - } -} - -/// The type a freshly opened box starts in: the sticky, **downgraded to SAY when the group it -/// names is gone** (ref `ChatFrame_OpenChat` l.1554-1565 — a sticky PARTY with an empty party opens -/// as SAY, and so does a sticky RAID outside a raid). The sticky itself is untouched, so rejoining -/// restores it. One function because two callers need the identical law: the ENTER binding -/// ([`open_chat_keys`]) and an addon's `ChatFrame_OpenChat` ([`open_chat_requests`]). -pub(super) fn sticky_on_open(sticky: SendType, group: &crate::ui_party::GroupState) -> SendType { - match sticky { - SendType::Party if !group.in_group => SendType::Say, - SendType::Raid | SendType::RaidWarning if !(group.in_group && group.group_type == 1) => { - SendType::Say - } - sticky => sticky, - } -} - -/// `ChatFrame_OpenChat(text[, chatFrame])` — an addon asking for the chat box, prefilled -/// (`benilla_ui::script::chat_window` registers the verb and queues the text). -/// -/// The reference shows the box, stashes the text on it, and lets `ChatEdit_OnUpdate` type it in -/// (`this.setText == 1` → `this:SetText(this.text)`, ChatFrame.lua l.1795) — the fill is a frame -/// late there too. Ours focuses the box (which shows it) and writes the text now; `last_text` is -/// deliberately left EMPTY rather than mirroring what we just wrote, so the next frame's -/// [`chat_edit_live`] sees a change and runs the live parse over it. That is the whole point for -/// two of the three corpus callers: they prefill `"/w "`, and it is the live parse — not -/// this function — that turns those characters into whisper mode with the target extracted, -/// exactly as it would for a human typing them. -pub(super) fn open_chat_requests( - script: Option>, - mut state: ResMut, - group: Res, -) { - let Some(mut script) = script else { - return; - }; - // Last request wins — two opens in one frame are one box, and the later caller is the one - // whose text the user is about to see. - let Some(text) = script.take_open_chat_requests().pop() else { - return; - }; - state.chat_type = sticky_on_open(state.sticky, &group); - state.header_dirty = true; - state.last_text.clear(); - script.focus_editbox("ChatFrameEditBox"); - let lua_text = text.replace('\\', "\\\\").replace('"', "\\\""); - run_or_warn( - &script, - &format!("ChatFrameEditBox:SetText(\"{lua_text}\")"), - ); -} - -/// The unit popup's WHISPER action (`ChatFrame_SendTell` → the engine's tell queue, decision -/// 0434 §5): open the edit box in whisper mode to the named player — the ref fills its box with -/// "/w name "; our box's whisper form is chat_type + tell_target, the reply key's exact shape. -pub(super) fn open_tell_requests( - script: Option>, - mut state: ResMut, -) { - let Some(mut script) = script else { - return; - }; - let Some(name) = script.take_tell_requests().pop() else { - return; - }; - state.chat_type = SendType::Whisper; - state.tell_target = name; - state.header_dirty = true; - state.last_text.clear(); - script.focus_editbox("ChatFrameEditBox"); -} - -/// Tab in the box (the engine's `OnTabPressed` → `BenillaChatTabPressed` queue): whisper mode -/// cycles the `lastTell` ring (`ChatEdit_OnTabPressed` l.1983-1991). The slash tab-COMPLETION -/// walk (cycling `SLASH_*` prefix matches) is deferred with the P8 polish — flagged, not silent. -pub(super) fn chat_tab_cycle( - script: Option>, - mut state: ResMut, -) { - let Some(mut script) = script else { - return; - }; - if !script.take_chat_tab() { - return; - } - if state.chat_type == SendType::Whisper { - let current = state.tell_target.clone(); - if let Some(next) = state.next_tell(¤t) { - state.tell_target = next; - state.header_dirty = true; - } - } -} diff --git a/crates/benilla-app/src/ui_chat/event.rs b/crates/benilla-app/src/ui_chat/event.rs index 4477c62e..dcb75866 100644 --- a/crates/benilla-app/src/ui_chat/event.rs +++ b/crates/benilla-app/src/ui_chat/event.rs @@ -616,93 +616,8 @@ pub(crate) fn notice_token(byte: u8) -> Option<&'static str> { }) } -/// The message groups a window registers — `ChatTypeGroup`'s keys (transcribed; the chat-cache -/// WINDOW blocks list these names). Only the groups the current kind set can carry. -/// -/// **Most of `ChatTypeGroup` is 1:1 with a chat type**, and the whole combat-log block is: ref -/// `ChatFrame.lua` l.181-330 is thirty-odd `ChatTypeGroup["X"] = { "CHAT_MSG_X" }` one-liners. -/// [`Self::Own`] is that shape, so those groups cost one variant between them instead of one each — -/// the enum below carries only the genuinely *multi*-kind groups, which is the information a reader -/// wants from it. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub(crate) enum ChatGroup { - /// A group whose `ChatTypeGroup` list is exactly the one kind it is named for — every - /// `COMBAT_*`/`SPELL_*` group, and the two `COMBAT_*_GAIN` ones that used to be spelled out. - Own(ChatEventKind), - System, - Say, - Yell, - Whisper, - Party, - Guild, - Creature, - Channel, - Skill, - Loot, - Money, -} - -/// Does this group registration subscribe a window to `kind`? The one predicate the router asks, -/// so that [`ChatGroup::Own`] — whose "list" is its own kind and needs no slice to live in — reads -/// the same as every multi-kind group. -pub(crate) fn group_wants(group: ChatGroup, kind: ChatEventKind) -> bool { - match group { - ChatGroup::Own(k) => k == kind, - multi => group_kinds(multi).contains(&kind), - } -} - -/// `ChatTypeGroup` transcribed (ref ChatFrame.lua l.116-174): which kinds a group registration -/// subscribes a window to. `MONEY` rides both the LOOT group (l.171-174) and window 2's explicit -/// MONEY registration (chat-cache), hence its own group here. -pub(crate) fn group_kinds(group: ChatGroup) -> &'static [ChatEventKind] { - use ChatEventKind as K; - match group { - // Answered by [`group_wants`] without a slice — the kind is the group. - ChatGroup::Own(_) => &[], - ChatGroup::System => &[ - K::System, - K::Afk, - K::Dnd, - K::Ignored, - K::ChannelList, - K::BgSystemNeutral, - K::BgSystemAlliance, - K::BgSystemHorde, - ], - ChatGroup::Say => &[K::Say, K::Emote, K::TextEmote], - ChatGroup::Yell => &[K::Yell], - ChatGroup::Whisper => &[K::Whisper, K::WhisperInform], - ChatGroup::Party => &[ - K::Party, - K::Raid, - K::RaidLeader, - K::RaidWarning, - K::Battleground, - K::BattlegroundLeader, - ], - ChatGroup::Guild => &[K::Guild, K::Officer], - ChatGroup::Creature => &[ - K::MonsterSay, - K::MonsterYell, - K::MonsterEmote, - K::MonsterWhisper, - K::RaidBossEmote, - ], - ChatGroup::Channel => &[ - K::ChannelJoin, - K::ChannelLeave, - K::ChannelNotice, - K::ChannelNoticeUser, - ], - ChatGroup::Skill => &[K::Skill], - ChatGroup::Loot => &[K::Loot, K::Money], - ChatGroup::Money => &[K::Money], - } -} - -/// The kind's row in the color table — NOT necessarily the color a line renders in. The channel -/// family's row is looked up and then *replaced*; [`resolved_color`] is the law that renders. +/// The kind's row in the shipped colour table — what the chat bubble and the edit box's header +/// tint with. The window's own line colour is `ChatTypeInfo`'s, in the reference's Lua (1948). /// /// The complete shipped table (chat-cache COLORS ≡ wow-re `chat-color-table.md`, both quoted in /// 0288's pin; entries this kind set carries). @@ -803,48 +718,6 @@ pub(crate) fn default_color(kind: ChatEventKind) -> [u8; 3] { } } -/// `ChatTypeInfo["CHANNEL"..n]` — the color of the numbered channel in slot `n` (arg8). -/// -/// These are not part of the 94-entry static table: the boot seed creates ten *extra* registry -/// entries named `CHANNEL1`…`CHANNEL10` and colors every one of them from the **live CHANNEL -/// entry**, so they all start at CHANNEL's FFC0C0 (wow-re `chat-color-table.md`, "Seeding" — -/// `0x4982c0`, and `ResetChatColors 0x4a09e0` re-does exactly that). Per-number recolor is a -/// `ChangeChatColor` away, so this stays a function of `n` even though nothing varies by it yet. -fn channel_row_color(_number: u32) -> [u8; 3] { - [255, 192, 192] -} - -/// The color a line actually renders in — `ChatFrame_OnEvent`'s `info` resolution, which is not -/// simply [`default_color`] of the kind (ref ChatFrame.lua l.1371-1386). -/// -/// The handler looks up `ChatTypeInfo[type]` and then, for every `CHANNEL*` type, **replaces** it -/// with the numbered channel's own row — `info = ChatTypeInfo["CHANNEL"..arg8]` (l.1381). Its -/// guard is `strsub(type,1,7) == "CHANNEL" and type ~= "CHANNEL_LIST" and (arg1 ~= "INVITE" or -/// type ~= "CHANNEL_NOTICE_USER")`, transcribed below. So the grey CHANNEL_NOTICE row (C0C0C0) is -/// looked up and then thrown away: a join/leave notice renders in the channel's FFC0C0, which is -/// what makes those lines read warm rather than white in the real client (1275). -/// -/// **KNOWN DIVERGENCE — arg8 == 0.** The reference reaches its override only after finding the -/// channel in `ChatFrame1.channelList`; a miss `return`s and the line never renders at all. That -/// list is FrameXML's own (`ChatFrame_AddChannel`), which we do not model — our channel list is -/// the *client-side* one ([`super::edit::ChannelState`]) — so implementing the drop would gate on -/// the wrong list and silently eat notices about channels we are not in ("Not on channel %s." -/// being the sharpest case). We render those, in the row the extras are seeded from. 1275. -pub(crate) fn resolved_color(event: &ChatEvent, kind: ChatEventKind) -> [u8; 3] { - use ChatEventKind as K; - let invite_notice = kind == K::ChannelNoticeUser - && event.notice_byte() == Some(benilla_protocol::messages::channel_notice::INVITE); - match kind { - K::Channel | K::ChannelJoin | K::ChannelLeave | K::ChannelNotice | K::ChannelNoticeUser - if !invite_notice => - { - channel_row_color(event.channel_number) - } - // CHANNEL_LIST and the INVITE notice keep the row they were looked up in. - _ => default_color(kind), - } -} - /// Map a wire `ChatMsg` byte (`SMSG_MESSAGECHAT.chat_type`) to its event kind. `None` = a type /// vmangos never emits as wire chat (the combat-log block) or one we don't model — the router /// drops it loudly. @@ -875,6 +748,9 @@ pub(crate) fn kind_of_wire(chat_type: u8) -> Option { m::CHAT_MSG_RAID_BOSS_EMOTE => K::RaidBossEmote, m::CHAT_MSG_BATTLEGROUND => K::Battleground, m::CHAT_MSG_BATTLEGROUND_LEADER => K::BattlegroundLeader, + m::CHAT_MSG_BG_SYSTEM_NEUTRAL => K::BgSystemNeutral, + m::CHAT_MSG_BG_SYSTEM_ALLIANCE => K::BgSystemAlliance, + m::CHAT_MSG_BG_SYSTEM_HORDE => K::BgSystemHorde, _ => return None, }) } diff --git a/crates/benilla-app/src/ui_chat/feed.rs b/crates/benilla-app/src/ui_chat/feed.rs index f2750c47..22a19b37 100644 --- a/crates/benilla-app/src/ui_chat/feed.rs +++ b/crates/benilla-app/src/ui_chat/feed.rs @@ -287,12 +287,9 @@ impl ChatLog { tries: 0, }); } else { - self.pending.push(Pending::Event(notice_event( - notice_byte, - channel, - name, - None, - ))); + if let Some(event) = notice_event(notice_byte, channel, name, None) { + self.pending.push(Pending::Event(event)); + } } } @@ -578,20 +575,55 @@ fn notice_event( channel: String, a: Option, b: Option, -) -> ChatEvent { +) -> Option { + // The display type per notice byte — wow-re `chat-msg-event-args.md`'s notice table, read + // off each arm's `mov edi`: 0x00/0x01 are the join/leave lines, MODE_CHANGE (0x0c) fires no + // chat event at all, a byte past THROTTLED fires SYSTEM with an empty token, and the rest + // split between CHANNEL_NOTICE (0x12) and CHANNEL_NOTICE_USER (0x13) exactly as listed. + use channel_notice as n; let kind = match notice_byte { - channel_notice::JOINED => ChatEventKind::ChannelJoin, - channel_notice::LEFT => ChatEventKind::ChannelLeave, - _ => ChatEventKind::ChannelNotice, + n::JOINED => ChatEventKind::ChannelJoin, + n::LEFT => ChatEventKind::ChannelLeave, + n::MODE_CHANGE => return None, + n::YOU_JOINED + | n::YOU_LEFT + | n::WRONG_PASSWORD + | n::NOT_MEMBER + | n::NOT_MODERATOR + | n::NOT_OWNER + | n::MUTED + | n::BANNED + | n::THROTTLED => ChatEventKind::ChannelNotice, + n::PASSWORD_CHANGED + | n::OWNER_CHANGED + | n::PLAYER_NOT_FOUND + | n::CHANNEL_OWNER + | n::ANNOUNCEMENTS_ON + | n::ANNOUNCEMENTS_OFF + | n::MODERATION_ON + | n::MODERATION_OFF + | n::PLAYER_KICKED + | n::PLAYER_BANNED + | n::PLAYER_UNBANNED + | n::PLAYER_NOT_BANNED + | n::PLAYER_ALREADY_MEMBER + | n::INVITE + | n::INVITE_WRONG_FACTION + | n::WRONG_FACTION + | n::INVALID_NAME + | n::NOT_MODERATED + | n::PLAYER_INVITED + | n::PLAYER_INVITE_BANNED => ChatEventKind::ChannelNoticeUser, + _ => ChatEventKind::System, }; - ChatEvent { + Some(ChatEvent { kind: Some(kind), sender: a.unwrap_or_default(), target: b.unwrap_or_default(), channel, notice: notice_byte.to_string(), ..Default::default() - } + }) } /// Deliver one built event: the joined-list upkeep on **both sides** of the render, with the @@ -683,12 +715,10 @@ pub(super) fn feed_chat( script: Option>, mut log: ResMut, mut windows: ResMut, - mut edit: ResMut, mut channels: ResMut, mut names: ResMut, mut speaker: SpeakerEffects, commands: Res, - time: Res