LUA Toasts Builder v1.0

This bundle is marked as pending. It has not been reviewed by a staff member yet.

Toasts — Synced Toast Notification System​

Lua · Blz Frame API · Reforged 1.31+ · Multiplayer-safe




Author: Philipp Kruglyakov + Claude Sonnet (Developed by Vibecode)
Game design: Philipp Kruglyakov
Built for: Divine Roguelike
Discord: discord.com/invite/Y2zQ8Eqakg




Overview​


Toasts is a lightweight notification column for the bottom-right of the screen — quest trackers, loot pings, world events, and reward chains. It uses the Blz Frame API only (no third-party frameworks) and is designed for synchronous multiplayer maps.

  • Named styles in config — colors, sounds, sizes, progress type
  • key + replace — update one tracker in place (Hades / Destiny-style)
  • waitKey / waitKeyFade — queue and seamless handoff (quest complete → reward)
  • Progress bars — segmented, solid, optional timer
  • Accumulation — merge +50 +100 gold into one +150 line
  • Logical capacity — compact toasts (0.5 weight) let more fit in one column
  • RTL layout — icon right, text right-aligned, WC3-native look

Requirements: Warcraft III Reforged 1.31+, Lua mode enabled.
Call
Code:
Toasts.show( {..toastParams..} )
only from synchronous context on all clients with identical options.




Features​


Tracker logic​

OptionWhat it does
keyOne logical line per objective (e.g. one quest)
replaceUpdate active toast in place + pulse animation
waitKeyQueue until toast with same key expires
waitKeyFadePromote when previous toast starts fading
waitKeyFade + replaceSwap content in place — no entry animation, no double stack

Progress bars​

  • segmented — discrete sections (kill 2/5 wolves)
  • solid — single bar (quest complete)
  • timer — optional TimerBar.mdx (config flag)
  • Smooth displayProgress lerp on replace — no visual reset

Accumulation toasts​

  • isAccumulation style — sum accumulatedValue on replace with same key
  • textValueFunction / textColorFunction — dynamic body text and color
  • accumulationSmall — compact loot/XP feed (small icon, low height, logic weight 0.6)

Capacity & layout​

  • maxVisibleToasts — column capacity in logical units (not raw count)
  • toastLogicSize — per-toast weight (1.0 = normal; 0.5 ≈ 10 toasts in capacity 5)
  • Per-style: toastWidth, toastMinHeight, toastPadding, iconSize, contentVCenter

Audio​

  • showSound / updateSound per style
  • Sound pool (soundPoolSize) for overlapping plays




Installation​


  1. Copy into your map:
    • Toasts/ToastsConfig.lua
    • Toasts/Toasts.lua
  2. Load order: ToastsConfig.luaToasts.lua
  3. Call from sync triggers on all clients (see examples below)




Quick examples​


Quest progress​

Lua:
Toasts.show({
  style = "taskReceived",
  key = "wolf",
  icon = "ReplaceableTextures\\CommandButtons\\BTNWolf.blp",
  title = "Wolf Hunt",
  text = "Killed 1/3",
  progressCurrent = 1,
  progressMax = 3,
})

Update in place​

Lua:
Toasts.show({
  style = "taskUpdated",
  key = "wolf",
  replace = true,
  title = "Wolf Hunt",
  text = "Killed 2/3",
  progressCurrent = 2,
  progressMax = 3,
})

Quest complete → reward chain​

Lua:
Toasts.show({
  style = "taskCompleted",
  key = "wolf",
  replace = true,
  title = "Wolf Hunt",
  text = "Quest complete!",
  progressCurrent = 3,
  progressMax = 3,
})

Toasts.show({
  style = "resourcesReceived",
  key = "wolf",
  waitKey = true,
  waitKeyFade = true,
  replace = true,
  title = "+250 Gold",
  text = "Reward received",
})

Accumulated loot (compact)​

Lua:
Toasts.show({
  style = "accumulationSmall",
  key = "gold",
  icon = "ReplaceableTextures\\CommandButtons\\BTNGoldMine.blp",
  accumulatedValue = 50,
})

Toasts.show({
  style = "accumulationSmall",
  key = "gold",
  accumulatedValue = 100,
})
-- One toast shows +150




API​


FunctionDescription
Toasts.show(options)Show, update, or queue a toast
Toasts.diagnose()Check Blz Frame API availability
Toasts.setDebug(bool)Verbose logging

Main options: style, key, replace, waitKey, waitKeyFade, duration, players, progressCurrent, progressMax, accumulatedValue, toastLogicSize

Full reference is in file headers of Toasts.lua and ToastsConfig.lua (English + Russian).




Sync rules​


  • Call
    Code:
    Toasts.show()
    only from synchronous code on every client
  • Code:
    GetLocalPlayer()
    is used only for rendering and sounds
  • Pass identical options on all clients (key, waitKey, replace, etc.)
  • duration / fade start when the toast is actually shown, not when queued




Default styles​


default, success, warning, error, accumulation, accumulationSmall, resourcesReceived, taskReceived, taskUpdated, taskCompleted, worldMoreDanger, blessingReceived, curseReceived — all editable in ToastsConfig.lua without touching core logic.




Screenshots & demo​






Changelog​


v1.0​

  • Initial public release
  • Quest tracker, accumulation, compact toasts, logical capacity, progress bars





Game design: Philipp Kruglyakov
Divine Roguelike · Discord

Feedback, bug reports, and feature requests welcome!

ToastsConfig.lua
Lua:
--[[
================================================================================
  ToastsConfig — global layout, timing, limits, and named styles.

  Load order: ToastsConfig.lua → Toasts.lua

  Project: Divine Roguelike
           https://www.hiveworkshop.com/threads/346860/
  Discord: https://discord.com/invite/Y2zQ8Eqakg
  Game design: Philipp Kruglyakov (Филипп Кругляков)

  Style-level overrides (per entry in ToastsConfig.styles):
  EN: titleColor, textColor — WC3 color codes for title/body.
  RU: titleColor, textColor — цветовые коды WC3 для заголовка и текста.

  EN: titleHeight, textHeight — line heights; nil textHeight uses textLineHeight.
  RU: titleHeight, textHeight — высота строк; nil у textHeight → textLineHeight.

  EN: toastWidth, toastMinHeight, toastPadding, iconSize, iconTextGap — compact layout.
  RU: toastWidth, toastMinHeight, toastPadding, iconSize, iconTextGap — размеры плашки.

  EN: contentVCenter — vertically center icon+text when no title/progress (default auto).
  RU: contentVCenter — вертикальное центрирование иконки и текста без заголовка.

  EN: toastLogicSize — weight toward maxVisibleToasts capacity (1 = normal toast).
  RU: toastLogicSize — «вес» тоста в лимите maxVisibleToasts (1 = обычный).

  EN: textCharsPerLine — wrap estimate; scales from toastWidth if omitted.
  RU: textCharsPerLine — перенос строк; без значения масштабируется от toastWidth.

  EN: showSound, updateSound — paths played on show and on replace (local player).
  RU: showSound, updateSound — звуки при показе и при replace (локальный игрок).

  EN: progressBar — nil | "segmented" | "solid" | "timer" (timer needs progressUseTimerBar).
  RU: progressBar — nil | "segmented" | "solid" | "timer".

  EN: progressTrackAlpha, progressFillAlpha — per-style bar opacity (0–255).
  RU: progressTrackAlpha, progressFillAlpha — прозрачность полосы в стиле.

  EN: replace, duration — defaults for Toasts.show(); options override.
  RU: replace, duration — значения по умолчанию для Toasts.show(); options важнее.

  EN: isAccumulation — merge accumulatedValue on replace with same key.
  RU: isAccumulation — суммировать accumulatedValue при replace с тем же key.

  EN: textValueFunction(toast), textColorFunction(toast) — dynamic body text/color.
  RU: textValueFunction(toast), textColorFunction(toast) — текст и цвет тела.
================================================================================
]]

do
  ToastsConfig = ToastsConfig or {}

  -- ============================================================================
  -- Layout / Вёрстка
  -- ============================================================================

  -- EN: Horizontal inset of the toast column from the right screen edge (0–1).
  -- RU: Отступ колонки тостов от правого края экрана (доля 0–1).
  ToastsConfig.screenMarginRight = 0.012

  -- EN: Y position of the bottom-most toast anchor from the screen bottom (0–1).
  -- RU: Высота нижнего края колонки от низа экрана (доля 0–1).
  ToastsConfig.screenMarginBottom = 0.167

  -- EN: Default toast panel width (0–1). Override per style: toastWidth.
  -- RU: Ширина плашки по умолчанию (0–1). В стиле: toastWidth.
  ToastsConfig.toastWidth = 0.20

  -- EN: Minimum panel height when content is smaller (0–1). Override: toastMinHeight.
  -- RU: Минимальная высота плашки (0–1). В стиле: toastMinHeight.
  ToastsConfig.toastMinHeight = 0.042

  -- EN: Inner padding around icon/text/progress (0–1). Override: toastPadding.
  -- RU: Внутренние отступы плашки (0–1). В стиле: toastPadding.
  ToastsConfig.toastPadding = 0.005

  -- EN: Vertical gap between stacked toasts (0–1).
  -- RU: Зазор между тостами в колонке (0–1).
  ToastsConfig.toastGap = 0.004

  -- EN: Default square icon size (0–1). Override per style: iconSize.
  -- RU: Размер иконки по умолчанию (0–1). В стиле: iconSize.
  ToastsConfig.iconSize = 0.032

  -- EN: Gap between text block and icon (0–1). Override: iconTextGap.
  -- RU: Зазор между текстом и иконкой (0–1). В стиле: iconTextGap.
  ToastsConfig.iconTextGap = 0.004

  -- EN: true — icon on the right (RTL column at screen edge).
  -- RU: true — иконка справа (колонка у правого края).
  ToastsConfig.iconOnRight = true

  -- EN: true — title/body text aligned to the right.
  -- RU: true — выравнивание заголовка и текста по правому краю.
  ToastsConfig.textAlignRight = true

  -- EN: true — progress fill grows from right to left (matches RTL layout).
  -- RU: true — заливка прогресс-бара справа налево.
  ToastsConfig.progressFillFromRight = true

  -- EN: Default title line height (0–1). Override: style.titleHeight.
  -- RU: Высота строки заголовка (0–1). В стиле: titleHeight.
  ToastsConfig.titleHeight = 0.019

  -- EN: Default body line height (0–1). Override: style.textHeight.
  -- RU: Высота строки текста (0–1). В стиле: textHeight.
  ToastsConfig.textLineHeight = 0.011

  -- EN: Chars per line for height estimate; scales with toastWidth in styles.
  -- RU: Символов в строке для оценки высоты; в стилях масштабируется с toastWidth.
  ToastsConfig.textCharsPerLine = 28

  -- ============================================================================
  -- Progress bar / Прогресс-бар
  -- ============================================================================

  -- EN: true — use SIMPLESTATUSBAR + XP-bar textures (lightweight, recommended).
  -- RU: true — SIMPLESTATUSBAR и текстуры XP-бара (лёгкий вариант, рекомендуется).
  ToastsConfig.progressUseSimpleBar = true

  -- EN: true — allow progressBar="timer" (TimerBar.mdx model; heavier).
  -- RU: true — разрешить progressBar="timer" (модель TimerBar.mdx; тяжелее).
  ToastsConfig.progressUseTimerBar = false

  -- EN: Path to TimerBar.mdx when progressBar="timer".
  -- RU: Путь к TimerBar.mdx для progressBar="timer".
  ToastsConfig.progressTimerModel = "UI\\Feedback\\ProgressBar\\TimerBar.mdx"

  -- EN: Scale factor for the timer bar model.
  -- RU: Масштаб модели таймер-бара.
  ToastsConfig.progressTimerScale = 1

  -- EN: Internal max value for timer bar API (usually 100).
  -- RU: Внутренний максимум для API таймер-бара (обычно 100).
  ToastsConfig.progressValueMax = 100

  -- EN: true — invert timer bar fill direction.
  -- RU: true — инвертировать направление заливки таймер-бара.
  ToastsConfig.progressTimerInvert = false

  -- EN: Progress bar height (0–1).
  -- RU: Высота полосы прогресса (0–1).
  ToastsConfig.progressBarHeight = 0.004

  -- EN: Gap between text block and progress bar (0–1).
  -- RU: Отступ между текстом и прогресс-баром (0–1).
  ToastsConfig.progressBarGap = 0.003

  -- EN: Gap between segmented progress sections (0–1).
  -- RU: Зазор между сегментами segmented-бара (0–1).
  ToastsConfig.progressSegmentGap = 0.002

  -- EN: Max segments for progressBar="segmented" (caps progressMax visually).
  -- RU: Максимум сегментов для segmented (ограничивает отображение progressMax).
  ToastsConfig.maxProgressSegments = 12

  -- EN: Texture for empty/dim track (segmented + solid track).
  -- RU: Текстура пустой/тусклой дорожки (track в segmented и solid).
  ToastsConfig.progressTrackTexture = "UI\\Feedback\\XpBar\\human-bigbar-fill.blp"

  -- EN: Texture for filled portion of the bar.
  -- RU: Текстура заливки прогресс-бара.
  ToastsConfig.progressFillTexture = "UI\\Feedback\\XpBar\\human-bigbar-fill.blp"

  -- EN: Track alpha 0–255 (dim segments / solid background).
  -- RU: Прозрачность дорожки 0–255 (пустые сегменты / фон solid).
  ToastsConfig.progressTrackAlpha = 38

  -- EN: Fill alpha 0–255.
  -- RU: Прозрачность заливки 0–255.
  ToastsConfig.progressFillAlpha = 200

  -- EN: BlzFrameSetTexture blend flag for progress textures.
  -- RU: Флаг blend для текстур прогресс-бара.
  ToastsConfig.progressTextureBlend = false

  -- EN: true — displayProgress lerps toward progressCurrent on replace/update.
  -- RU: true — displayProgress плавно догоняет progressCurrent при replace.
  ToastsConfig.progressAnimate = true

  -- EN: Lerp speed for progress animation (per render tick step).
  -- RU: Скорость lerp анимации прогресса (за шаг render-тикера).
  ToastsConfig.progressLerpSpeed = 0.14

  -- EN: Snap threshold — stop lerping when closer than this.
  -- RU: Порог остановки lerp, когда разница меньше значения.
  ToastsConfig.progressAnimSnapThreshold = 0.002

  -- ============================================================================
  -- Limits / Лимиты
  -- ============================================================================

  -- EN: Column capacity in logical units (not toast count). Sum of toastLogicSize.
  -- RU: Ёмкость колонки в лог. единицах (не в штуках). Сумма toastLogicSize.
  ToastsConfig.maxVisibleToasts = 5

  -- EN: Default weight of one toast toward maxVisibleToasts (1 = full slot).
  -- RU: Вес одного тоста по умолчанию (1 = целая единица лимита).
  ToastsConfig.toastLogicSize = 1

  -- EN: Smallest expected logic size — used to size the UI frame pool.
  -- RU: Минимальный ожидаемый вес — для расчёта числа UI-слотов.
  ToastsConfig.minToastLogicSize = 0.25

  -- EN: Hard cap on UI slots; nil = ceil(maxVisibleToasts / minToastLogicSize).
  -- RU: Жёсткий лимит UI-слотов; nil = ceil(maxVisibleToasts / minToastLogicSize).
  ToastsConfig.maxToastSlots = nil

  -- EN: Max players with per-player toast state (0 .. maxPlayers-1).
  -- RU: Число игроков с отдельным состоянием тостов.
  ToastsConfig.maxPlayers = 12

  -- EN: Max pending queue length; 0 = unlimited.
  -- RU: Длина очереди pending; 0 = без лимита.
  ToastsConfig.maxPendingQueue = 0

  -- ============================================================================
  -- Timing / Время
  -- ============================================================================

  -- EN: Default visible duration (seconds) before fade starts.
  -- RU: Длительность показа по умолчанию (сек) до начала fade.
  ToastsConfig.defaultDuration = 4.0

  -- EN: Fade-out duration (seconds) at end of toast lifetime.
  -- RU: Длительность затухания (сек) в конце жизни тоста.
  ToastsConfig.fadeDuration = 1.0

  -- EN: Min delay between entry animations (seconds); 0 = no throttle.
  -- RU: Мин. пауза между entry-анимациями (сек); 0 = без задержки.
  ToastsConfig.minShowInterval = 0.35

  -- EN: CreateSound instances per path for overlapping showSound playback.
  -- RU: Экземпляров CreateSound на путь для наслоения showSound.
  ToastsConfig.soundPoolSize = 4

  -- ============================================================================
  -- Animation / Анимация
  -- ============================================================================

  -- EN: Logic ticker interval (seconds) — expiry, queue, waitKey.
  -- RU: Интервал логического тикера (сек) — истечение, очередь, waitKey.
  ToastsConfig.tickerInterval = 0.03

  -- EN: Render ticker interval (seconds) — layout, fade, lerp, pulse.
  -- RU: Интервал render-тикера (сек) — вёрстка, fade, lerp, pulse.
  ToastsConfig.renderTickerInterval = 0.016

  -- EN: Speed of vertical position lerp when stack reflows.
  -- RU: Скорость вертикального lerp при перестройке колонки.
  ToastsConfig.positionLerpSpeed = 0.18

  -- EN: true — slide/fade in on show (frame-lerp, not gameTime).
  -- RU: true — анимация появления (frame-lerp, не gameTime).
  ToastsConfig.entryAnimEnabled = true

  -- EN: Entry animation lerp speed per render tick.
  -- RU: Скорость entry-анимации за шаг render-тикера.
  ToastsConfig.entryAnimSpeed = 0.04

  -- EN: Entry start offset below target Y (negative = from below).
  -- RU: Стартовое смещение entry ниже целевой Y (отрицательное = снизу).
  ToastsConfig.entryAnimFromY = -0.08

  -- EN: true — fade alpha during entry animation.
  -- RU: true — затухание альфы во время entry.
  ToastsConfig.entryFadeEnabled = true

  -- EN: true — pulse icon/panel on replace update.
  -- RU: true — пульс иконки/плашки при replace.
  ToastsConfig.updatePulseEnabled = true

  -- EN: Pulse phase step per render tick (~0.5s cycle at 0.016 interval).
  -- RU: Шаг фазы пульса за render-тик (~0.5 с при интервале 0.016).
  ToastsConfig.updatePulseStep = 0.032

  -- EN: Peak icon scale multiplier during pulse.
  -- RU: Пиковый масштаб иконки при пульсе.
  ToastsConfig.updatePulseIconScale = 1.42

  -- EN: Extra panel alpha added at pulse peak (0–255).
  -- RU: Добавка альфы плашки на пике пульса (0–255).
  ToastsConfig.updatePulsePanelAlphaBoost = 130

  -- EN: Icon glow alpha at pulse peak (0–255).
  -- RU: Альфа свечения иконки на пике пульса (0–255).
  ToastsConfig.updatePulseGlowAlpha = 210

  -- ============================================================================
  -- Panel background / Фон плашки
  -- ============================================================================

  -- EN: true — draw tooltip-style background behind toast content.
  -- RU: true — рисовать фон в стиле tooltip за содержимым.
  ToastsConfig.showPanelBackground = true

  -- EN: true — use imported SimpleFrame panel (needs TGA + TOC).
  -- RU: true — импортированная SimpleFrame-плашка (нужны TGA + TOC).
  ToastsConfig.useSimplePanel = false

  -- EN: true — use war3mapImported texture paths below.
  -- RU: true — использовать пути war3mapImported ниже.
  ToastsConfig.useImportedTextures = false

  -- EN: Built-in BLP for default BACKDROP panel.
  -- RU: Встроенный BLP для BACKDROP-плашки по умолчанию.
  ToastsConfig.panelTexture = "UI\\Widgets\\ToolTips\\Human\\human-tooltip-background.blp"

  -- EN: Panel transparency 0–1 (0 = opaque, 0.8 = 80% transparent).
  -- RU: Прозрачность плашки 0–1 (0 = непрозрачная, 0.8 = 80% прозрачности).
  ToastsConfig.panelBgTransparency = 0.80

  -- EN: Derived panel alpha 0–255 (computed from panelBgTransparency).
  -- RU: Вычисленная альфа плашки 0–255 (из panelBgTransparency).
  ToastsConfig.panelBgAlpha = math.floor(255 * (1 - ToastsConfig.panelBgTransparency) + 0.5)

  -- EN: BlzFrameSetTexture blend for panel texture.
  -- RU: Флаг blend для текстуры плашки.
  ToastsConfig.panelTextureBlend = false

  -- EN: Imported TOC path for SimpleFrame panel.
  -- RU: Путь к импортированному TOC для SimpleFrame.
  ToastsConfig.panelTocFile = "war3mapImported\\Toasts\\Toasts.toc"

  -- EN: FDF template name inside the TOC.
  -- RU: Имя FDF-шаблона в TOC.
  ToastsConfig.panelFdfTemplate = "ToastsPanelTemplate"

  -- EN: Child frame name for panel texture inside the template.
  -- RU: Имя дочернего фрейма текстуры в шаблоне.
  ToastsConfig.panelTextureFrameName = "ToastsPanelTexture"

  -- EN: true — apply panelTint color to imported SimpleFrame.
  -- RU: true — применять panelTint к импортированной SimpleFrame.
  ToastsConfig.usePanelTint = false

  -- EN: RGBA tint for imported panel (0–255 per channel).
  -- RU: RGBA-оттенок импортированной плашки (0–255 на канал).
  ToastsConfig.panelTint = { r = 24, g = 32, b = 48, a = 235 }

  -- EN: Imported panel TGA (dark variant).
  -- RU: Импортированная TGA плашки (тёмный вариант).
  ToastsConfig.importedPanelTexture = "war3mapImported\\Toasts\\toast_panel_dark.tga"

  -- EN: Imported panel TGA (simple variant).
  -- RU: Импортированная TGA плашки (простой вариант).
  ToastsConfig.importedPanelTextureSimple = "war3mapImported\\Toasts\\toast_panel.tga"

  -- EN: Imported progress track TGA (optional).
  -- RU: Импортированная TGA дорожки прогресса.
  ToastsConfig.importedProgressTrackTexture = "war3mapImported\\Toasts\\progress_track.tga"

  -- EN: Imported progress fill TGA (optional).
  -- RU: Импортированная TGA заливки прогресса.
  ToastsConfig.importedProgressFillTexture = "war3mapImported\\Toasts\\progress_fill.tga"

  -- EN: true — use imported progress textures instead of BLP.
  -- RU: true — импортированные текстуры прогресса вместо BLP.
  ToastsConfig.useImportedProgressTextures = false

  -- ============================================================================
  -- Frame tree / Дерево фреймов
  -- ============================================================================

  -- EN: Parent frame name for toast roots (Reforged Blz API).
  -- RU: Имя родительского фрейма для корней тостов (Blz API Reforged).
  ToastsConfig.frameParent = "ConsoleUIBackdrop"

  -- EN: Frame level / z-order offset for toast frames.
  -- RU: Уровень фрейма / z-order для тостов.
  ToastsConfig.frameLevel = 1

  -- EN: true — anchor column to widescreen bottom-right helper frame.
  -- RU: true — привязка к widescreen-якорю в правом нижнем углу.
  ToastsConfig.useWidescreenAnchor = true

  -- EN: How often to refresh widescreen anchor position (seconds).
  -- RU: Как часто обновлять позицию widescreen-якоря (сек).
  ToastsConfig.widescreenUpdateInterval = 0.25

  -- ============================================================================
  -- Styles / Стили (named presets for Toasts.show({ style = "..." }))
  -- ============================================================================

  ToastsConfig.styles = {
    default = {
      titleColor = "|cffFFCC00",
      textColor = "|cffFFFFFF",
      textHeight = nil,
      showSound = "Sound\\Interface\\QuestActivateWhat1.wav",
    },
    success = {
      titleColor = "|cff40FF40",
      textColor = "|cffB0FFB0",
      titleHeight = 0.019,
      textHeight = nil,
    },
    warning = {
      titleColor = "|cffFFFF00",
      textColor = "|cffFFF8C0",
      titleHeight = 0.019,
      textHeight = nil,
    },
    error = {
      titleColor = "|cffFF4040",
      textColor = "|cffFFB0B0",
      titleHeight = 0.019,
      textHeight = nil,
    },

    -- EN: Full-size accumulation toast (loot/XP batching). RU: Полноразмерный аккумулирующий тост.
    accumulation = {
      titleColor = "|cffFFCC00",
      isAccumulation = true,
      textColorFunction = function( toast )
        if toast.accumulatedValue == nil or toast.accumulatedValue == 0 then
          return "|cffFFFFFF"
        elseif toast.accumulatedValue < 0 then
          return "|cffFF4040"
        else
          return "|cff40FF40"
        end
      end,
      textValueFunction = function( toast )
        local value = toast.accumulatedValue or 0
        if value > 0 then
          return "+" .. tostring(value)
        end
        return tostring(value)
      end,
      showSound = "Abilities\\Spells\\Items\\ResourceItems\\ReceiveGold.wav",
      updateSound = "Sound\\Interface\\QuestActivateWhat1.wav",
      replace = true,
      duration = 4,
    },

    -- EN: Compact accumulation (small icon, low height, logic weight 0.6).
    -- RU: Компактный аккумулирующий тост (маленькая иконка, низкая плашка, вес 0.6).
    accumulationSmall = {
      titleColor = "|cffFFCC00",
      iconSize = 0.016,
      iconTextGap = 0.002,
      toastPadding = 0.002,
      toastMinHeight = 0.016,
      textHeight = 0.009,
      contentVCenter = true,
      toastLogicSize = 0.6,
      toastWidth = 0.08,
      isAccumulation = true,
      textColorFunction = function( toast )
        if toast.accumulatedValue == nil or toast.accumulatedValue == 0 then
          return "|cffFFFFFF"
        elseif toast.accumulatedValue < 0 then
          return "|cffFF4040"
        else
          return "|cff40FF40"
        end
      end,
      textValueFunction = function( toast )
        local value = toast.accumulatedValue or 0
        if value > 0 then
          return "+" .. tostring(value)
        end
        return tostring(value)
      end,
      showSound = "Abilities\\Spells\\Items\\ResourceItems\\ReceiveGold.wav",
      updateSound = "Sound\\Interface\\QuestActivateWhat1.wav",
      replace = true,
      duration = 4,
    },

    resourcesReceived = {
      titleColor = "|cffFFCC00",
      textColor = "|cffFFF8E0",
      showSound = "Abilities\\Spells\\Items\\ResourceItems\\ReceiveGold.wav",
      progressBar = nil,
    },
    playerLeftTeam = {
      titleColor = "|cffFF8080",
      textColor = "|cffE8E8E8",
      showSound = "Sound\\Interface\\Error.wav",
      progressBar = nil,
    },
    worldMoreDanger = {
      titleColor = "|cffFFAA00",
      textColor = "|cffFFE8A0",
      showSound = "Sound\\Buildings\\Undead\\UndeadBuildingBirth1.flac",
      updateSound = "Sound\\Interface\\QuestActivateWhat1.wav",
      progressBar = "segmented",
      progressTrackAlpha = 100,
      progressFillAlpha = 255,
    },
    worldMoreDanger2 = {
      titleColor = "|cffFF4040",
      textColor = "|cffFFB0B0",
      showSound = "Sound\\Buildings\\Undead\\UndeadBuildingBirth3.flac",
      updateSound = "Sound\\Interface\\QuestActivateWhat3.wav",
      progressBar = "segmented",
      progressTrackAlpha = 100,
      progressFillAlpha = 255,
    },
    taskReceived = {
      titleColor = "|cffFFCC00",
      textColor = "|cffFFFFFF",
      toastWidth = 0.22,
      showSound = "Sound\\Interface\\QuestNew.wav ",
      progressBar = "segmented",
      progressTrackAlpha = 40,
      progressFillAlpha = 210,
    },
    taskUpdated = {
      titleColor = "|cffFFCC00",
      textColor = "|cffFFFFFF",
      toastWidth = 0.14,
      showSound = "Sound\\Interface\\QuestActivateWhat1.wav",
      updateSound = "Sound\\Interface\\QuestActivateWhat1.wav",
      progressBar = "segmented",
      progressTrackAlpha = 40,
      progressFillAlpha = 210,
    },
    taskCompleted = {
      titleColor = "|cff40FF40",
      textColor = "|cffB0FFB0",
      toastWidth = 0.14,
      showSound = "Sound\\Interface\\QuestCompleted.wav",
      progressBar = "solid",
      progressTrackAlpha = 40,
      progressFillAlpha = 210,
    },
    blessingReceived = {
      titleColor = "|cff40FF40",
      textColor = "|cffC8FFC8",
      toastWidth = 0.22,
      showSound = "Sound\\Interface\\GoodJob.flac",
      progressBar = nil,
    },
    curseReceived = {
      titleColor = "|cffC080FF",
      textColor = "|cffE8C8FF",
      showSound = "Sound\\Interface\\Evil.wav",
      progressBar = nil,
    },
  }
end

Toasts.lua
Lua:
--[[
================================================================================
  TOASTS — Toast notification system (Warcraft III, Lua)
  Blz Frame API (Reforged 1.31+), no third-party frameworks.

  Project:  Divine Roguelike
            https://www.hiveworkshop.com/threads/346860/
  Discord:  https://discord.com/invite/Y2zQ8Eqakg
  Game design: Philipp Kruglyakov

  Load order: ToastsConfig.lua → Toasts.lua

  SYNC
  ----
  Call Toasts.show() only from synchronous context on all clients.
  GetLocalPlayer() is used for rendering and sounds only.
  waitKey / minShowInterval depend on game state — pass identical options everywhere.

  LIFETIME
  --------
  duration / fade start when the toast is actually shown:
  activateToast, tryPromotePending, or replace of an active toast (not while pending).
  showSound on promote/activate; updateSound on visible replace (not waitKey pending).

  Toasts.show() OPTIONS
  ---------------------
  icon, title, text — at least one required (or accumulatedValue for accumulation styles).

  style           — name from ToastsConfig.styles (default: "default").
  key             — tracker id; same key + replace updates in place.
  replace         — merge/update toast with same key (style can set default).
  waitKey         — queue until active toast with same key expires.
  waitKeyFade     — with waitKey: promote when previous toast starts fading.
  waitKeyFade+replace — swap content in place (pulse), no entry animation.
  replace+waitKey — updates pending entry only, not the active toast.

  duration        — visible seconds before fade (style default, options override).
  players         — player or { players } list; default: triggering player.

  progressCurrent, progressMax — show progress bar per style.progressBar.
  accumulatedValue — for isAccumulation styles; summed on replace with same key.
  textValueFunction(toast), textColorFunction(toast) — override style functions.
  toastLogicSize  — weight toward maxVisibleToasts capacity (style/options).

  CAPACITY
  --------
  maxVisibleToasts is logical capacity (not toast count).
  toastLogicSize 1.0 = normal; 0.5 allows ~10 toasts in a column of capacity 5.

  ACCUMULATION
  ------------
  style.isAccumulation: accumulatedValue adds on replace with same key.
  textValueFunction / textColorFunction — in style or options (options win).

  UI
  --
  Default: human-tooltip-background.blp + BlzFrameSetAlpha.
  RTL layout: icon right, text right-aligned, progress fills right-to-left.
  Progress: solid / segmented (SIMPLESTATUSBAR); optional progressBar="timer".
  displayProgress, entry, pulse — frame-lerp (not gameTime).
  showSound pool: soundPoolSize instances per path for overlapping playback.

  API
  ---
  Toasts.show(options) → success, plays sound locally when applicable.
  Toasts.diagnose()    — check Blz Frame API availability.
  Toasts.setDebug(bool) — verbose logging.
================================================================================

================================================================================
  TOASTS — Система тост-уведомлений (Warcraft III, Lua)
  Blz Frame API (Reforged 1.31+), без сторонних фреймворков.

  Проект:   Divine Roguelike
            https://www.hiveworkshop.com/threads/346860/
  Discord:  https://discord.com/invite/Y2zQ8Eqakg
  Геймдизайн: Филипп Кругляков (Philipp Kruglyakov)

  Порядок загрузки: ToastsConfig.lua → Toasts.lua

  СИНХРОНИЗАЦИЯ
  -------------
  Toasts.show() — только из синхронного контекста на всех клиентах.
  GetLocalPlayer() — только отрисовка и звуки.
  waitKey / minShowInterval — общее состояние; одинаковые options на всех клиентах.

  ВРЕМЯ ЖИЗНИ
  -----------
  duration / fade стартуют при фактическом показе:
  activateToast, tryPromotePending или replace активного (не в pending).
  showSound — при promote/activate; updateSound — replace видимого (не pending waitKey).

  ПАРАМЕТРЫ Toasts.show()
  -----------------------
  icon, title, text — нужен хотя бы один (или accumulatedValue для accumulation).

  style           — имя из ToastsConfig.styles (по умолчанию "default").
  key             — id трекера; тот же key + replace обновляет на месте.
  replace         — обновить тост с тем же key (можно задать в стиле).
  waitKey         — ждать исчезновения активного тоста с тем же key.
  waitKeyFade     — с waitKey: показать, когда предыдущий начнёт fade.
  waitKeyFade+replace — замена плашки на месте (pulse), без entry.
  replace+waitKey — обновляет pending, не активный тост.

  duration        — секунды до fade (стиль по умолчанию, options переопределяют).
  players         — игрок или список; по умолчанию — вызывающий игрок.

  progressCurrent, progressMax — прогресс-бар по style.progressBar.
  accumulatedValue — для isAccumulation; суммируется при replace с тем же key.
  textValueFunction(toast), textColorFunction(toast) — переопределение функций стиля.
  toastLogicSize  — вес в лимите maxVisibleToasts (стиль/options).

  ЁМКОСТЬ
  -------
  maxVisibleToasts — логические единицы, не число тостов.
  toastLogicSize 1.0 — обычный; 0.5 → до ~10 тостов при лимите 5.

  АККУМУЛИРОВАНИЕ
  ---------------
  style.isAccumulation: accumulatedValue суммируется при replace с тем же key.
  textValueFunction / textColorFunction — в стиле или options (options важнее).

  ИНТЕРФЕЙС
  ---------
  По умолчанию: human-tooltip-background.blp + BlzFrameSetAlpha.
  RTL: иконка справа, текст по правому краю, прогресс справа налево.
  Прогресс: solid / segmented (SIMPLESTATUSBAR); опционально progressBar="timer".
  displayProgress, entry, pulse — frame-lerp (не gameTime).
  Пул showSound: soundPoolSize экземпляров на путь для наслоения.

  API
  ---
  Toasts.show(options) — успех; звук локально при необходимости.
  Toasts.diagnose()    — проверка Blz Frame API.
  Toasts.setDebug(bool) — подробный лог.
================================================================================
]]

do
  Toasts = Toasts or {}

  function Toasts._log(msg, isError)
    local text = "[Toasts] " .. tostring(msg)
    print(text)
    if type(BJDebugMsg) == "function" then
      BJDebugMsg(text)
    end
  end

  function Toasts._trace(msg)
    Toasts._log("[trace] " .. tostring(msg))
  end

  if Toasts._loaded then
    -- уже загружено
  else
    Toasts._loaded = true
    Toasts._debug = false

    local CFG = ToastsConfig or {}

    local ORIGIN_FRAME_GAME_UI = ORIGIN_FRAME_GAME_UI or 7
    local FRAMEPOINT_TOPLEFT = FRAMEPOINT_TOPLEFT or 0
    local FRAMEPOINT_TOPRIGHT = FRAMEPOINT_TOPRIGHT or 2
    local FRAMEPOINT_BOTTOMLEFT = FRAMEPOINT_BOTTOMLEFT or 6
    local FRAMEPOINT_BOTTOMRIGHT = FRAMEPOINT_BOTTOMRIGHT or 8
    local FRAME_CONTEXT = 0

    local SCREEN_HEIGHT = 0.6
    local SCREEN_CENTER_X = 0.4
    local ALPHA_OPAQUE = 255
    local ALPHA_TRANSPARENT = 0

    local PLAYER_SLOT_PLAYING = PLAYER_SLOT_STATE_PLAYING or 1
    local PROGRESS_BAR_SOLID = "solid"
    local PROGRESS_BAR_SEGMENTED = "segmented"
    local PROGRESS_BAR_TIMER = "timer"

    local TEXT_JUSTIFY_MIDDLE = TEXT_JUSTIFY_MIDDLE or 1
    local TEXT_JUSTIFY_LEFT = TEXT_JUSTIFY_LEFT or 0
    local TEXT_JUSTIFY_RIGHT = TEXT_JUSTIFY_RIGHT or 2

    local _widescreen = {
      ready = false,
      parent = 0,
      anchor = 0,
      timer = nil,
      lastWidth = nil,
    }

    local _toastPool = {}
    local _requestPool = {}
    local _playerListScratch = {}
    local _playerState = {}
    local _frameSlots = nil
    local _localUiReady = false
    local _logicTicker = nil
    local _renderTicker = nil
    local _gameClock = nil
    local _layoutDirty = false
    local _soundPools = {}

    local getAnchorFrame
    local getUIParent
    local recalcTargetY
    local applyLocalLayout
    local ensureLocalUI
    local ensureTickers
    local computeToastAlpha
    local playStyleSound

    local function log(msg, isError)
      if isError then
        Toasts._log(msg, true)
      elseif Toasts._debug then
        Toasts._log(msg, false)
      end
    end

    local function cfg(key, fallback)
      local v = CFG[key]
      if v == nil then
        return fallback
      end
      return v
    end

    local function hasBlzApi()
      return type(BlzCreateFrameByType) == "function"
        and type(BlzGetOriginFrame) == "function"
        and type(BlzFrameSetPoint) == "function"
        and type(BlzFrameSetSize) == "function"
        and type(BlzFrameSetTexture) == "function"
        and type(BlzFrameSetText) == "function"
        and type(BlzFrameSetVisible) == "function"
        and type(BlzFrameSetAlpha) == "function"
    end

    local function isValidFrame(frame)
      return frame and frame ~= 0
    end

    local function getGameUI()
      return BlzGetOriginFrame(ORIGIN_FRAME_GAME_UI, FRAME_CONTEXT)
    end

    local function resolveNamedFrame(name)
      if name == nil or name == "" then
        return 0
      end
      if type(BlzGetFrameByName) == "function" then
        local frame = BlzGetFrameByName(name, FRAME_CONTEXT)
        if isValidFrame(frame) then
          return frame
        end
      end
      return 0
    end

    local function getBaseUIParent()
      local parentName = cfg("frameParent", "ConsoleUIBackdrop")
      local named = resolveNamedFrame(parentName)
      if isValidFrame(named) then
        return named
      end
      if parentName ~= nil and parentName ~= "" and parentName ~= "GAME_UI" then
        log("getBaseUIParent: frame '" .. tostring(parentName) .. "' not found, using GAME_UI", true)
      end
      return getGameUI()
    end

    local function getWidescreenWidth()
      if type(BlzGetLocalClientWidth) == "function" and type(BlzGetLocalClientHeight) == "function" then
        local clientW = BlzGetLocalClientWidth()
        local clientH = BlzGetLocalClientHeight()
        if clientW and clientH and clientH > 0 then
          return clientW / clientH * SCREEN_HEIGHT
        end
      end
      return 0.8
    end

    local function applyFrameLevel(frame)
      if not isValidFrame(frame) or type(BlzFrameSetLevel) ~= "function" then
        return
      end
      local level = cfg("frameLevel", 1)
      if level ~= nil then
        BlzFrameSetLevel(frame, level)
      end
    end

    local function slotFrameName(slotId, suffix)
      return ("Toasts_%d_%s"):format(slotId, suffix)
    end

    local function setFrameAlpha(frame, alpha)
      if isValidFrame(frame) and type(BlzFrameSetAlpha) == "function" then
        BlzFrameSetAlpha(frame, alpha)
      end
    end

    local function setFrameScale(frame, scale)
      if isValidFrame(frame) and type(BlzFrameSetScale) == "function" then
        BlzFrameSetScale(frame, scale)
      end
    end

    local function clamp01(value)
      if value < 0 then
        return 0
      end
      if value > 1 then
        return 1
      end
      return value
    end

    local function easeOutCubic(t)
      t = clamp01(t)
      local inv = 1 - t
      return 1 - inv * inv * inv
    end

    local function toastBaseY(toast)
      return toast.targetY or cfg("screenMarginBottom", 0.16)
    end

    local function startEntryAnimation(toast)
      if not cfg("entryAnimEnabled", true) then
        toast.entryAnimating = false
        toast.entryT = 1
        toast.entryFromY = nil
        toast.displayY = toast.targetY
        return
      end
      toast.entryAnimating = true
      toast.entryT = 0
      toast.entryFromY = toastBaseY(toast) + cfg("entryAnimFromY", -0.08)
      toast.displayY = toast.entryFromY
    end

    local function triggerUpdatePulse(toast)
      if not cfg("updatePulseEnabled", true) then
        return
      end
      toast.pulseActive = true
      toast.pulseT = 0
    end

    local function computeEntryAlphaMult(toast)
      if not cfg("entryFadeEnabled", true) or toast.entryAnimating ~= true then
        return 1
      end
      return easeOutCubic(clamp01((toast.entryT or 0) / 0.55))
    end

    local function advanceEntryAnimation(toast)
      if toast.entryAnimating ~= true then
        return
      end
      local speed = cfg("entryAnimSpeed", 0.04)
      local t = toast.entryT or 0
      t = t + (1 - t) * speed
      if t >= 0.999 then
        t = 1
        toast.entryAnimating = false
        toast.entryFromY = nil
      end
      toast.entryT = t
      local target = toastBaseY(toast)
      local fromY = toast.entryFromY
      if fromY == nil then
        fromY = target + cfg("entryAnimFromY", -0.08)
      end
      toast.displayY = fromY + (target - fromY) * easeOutCubic(t)
    end

    local function cancelEntryForQueueLerp(toast)
      if toast.entryAnimating ~= true then
        return
      end
      toast.entryAnimating = false
      toast.entryT = nil
      toast.entryFromY = nil
    end

    local function advanceToastQueuePosition(toast, lerpSpeed)
      if toast.entryAnimating == true then
        return
      end
      local target = toastBaseY(toast)
      local current = toast.displayY
      if current == nil then
        current = target
      end
      if math.abs(target - current) > 0.0001 then
        current = current + (target - current) * lerpSpeed
      else
        current = target
      end
      toast.displayY = current
    end

    local function advanceUpdatePulse(toast)
      if not cfg("updatePulseEnabled", true) or toast.pulseActive ~= true then
        toast.pulseIconScale = 1
        toast.pulseStrength = 0
        return
      end
      local step = cfg("updatePulseStep", 0.032)
      local phase = (toast.pulseT or 0) + step
      if phase >= 1 then
        toast.pulseActive = false
        toast.pulseT = 0
        toast.pulseIconScale = 1
        toast.pulseStrength = 0
        return
      end
      toast.pulseT = phase
      local pulse = math.sin(phase * math.pi)
      local scalePeak = cfg("updatePulseIconScale", 1.42)
      toast.pulseIconScale = 1 + (scalePeak - 1) * pulse
      toast.pulseStrength = pulse
    end

    local function advanceToastVisuals(toast, lerpSpeed)
      advanceEntryAnimation(toast)
      advanceToastQueuePosition(toast, lerpSpeed)
      advanceUpdatePulse(toast)
    end

    local function setFrameSize(frame, width, height)
      BlzFrameSetSize(frame, width, height, FRAME_CONTEXT)
    end

    local function setFramePoint(frame, point, relative, relativePoint, x, y)
      BlzFrameClearAllPoints(frame)
      BlzFrameSetPoint(frame, point, relative, relativePoint, x, y)
    end

    local function createFrame(frameType, name, parent, template)
      local frame = BlzCreateFrameByType(frameType, name, parent, template or "", FRAME_CONTEXT)
      if isValidFrame(frame) then
        BlzFrameSetVisible(frame, false)
      end
      return frame
    end

    local function showFrame(frame)
      if isValidFrame(frame) then
        BlzFrameSetVisible(frame, true)
      end
    end

    local function hideFrame(frame)
      if isValidFrame(frame) then
        BlzFrameSetVisible(frame, false)
      end
    end

    local function enableFrame(frame, enabled)
      if isValidFrame(frame) and type(BlzFrameSetEnable) == "function" then
        BlzFrameSetEnable(frame, enabled)
      end
    end

    local _panelFdfReady = false

    local function scaleAlpha(baseAlpha, fadeAlpha)
      local base = baseAlpha or ALPHA_OPAQUE
      local fade = fadeAlpha or ALPHA_OPAQUE
      return math.floor(base * fade / ALPHA_OPAQUE + 0.5)
    end

    local function resolvePanelBgAlpha(style, fadeAlpha)
      style = style or {}
      local base = style.panelBgAlpha
      if base == nil then
        local transparency = cfg("panelBgTransparency", nil)
        if transparency ~= nil then
          base = math.floor(ALPHA_OPAQUE * (1 - transparency) + 0.5)
        else
          base = cfg("panelBgAlpha", 51)
        end
      end
      if fadeAlpha ~= nil then
        return scaleAlpha(base, fadeAlpha)
      end
      return base
    end

    local function resolvePanelTexture(style)
      style = style or {}
      if cfg("useImportedTextures", false) then
        return style.panelTexture or cfg("importedPanelTexture") or cfg("panelTexture", "")
      end
      return style.panelTexture or cfg("panelTexture", "UI\\Widgets\\ToolTips\\Human\\human-tooltip-background.blp")
    end

    local function resolveProgressTrackAlpha(style, fadeAlpha)
      style = style or {}
      local base = style.progressTrackAlpha or cfg("progressTrackAlpha", 35)
      if fadeAlpha ~= nil then
        return scaleAlpha(base, fadeAlpha)
      end
      return base
    end

    local function resolveProgressFillAlpha(style, fadeAlpha)
      style = style or {}
      local base = style.progressFillAlpha or cfg("progressFillAlpha", 155)
      if fadeAlpha ~= nil then
        return scaleAlpha(base, fadeAlpha)
      end
      return base
    end

    local function segmentFillRatio(curVal, maxVal, segId, segCount)
      if segCount < 1 then
        return 0
      end
      local unitsPerSeg = maxVal / segCount
      if unitsPerSeg <= 0 then
        return 0
      end
      local segStart = (segId - 1) * unitsPerSeg
      local segEnd = segId * unitsPerSeg
      if curVal <= segStart then
        return 0
      end
      if curVal >= segEnd then
        return 1
      end
      return (curVal - segStart) / unitsPerSeg
    end

    local function bindAllPoints(child, parent)
      if isValidFrame(child) and isValidFrame(parent) and type(BlzFrameSetAllPoints) == "function" then
        BlzFrameSetAllPoints(child, parent)
      end
    end

    local function ensurePanelFdf()
      if _panelFdfReady then
        return true
      end
      if not cfg("useSimplePanel", false) then
        return false
      end
      if type(BlzLoadTOCFile) ~= "function" then
        return false
      end
      local toc = cfg("panelTocFile", "war3mapImported\\Toasts\\Toasts.toc")
      local ok, err = pcall(BlzLoadTOCFile, toc)
      if not ok then
        log("ensurePanelFdf: " .. tostring(err), true)
        return false
      end
      _panelFdfReady = true
      return true
    end

    local function setFrameTexture(frame, texturePath, useBlend)
      if not isValidFrame(frame) or type(BlzFrameSetTexture) ~= "function" then
        return
      end
      if useBlend == nil then
        useBlend = cfg("panelTextureBlend", true)
      end
      if useBlend then
        BlzFrameSetTexture(frame, texturePath or "", 0, true)
      else
        BlzFrameSetTexture(frame, texturePath or "", 0)
      end
    end

    local function applyVertexColorToFrame(frame, tint)
      if not isValidFrame(frame) or type(BlzFrameSetVertexColor) ~= "function" then
        return false
      end
      if tint == nil then
        return false
      end
      local r = tint.r or 255
      local g = tint.g or 255
      local b = tint.b or 255
      local a = tint.a or cfg("panelBgAlpha", 235)
      if type(BlzConvertColor) == "function" then
        BlzFrameSetVertexColor(frame, BlzConvertColor(a, r, g, b))
      else
        BlzFrameSetVertexColor(frame, r, g, b, a)
      end
      return true
    end

    local function resolvePanelTint(style)
      if not cfg("usePanelTint", false) then
        return nil
      end
      if type(style) == "table" and style.panelTint ~= nil then
        return style.panelTint
      end
      return cfg("panelTint", nil)
    end

    local function applyPanelStyle(frame, style, fadeAlpha, useSimpleTexture)
      if not isValidFrame(frame) then
        return
      end
      style = style or {}
      local alpha = resolvePanelBgAlpha(style, fadeAlpha)

      if useSimpleTexture then
        local texture = style.panelTextureSimple or cfg("importedPanelTextureSimple")
          or resolvePanelTexture(style)
        setFrameTexture(frame, texture, style.panelTextureBlend)
        local tint = resolvePanelTint(style) or cfg("panelTint", nil)
        local c = tint or { r = 24, g = 32, b = 48, a = alpha }
        applyVertexColorToFrame(frame, {
          r = c.r,
          g = c.g,
          b = c.b,
          a = alpha,
        })
        return
      end

      setFrameTexture(
        frame,
        resolvePanelTexture(style),
        style.panelTextureBlend ~= nil and style.panelTextureBlend or cfg("panelTextureBlend", false)
      )
      setFrameAlpha(frame, alpha)
    end

    local function applyProgressTrackStyle(frame, style)
      if not isValidFrame(frame) then
        return
      end
      style = style or {}
      local texture = style.progressTrackTexture or cfg("progressTrackTexture", "")
      if texture == "" then
        texture = style.progressFillTexture or cfg("progressFillTexture", "")
      end
      setFrameTexture(frame, texture, cfg("progressTextureBlend", false))
    end

    local function initBarValueRange(bar, initialValue)
      if not isValidFrame(bar) then
        return
      end
      local valueMax = cfg("progressValueMax", 100)
      if type(BlzFrameSetMinMaxValue) == "function" then
        BlzFrameSetMinMaxValue(bar, 0, valueMax)
      end
      if type(BlzFrameSetValue) == "function" then
        BlzFrameSetValue(bar, initialValue or 0)
      end
      enableFrame(bar, false)
    end

    local function setupProgressTimerBar(bar)
      if not isValidFrame(bar) then
        return
      end
      if type(BlzFrameSetModel) == "function" then
        BlzFrameSetModel(
          bar,
          cfg("progressTimerModel", "UI\\Feedback\\ProgressBar\\TimerBar.mdx"),
          0
        )
      end
      if type(BlzFrameSetScale) == "function" then
        BlzFrameSetScale(bar, cfg("progressTimerScale", 1.0))
      end
      initBarValueRange(bar, 0)
    end

    local function progressTimerValue(ratio)
      local valueMax = cfg("progressValueMax", 100)
      ratio = math.max(0, math.min(1, ratio or 0))
      if cfg("progressTimerInvert", false) then
        return (1 - ratio) * valueMax
      end
      return ratio * valueMax
    end

    local function applyProgressFillStyle(frame, style)
      if not isValidFrame(frame) then
        return
      end
      style = style or {}
      local texture = style.progressFillTexture or cfg("progressFillTexture", "")
      setFrameTexture(frame, texture, cfg("progressTextureBlend", false))
    end

    local function setTextAlignment(frame, alignRight)
      if not isValidFrame(frame) or type(BlzFrameSetTextAlignment) ~= "function" then
        return
      end
      if alignRight == nil then
        alignRight = cfg("textAlignRight", true)
      end
      local hAlign = alignRight and TEXT_JUSTIFY_RIGHT or TEXT_JUSTIFY_LEFT
      BlzFrameSetTextAlignment(frame, TEXT_JUSTIFY_MIDDLE, hAlign)
    end

    local function createText(parent, name, text, width, height, template)
      local f = createFrame("TEXT", name, parent, template or "SystemNormalTextOutline")
      if not isValidFrame(f) then
        f = createFrame("TEXT", name, parent, "StandardSmallTextTemplate")
      end
      if not isValidFrame(f) then
        return 0
      end
      BlzFrameSetText(f, text or "")
      setFrameSize(f, width, height)
      setFrameAlpha(f, ALPHA_OPAQUE)
      setTextAlignment(f)
      showFrame(f)
      return f
    end

    local function updateWidescreenAnchorFrame()
      if not isValidFrame(_widescreen.anchor) then
        return
      end
      local width = getWidescreenWidth()
      _widescreen.lastWidth = width
      setFrameSize(_widescreen.anchor, width, SCREEN_HEIGHT)
      if type(BlzFrameSetAbsPoint) == "function" then
        BlzFrameClearAllPoints(_widescreen.anchor)
        BlzFrameSetAbsPoint(_widescreen.anchor, FRAMEPOINT_BOTTOM, SCREEN_CENTER_X, 0.0)
      end
    end

    local function refreshToastAnchors()
      if cfg("useWidescreenAnchor", true) then
        updateWidescreenAnchorFrame()
      end
      _layoutDirty = true
    end

    local function startWidescreenTimer()
      if _widescreen.timer ~= nil then
        return
      end
      if type(CreateTimer) ~= "function" or type(TimerStart) ~= "function" then
        return
      end
      local interval = cfg("widescreenUpdateInterval", 0.25)
      if interval <= 0 then
        return
      end
      _widescreen.timer = CreateTimer()
      TimerStart(_widescreen.timer, interval, true, function()
        if not cfg("useWidescreenAnchor", true) then
          return
        end
        local width = getWidescreenWidth()
        if _widescreen.lastWidth ~= width then
          refreshToastAnchors()
        end
      end)
    end

    local function ensureWidescreenAnchor()
      if not cfg("useWidescreenAnchor", true) then
        return false
      end
      if not _widescreen.ready then
        local base = getBaseUIParent()
        if not isValidFrame(base) then
          return false
        end
        local parent = createFrame("FRAME", "Toasts_FullScreenParent", base, "")
        if not isValidFrame(parent) then
          return false
        end
        applyFrameLevel(parent)
        local anchor = createFrame("FRAME", "Toasts_FullScreenAnchor", parent, "")
        if not isValidFrame(anchor) then
          if type(BlzDestroyFrame) == "function" then
            BlzDestroyFrame(parent)
          end
          return false
        end
        hideFrame(anchor)
        enableFrame(anchor, false)
        _widescreen.parent = parent
        _widescreen.anchor = anchor
        _widescreen.ready = true
        showFrame(parent)
        updateWidescreenAnchorFrame()
        startWidescreenTimer()
      end
      return isValidFrame(_widescreen.anchor)
    end

    getAnchorFrame = function()
      if ensureWidescreenAnchor() then
        return _widescreen.anchor
      end
      return getGameUI()
    end

    getUIParent = function()
      if ensureWidescreenAnchor() and isValidFrame(_widescreen.parent) then
        return _widescreen.parent
      end
      return getBaseUIParent()
    end

    local function gameTime()
      if _gameClock == nil then
        if type(CreateTimer) ~= "function" or type(TimerStart) ~= "function" then
          return 0
        end
        _gameClock = CreateTimer()
        TimerStart(_gameClock, 10000000, false, function() end)
      end
      if type(TimerGetElapsed) == "function" then
        return TimerGetElapsed(_gameClock)
      end
      return 0
    end

    local function playerId(player)
      if type(GetPlayerId) == "function" and player ~= nil then
        return GetPlayerId(player)
      end
      return 0
    end

    local function localPlayerId()
      if type(GetLocalPlayer) == "function" then
        return playerId(GetLocalPlayer())
      end
      return 0
    end

    local function isPlayerHandle(value)
      if value == nil then
        return false
      end
      if type(GetPlayerId) ~= "function" then
        return false
      end
      local ok, pid = pcall(GetPlayerId, value)
      return ok and pid ~= nil and pid >= 0
    end

    local function clearScratchList(list)
      for i = #list, 1, -1 do
        list[i] = nil
      end
    end

    local function resolvePlayers(target)
      clearScratchList(_playerListScratch)
      if target == nil then
        local maxPlayers = cfg("maxPlayers", 12)
        if type(GetPlayerSlotState) == "function" and type(Player) == "function" then
          for pid = 0, maxPlayers - 1 do
            local p = Player(pid)
            if GetPlayerSlotState(p) == PLAYER_SLOT_PLAYING then
              _playerListScratch[#_playerListScratch + 1] = p
            end
          end
        end
        return _playerListScratch
      end

      if type(target) == "table" then
        return target
      end

      if isPlayerHandle(target) then
        _playerListScratch[1] = target
        return _playerListScratch
      end

      if type(ForForce) == "function" and type(GetEnumPlayer) == "function" then
        ForForce(target, function()
          _playerListScratch[#_playerListScratch + 1] = GetEnumPlayer()
        end)
        if #_playerListScratch > 0 then
          return _playerListScratch
        end
      end

      _playerListScratch[1] = target
      return _playerListScratch
    end

    local LOGIC_SIZE_EPS = 0.001

    local function resolveStyle(styleName)
      local styles = cfg("styles", {})
      local style = styles[styleName or "default"]
      if style == nil then
        style = styles.default or {}
      end
      return style
    end

    local function resolveIconSize(style)
      style = style or {}
      if style.iconSize ~= nil then
        return style.iconSize
      end
      return cfg("iconSize", 0.032)
    end

    local function resolveToastPadding(style)
      style = style or {}
      if style.toastPadding ~= nil then
        return style.toastPadding
      end
      return cfg("toastPadding", 0.005)
    end

    local function resolveToastMinHeight(style)
      style = style or {}
      if style.toastMinHeight ~= nil then
        return style.toastMinHeight
      end
      return cfg("toastMinHeight", 0.042)
    end

    local function resolveIconTextGap(style)
      style = style or {}
      if style.iconTextGap ~= nil then
        return style.iconTextGap
      end
      return cfg("iconTextGap", 0.004)
    end

    local function resolveTextLineHeight(style)
      style = style or {}
      if style.textHeight ~= nil then
        return style.textHeight
      end
      return cfg("textLineHeight", 0.011)
    end

    local function resolveToastLogicSize(source)
      if source == nil then
        return tonumber(cfg("toastLogicSize", 1)) or 1
      end
      local size = tonumber(source.toastLogicSize)
      if size ~= nil and size > 0 then
        return size
      end
      local style = resolveStyle(source.style)
      size = tonumber(style.toastLogicSize)
      if size ~= nil and size > 0 then
        return size
      end
      return tonumber(cfg("toastLogicSize", 1)) or 1
    end

    local function maxToastCapacity()
      return tonumber(cfg("maxVisibleToasts", 5)) or 5
    end

    local function maxToastSlotCount()
      local configured = cfg("maxToastSlots", nil)
      if configured ~= nil then
        return math.max(1, math.floor(tonumber(configured) or 1))
      end
      local minSize = tonumber(cfg("minToastLogicSize", 0.25)) or 0.25
      if minSize <= 0 then
        minSize = 0.25
      end
      return math.max(1, math.ceil(maxToastCapacity() / minSize - LOGIC_SIZE_EPS))
    end

    local function activeLogicLoad(state)
      local sum = 0
      for i = 1, #state.active do
        sum = sum + (state.active[i].logicSize or resolveToastLogicSize(state.active[i]))
      end
      return sum
    end

    local function canAcceptToast(state, incomingSize)
      incomingSize = incomingSize or 1
      return activeLogicLoad(state) + incomingSize <= maxToastCapacity() + LOGIC_SIZE_EPS
    end

    local function parseAccumulatedValue(value)
      if value == nil then
        return 0
      end
      return tonumber(value) or 0
    end

    local function isAccumulationStyle(styleName)
      local style = resolveStyle(styleName)
      return style.isAccumulation == true
    end

    local function resolveReplaceFromOptions(options)
      if options.replace ~= nil then
        return options.replace == true
      end
      local style = resolveStyle(options.style)
      return style.replace == true
    end

    local function resolveDurationFromOptions(options)
      local duration = tonumber(options.duration)
      if duration ~= nil then
        return duration
      end
      local style = resolveStyle(options.style)
      return tonumber(style.duration)
    end

    local function resolveTextColorFunction(record, style)
      if type(record.textColorFunction) == "function" then
        return record.textColorFunction
      end
      if type(style.textColorFunction) == "function" then
        return style.textColorFunction
      end
      return nil
    end

    local function resolveTextValueFunction(record, style)
      if type(record.textValueFunction) == "function" then
        return record.textValueFunction
      end
      if type(style.textValueFunction) == "function" then
        return style.textValueFunction
      end
      return nil
    end

    local function applyToastDisplayText(record, source, style)
      style = style or resolveStyle(record.style)
      record.textColorFunction = source.textColorFunction
      record.textValueFunction = source.textValueFunction

      local textFn = resolveTextValueFunction(record, style)
      if textFn ~= nil then
        local display = textFn(record)
        record.text = display ~= nil and tostring(display) or ""
      else
        record.text = source.text
      end
    end

    local function resolveBodyTextColor(toast, style)
      local colorFn = resolveTextColorFunction(toast, style)
      if colorFn ~= nil then
        local color = colorFn(toast)
        if color ~= nil and color ~= "" then
          return color
        end
      end
      return style.textColor
    end

    local function resolveToastWidth(style)
      style = style or {}
      if style.toastWidth ~= nil then
        return style.toastWidth
      end
      return cfg("toastWidth", 0.22)
    end

    local function resolveTextCharsPerLine(style)
      style = style or {}
      if style.textCharsPerLine ~= nil then
        return style.textCharsPerLine
      end
      local toastW = resolveToastWidth(style)
      local baseW = cfg("toastWidth", 0.22)
      local baseChars = cfg("textCharsPerLine", 28)
      if baseW > 0 then
        return math.max(8, math.floor(baseChars * toastW / baseW + 0.5))
      end
      return baseChars
    end

    local function plainTextLength(text)
      return #tostring(text):gsub("|c%x%x%x%x%x%x%x", ""):gsub("|r", ""):gsub("\n", "")
    end

    local function estimateTextLines(text, style)
      local charsPerLine = resolveTextCharsPerLine(style)
      local lines = 1
      for _ in tostring(text):gmatch("\n") do
        lines = lines + 1
      end
      local plainLen = plainTextLength(text)
      lines = math.max(lines, math.ceil(plainLen / charsPerLine))
      return lines
    end

    local function colorizeText(text, color)
      if text == nil or text == "" then
        return ""
      end
      if tostring(text):match("|c%x%x%x%x%x%x%x") then
        return tostring(text)
      end
      return tostring(color or "") .. tostring(text) .. "|r"
    end

    local function parseProgressValue(value)
      if value == nil then
        return nil
      end
      local n = tonumber(value)
      if n == nil then
        return nil
      end
      return n
    end

    local function hasProgressData(toast)
      local maxVal = parseProgressValue(toast.progressMax)
      local curVal = parseProgressValue(toast.progressCurrent)
      return maxVal ~= nil and curVal ~= nil and maxVal > 0
    end

    local function resolveProgressBarType(style)
      style = style or {}
      local barType = style.progressBar
      if barType == nil then
        return nil
      end
      if barType == PROGRESS_BAR_TIMER and not cfg("progressUseTimerBar", false) then
        return PROGRESS_BAR_SOLID
      end
      return barType
    end

    local function progressBarUsesSimpleFill(barType)
      if not cfg("progressUseSimpleBar", true) then
        return false
      end
      return barType == PROGRESS_BAR_SEGMENTED or barType == PROGRESS_BAR_SOLID
    end

    local function setupSimpleProgressFill(frame)
      if not isValidFrame(frame) then
        return
      end
      local tex = cfg("progressFillTexture", "")
      if type(BlzFrameSetTexture) == "function" and tex ~= "" then
        BlzFrameSetTexture(frame, tex, 0, cfg("progressTextureBlend", false))
      end
      initBarValueRange(frame, 0)
    end

    local function progressBarHeight()
      return cfg("progressBarHeight", 0.004)
    end

    local function positionBarOnRoot(frame, root, barInset, barTop, barW, barH, fromRight)
      setFrameSize(frame, barW, barH)
      if fromRight then
        setFramePoint(frame, FRAMEPOINT_TOPRIGHT, root, FRAMEPOINT_TOPRIGHT, -barInset, -barTop)
      else
        setFramePoint(frame, FRAMEPOINT_TOPLEFT, root, FRAMEPOINT_TOPLEFT, barInset, -barTop)
      end
    end

    local function positionSegmentOnRoot(frame, root, segW, barH, barTop, segRightInset, segX, fromRight)
      setFrameSize(frame, segW, barH)
      if fromRight then
        setFramePoint(frame, FRAMEPOINT_TOPRIGHT, root, FRAMEPOINT_TOPRIGHT, -segRightInset, -barTop)
      else
        setFramePoint(frame, FRAMEPOINT_TOPLEFT, root, FRAMEPOINT_TOPLEFT, segX, -barTop)
      end
    end

    local function hideProgressTimerBar(slot)
      if isValidFrame(slot.progressTimerBar) then
        hideFrame(slot.progressTimerBar)
      end
    end

    local function bindFillToTrack(fill, track)
      if not isValidFrame(fill) or not isValidFrame(track) then
        return
      end
      if type(BlzFrameSetAllPoints) == "function" then
        BlzFrameSetAllPoints(fill, track)
        return
      end
      setFramePoint(fill, FRAMEPOINT_TOPLEFT, track, FRAMEPOINT_TOPLEFT, 0, 0)
      setFramePoint(fill, FRAMEPOINT_BOTTOMRIGHT, track, FRAMEPOINT_BOTTOMRIGHT, 0, 0)
    end

    local function setSimpleFillRatio(fill, ratio, style, alpha, useTrackAlpha)
      if not isValidFrame(fill) then
        return
      end
      applyProgressFillStyle(fill, style)
      if useTrackAlpha then
        setFrameAlpha(fill, resolveProgressTrackAlpha(style, alpha))
      else
        setFrameAlpha(fill, resolveProgressFillAlpha(style, alpha))
      end
      if type(BlzFrameSetValue) == "function" then
        local valueMax = cfg("progressValueMax", 100)
        ratio = math.max(0, math.min(1, ratio or 0))
        BlzFrameSetValue(fill, ratio * valueMax)
      end
      showFrame(fill)
    end

    local function shouldShowProgress(toast)
      if not hasProgressData(toast) then
        return false
      end
      local style = resolveStyle(toast.style)
      local barType = resolveProgressBarType(style)
      return barType == PROGRESS_BAR_SOLID
        or barType == PROGRESS_BAR_SEGMENTED
        or barType == PROGRESS_BAR_TIMER
    end

    local function resolveDisplayProgress(toast)
      if not hasProgressData(toast) then
        return nil
      end
      if not cfg("progressAnimate", true) then
        return parseProgressValue(toast.progressCurrent)
      end
      local display = toast.displayProgress
      if display == nil then
        return 0
      end
      return display
    end

    local function updateToastDisplayProgress(toast)
      if not hasProgressData(toast) then
        toast.displayProgress = nil
        return
      end

      local target = parseProgressValue(toast.progressCurrent)
      local maxVal = parseProgressValue(toast.progressMax)
      if target == nil or maxVal == nil then
        return
      end
      if target > maxVal then
        target = maxVal
      end
      if target < 0 then
        target = 0
      end

      if not cfg("progressAnimate", true) then
        toast.displayProgress = target
        return
      end

      if toast.displayProgress == nil then
        toast.displayProgress = 0
      end

      if toast.displayProgress > maxVal then
        toast.displayProgress = maxVal
      end
      if toast.displayProgress < 0 then
        toast.displayProgress = 0
      end

      local diff = target - toast.displayProgress
      local snap = cfg("progressAnimSnapThreshold", 0.002)
      if math.abs(diff) <= snap then
        toast.displayProgress = target
        return
      end

      toast.displayProgress = toast.displayProgress + diff * cfg("progressLerpSpeed", 0.14)
    end

    local function tickLocalProgressAnimation()
      local state = _playerState[localPlayerId()]
      if state == nil then
        return
      end
      for i = 1, #state.active do
        updateToastDisplayProgress(state.active[i])
      end
    end

    local function progressSegmentCount(maxVal)
      local count = math.floor(maxVal + 0.0001)
      if count < 1 then
        count = 1
      end
      local limit = cfg("maxProgressSegments", 12)
      if count > limit then
        count = limit
      end
      return count
    end

    computeToastAlpha = function(toast, now)
      if toast.expiresAt == nil then
        return ALPHA_OPAQUE
      end
      if now >= toast.expiresAt then
        return ALPHA_TRANSPARENT
      end
      if toast.fadeStartAt == nil or now < toast.fadeStartAt then
        return ALPHA_OPAQUE
      end
      local fadeDuration = cfg("fadeDuration", 1.0)
      if fadeDuration <= 0 then
        return ALPHA_TRANSPARENT
      end
      local elapsed = now - toast.fadeStartAt
      local ratio = 1 - (elapsed / fadeDuration)
      if ratio < 0 then
        ratio = 0
      elseif ratio > 1 then
        ratio = 1
      end
      return math.floor(ALPHA_OPAQUE * ratio + 0.5)
    end

    local function acquireToast()
      local toast = table.remove(_toastPool)
      if toast == nil then
        toast = {}
      end
      return toast
    end

    local function releaseToast(toast)
      if toast == nil then
        return
      end
      toast.icon = nil
      toast.title = nil
      toast.text = nil
      toast.style = nil
      toast.duration = nil
      toast.key = nil
      toast.replace = nil
      toast.accumulatedValue = nil
      toast.textColorFunction = nil
      toast.textValueFunction = nil
      toast.logicSize = nil
      toast.progressCurrent = nil
      toast.progressMax = nil
      toast.displayProgress = nil
      toast.expiresAt = nil
      toast.fadeStartAt = nil
      toast.shownAt = nil
      toast.height = nil
      toast.displayY = nil
      toast.targetY = nil
      toast.entryAnimating = nil
      toast.entryT = nil
      toast.entryFromY = nil
      toast.pulseActive = nil
      toast.pulseT = nil
      toast.pulseIconScale = nil
      toast.pulseStrength = nil
      _toastPool[#_toastPool + 1] = toast
    end

    local function acquireRequest()
      local req = table.remove(_requestPool)
      if req == nil then
        req = {}
      end
      return req
    end

    local function releaseRequest(req)
      if req == nil then
        return
      end
      req.icon = nil
      req.title = nil
      req.text = nil
      req.style = nil
      req.duration = nil
      req.key = nil
      req.replace = nil
      req.accumulatedValue = nil
      req.textColorFunction = nil
      req.textValueFunction = nil
      req.logicSize = nil
      req.waitKey = nil
      req.waitKeyFade = nil
      req.progressCurrent = nil
      req.progressMax = nil
      _requestPool[#_requestPool + 1] = req
    end

    local function applyRequestFields(req, options)
      local style = resolveStyle(options.style)
      req.icon = options.icon
      req.title = options.title
      req.style = options.style or "default"
      req.key = options.key
      req.replace = resolveReplaceFromOptions(options)
      req.waitKey = options.waitKey == true
      req.waitKeyFade = options.waitKeyFade == true
      req.progressCurrent = options.progressCurrent
      req.progressMax = options.progressMax
      req.duration = resolveDurationFromOptions(options)
      req.logicSize = resolveToastLogicSize(options)

      if style.isAccumulation == true then
        req.accumulatedValue = parseAccumulatedValue(options.accumulatedValue)
      else
        req.accumulatedValue = nil
      end

      applyToastDisplayText(req, options, style)
    end

    local function copyRequestFromOptions(options)
      local req = acquireRequest()
      applyRequestFields(req, options)
      return req
    end

    local function hasToastContent(options)
      if options == nil then
        return false
      end
      if options.icon ~= nil and options.icon ~= "" then
        return true
      end
      if options.title ~= nil and options.title ~= "" then
        return true
      end
      if options.text ~= nil and options.text ~= "" then
        return true
      end
      if isAccumulationStyle(options.style) and options.accumulatedValue ~= nil then
        return true
      end
      return false
    end

    local function calcToastHeight(toast)
      local style = resolveStyle(toast.style)
      local pad = resolveToastPadding(style)
      local hasTitle = toast.title ~= nil and toast.title ~= ""
      local hasText = toast.text ~= nil and toast.text ~= ""
      local hasProgress = shouldShowProgress(toast)
      local hasIcon = toast.icon ~= nil and toast.icon ~= ""

      local titleH = 0
      if hasTitle then
        titleH = style.titleHeight or cfg("titleHeight", 0.017)
      end

      local bodyH = 0
      if hasText then
        local lines = estimateTextLines(toast.text, style)
        bodyH = lines * resolveTextLineHeight(style)
      end

      local progressH = 0
      if hasProgress then
        progressH = cfg("progressBarGap", 0.003) + progressBarHeight()
      end

      local iconH = hasIcon and resolveIconSize(style) or 0
      local contentH
      if not hasTitle and not hasProgress and hasText then
        contentH = math.max(iconH, bodyH) + progressH
      else
        contentH = math.max(titleH + bodyH + progressH, iconH)
      end

      local total = contentH + pad * 2
      return math.max(total, resolveToastMinHeight(style))
    end

    local function getPlayerState(pid)
      local state = _playerState[pid]
      if state == nil then
        state = {
          active = {},
          pending = {},
          nextShowAt = 0,
        }
        _playerState[pid] = state
      end
      return state
    end

    local function applyToastFields(toast, source, opts)
      opts = opts or {}
      local style = resolveStyle(source.style or toast.style or "default")
      local isAccum = style.isAccumulation == true
      local mergeAccum = opts.mergeAccumulation == true and isAccum

      if mergeAccum then
        if source.icon ~= nil and source.icon ~= "" then
          toast.icon = source.icon
        end
        if source.title ~= nil and source.title ~= "" then
          toast.title = source.title
        end
      else
        toast.icon = source.icon
        toast.title = source.title
      end

      toast.style = source.style or "default"
      toast.key = source.key
      toast.replace = resolveReplaceFromOptions(source)
      toast.progressCurrent = source.progressCurrent
      toast.progressMax = source.progressMax
      toast.duration = resolveDurationFromOptions(source) or cfg("defaultDuration", 3.0)
      toast.logicSize = resolveToastLogicSize(source)

      if isAccum then
        local incoming = parseAccumulatedValue(source.accumulatedValue)
        if mergeAccum then
          toast.accumulatedValue = parseAccumulatedValue(toast.accumulatedValue) + incoming
        else
          toast.accumulatedValue = incoming
        end
      else
        toast.accumulatedValue = nil
      end

      applyToastDisplayText(toast, source, style)
      toast.height = calcToastHeight(toast)
    end

    -- Lifetime только при фактическом показе (не при постановке в pending).
    local function setToastLifetime(toast, now)
      local duration = toast.duration
      local fadeDuration = cfg("fadeDuration", 1.0)
      toast.shownAt = now
      toast.expiresAt = now + duration
      toast.fadeStartAt = toast.expiresAt - fadeDuration
    end

    local function createStyleSoundHandle(path)
      if type(CreateSound) ~= "function" or type(SetSoundVolume) ~= "function" then
        return nil
      end
      local snd = CreateSound(path, false, false, false, 0, 0, "DefaultEAX")
      if snd == nil then
        return nil
      end
      SetSoundVolume(snd, 127)
      return snd
    end

    local function acquireStyleSound(path)
      local pool = _soundPools[path]
      if pool == nil then
        pool = {}
        _soundPools[path] = pool
      end

      if type(GetSoundIsPlaying) == "function" then
        for i = 1, #pool do
          local snd = pool[i]
          if snd ~= nil and not GetSoundIsPlaying(snd) then
            return snd, false
          end
        end
      end

      local poolSize = cfg("soundPoolSize", 4)
      if #pool < poolSize then
        local snd = createStyleSoundHandle(path)
        if snd == nil then
          return nil, false
        end
        pool[#pool + 1] = snd
        return snd, false
      end

      local snd = createStyleSoundHandle(path)
      if snd == nil then
        return nil, false
      end
      return snd, true
    end

    playStyleSound = function(styleName, soundKey)
      local style = resolveStyle(styleName)
      local path = style[soundKey]
      if path == nil or path == "" then
        return
      end
      if type(GetLocalPlayer) ~= "function" then
        return
      end

      local snd, isTemp = acquireStyleSound(path)
      if snd == nil then
        return
      end

      if type(StartSound) == "function" then
        StartSound(snd)
      end
      if isTemp and type(KillSoundWhenDone) == "function" then
        KillSoundWhenDone(snd)
      end
    end

    recalcTargetY = function(state)
      local y = cfg("screenMarginBottom", 0.16)
      local gap = cfg("toastGap", 0.004)
      for i = 1, #state.active do
        local toast = state.active[i]
        local prevTarget = toast.targetY
        local newTarget = y

        if prevTarget ~= nil and prevTarget ~= newTarget then
          if toast.displayY == nil then
            toast.displayY = prevTarget
          end
          cancelEntryForQueueLerp(toast)
        end

        toast.targetY = newTarget
        y = newTarget + toast.height + gap
      end
    end

    local function createProgressSegment(parent, slotId, segId)
      if cfg("progressUseSimpleBar", true) then
        local track = createFrame("SIMPLESTATUSBAR", slotFrameName(slotId, "pbt_" .. segId), parent, "")
        local fill = createFrame("SIMPLESTATUSBAR", slotFrameName(slotId, "pbf_" .. segId), parent, "")
        setupSimpleProgressFill(track)
        setupSimpleProgressFill(fill)
        hideFrame(track)
        hideFrame(fill)
        return { track = track, fill = fill }
      end
      local track = createFrame("BACKDROP", slotFrameName(slotId, "pbt_" .. segId), parent, "")
      local fill = createFrame("BACKDROP", slotFrameName(slotId, "pbf_" .. segId), track, "")
      hideFrame(track)
      hideFrame(fill)
      return { track = track, fill = fill }
    end

    local function createToastSlot(slotId)
      local parent = getUIParent()
      local root = createFrame("FRAME", slotFrameName(slotId, "root"), parent, "")
      if not isValidFrame(root) then
        return nil
      end
      applyFrameLevel(root)
      enableFrame(root, false)

      local useSimplePanel = false
      local panelSf = 0
      local panelTex = 0
      local createContext = slotId - 1

      local panelBackdrop = createFrame("BACKDROP", slotFrameName(slotId, "panel"), root, "")
      if not isValidFrame(panelBackdrop) then
        return nil
      end
      hideFrame(panelBackdrop)

      if cfg("useSimplePanel", false) and ensurePanelFdf() and type(BlzCreateSimpleFrame) == "function" then
        local template = cfg("panelFdfTemplate", "ToastsPanelTemplate")
        panelSf = BlzCreateSimpleFrame(template, root, createContext)
        if isValidFrame(panelSf) and type(BlzGetFrameByName) == "function" then
          local texName = cfg("panelTextureFrameName", "ToastsPanelTexture")
          panelTex = BlzGetFrameByName(texName, createContext)
          if isValidFrame(panelTex) then
            useSimplePanel = true
            bindAllPoints(panelSf, root)
            bindAllPoints(panelTex, panelSf)
          else
            log("createToastSlot: ToastsPanelTexture not found, context=" .. createContext, true)
          end
        else
          log("createToastSlot: BlzCreateSimpleFrame failed for slot " .. slotId, true)
        end
      end

      local icon = createFrame("BACKDROP", slotFrameName(slotId, "icon"), root, "")
      local iconGlow = createFrame("BACKDROP", slotFrameName(slotId, "icon_glow"), root, "")
      hideFrame(iconGlow)
      local title = createText(root, slotFrameName(slotId, "title"), "", 0.1, 0.1, "SystemNormalTextOutline")
      local body = createText(root, slotFrameName(slotId, "body"), "", 0.1, 0.1, "SystemNormalTextOutline")
      local progressTrack = createFrame("BACKDROP", slotFrameName(slotId, "p_track"), root, "")
      local progressFill
      if cfg("progressUseSimpleBar", true) then
        progressFill = createFrame("SIMPLESTATUSBAR", slotFrameName(slotId, "p_fill"), progressTrack, "")
        setupSimpleProgressFill(progressFill)
      else
        progressFill = createFrame("BACKDROP", slotFrameName(slotId, "p_fill"), progressTrack, "")
      end
      hideFrame(progressTrack)
      hideFrame(progressFill)

      local progressTimerBar = 0
      if cfg("progressUseTimerBar", false) then
        progressTimerBar = createFrame("STATUSBAR", slotFrameName(slotId, "p_timer"), root, "")
        if isValidFrame(progressTimerBar) then
          setupProgressTimerBar(progressTimerBar)
          hideFrame(progressTimerBar)
        else
          progressTimerBar = 0
        end
      end

      local progressSegments = {}
      local maxSeg = cfg("maxProgressSegments", 12)
      for segId = 1, maxSeg do
        progressSegments[segId] = createProgressSegment(root, slotId, segId)
      end

      return {
        id = slotId,
        root = root,
        useSimplePanel = useSimplePanel,
        panelSf = panelSf,
        panelTex = panelTex,
        panelBackdrop = panelBackdrop,
        icon = icon,
        iconGlow = iconGlow,
        title = title,
        body = body,
        progressTrack = progressTrack,
        progressFill = progressFill,
        progressTimerBar = progressTimerBar,
        progressSegments = progressSegments,
      }
    end

    ensureLocalUI = function()
      if _localUiReady then
        return true
      end
      if not hasBlzApi() then
        return false
      end
      if not isValidFrame(getUIParent()) then
        return false
      end

      local slotCount = maxToastSlotCount()
      _frameSlots = {}
      for slotId = 1, slotCount do
        local slot = createToastSlot(slotId)
        if slot == nil then
          log("ensureLocalUI: failed to create slot " .. tostring(slotId), true)
          return false
        end
        _frameSlots[slotId] = slot
      end

      _localUiReady = true
      _layoutDirty = true
      return true
    end

    local function hideProgressFrames(slot)
      if isValidFrame(slot.progressTrack) then
        hideFrame(slot.progressTrack)
      end
      if isValidFrame(slot.progressFill) then
        hideFrame(slot.progressFill)
      end
      if isValidFrame(slot.progressTimerBar) then
        hideFrame(slot.progressTimerBar)
      end
      if slot.progressSegments ~= nil then
        for segId = 1, #slot.progressSegments do
          local seg = slot.progressSegments[segId]
          if seg ~= nil then
            hideFrame(seg.track)
            hideFrame(seg.fill)
          end
        end
      end
    end

    local function hideSlotVisuals(slot)
      hideProgressFrames(slot)
      if isValidFrame(slot.iconGlow) then
        hideFrame(slot.iconGlow)
      end
      if isValidFrame(slot.icon) then
        hideFrame(slot.icon)
      end
      if isValidFrame(slot.title) then
        hideFrame(slot.title)
      end
      if isValidFrame(slot.body) then
        hideFrame(slot.body)
      end
      if isValidFrame(slot.panelSf) then
        hideFrame(slot.panelSf)
      end
      if isValidFrame(slot.panelBackdrop) then
        hideFrame(slot.panelBackdrop)
      end
    end

    local function layoutTimerProgress(slot, toast, barInset, barTop, barW, barH, alpha, fromRight)
      hideProgressFrames(slot)

      local bar = slot.progressTimerBar
      if not isValidFrame(bar) then
        return
      end

      local maxVal = parseProgressValue(toast.progressMax)
      local curVal = resolveDisplayProgress(toast) or 0
      local ratio = math.max(0, math.min(1, curVal / maxVal))
      if fromRight == nil then
        fromRight = cfg("progressFillFromRight", true)
      end

      positionBarOnRoot(bar, slot.root, barInset, barTop, barW, barH, fromRight)
      if type(BlzFrameSetValue) == "function" then
        BlzFrameSetValue(bar, progressTimerValue(ratio))
      end
      setFrameAlpha(bar, alpha)
      showFrame(bar)
    end

    local function layoutSolidProgress(slot, toast, style, barInset, barTop, barW, barH, alpha, fromRight)
      hideProgressTimerBar(slot)
      if slot.progressSegments ~= nil then
        for segId = 1, #slot.progressSegments do
          hideFrame(slot.progressSegments[segId].track)
          hideFrame(slot.progressSegments[segId].fill)
        end
      end
      local maxVal = parseProgressValue(toast.progressMax)
      local curVal = resolveDisplayProgress(toast) or 0
      local ratio = math.max(0, math.min(1, curVal / maxVal))
      if fromRight == nil then
        fromRight = cfg("progressFillFromRight", true)
      end

      positionBarOnRoot(slot.progressTrack, slot.root, barInset, barTop, barW, barH, fromRight)
      applyProgressTrackStyle(slot.progressTrack, style)
      setFrameAlpha(slot.progressTrack, resolveProgressTrackAlpha(style, alpha))
      showFrame(slot.progressTrack)

      if progressBarUsesSimpleFill(PROGRESS_BAR_SOLID) then
        bindFillToTrack(slot.progressFill, slot.progressTrack)
        setSimpleFillRatio(slot.progressFill, ratio, style, alpha, false)
      else
        local fillW = barW * ratio
        if fillW > 0.0001 and isValidFrame(slot.progressFill) then
          setFrameSize(slot.progressFill, fillW, barH)
          if fromRight then
            setFramePoint(
              slot.progressFill,
              FRAMEPOINT_BOTTOMRIGHT,
              slot.progressTrack,
              FRAMEPOINT_BOTTOMRIGHT,
              0,
              0
            )
          else
            setFramePoint(
              slot.progressFill,
              FRAMEPOINT_BOTTOMLEFT,
              slot.progressTrack,
              FRAMEPOINT_BOTTOMLEFT,
              0,
              0
            )
          end
          applyProgressFillStyle(slot.progressFill, style)
          setFrameAlpha(slot.progressFill, resolveProgressFillAlpha(style, alpha))
          showFrame(slot.progressFill)
        elseif isValidFrame(slot.progressFill) then
          hideFrame(slot.progressFill)
        end
      end

      if slot.progressSegments ~= nil then
        for segId = 1, #slot.progressSegments do
          hideFrame(slot.progressSegments[segId].track)
          hideFrame(slot.progressSegments[segId].fill)
        end
      end
    end

    local function layoutSegmentProgressPair(seg, root, segW, barH, barTop, segRightInset, segX, fromRight, fillRatio, style, alpha)
      positionSegmentOnRoot(seg.track, root, segW, barH, barTop, segRightInset, segX, fromRight)
      setSimpleFillRatio(seg.track, 1, style, alpha, true)

      if fillRatio > 0.001 then
        positionSegmentOnRoot(seg.fill, root, segW, barH, barTop, segRightInset, segX, fromRight)
        setSimpleFillRatio(seg.fill, fillRatio, style, alpha, false)
      else
        hideFrame(seg.fill)
      end
    end

    local function layoutBackdropSegment(seg, root, segW, barH, barTop, segRightInset, segX, fromRight, fillRatio, style, alpha)
      positionSegmentOnRoot(seg.track, root, segW, barH, barTop, segRightInset, segX, fromRight)
      applyProgressTrackStyle(seg.track, style)
      setFrameAlpha(seg.track, resolveProgressTrackAlpha(style, alpha))
      showFrame(seg.track)

      local fillW = segW * fillRatio
      if fillW > 0.0001 then
        setFrameSize(seg.fill, fillW, barH)
        if fromRight then
          setFramePoint(seg.fill, FRAMEPOINT_BOTTOMRIGHT, seg.track, FRAMEPOINT_BOTTOMRIGHT, 0, 0)
        else
          setFramePoint(seg.fill, FRAMEPOINT_BOTTOMLEFT, seg.track, FRAMEPOINT_BOTTOMLEFT, 0, 0)
        end
        applyProgressFillStyle(seg.fill, style)
        setFrameAlpha(seg.fill, resolveProgressFillAlpha(style, alpha))
        showFrame(seg.fill)
      else
        hideFrame(seg.fill)
      end
    end

    local function layoutSegmentedProgress(slot, toast, style, barInset, barTop, barW, barH, alpha, fromRight)
      hideProgressTimerBar(slot)
      local maxVal = parseProgressValue(toast.progressMax)
      local curVal = resolveDisplayProgress(toast) or 0
      local segCount = progressSegmentCount(maxVal)
      local segGap = cfg("progressSegmentGap", 0.001)
      local totalGap = segGap * math.max(0, segCount - 1)
      local segW = (barW - totalGap) / segCount
      if fromRight == nil then
        fromRight = cfg("progressFillFromRight", true)
      end

      hideFrame(slot.progressTrack)
      hideFrame(slot.progressFill)

      for segId = 1, #slot.progressSegments do
        local seg = slot.progressSegments[segId]
        if segId <= segCount then
          local logicalSegId = segId
          if fromRight then
            logicalSegId = segCount - segId + 1
          end
          local fillRatio = segmentFillRatio(curVal, maxVal, logicalSegId, segCount)
          local segRightInset = barInset + (segId - 1) * (segW + segGap)
          local segX = barInset + (segId - 1) * (segW + segGap)

          if progressBarUsesSimpleFill(PROGRESS_BAR_SEGMENTED) then
            layoutSegmentProgressPair(
              seg,
              slot.root,
              segW,
              barH,
              barTop,
              segRightInset,
              segX,
              fromRight,
              fillRatio,
              style,
              alpha
            )
          else
            layoutBackdropSegment(
              seg,
              slot.root,
              segW,
              barH,
              barTop,
              segRightInset,
              segX,
              fromRight,
              fillRatio,
              style,
              alpha
            )
          end
        else
          hideFrame(seg.track)
          hideFrame(seg.fill)
        end
      end
    end

    local function layoutToastContent(slot, toast, now)
      local style = resolveStyle(toast.style)
      local pad = resolveToastPadding(style)
      local toastW = resolveToastWidth(style)
      local iconSize = resolveIconSize(style)
      local iconGap = resolveIconTextGap(style)
      local iconOnRight = cfg("iconOnRight", true)

      local fadeAlpha = computeToastAlpha(toast, now)
      local entryMult = computeEntryAlphaMult(toast)
      local iconScale = toast.pulseIconScale or 1
      local pulseStrength = toast.pulseStrength or 0
      local contentFade = math.floor(fadeAlpha * entryMult + 0.5)

      local function applyPanelWithPulse(frame, useSimpleTexture)
        applyPanelStyle(frame, style, contentFade, useSimpleTexture)
        if pulseStrength > 0 and isValidFrame(frame) then
          local base = resolvePanelBgAlpha(style, contentFade)
          setFrameAlpha(
            frame,
            math.min(
              255,
              base + math.floor(cfg("updatePulsePanelAlphaBoost", 130) * pulseStrength + 0.5)
            )
          )
        end
      end

      local hasIcon = toast.icon ~= nil and toast.icon ~= ""
      local hasTitle = toast.title ~= nil and toast.title ~= ""
      local hasText = toast.text ~= nil and toast.text ~= ""
      local hasProgress = shouldShowProgress(toast)

      local iconReserve = hasIcon and (iconSize + iconGap) or 0
      local textW = toastW - pad * 2 - iconReserve
      local textRightInset = pad + iconReserve
      local textLeftInset = pad

      local titleH = 0
      if hasTitle then
        titleH = style.titleHeight or cfg("titleHeight", 0.017)
      end

      local bodyH = 0
      if hasText then
        bodyH = estimateTextLines(toast.text, style) * resolveTextLineHeight(style)
      end

      local progressBlockH = 0
      if hasProgress then
        progressBlockH = cfg("progressBarGap", 0.003) + progressBarHeight()
      end

      local textBlockH = titleH + bodyH
      local rowH = math.max(hasIcon and iconSize or 0, textBlockH)
      if hasText and not hasTitle and not hasProgress then
        bodyH = rowH
      end

      local contentH = math.max(textBlockH + progressBlockH, hasIcon and iconSize or 0, rowH)
      local minH = resolveToastMinHeight(style)
      local rootH = math.max(toast.height or 0, contentH + pad * 2, minH)
      toast.height = rootH

      local innerH = rootH - pad * 2
      local contentBlockH = math.max(rowH + progressBlockH, hasIcon and iconSize or 0)
      local contentTop = pad
      if not hasTitle and not hasProgress and innerH > contentBlockH + 0.0001 then
        if style.contentVCenter ~= false then
          contentTop = pad + (innerH - contentBlockH) * 0.5
        end
      end

      setFrameSize(slot.root, toastW, rootH)

      if cfg("showPanelBackground", true) then
        if slot.useSimplePanel and isValidFrame(slot.panelTex) then
          bindAllPoints(slot.panelSf, slot.root)
          bindAllPoints(slot.panelTex, slot.panelSf)
          applyPanelWithPulse(slot.panelTex, true)
          showFrame(slot.panelSf)
          hideFrame(slot.panelBackdrop)
        elseif isValidFrame(slot.panelBackdrop) then
          bindAllPoints(slot.panelBackdrop, slot.root)
          applyPanelWithPulse(slot.panelBackdrop, false)
          showFrame(slot.panelBackdrop)
          if isValidFrame(slot.panelSf) then
            hideFrame(slot.panelSf)
          end
        end
      else
        if isValidFrame(slot.panelSf) then
          hideFrame(slot.panelSf)
        end
        if isValidFrame(slot.panelBackdrop) then
          hideFrame(slot.panelBackdrop)
        end
      end

      if isValidFrame(slot.iconGlow) then
        hideFrame(slot.iconGlow)
      end

      if hasIcon and isValidFrame(slot.icon) then
        local drawIconSize = iconSize * iconScale
        setFrameSize(slot.icon, drawIconSize, drawIconSize)
        setFrameTexture(slot.icon, toast.icon, true)
        setFrameScale(slot.icon, 1)
        local iconTop = contentTop
        if not hasTitle and not hasProgress then
          iconTop = contentTop + (rowH - drawIconSize) * 0.5
        end
        if iconOnRight then
          setFramePoint(slot.icon, FRAMEPOINT_TOPRIGHT, slot.root, FRAMEPOINT_TOPRIGHT, -pad, -iconTop)
        else
          setFramePoint(slot.icon, FRAMEPOINT_TOPLEFT, slot.root, FRAMEPOINT_TOPLEFT, pad, -iconTop)
        end
        setFrameAlpha(slot.icon, contentFade)
        showFrame(slot.icon)

        if pulseStrength > 0.01 and isValidFrame(slot.iconGlow) then
          local glowSize = iconSize * (1.15 + 0.35 * pulseStrength)
          setFrameTexture(slot.iconGlow, resolvePanelTexture(style), cfg("panelTextureBlend", false))
          setFrameSize(slot.iconGlow, glowSize, glowSize)
          if iconOnRight then
            setFramePoint(slot.iconGlow, FRAMEPOINT_TOPRIGHT, slot.root, FRAMEPOINT_TOPRIGHT, -pad, -iconTop)
          else
            setFramePoint(slot.iconGlow, FRAMEPOINT_TOPLEFT, slot.root, FRAMEPOINT_TOPLEFT, pad, -iconTop)
          end
          setFrameAlpha(
            slot.iconGlow,
            math.floor(cfg("updatePulseGlowAlpha", 210) * pulseStrength * entryMult + 0.5)
          )
          showFrame(slot.iconGlow)
        end
      elseif isValidFrame(slot.icon) then
        hideFrame(slot.icon)
        setFrameScale(slot.icon, 1)
      end

      local textTop = contentTop
      if hasTitle and isValidFrame(slot.title) then
        setFrameSize(slot.title, textW, titleH)
        BlzFrameSetText(slot.title, colorizeText(toast.title, style.titleColor))
        if iconOnRight then
          setFramePoint(slot.title, FRAMEPOINT_TOPRIGHT, slot.root, FRAMEPOINT_TOPRIGHT, -textRightInset, -textTop)
        else
          setFramePoint(slot.title, FRAMEPOINT_TOPLEFT, slot.root, FRAMEPOINT_TOPLEFT, textLeftInset + iconReserve, -textTop)
        end
        setTextAlignment(slot.title)
        setFrameAlpha(slot.title, contentFade)
        showFrame(slot.title)
      elseif isValidFrame(slot.title) then
        hideFrame(slot.title)
      end

      if hasText and isValidFrame(slot.body) then
        local bodyTop = textTop + titleH
        if not hasTitle and not hasProgress then
          bodyTop = contentTop + (rowH - bodyH) * 0.5
        end
        setFrameSize(slot.body, textW, bodyH)
        BlzFrameSetText(slot.body, colorizeText(toast.text, resolveBodyTextColor(toast, style)))
        if iconOnRight then
          setFramePoint(slot.body, FRAMEPOINT_TOPRIGHT, slot.root, FRAMEPOINT_TOPRIGHT, -textRightInset, -(bodyTop))
        else
          setFramePoint(slot.body, FRAMEPOINT_TOPLEFT, slot.root, FRAMEPOINT_TOPLEFT, textLeftInset + iconReserve, -(bodyTop))
        end
        setTextAlignment(slot.body)
        setFrameAlpha(slot.body, contentFade)
        showFrame(slot.body)
      elseif isValidFrame(slot.body) then
        hideFrame(slot.body)
      end

      if hasProgress then
        local barH = progressBarHeight()
        local barTop = textTop + titleH + bodyH + cfg("progressBarGap", 0.003)
        local barType = resolveProgressBarType(style)
        -- Полная ширина тоста: правый край бара = правый край иконки.
        local barInset = pad
        local barW = toastW - pad * 2
        if barType == PROGRESS_BAR_TIMER then
          layoutTimerProgress(slot, toast, barInset, barTop, barW, barH, contentFade, iconOnRight)
        elseif barType == PROGRESS_BAR_SOLID then
          layoutSolidProgress(slot, toast, style, barInset, barTop, barW, barH, contentFade, iconOnRight)
        elseif barType == PROGRESS_BAR_SEGMENTED then
          layoutSegmentedProgress(slot, toast, style, barInset, barTop, barW, barH, contentFade, iconOnRight)
        else
          hideProgressFrames(slot)
        end
      else
        hideProgressFrames(slot)
      end
    end

    local function localHasActiveToasts()
      local state = _playerState[localPlayerId()]
      return state ~= nil and #state.active > 0
    end

    applyLocalLayout = function(pid)
      if pid ~= localPlayerId() then
        return
      end
      if not _localUiReady or _frameSlots == nil then
        return
      end

      local state = _playerState[pid]
      if state == nil then
        return
      end

      local anchor = getAnchorFrame()
      local marginRight = cfg("screenMarginRight", 0.012)
      local slotCount = maxToastSlotCount()
      local now = gameTime()
      local lerpSpeed = cfg("positionLerpSpeed", 0.18)

      for slotId = 1, slotCount do
        local slot = _frameSlots[slotId]
        local toast = state.active[slotId]
        if slot ~= nil and toast ~= nil then
          advanceToastVisuals(toast, lerpSpeed)
          layoutToastContent(slot, toast, now)
          setFramePoint(
            slot.root,
            FRAMEPOINT_BOTTOMRIGHT,
            anchor,
            FRAMEPOINT_BOTTOMRIGHT,
            -marginRight,
            toast.displayY or toast.targetY or 0
          )
          showFrame(slot.root)
        elseif slot ~= nil then
          hideSlotVisuals(slot)
          hideFrame(slot.root)
        end
      end

      _layoutDirty = false
    end

    local function playToastSound(styleName, soundEvent)
      if soundEvent == "update" then
        playStyleSound(styleName, "updateSound")
        local style = resolveStyle(styleName)
        if style.updateSound == nil or style.updateSound == "" then
          playStyleSound(styleName, "showSound")
        end
      elseif soundEvent == "show" then
        playStyleSound(styleName, "showSound")
      end
    end

    local function canStartShowing(state, now)
      local minInterval = cfg("minShowInterval", 0)
      if minInterval <= 0 then
        return true
      end
      return now >= (state.nextShowAt or 0)
    end

    local function markShowStarted(state, now)
      local minInterval = cfg("minShowInterval", 0)
      if minInterval > 0 then
        state.nextShowAt = now + minInterval
      end
    end

    local function hasActiveKey(state, key)
      if key == nil then
        return false
      end
      for i = 1, #state.active do
        if state.active[i].key == key then
          return true
        end
      end
      return false
    end

    local function isKeyWaitBlocking(state, key, waitKeyFade, now)
      if key == nil then
        return false
      end
      for i = 1, #state.active do
        local toast = state.active[i]
        if toast.key == key then
          if waitKeyFade == true
            and toast.fadeStartAt ~= nil
            and now >= toast.fadeStartAt then
            return false
          end
          return true
        end
      end
      return false
    end

    local function releaseFadingKeyToasts(state, key, now)
      if key == nil then
        return false
      end
      local removed = false
      for i = #state.active, 1, -1 do
        local toast = state.active[i]
        if toast.key == key
          and toast.fadeStartAt ~= nil
          and now >= toast.fadeStartAt then
          table.remove(state.active, i)
          releaseToast(toast)
          removed = true
        end
      end
      if removed then
        recalcTargetY(state)
        _layoutDirty = true
      end
      return removed
    end

    local function findFadingToastByKey(state, key, now)
      if key == nil then
        return nil
      end
      for i = 1, #state.active do
        local toast = state.active[i]
        if toast.key == key
          and toast.fadeStartAt ~= nil
          and now >= toast.fadeStartAt then
          return toast
        end
      end
      return nil
    end

    -- waitKeyFade + replace: та же плашка, новый контент, без entry.
    local function tryReplaceFadingKeyToast(state, source, now)
      if source.key == nil or not resolveReplaceFromOptions(source) then
        return false
      end
      local toast = findFadingToastByKey(state, source.key, now)
      if toast == nil then
        return false
      end
      applyToastFields(toast, source, {
        mergeAccumulation = isAccumulationStyle(source.style or toast.style),
      })
      setToastLifetime(toast, now)
      toast.entryAnimating = nil
      toast.entryT = nil
      toast.entryFromY = nil
      if toast.targetY ~= nil then
        toast.displayY = toast.targetY
      end
      triggerUpdatePulse(toast)
      recalcTargetY(state)
      _layoutDirty = true
      return true
    end

    local function enqueuePending(state, options)
      local maxPending = cfg("maxPendingQueue", 0)
      if maxPending > 0 and #state.pending >= maxPending then
        releaseRequest(table.remove(state.pending, 1))
      end
      if maxPending == 0 or #state.pending < maxPending then
        state.pending[#state.pending + 1] = copyRequestFromOptions(options)
        return true
      end
      return false
    end

    local function canPromoteRequest(state, req, now)
      if req == nil then
        return false
      end
      if req.waitKey == true and req.key ~= nil then
        if isKeyWaitBlocking(state, req.key, req.waitKeyFade == true, now) then
          return false
        end
      end
      return true
    end

    local function tryPromotePending(state, pid, now)
      local maxSlots = maxToastSlotCount()
      local isLocal = pid == localPlayerId()

      while #state.pending > 0 do
        local req = state.pending[1]
        if not canAcceptToast(state, resolveToastLogicSize(req)) then
          break
        end
        if #state.active >= maxSlots then
          break
        end
        if not canStartShowing(state, now) then
          break
        end
        if not canPromoteRequest(state, req, now) then
          break
        end
        req = table.remove(state.pending, 1)
        if req ~= nil then
          local replaced = false
          if req.waitKeyFade == true and req.key ~= nil then
            if req.replace == true then
              replaced = tryReplaceFadingKeyToast(state, req, now)
            end
            if not replaced then
              releaseFadingKeyToasts(state, req.key, now)
            end
          end

          if replaced then
            markShowStarted(state, now)
            if isLocal then
              playToastSound(req.style or "default", "update")
            end
            releaseRequest(req)
          else
            local toast = acquireToast()
            applyToastFields(toast, req)
            setToastLifetime(toast, now)
            table.insert(state.active, 1, toast)
            recalcTargetY(state)
            startEntryAnimation(toast)
            markShowStarted(state, now)
            if isLocal then
              playToastSound(toast.style or "default", "show")
            end
            releaseRequest(req)
            _layoutDirty = true
          end
        end
      end
    end

    local function findPendingByKey(state, key, useLast)
      if key == nil then
        return nil
      end
      if useLast then
        for i = #state.pending, 1, -1 do
          if state.pending[i].key == key then
            return i
          end
        end
        return nil
      end
      for i = 1, #state.pending do
        if state.pending[i].key == key then
          return i
        end
      end
      return nil
    end

    local function updatePendingRequest(req, options)
      local style = resolveStyle(options.style or req.style)
      local mergeAccum = style.isAccumulation == true
        and req.key ~= nil
        and req.key == options.key
      local savedIcon = req.icon
      local savedTitle = req.title
      local prevAccum = mergeAccum and parseAccumulatedValue(req.accumulatedValue) or 0

      applyRequestFields(req, options)

      if mergeAccum then
        req.accumulatedValue = prevAccum + parseAccumulatedValue(options.accumulatedValue)
        if req.icon == nil or req.icon == "" then
          req.icon = savedIcon
        end
        if req.title == nil or req.title == "" then
          req.title = savedTitle
        end
        applyToastDisplayText(req, req, style)
      end
    end

    local function activateToast(state, source, now)
      local toast = acquireToast()
      applyToastFields(toast, source)
      setToastLifetime(toast, now)
      table.insert(state.active, 1, toast)
      recalcTargetY(state)
      startEntryAnimation(toast)
      _layoutDirty = true
      return toast
    end

    local function activateAndShowToast(state, source, now)
      if source.waitKeyFade == true and source.key ~= nil and resolveReplaceFromOptions(source) then
        if tryReplaceFadingKeyToast(state, source, now) then
          markShowStarted(state, now)
          return true
        end
      end
      if source.waitKeyFade == true and source.key ~= nil then
        releaseFadingKeyToasts(state, source.key, now)
      end
      activateToast(state, source, now)
      markShowStarted(state, now)
      return false
    end

    -- soundEvent: nil | "show" | "update"
    local function enqueueToastForPlayer(pid, options, now)
      local state = getPlayerState(pid)
      local key = options.key
      local waitKey = options.waitKey == true
      local waitKeyFade = options.waitKeyFade == true
      local doReplace = resolveReplaceFromOptions(options) and key ~= nil

      if doReplace and waitKey then
        local pendingIndex = findPendingByKey(state, key, true)
        if hasActiveKey(state, key) then
          if pendingIndex ~= nil then
            updatePendingRequest(state.pending[pendingIndex], options)
          else
            enqueuePending(state, options)
          end
          return true, nil
        end
        if pendingIndex ~= nil then
          updatePendingRequest(state.pending[pendingIndex], options)
          return true, nil
        end
      elseif doReplace then
        for i = 1, #state.active do
          if state.active[i].key == key then
            local toast = state.active[i]
            applyToastFields(toast, options, {
              mergeAccumulation = isAccumulationStyle(options.style or toast.style),
            })
            setToastLifetime(toast, now)
            triggerUpdatePulse(toast)
            recalcTargetY(state)
            _layoutDirty = true
            return true, "update"
          end
        end
        local pendingIndex = findPendingByKey(state, key, true)
        if pendingIndex ~= nil then
          updatePendingRequest(state.pending[pendingIndex], options)
          return true, nil
        end
      end

      if waitKey and key ~= nil and isKeyWaitBlocking(state, key, waitKeyFade, now) then
        if doReplace then
          local pendingIndex = findPendingByKey(state, key, true)
          if pendingIndex ~= nil then
            updatePendingRequest(state.pending[pendingIndex], options)
            return true, nil
          end
        end
        enqueuePending(state, options)
        return true, nil
      end

      if not canAcceptToast(state, resolveToastLogicSize(options)) then
        enqueuePending(state, options)
        return true, nil
      end

      if #state.active >= maxToastSlotCount() then
        enqueuePending(state, options)
        return true, nil
      end

      if not canStartShowing(state, now) then
        enqueuePending(state, options)
        return true, nil
      end

      local replacedInPlace = activateAndShowToast(state, options, now)
      return true, replacedInPlace and "update" or "show"
    end

    local function tickPlayerState(pid, now)
      local state = _playerState[pid]
      if state == nil then
        return
      end

      local removed = false

      for i = #state.active, 1, -1 do
        local toast = state.active[i]
        if now >= toast.expiresAt then
          table.remove(state.active, i)
          releaseToast(toast)
          removed = true
        end
      end

      if removed then
        recalcTargetY(state)
        _layoutDirty = true
      end

      if #state.pending > 0 then
        local pendingBefore = #state.pending
        tryPromotePending(state, pid, now)
        if #state.pending < pendingBefore then
          _layoutDirty = true
        end
      end

      if pid == localPlayerId() and #state.active > 0 then
        for i = 1, #state.active do
          local toast = state.active[i]
          if toast.fadeStartAt ~= nil and now >= toast.fadeStartAt and now < toast.expiresAt then
            _layoutDirty = true
            break
          end
        end
      end
    end

    local function tickLogic()
      local now = gameTime()
      for pid = 0, cfg("maxPlayers", 12) - 1 do
        if _playerState[pid] ~= nil then
          tickPlayerState(pid, now)
        end
      end
    end

    local function tickRender()
      local localPid = localPlayerId()
      if not _localUiReady then
        ensureLocalUI()
      end
      tickLocalProgressAnimation()
      if _layoutDirty or localHasActiveToasts() then
        applyLocalLayout(localPid)
      end
    end

    ensureTickers = function()
      if type(CreateTimer) ~= "function" or type(TimerStart) ~= "function" then
        return
      end

      local logicInterval = cfg("tickerInterval", 0.03)
      if _logicTicker == nil and logicInterval > 0 then
        _logicTicker = CreateTimer()
        TimerStart(_logicTicker, logicInterval, true, tickLogic)
      end

      local renderInterval = cfg("renderTickerInterval", 0.016)
      if _renderTicker == nil and renderInterval > 0 then
        _renderTicker = CreateTimer()
        TimerStart(_renderTicker, renderInterval, true, tickRender)
      end
    end

    function Toasts.setDebug(enabled)
      Toasts._debug = enabled == true
    end

    function Toasts.diagnose()
      Toasts._log("=== DIAGNOSE ===")
      if not hasBlzApi() then
        Toasts._log("Blz Frame API unavailable (Reforged 1.31+ required)", true)
        return false
      end
      local ok, ui = pcall(getGameUI)
      if not ok or not isValidFrame(ui) then
        Toasts._log("BlzGetOriginFrame failed", true)
        return false
      end
      Toasts._log("OK")
      return true
    end

    function Toasts.show(options)
      options = options or {}
      if not hasToastContent(options) then
        log("show: icon, title or text is required", true)
        return false
      end

      ensureTickers()
      local now = gameTime()
      local players = resolvePlayers(options.players)
      local localPid = localPlayerId()
      local styleName = options.style or "default"
      local needLocalLayout = false

      for i = 1, #players do
        local pid = playerId(players[i])
        local _, soundEvent = enqueueToastForPlayer(pid, options, now)
        if pid == localPid then
          if soundEvent ~= nil then
            playToastSound(styleName, soundEvent)
          end
          needLocalLayout = true
        end
      end

      if needLocalLayout then
        ensureLocalUI()
        applyLocalLayout(localPid)
      end

      return true
    end
  end
end
Contents

Toasts example in Divine Roguelike (Map)

Back
Top