• Listen to a special audio message from Bill Roper to the Hive Workshop community (Bill is a former Vice President of Blizzard Entertainment, Producer, Designer, Musician, Voice Actor) 🔗Click here to hear his message!
  • Read Evilhog's interview with Gregory Alper, the original composer of the music for WarCraft: Orcs & Humans 🔗Click here to read the full interview.
  • Vote for the theme of Hive's HD Modeling Contest #7! Click here to vote! - Please only vote if you plan on participating❗️

[Lua] TimerQueue & Stopwatch

⚠️ This resource has been moved to a new page in the Spells and Systems forum. The old page will not be further maintained.

Overview

Documentation & Code

Changelog

TimerQueue

A TimerQueue is an object based on a single timer that lets you execute any number of delayed function calls.

Main Use

Lua:
-- TimerQueue:callDelayed(delayInSeconds, function, ...)
-- will call the specified function after the specified delay with the specified arguments (...)

--print "Hello World" after 5 seconds delay
TimerQueue:callDelayed(5., print, "Hello World")
--create a footman after 3 seconds delay (i.e. 2 seconds before printing "Hello World")
TimerQueue:callDelayed(3., CreateUnit, Player(0), FourCC('hfoo'), 0, 0,0)
--It also works well with anonymous functions
TimerQueue:callDelayed(2., function() print("Hello") end)

A TimerQueue really shines, when you have many functions waiting for execution at the same time. That normally would require starting a lot of independent timers, while the TimerQueue handles it with a single one (which basically times the deltas in between calls), thus saving performance overhead. You can of course disregard these internals. Simply use its callDelayed-method and the TimerQueue will handle the rest.

Periodic executions

TimerQueue provides the :callPeriodically method for periodic execution:
Lua:
-- TimerQueue:callPeriodically(delayInSeconds, stopCondition, func, ...)
-- will periodicaly call func(...), until stopCondition(...) resolves to true.

-- This example adds 10 to the gold of Player Red every two seconds, until his gold reaches 1000.
do
    ---Adds 10 to the specified player's gold.
    ---@param p player
    local function add10ToPlayerGold(p)
        AdjustPlayerStateBJ( 10, p, PLAYER_STATE_RESOURCE_GOLD )
    end
    ---Returns true, if the specified player's gold is at least 1000.
    ---@param p player
    ---@return boolean
    local function stopAt1000(p)
        return GetPlayerState(p, PLAYER_STATE_RESOURCE_GOLD) >= 1000
    end

    TimerQueue:callPeriodically(2., stopAt1000, add10ToPlayerGold, Player(0))
end

Alternatively, periodic executions can be achieved by simple recursion. As TimerQueues are based off a single timer, you don't need to worry about overhead from unnecessary timer creation.
Lua:
function printPeriodically(x)
    print(x)
    if <condition> then TimerQueue:callDelayed(2., printPeriodically, x) end
end

printPeriodically("Hello") --prints "Hello" every 2 seconds as long as the specified condition is met.

Instantiability

The above example used :callDelayed() directly on the TimerQueue class.
That works just fine and will satisfy most use cases, but sometimes it's just more convenient to create separate TimerQueues for dfferent things:
Lua:
tq = TimerQueue.create()
tq:callDelayed(5., print, "tq is a separate TimerQueue instance")

Creating a TimerQueue instance enables you to :pause(), :resume(), :reset() and :destroy() the instance as a whole.
Pausing a TimerQueue will pause the elapsed time for all currently queued function calls, until the TimerQueue is resumed.
Resetting a TimerQueue will discard all currently queued function calls without executing any of them.
Destroying a TimerQueue will reset it and destroy the underlying timer, which is eventually necessary to prevent memory leaks.

These methods have favorable advantages, if you want a specific set of your delayed executions to be potentially cancelable.
-> You basically call all cancel-candidates on a dedicated TimerQueue and can reset that queue at any time.
I personally need this cancelling of queued delayed functions pretty often during coding.

Imagine that you create a map centered around boss fights and the current boss is creating hostile effects on the ground, which players need to evade. These hostile effects display as red circles a few seconds before they apply, so code-wise they involve creating the circle plus a delayed function call for the actual effect. However, as soon as the boss dies, he is not supposed to cast any more abilities, even if those have already been announced by red circles, so you are required to cancel those waiting effect executions.
At the same time, players might also cast abilities involving delayed effects, which shall continue to resolve after the boss has died.

A TimerQueue can achieve that with ease: queue all boss effects into the same TimerQueue, so you can cancel all waiting executions with a single :reset().

Lua:
--We create a dedicated TimerQueue to queue the boss abilities and another one for the player abilities
bossTQ = TimerQueue.create()
playerTQ = TimerQueue.create() --in reality, we might actually want to create a separate queue per player to be able to cancel their effects upon death individually

--As the battle progresses, more and more delayed calls are added to the queue
bossTQ:callDelayed(delay, letBossDoSomething, ...)
playerTQ:callDelayed(delay, resolveSomePlayerEffect, ...)

--Finally the boss dies and you want to cancel all executions of boss effects that are still waiting in the queue
bossTQ:reset() --discards all waiting executions from the queue. You can still add new callbacks afterwards.

--The player timerqueue is not affected and will continue to resolve all waiting executions

Error Handling

The TimerQueue class provides the .debugMode property, which - in case true - lets all TimerQueues print error messages upon executing erroneous function calls. This can save a lot of frustration and time, as bugs within delayed function calls are rather hard to spot and debug.
TimerQueue.debugMode is set to true by default. It's wise to turn it off before releasing your map to the public.

Stopwatch

A Stopwatch is a timer that counts upwards, which (surprise) you can use to stop time.
You can create a new stopwatch sw via sw = Stopwatch.create(), start it with sw:start() and receive the current elapsed time via sw:getElapsed().
As you would expect, you can also :pause(), :resume() and :destroy() a stopwatch. Please refer to the Documentation & Code tab for further information.

Installation

Create a new script in your trigger editor (above your other code) and copy the code from the Documentation & Code tab into it.

Credits

Initial credits for this resource go to @AGD. We have developed this resource together in this thread, but AGD became inactive before publishing a final version. I eventually added instantiability and here we are.


Lua:
if Debug and Debug.beginFile then Debug.beginFile("TimerQueue") end
--[[------------------------------------------------------------------------------------------------------------------------------------------------------------
*
*    --------------------------------
*    | TimerQueue and Stopwatch 1.1 |
*    --------------------------------
*
*    - by Eikonium and AGD
*
*    -> https://www.hiveworkshop.com/threads/timerqueue-stopwatch.339411/
*    - This is basically the enhanced and instancifiable version of ExecuteDelayed 1.0.4 by AGD https://www.hiveworkshop.com/threads/lua-delayedaction.321072/
*
* --------------------
* | TimerQueue class |
* --------------------
*        - A TimerQueue is an object used to execute delayed function calls. It can queue any number of function calls at the same time, while being based on a single timer. This offers much better performance than starting many separate timers.
*        - The class also provides methods to pause, resume, reset and destroy a TimerQueue - and even includes error handling.
*        - As such, you can create as many independent TimerQueues as you like, which you can individually use, pause, reset, etc.
*        - All methods can also be called on the class directly, which frees you from needing to create a TimerQueue object in the first place. You still need colon-notation!
*    TimerQueue.create() --> TimerQueue
*        - Creates a new TimerQueue with its own independent timer and function queue.
*    <TimerQueue>:callDelayed(number delay, function callback, ...)
*        - Calls the specified function (or callable table) after the specified delay (in seconds) with the specified arguments (...). Does not delay the following lines of codes.
*    <TimerQueue>:callPeriodically(number delay, function|nil stopCondition, function callback, ...)
*        - Periodically calls the specified function (or callable table) after the specified delay (in seconds) with the specified arguments (...). Stops, when the specified condition resolves to true.
*        - The stop-condition must be a function returning a boolean. It is checked after each callback execution and is passed the same arguments as the callback (...) (which you can still ignore).
*        - You can pass nil instead of a function to let the periodic execution repeat forever.
*    <TimerQueue>:reset()
*        - Discards all queued function calls from the Timer Queue. Discarded function calls are not executed.
*        - You can continue to use <TimerQueue>:callDelayed after resetting it.
*    <TimerQueue>:pause()
*        - Pauses the TimerQueue at its current point in time, effectively freezing all delayed function calls that it currently holds, until the queue is resumed.
*        - Using <TimerQueue>:callDelayed on a paused queue will correctly add the new callback to the queue, but time will start ticking only after resuming the queue.
*    <TimerQueue>:isPaused() --> boolean
*        - Returns true, if the TimerQueue is paused, and false otherwise.
*    <TimerQueue>:resume()
*        - Resumes a TimerQueue that was previously paused. Has no effect on TimerQueues that are not paused.
*    <TimerQueue>:destroy()
*        - Destroys the Timer Queue. Remaining function calls are discarded and not being executed.
*    <TimerQueue>.debugMode : boolean
*        - TimerQueues come with their own error handling in case you are not using DebugUtils (https://www.hiveworkshop.com/threads/debug-utils-ingame-console-etc.330758/).
*        - Set to true to let erroneous function calls through <TimerQueue>:callDelayed print error messages on screen (only takes effect, if Debug Utils is not present. Otherwise you get Debug Utils error handling, which is even better).
*        - Set to false to not trigger error messages after erroneous callbacks. Do this before map release.
*        - Default: true.
* -------------------
* | Stopwatch class |
* -------------------
*        - Stopwatches count upwards, i.e. they measure the time passed since you've started them. Thus, they can't trigger any callbacks (use normal timers or TimerQueues for that).
*    Stopwatch.create(boolean startImmediately_yn) --> Stopwatch
*        - Creates a Stopwatch. Set boolean param to true to start it immediately.
*    <Stopwatch>:start()
*        - Starts or restarts a Stopwatch, i.e. resets the elapsed time of the Stopwatch to zero and starts counting upwards.
*    <Stopwatch>:getElapsed() --> number
*        - Returns the time in seconds that a Stopwatch is currently running, i.e. the elapsed time since start.
*    <Stopwatch>:pause()
*        - Pauses a Stopwatch, so it will retain its current elapsed time, until resumed.
*    <Stopwatch>:resume()
*        - Resumes a Stopwatch after having been paused.
*    <Stopwatch>:destroy()
*        - Destroys a Stopwatch. Maybe necessary to prevent memory leaks. Not sure, if lua garbage collection also collects warcraft objects...
---------------------------------------------------------------------------------------------------------------------------------------------------------]]
do
    ---@class TimerQueueElement
    ---@field [integer] any arguments to be passed to callback
    TimerQueueElement = {
        next = nil                      ---@type TimerQueueElement next TimerQueueElement to expire after this one
        ,   timeout = 0.                ---@type number time between previous callback and this one
        ,   callback = function() end   ---@type function callback to be executed
        ,   n = 0                       ---@type integer number of arguments passed
    }
    TimerQueueElement.__index = TimerQueueElement
    TimerQueueElement.__name = 'TimerQueueElement'
    ---Creates a new TimerQueueElement, which points to itself.
    ---@param timeout? number
    ---@param callback? function
    ---@param ... any arguments for callback
    ---@return TimerQueueElement
    function TimerQueueElement.create(timeout, callback, ...)
        local new = setmetatable({timeout = timeout, callback = callback, n = select('#', ...), ...}, TimerQueueElement)
        new.next = new
        return new
    end
    ---@class TimerQueue
    TimerQueue = {
        timer = nil                     ---@type timer the single timer this system is based on (one per instance of course)
        ,   queue = TimerQueueElement.create() -- queue of waiting callbacks to be executed in the future
        ,   n = 0                       ---@type integer number of elements in the queue
        ,   on_expire = function() end  ---@type function callback to be executed upon timer expiration (defined further below).
        ,   debugMode = true           ---@type boolean setting this to true will print error messages, when the input function couldn't be executed properly. Set this to false before releasing your map.
        ,   paused = false              ---@type boolean whether the queue is paused or not
    }
    TimerQueue.__index = TimerQueue
    TimerQueue.__name = 'TimerQueue'
    --Creates a timer on first access of the static TimerQueue:callDelayed method. Avoids timer creation inside the Lua root.
    setmetatable(TimerQueue, {__index = function(t,k) if k == 'timer' then t[k] = CreateTimer() end; return rawget(t,k) end})
    local unpack, max, timerStart, timerGetElapsed, pauseTimer = table.unpack, math.max, TimerStart, TimerGetElapsed, PauseTimer
    ---@param timerQueue TimerQueue
    local function on_expire(timerQueue)
        local queue, timer = timerQueue.queue, timerQueue.timer
        local topOfQueue = queue.next
        queue.next = topOfQueue.next
        timerQueue.n = timerQueue.n - 1
        if timerQueue.n > 0 then
            timerStart(timer, queue.next.timeout, false, timerQueue.on_expire)
        else
            -- These two functions below may not be necessary
            timerStart(timer, 0, false, nil) --don't put in on_expire as handlerFunc, because it can still expire and reduce n to a value < 0.
            pauseTimer(timer)
        end
        if Debug and Debug.try then
            Debug.try(topOfQueue.callback, unpack(topOfQueue, 1, topOfQueue.n))
        else
            local errorStatus, errorMessage = pcall(topOfQueue.callback, unpack(topOfQueue, 1, topOfQueue.n))
            if timerQueue.debugMode and not errorStatus then
                print("|cffff5555ERROR during TimerQueue callback: " .. errorMessage .. "|r")
            end
        end
    end
    TimerQueue.on_expire = function() on_expire(TimerQueue) end
    ---@return TimerQueue
    function TimerQueue.create()
        local new = {}
        setmetatable(new, TimerQueue)
        new.timer = CreateTimer()
        new.queue = TimerQueueElement.create()
        new.on_expire = function() on_expire(new) end
        return new
    end
    ---Calls a function (or callable table) after the specified timeout (in seconds) with all specified arguments (...). Does not delay the following lines of codes.
    ---@param timeout number
    ---@param callback function|table if table, must be callable
    ---@param ... any arguments of the callback function
    function TimerQueue:callDelayed(timeout, callback, ...)
        timeout = math.max(timeout, 0.)
        local queue = self.queue
        self.n = self.n + 1
        -- Sort timeouts in descending order
        local current = queue
        local current_timeout = current.next.timeout - max(timerGetElapsed(self.timer), 0.) -- don't use TimerGetRemaining to prevent bugs for expired and previously paused timers.
        while current.next ~= queue and timeout >= current_timeout do --there is another element in the queue and the new element shall be executed later than the current
            timeout = timeout - current_timeout
            current = current.next
            current_timeout = current.next.timeout
        end
        -- after loop, current is the element that executes right before the new callback. If the new is the front of the queue, current is the root element (queue).
        local new = TimerQueueElement.create(timeout, callback, ...)
        new.next = current.next
        current.next = new
        -- if the new callback is the next to expire, restart timer with new timeout
        if current == queue then --New callback is the next to expire
            new.next.timeout = max(current_timeout - timeout, 0.) --adapt element that was previously on top. Subtract new timeout and subtract timer elapsed time to get new timeout.
            timerStart(self.timer, timeout, false, self.on_expire)
            if self.paused then
                self:pause()
            end
        else
            new.next.timeout = max(new.next.timeout - timeout, 0.) --current.next might be the root element (queue), so prevent that from dropping below 0. (although it doesn't really matter)
        end
    end
    ---Calls the specified callback with the specified argumets (...) every <timeout> seconds, until the specified stop-condition holds.
    ---The stop-condition must be a function returning a boolean. It is checked after every callback execution. All arguments (...) are also passed to the stop-conditon (you can still ignore them).
    ---@param timeout number time between calls
    ---@param stopCondition? fun(...):boolean callback will stop to repeat, when this condition holds. You can pass nil to skip the condition (i.e. the periodic execution will run forever).
    ---@param callback function the callback to be executed
    ---@param ... any arguments for the callback
    function TimerQueue:callPeriodically(timeout, stopCondition, callback, ...)
        local func
        func = function(...)
            callback(...)
            if not (stopCondition and stopCondition(...)) then
                self:callDelayed(timeout, func, ...)
            end
        end
        self:callDelayed(timeout, func, ...)
    end
    ---Removes all queued calls from the Timer Queue, so any remaining actions will not be executed.
    ---Using <TimerQueue>:callDelayed afterwards will still work.
    function TimerQueue:reset()
        timerStart(self.timer, 0., false, nil) --dont't put in on_expire as handlerFunc. callback can still expire after pausing and resuming the empty queue, which would set n to a value < 0.
        pauseTimer(self.timer)
        self.n = 0
        self.queue = TimerQueueElement.create()
    end
    ---Pauses the TimerQueue at its current point in time, preventing all queued callbacks from being executed, until the queue is resumed.
    ---Using <TimerQueue>:callDelayed on a paused queue will correctly add the new callback to the queue, but time will start ticking only after the queue is being resumed.
    function TimerQueue:pause()
        self.paused = true
        pauseTimer(self.timer)
    end
    ---Returns true, if the timer queue is paused, and false otherwise.
    ---@return boolean
    function TimerQueue:isPaused()
        return self.paused
    end
    ---Resumes a TimerQueue that was paused previously. Has no effect on running TimerQueues.
    function TimerQueue:resume()
        if self.paused then
            self.paused = false
            self.queue.next.timeout = self.queue.next.timeout - timerGetElapsed(self.timer) --need to restart from 0, because TimerGetElapsed(resumedTimer) is doing so as well after a timer is resumed.
            ResumeTimer(self.timer)
        end
    end
    ---Destroys the timer object behind the TimerQueue. The Lua object will be automatically garbage collected once you ensure that there is no more reference to it.
    function TimerQueue:destroy()
        pauseTimer(self.timer) --https://www.hiveworkshop.com/threads/issues-with-timer-functions.309433/ suggests that non-paused destroyed timers can still execute their callback
        DestroyTimer(self.timer)
    end
    ---Prints the queued callbacks within the TimerQueue. For debugging purposes.
    ---@return string
    function TimerQueue:tostring()
        local current, result, i = self.queue.next, {}, 0
        local args = {}
        while current ~= self.queue do
            i = i + 1
            for j = 1, current.n do
                args[j] = tostring(current[j])
            end
            result[i] = '(i=' .. i .. ',timeout=' .. current.timeout .. ',f=' .. tostring(current.callback) .. ',args={' .. table.concat(args, ',',1,current.n) .. '})'
            current = current.next
        end
        return '{n = ' .. self.n .. ',queue=(' .. table.concat(result, ',', 1, i) .. ')}'
    end
    ---@class Stopwatch
    Stopwatch = {
        timer = {}                                  ---@type timer the countdown-timer permanently cycling
        ,   elapsed = 0.                            ---@type number the number of times the timer reached 0 and restarted
        ,   increaseElapsed = function() end        ---@type function timer callback function to increase numCycles by 1 for a specific Stopwatch.
    }
    Stopwatch.__index = Stopwatch
    local CYCLE_LENGTH = 3600. --time in seconds that a timer needs for one cycle. doesn't really matter.
    ---Creates a Stopwatch.
    ---@param startImmediately_yn boolean Set to true to start immediately. If not specified or set to false, the Stopwatch will not start to count upwards.
    function Stopwatch.create(startImmediately_yn)
        local new = {}
        setmetatable(new, Stopwatch)
        new.timer = CreateTimer()
        new.elapsed = 0.
        new.increaseElapsed = function() new.elapsed = new.elapsed + CYCLE_LENGTH end
        if startImmediately_yn then
            new:start()
        end
        return new
    end
    ---Starts or restarts a Stopwatch, i.e. resets the elapsed time of the Stopwatch to zero and starts counting upwards.
    function Stopwatch:start()
        self.elapsed = 0.
        TimerStart(self.timer, CYCLE_LENGTH, true, self.increaseElapsed)
    end
    ---Returns the time in seconds that a Stopwatch is currently running, i.e. the elapsed time since start.
    ---@return number
    function Stopwatch:getElapsed()
        return self.elapsed + TimerGetElapsed(self.timer)
    end
    ---Pauses a Stopwatch, so it will retain its current elapsed time, until resumed.
    function Stopwatch:pause()
        PauseTimer(self.timer)
    end
    ---Resumes a Stopwatch after having been paused.
    function Stopwatch:resume()
        self.elapsed = self.elapsed + TimerGetElapsed(self.timer)
        TimerStart(self.timer, CYCLE_LENGTH, true, self.increaseElapsed) --not using ResumeTimer here, as it actually starts timer from new with the remaining time and thus screws up TimerGetElapsed().
    end
    ---Destroys the timer object behind the Stopwatch. The Lua object will be automatically garbage collected once you ensure that there is no more reference to it.
    function Stopwatch:destroy()
        DestroyTimer(self.timer)
    end
end
if Debug and Debug.endFile then Debug.endFile() end

30.4.2022, v1.0 (Initial Release):
  • TimerQueue as an instancifiable version of [Lua] - DelayedAction
  • Added :pause(), :resume(), [ icode=Lua]:reset()[/icode], :destroy() and error handling for erroneous callbacks.
  • Added Stopwatch functionality

01.05.2023, v1.1:
  • Switched from array-based to linked-list-based queue to improve performance of TimerQueue:callDelayed.
  • Switched from absolute timestamps to relative timestamps to drastically improve performance of TimerQueue:pause().
  • Integrated the newest version of Debug Utils for better error messages and local file references in particular.
  • Optimized script to prevent unnecessary table creation.
  • Added TimerQueue:printPeriodically(timeout, stopCondition, callback, ...), :isPaused() and TimerQueue:tostring().
  • Fixed a bug, where using :callDelayed on a paused empty TimerQueue would unintentionally resume the Queue. Thanks to @Insanity_AI for reporting this.
 
Last edited:
Can you please let me know what the below are doing?

Stopwatch.__index = Stopwatch

and

TimerQueue.__index = TimerQueue

Without this functionality, would the methods not be able to be called directly with the : operator? I’m curious as to how it works, since you immediately call setmetatable on the TimerQueue to give __index a defined behavior.
 
Without this functionality, would the methods not be able to be called directly with the : operator?
Yes exactly.

First things first, the __index metatamethod can be set to either a function f or a table t. In the latter case, it redirects all read access from the original table to t, if the original table doesn't hold the requested key.
So, in short, metatable.__index = class is just a convenient way of writing metatable.__index = function(_,k) return class[k] end.

We generally store methods in the class (i.e. function class:someMethod() end saves a function in class["someMethod"]), so if you create any object with its metatable __index pointing to the class, object:someMethod(...) will resolve to object.someMethod(object, ...) by definition of the :-operator, then to object["someMethod"](object, ...) by definition of the .-operator and eventually to class["someMethod"](object, ...) by use of the __index-metamethod.
That's the whole magic behind object-oriented programming in Lua.

Now, it's common practice to use the class itself as metatable for its objects, which leads to the following style:
Lua:
class = {}
class.__index = class --the class will be both the metatable and the redirected location

function class.create()
    local new = {}
    setmetatable(new, class)
    return new
end

function class:someMethod()
    doSomething(self)
end
which after creating an object of the class enables you to write object:someMethod().

Actually, using the class itself as a metatable for its object merely saves us one dedicated metatable, so that's not super relevant. It's more a convenience thing than anything else.

As an additional sidenote, the line setmetatable(TimerQueue, {__index = function(t,k) if k == 'timer' then t[k] = CreateTimer() end; return rawget(t,k) end}) is independent from what I've written above.
The class itself most of the time doesn't need it's own metatable, because even if you want to use methods directly on the class, those are already stored in the class and you don't need to redirect any read access.
That line has a different purpose: It creates a timer "on demand", if someone calls class:callDelayed() directly on the class, which frees us from timer creation in the Lua root. (Creating Wc3 objects during Lua root can lead to desyncs, as @Tasyen has pointed out in some of his posts).
 
Last edited:
(Creating Wc3 objects during Lua root can lead to desyncs, as @Tasyen has pointed out in some of his posts).
Yeah that is dangerous for mp and solo. In mp it might desync. In solo play the stuff might stop working at some random time (has some thing to do with garbage collecotor, I think).
 
Yeah that is dangerous for mp and solo. In mp it might desync. In solo play the stuff might stop working at some random time (has some thing to do with garbage collecotor, I think).
Do you also happen to know, whether accessing static objects like Player(0) in the Lua root is fine or not?

I reckon it should be fine, because it doesn't create any new objects, but I want to be sure :)
 

AGD

AGD

Level 16
Joined
Mar 29, 2016
Messages
688
I think the TimerQueue and Stopwatch could be split into two different resources.

Btw it's also possible to make all instances of Stopwatch work off a single timer, something like this:
Lua:
do
    ---@class Stopwatch
    Stopwatch = setmetatable({}, {})

    local mt = getmetatable(Stopwatch)
    mt.__index = mt

    local CYCLE_PERIOD = 3600.
    local cycles = 0

    local timer
    local function getTimerElapsed()
        if timer == nil then
            timer = CreateTimer() -- The only timer you'll ever need
            TimerStart(timer, CYCLE_PERIOD, false, function()
                cycles = cycles + 1
            end)
        end
        return TimerGetElapsed(timer) + CYCLE_PERIOD * cycles
    end

    ---Creates a Stopwatch.
    ---@return Stopwatch
    function mt.__call()
        return setmetatable({}, mt)
    end

    ---Creates a Stopwatch and optionally starts it.
    ---@param startImmediately boolean
    ---@return Stopwatch
    function mt.create(startImmediately)
        local new = Stopwatch()
        if startImmediately == true then
            new:start()
        end
        return new
    end

    ---"Destroys" a Stopwatch, effectively disabling all its methods.
    function mt:destroy()
        setmetatable(self, nil)
    end

    ---Starts/Restarts/Resets a Stopwatch.
    function mt:start()
        self._start_time = getTimerElapsed()
        if self._pause_time ~= nil then
            self._pause_time = 0.
        end
    end

    ---Pauses a Stopwatch, so it will retain its current elapsed time, until resumed.
    function mt:pause()
        if self._start_time == nil then return end
        self._pause_time = self:getElapsed()
    end

    ---Resumes a Stopwatch after having been paused.
    function mt:resume()
        if self._pause_time == nil then return end
        local pause_duration = getTimerElapsed() - self._pause_time
        self._start_time = self._start_time + pause_duration
        self._pause_time = nil
    end

    ---Returns the time in seconds that a Stopwatch is currently running, i.e. the elapsed time since start.
    ---@return number
    function mt:getElapsed()
        if self._pause_time == nil then
            return getTimerElapsed() - self._start_time
        end
        return self._pause_time
    end
end
I haven't tested the above code if it works correctly but hopefully this illustrates how it could work with only a single timer.

I also like the fact that TimerQueue is instantiable unlike the original and gives more flexibility. What would make it cooler further still is if different instances could also share from a single timer. I think it's possible but I haven't thought of how to do it yet so I don't know if the additional complexity would be worth it.
 
Last edited:
Nice to see that you are back! :)

I think the TimerQueue and Stopwatch could be split into two different resources.
I personally think about the resource as some kind of TimerUtils, so I didn't want to separate it. Stopwatch is such a small addon that it doesn't make a big splash anyway.

Btw it's also possible to make all instances of Stopwatch work off a single timer, something like this:
Absolutely! Just, I didn't see many use cases, where you want to run a lot of Stopwatches at the same time. I can think of scenarios, where you'd probably run one per player, but in all honesty, that's still a little amount and shouldn't affect performance (in contrast to delayed function calls, where I see cases where people need hundreds of calls queued at the same time).
But I can see that your idea doesn't have any disadvantages and you have nicely prepared it, so I will think about developing it further, when I find time.

What would make it cooler further still is if different instances could also share from a single timer. I think it's possible but I haven't thought of how to do it yet so I don't know if the additional complexity would be worth it.
Hmm. Users probably want a few different TimerQueues for the sole purpose of resetting them individually, but I don't think that anybody would run many TimerQueues at the same time, given the fact that you can queue any number of delayed function calls on every single one.
Using one timer would also add overhead in managing the data structure(s). Putting all delayed calls from different TimerQueues into one data structure would increase the overhead on pausing and resetting a single queue (and I imagine that to be a pain code-wise). Leaving delayed calls in different queues would require searching all top elements of all queues for the next one to execute after every delayed execution. Both implies performance overhead that might even exceed the overhead produced by just running separate timers (which is the current status quo). So from my point of view, the effort would not be worth it.

Actually, if we are talking about further performance increase, I think implementing a LinkedList-based queue instead of an array-based queue could be more beneficial. Insertion Sort still requires looping through the structure, but inserting the new element would not require an array-shift anymore.
 

AGD

AGD

Level 16
Joined
Mar 29, 2016
Messages
688
I personally think about the resource as some kind of TimerUtils, so I didn't want to separate it. Stopwatch is such a small addon that it doesn't make a big splash anyway.
Personally I wouldn't really mind if the code is a bit short, as long as it's not 20ish lines. And even if it's like an addon and related as long as it has a different purpose and can 100% function in isolation, and both are not interdependent on each other's functionality, then I think it's cleaner to make them separate. Or my other fallback option would be 2 separate code blocks in 1 thread.

Hmm. Users probably want a few different TimerQueues for the sole purpose of resetting them individually, but I don't think that anybody would run many TimerQueues at the same time, given the fact that you can queue any number of delayed function calls on every single one.
Using one timer would also add overhead in managing the data structure(s). Putting all delayed calls from different TimerQueues into one data structure would increase the overhead on pausing and resetting a single queue (and I imagine that to be a pain code-wise). Leaving delayed calls in different queues would require searching all top elements of all queues for the next one to execute after every delayed execution. Both implies performance overhead that might even exceed the overhead produced by just running separate timers (which is the current status quo). So from my point of view, the effort would not be worth it.

Actually, if we are talking about further performance increase, I think implementing a LinkedList-based queue instead of an array-based queue could be more beneficial. Insertion Sort still requires looping through the structure, but inserting the new element would not require an array-shift anymore.
You're totally right about this one.
 
Personally I wouldn't really mind if the code is a bit short, as long as it's not 20ish lines. And even if it's like an addon and related as long as it has a different purpose and can 100% function in isolation, and both are not interdependent on each other's functionality, then I think it's cleaner to make them separate. Or my other fallback option would be 2 separate code blocks in 1 thread.
Fair enough. I personally find it a good habit to collect functions in the same library, when they are centered around the same functionality (in this case timer utility), as long as the resulting library is not too bloated up with random stuff. But that's maybe a matter of preference. I prefer to leave this specific resource as is, but will take your consideration into account, when I upload my next resource :)
 
Level 7
Joined
Jun 30, 2017
Messages
49
---Using <TimerQueue>:callDelayed on a paused queue will correctly add the new callback to the queue, but time will start ticking only after the queue is being resumed.
I found a bug regarding this feature, it should be reproducible with following steps:
  1. Create a timer queue.
  2. Pause the queue
  3. Make a callDelayed method call on created timer queue
What happens is, the TimerQueue will no longer be paused and will execute it after specified delay.

Below I did a fix where I also added a "paused" property to determine if TimerQueue is paused or not.
Lua:
--[[------------------------------------------------------------------------------------------------------------------------------------------------------------
*
*    TimerQueue and Stopwatch 1.0 by AGD and Eikonium
*    -> https://www.hiveworkshop.com/threads/timerqueue-stopwatch.339411/
*
* --------------------
* | TimerQueue class |
* --------------------
*        - A TimerQueue is an object that can queue any number of delayed function calls, while being based on a single timer.
*        - The main use of a timer queue is to simplify delayed function calls. The class however also provides methods to pause, resume, reset and destroy a TimerQueue as a whole - and even includes error handling.
*        - As such, you can create as many independent TimerQueues as you like, which you can individually pause, reset, etc.
*        - All methods can also be called on the class directly, which frees you from needing to create a TimerQueue object in the first place. You still need colon-notation!
*    TimerQueue.create() --> TimerQueue
*        - Creates a new TimerQueue with its own independent timer and function queue.
*    <TimerQueue>:callDelayed(number delay, function callback, ...)
*        - Calls the specified function (or callable table) after the specified delay (in seconds) with the specified arguments (...). Does not delay the following lines of codes.
*    <TimerQueue>:reset()
*        - Discards all queued function calls from the Timer Queue. Discarded function calls are not executed.
*        - You can continue to use <TimerQueue>:callDelayed after resetting it.
*    <TimerQueue>:pause()
*        - Pauses the TimerQueue at its current point in time, effectively freezing all delayed function calls that it currently holds, until the queue is resumed.
*        - Using <TimerQueue>:callDelayed on a paused queue will correctly add the new callback to the queue, but time will start ticking only after resuming the queue.
*    <TimerQueue>:resume()
*        - Resumes a TimerQueue that was previously paused. Has no effect on TimerQueues that are not paused.
*    <TimerQueue>:destroy()
*        - Destroys the Timer Queue. Remaining function calls are discarded and not being executed.
*    <TimerQueue>.debugMode : boolean
*        - Set to true to let erroneous function calls through <TimerQueue>:callDelayed print error messages on screen.
*        - Default: true.
* -------------------
* | Stopwatch class |
* -------------------
*        - Stopwatches are similar to normal timers, except that they count upwards instead of downwards. Thus, they can't trigger any callbacks (use normal timers or TimerQueues for that),
*           but are just measures for how much time has passed since you have started it.
*    Stopwatch.create(boolean startImmediately_yn) --> Stopwatch
*        - Creates a Stopwatch, which you can choose to start immediately.
*    <Stopwatch>:start()
*        - Starts or restarts a Stopwatch, i.e. resets the elapsed time of the Stopwatch to zero and starts counting upwards.
*    <Stopwatch>:getElapsed() --> number
*        - Returns the time in seconds that a Stopwatch is currently running, i.e. the elapsed time since start.
*    <Stopwatch>:pause()
*        - Pauses a Stopwatch, so it will retain its current elapsed time, until resumed.
*    <Stopwatch>:resume()
*        - Resumes a Stopwatch after having been paused.
*    <Stopwatch>:destroy()
*        - Destroys a Stopwatch. Necessary to prevent memory leaks.
---------------------------------------------------------------------------------------------------------------------------------------------------------]]

do

    ---@class TimerQueue
    TimerQueue = {
        timer = nil                     ---@type timer the single timer this system is based on (one per instance of course)
        ,   elapsed = 0.                ---@type number current elapsed mark that gets added to the callback delay to receive the "absolute" point in time, where a function will be called.
        ,   queue = {}                  ---@type table queue of waiting callbacks to be executed in the future
        ,   n = 0                       ---@type integer number of elements in the queue
        ,   paused = false              ---@type boolean if TimerQueue is paused
        ,   on_expire = function() end  ---@type function callback upon expiring.
        ,   debugMode = true            ---@type boolean setting this to true will print error messages, when the input function couldn't be executed properly
    }
    TimerQueue.__index = TimerQueue

    --Creates a timer on first access of the static TimerQueue:callDelayed method. Avoids timer creation inside the Lua root.
    setmetatable(TimerQueue, {__index = function(t,k) if k == 'timer' then t[k] = CreateTimer() end; return rawget(t,k) end})

    local pack, unpack, timerStart, pauseTimer, getElapsed = table.pack, table.unpack, TimerStart, PauseTimer, TimerGetElapsed

    ---@param timerQueue TimerQueue
    local function on_expire(timerQueue)
        local queue, timer = timerQueue.queue, timerQueue.timer
        local topOfQueue = queue[timerQueue.n] --localize top element of queue
        queue[timerQueue.n] = nil --pop top element of queue to prevent pcall (below) from resorting the queue (might happen for nested CallDelayed)
        timerQueue.n = timerQueue.n - 1
        timerQueue.elapsed = topOfQueue[1]
        if timerQueue.n > 0 then
            timerStart(timer, queue[timerQueue.n][1] - topOfQueue[1], false, timerQueue.on_expire)
        else
            timerQueue.elapsed = 0.
            -- These two functions below may not be necessary
            timerStart(timer, 0, false)
            pauseTimer(timer)
        end
        local errorStatus, errorMessage = pcall(topOfQueue[2], unpack(topOfQueue[3], 1, topOfQueue[3].n))
        if timerQueue.debugMode and not errorStatus then
            print("|cffff5555" .. errorMessage .. "|r")
        end
    end

    TimerQueue.on_expire = function() on_expire(TimerQueue) end

    ---Creates a new TimerQueue.
    ---@param pause boolean?
    ---@return TimerQueue
    function TimerQueue.create(pause)
        local new = {}
        setmetatable(new, TimerQueue)
        new.timer = CreateTimer()
        new.elapsed = 0.
        new.queue = {}
        new.n = 0
        new.paused = pause or false
        new.on_expire = function() on_expire(new) end
        return new
    end

    ---Calls a function (or callable table) after the specified timeout (in seconds) with all specified arguments (...). Does not delay the following lines of codes.
    ---@param timeout number
    ---@param callback function|table if table, must be callable
    ---@vararg any arguments of the callback function
    function TimerQueue:callDelayed(timeout, callback, ...)
        timeout = math.max(timeout, 0.)
        local queue, timer = self.queue, self.timer
        local queue_timeout = timeout + self.elapsed + math.max(getElapsed(timer), 0.) --TimerGetElapsed() can return negative values sometimes, not sure why.
        self.n = self.n + 1
        local i = self.n
        queue[i] = {queue_timeout, callback, pack(...)}
        -- Sort timeouts in descending order
        while i > 1 and queue_timeout >= queue[i - 1][1] do
            queue[i], queue[i - 1] = queue[i - 1], queue[i]
            i = i - 1
        end
        if i == self.n then --New callback is the next to expire (i == self.n means that no sorting happened)
            -- Update timer timeout to the new callback timeout.
            self.elapsed = queue_timeout - timeout
            timerStart(timer, timeout, false, self.on_expire)
            if self.paused then
                self:pause()
            end
        end
    end

    ---Removes all queued calls from the Timer Queue, so any remaining actions will not be executed.
    ---Using <TimerQueue>:callDelayed afterwards will still work.
    function TimerQueue:reset()
        timerStart(self.timer, 0., false, nil)
        pauseTimer(self.timer)
        self.elapsed = 0.
        self.n = 0
        self.queue = {}
    end

    ---Pauses the TimerQueue at its current point in time, preventing all queued callbacks from being executed, until the queue is resumed.
    ---Using <TimerQueue>:callDelayed on a paused queue will correctly add the new callback to the queue, but time will start ticking only after the queue is being resumed.
    function TimerQueue:pause()
        local timer = self.timer
        local queue, elapsed = self.queue, self.elapsed + math.max(getElapsed(timer), 0.)
        self.elapsed = 0.
        for i = 1, self.n do
            queue[i][1] = queue[i][1] - elapsed
        end
        timerStart(timer, (self.n > 0 and queue[self.n][1]) or 0., false, self.on_expire) --lets TimerGetElapsed return the new elapsed value, when calling callDelayed, while the queue is paused.
        pauseTimer(self.timer)
        self.paused = true
    end

    ---Resumes a TimerQueue that was paused previously. Has no effect on running TimerQueues.
    function TimerQueue:resume()
        self.paused = false
        ResumeTimer(self.timer) --ResumeTimer has no effects on timers that are not paused.
    end

    ---Destroys the timer object behind the TimerQueue. The Lua object will be automatically garbage collected once you ensure that there is no more reference to it.
    function TimerQueue:destroy()
        pauseTimer(self.timer) --https://www.hiveworkshop.com/threads/issues-with-timer-functions.309433/ suggests that non-paused destroyed timers can still execute their callback
        DestroyTimer(self.timer)
    end

    ---@class Stopwatch
    Stopwatch = {
        timer = {}                                  ---@type timer the countdown-timer permanently cycling
        ,   elapsed = 0.                            ---@type number the number of times the timer reached 0 and restarted
        ,   increaseElapsed = function() end        ---@type function timer callback function to increase numCycles by 1 for a specific Stopwatch.
    }
    Stopwatch.__index = Stopwatch

    local CYCLE_LENGTH = 3600. --time in seconds that a timer needs for one cycle. doesn't numberly matter.

    ---Creates a Stopwatch.
    ---@param startImmediately_yn boolean Set to true to start immediately. If not specified or set to false, the Stopwatch will not start to count upwards.
    function Stopwatch.create(startImmediately_yn)
        local new = {}
        setmetatable(new, Stopwatch)
        new.timer = CreateTimer()
        new.elapsed = 0.
        new.increaseElapsed = function() new.elapsed = new.elapsed + CYCLE_LENGTH end
        if startImmediately_yn then
            new:start()
        end
        return new
    end

    ---Starts or restarts a Stopwatch, i.e. resets the elapsed time of the Stopwatch to zero and starts counting upwards.
    function Stopwatch:start()
        self.elapsed = 0.
        timerStart(self.timer, CYCLE_LENGTH, true, self.increaseElapsed)
    end

    ---Returns the time in seconds that a Stopwatch is currently running, i.e. the elapsed time since start.
    ---@return number
    function Stopwatch:getElapsed()
        return self.elapsed + getElapsed(self.timer)
    end

    ---Pauses a Stopwatch, so it will retain its current elapsed time, until resumed.
    function Stopwatch:pause()
        pauseTimer(self.timer)
    end

    ---Resumes a Stopwatch after having been paused.
    function Stopwatch:resume()
        self.elapsed = self.elapsed + getElapsed(self.timer)
        timerStart(self.timer, CYCLE_LENGTH, true, self.increaseElapsed) --not using ResumeTimer here, as it actually starts timer from new with the remaining time and thus screws up TimerGetElapsed().
    end

    ---Destroys the timer object behind the Stopwatch. The Lua object will be automatically garbage collected once you ensure that there is no more reference to it.
    function Stopwatch:destroy()
        DestroyTimer(self.timer)
    end
end
 
I found a bug regarding this feature
Good catch, thanks! Will fix as described by you, probably next weekend.
I should maybe take the opportunity and implement the linked list based queue at that point. Which would avoid array shifts on :callDelayed and further improve performance.
 
Update to version 1.1:
  • Switched from array-based to linked-list-based queue to improve performance of TimerQueue:callDelayed.
  • Switched from absolute timestamps to relative timestamps to drastically improve performance of TimerQueue:pause().
  • Integrated the newest version of Debug Utils for better error messages and local file references in particular.
  • Optimized script to prevent unnecessary table creation.
  • Added TimerQueue:printPeriodically(timeout, stopCondition, callback, ...), TimerQueue:isPaused() and TimerQueue:tostring().
  • Fixed a bug, where using :callDelayed on a paused empty TimerQueue would unintentionally resume the Queue. Thanks to @Insanity_AI for reporting this.
 
Last edited:
Level 5
Joined
Oct 29, 2024
Messages
27
Hey, in my lua map, I'm using TimerQueue and PreciseWait [Lua] - Precise Wait (GUI-friendly). I've changed the timer PreciseWait is using in the function it replaces TriggerSleepAction to a TimerQueue:callDelayed.
At exactly 1 hour mark, all my TriggerSleepAction stop working.

I saw that line in the code:
local CYCLE_LENGTH = 3600. --time in seconds that a timer needs for one cycle. doesn't really matter.

Could it be that the timer is restarting after exactly one hour (despite the comment saying "doesn't really matter")?
Can I increase this value to 36000 to have it work for 10 hours instead?

Thanks
 
Hey, in my lua map, I'm using TimerQueue and PreciseWait [Lua] - Precise Wait (GUI-friendly). I've changed the timer PreciseWait is using in the function it replaces TriggerSleepAction to a TimerQueue:callDelayed.
At exactly 1 hour mark, all my TriggerSleepAction stop working.

I saw that line in the code:
local CYCLE_LENGTH = 3600. --time in seconds that a timer needs for one cycle. doesn't really matter.

Could it be that the timer is restarting after exactly one hour (despite the comment saying "doesn't really matter")?
Can I increase this value to 36000 to have it work for 10 hours instead?

Thanks
That line is part of the stopwatch code and doesn't affect TimerQueue. Increasing it will not fix your bug.
The bug itself is interesting. I currently have no idea what could cause it. It could come from TimerQueue, from PreciseWait, from your implementation or from other processes in your map. Maybe you could investigate further?

Do you have many TriggerSleepAction in your code or would it be suitable to refactor your code to only use TimerQueue? This could help investigating on the issue.
Lua:
--If you currently have this code:
function someProcess()
    a()
    b()
    TriggerSleepAction(5.)
    c()
    d()
end

--Replace it by this:
function someProcess2()
    c()
    d()
end

function someProcess()
    a()
    b()
    TimerQueue:callDelayed(5., someProcess2)
end

The second (not using TriggerSleepAction) is what I do in my map and I'd recommend this style to you anyway. It is universally applicable and doesn't require code to run in a TriggerAction or coroutine to work (like PreciseWait does).
 
Last edited:
Level 5
Joined
Oct 29, 2024
Messages
27
Sadly there are thousands of TriggerSleepActions in the map.
Changing them all is impractical.

_____

Unrelated suggestion - I think that if you save the expiration time of timers instead of the diff between each timer and the previous one, your code will be much cleaner and easier to understand/maintain.
In this case you will need to save a single timer that will start ticking when the first item is added the queue (and can be destroyed if the queue is empty) and the "expiration time" will be the time expected for that timer when the current record in the queue should be played.
 
Last edited:
Sadly there are thousands of TriggerSleepActions in the map.
Changing them all is impractical.

If you don't use TimerQueue features apart from TimerQueue:callDelayed (including that you don't create TimerQueue instances), you can replace the library by this code for testing purposes:

Lua:
do
    TimerQueue = {}
    
    function TimerQueue:callDelayed(timeout, callback, ...)
        local t = CreateTimer()
        local args = table.pack(...)
        TimerStart(t, timeout, false, function() DestroyTimer(t); callback(table.unpack(args, 1, args.n)) end)
    end
end

If you still encounter the 1-hour-bug, the issue is somewhere else. Otherwise we still don't know ;)

Unrelated suggestion - I think that if you save the expiration time of timers instead of the diff between each timer and the previous one, your code will be much cleaner and easier to understand/maintain.
In this case you will need to save a single timer that will start ticking when the first item is added the queue (and can be destroyed if the queue is empty) and the "expiration time" will be the time expected for that timer when the current record in the queue should be played.

Doesn't TimerQueue already operate this way? It's using a single timer and the "time expected for that timer when the current record in the queue should be played" is exactly the timeout diff between current and next callback.

Destroying the timer if the queue is empty is not a good idea with regards to performance. Depending on the map implementation, empty queues can be a rather regular case, leading to frequent deletion and re-creation of the single timer necessary for the system. Better avoid that overhead and leave the existing timer in place. It doesn't do anything while idle anyway.
 
Level 5
Joined
Oct 29, 2024
Messages
27
If you don't use TimerQueue features apart from TimerQueue:callDelayed (including that you don't create TimerQueue instances), you can replace the library by this code for testing purposes:

I already started debugging the issue. I added a command to dump some debug data about the queue. After the issue happened, I used it twice and got these results
####TimerQueue Debug trace####
Time elapsed on timer: 0.0
Time elapsed on game: 3816.938
Timer count: 60
Timer paused: false
next id: 7335
next nextId: 7377
next timeout: 0.01
next enabled: true

####TimerQueue Debug trace####
Time elapsed on timer: 0.0
Time elapsed on game: 3821.188
Timer count: 61
Timer paused: false
next id: 7335
next nextId: 7378
next timeout: 0.01
next enabled: true

The first 4 lines in each part are global, and the last 4 are for the first entry in the queue (self.queue.next)

So it seems the first entry had very short time until expiration, the timer was enabled, and yet no function has started.

Your suggestion for the debug check sounds great. The problem is that the issue only happens after a full hour of play (from the debug data above, I guess not exactly an hour, and it depends on game state) and I failed reproducing it in single player, so takes me a long time to try.
I tried adding some fixup code to my debug command:
Code:
        if self.n > 0 then
            local time_left = self.queue.next.expiration_time - GetElapsedGameTime()
            if time_left <= 0 then
                self.on_expire()
            else
                self.is_ticking = true
                timerStart(self.timer, time_left, false, function()
                    self.on_expire()
                end)
                if self.pause_time ~= 0 then
                    self:pause()
                end
            end
        end
And I changed the code to be with global expiration time (can see how it looks now and what I meant - most changes are timeout->expiration_time)
Code:
if Debug and Debug.beginFile then Debug.beginFile("TimerQueue") end
--[[------------------------------------------------------------------------------------------------------------------------------------------------------------
*
*    --------------------------------
*    | TimerQueue and Stopwatch 1.3 |
*    --------------------------------
*
*    - by Eikonium
*
*    -> https://www.hiveworkshop.com/threads/timerqueue-stopwatch.353718/
*    - Credits to AGD, who's "ExecuteDelayed 1.0.4" code was used as the basis for TimerQueue. See https://www.hiveworkshop.com/threads/lua-delayedaction.321072/
*
* --------------------
* | TimerQueue class |
* --------------------
*        - A TimerQueue is an object used to execute delayed function calls. It can queue any number of function calls at the same time, while being based on a single timer. This offers much better performance than starting many separate timers.
*        - The class also provides methods to pause, resume, reset and destroy a TimerQueue - and even includes error handling.
*        - As such, you can create as many independent TimerQueues as you like, which you can individually use, pause, reset, etc.
*        - All methods can also be called on the class directly, which frees you from needing to create a TimerQueue object in the first place. You still need colon-notation!
*    TimerQueue.create() --> TimerQueue
*        - Creates a new TimerQueue with its own independent timer and function queue.
*    <TimerQueue>:callDelayed(number delay, function callback, ...) --> integer (callbackId)
*        - Calls the specified function (or callable table) after the specified delay (in seconds) with the specified arguments (...). Does not delay the following lines of codes.
*        - The returned integer can usually be discarded. Saving it to a local var allows you to TimerQueue:disableCallback(callbackId) or TimerQueue:enableCallback(callbackId) later. The callbackId is unique per callback and never reused.
*    <TimerQueue>:callPeriodically(number delay, function|nil stopCondition, function callback, ...)
*        - Periodically calls the specified function (or callable table) after the specified delay (in seconds) with the specified arguments (...). Stops, when the specified condition resolves to true.
*        - The stop-condition must be a function returning a boolean. It is checked after each callback execution and is passed the same arguments as the callback (...) (which you can still ignore).
*        - You can pass nil instead of a function to let the periodic execution repeat forever.
*        - Resetting the TimerQueue will stop all periodic executions, even if the reset happened within the periodic callback.
*        - Doesn't return a callbackId, so disabling a periodic callback is only possible via either meeting the stop-condition or resetting the queue.
*    <TimerQueue>:reset()
*        - Discards all queued function calls from the Timer Queue. Discarded function calls are not executed.
*        - You can continue to use <TimerQueue>:callDelayed after resetting it.
*    <TimerQueue>:pause()
*        - Pauses the TimerQueue at its current point in time, effectively freezing all delayed function calls that it currently holds, until the queue is resumed.
*        - Using <TimerQueue>:callDelayed on a paused queue will correctly add the new callback to the queue, but time will start ticking only after resuming the queue.
*    <TimerQueue>:isPaused() --> boolean
*        - Returns true, if the TimerQueue is paused, and false otherwise.
*    <TimerQueue>:resume()
*        - Resumes a TimerQueue that was previously paused. Has no effect on TimerQueues that are not paused.
*    <TimerQueue>:destroy()
*        - Destroys the Timer Queue. Remaining function calls are discarded and not being executed.
*    <TimerQueue>:tostring() --> string
*        - Represents a TimerQueue as a list of its tasks. For debugging purposes.
*    <TimerQueue>:disableCallback(callbackId)
*        - Disables the specified callback (as per Id returned by TimerQueue:callDelayed), making it not execute upon timeout.
*        - The disabled callback will stay in the queue until timeout, allowing you to TimerQueue:enableCallback it, if you changed your mind.
*        - Use this to cancel a future callback, when resetting the whole queue is not a suitable solution.
*        - CallbackId's are unique and never reused. Using one within TimerQueue:disableCallback after original timeout will not have any effect, but you don't need to worry about accidently disabling another callback.
*    <TimerQueue>:enableCallback(callbackId)
*        - Enables the specified callback (as per Id returned by TimerQueue:callDelayed) after you have previously disabled it, making it again execute upon timeout.
*        - Enabling a callback after its timeout has already passed while disabled will not have any effect.
*    <TimerQueue>.debugMode : boolean
*        - TimerQueues come with their own error handling in case you are not using DebugUtils (https://www.hiveworkshop.com/threads/debug-utils-ingame-console-etc.330758/).
*        - Set to true to let erroneous function calls through <TimerQueue>:callDelayed print error messages on screen (only takes effect, if Debug Utils is not present. Otherwise you get Debug Utils error handling, which is even better).
*        - Set to false to not trigger error messages after erroneous callbacks. Do this before map release.
*        - Default: false (because I assume you also use DebugUtils, which provides its own error handling).
*    local MAX_STACK_SIZE : integer
*        - TimerQueue uses table recycling to unburden the garbage collector.
*        - This constant defines the maximum number of tables that can wait for reusage at the same time. Tables freed while this limit is reached will be garbage collected as normal.
*        - Can be set to 0 to disable table recycling.
*        - Default: 128. Should be fine in most scenarios. Increase, if you expect to have a lot of callbacks in the queue.
* -------------------
* | Stopwatch class |
* -------------------
*        - Stopwatches count upwards, i.e. they measure the time passed since you've started them. Thus, they can't trigger any callbacks (use normal timers or TimerQueues for that).
*    Stopwatch.create(boolean startImmediately_yn) --> Stopwatch
*        - Creates a Stopwatch. Set boolean param to true to start it immediately.
*    <Stopwatch>:start()
*        - Starts or restarts a Stopwatch, i.e. resets the elapsed time of the Stopwatch to zero and starts counting upwards.
*    <Stopwatch>:getElapsed() --> number
*        - Returns the time in seconds that a Stopwatch is currently running, i.e. the elapsed time since start.
*    <Stopwatch>:pause()
*        - Pauses a Stopwatch, so it will retain its current elapsed time, until resumed.
*    <Stopwatch>:resume()
*        - Resumes a Stopwatch after having been paused.
*    <Stopwatch>:destroy()
*        - Destroys a Stopwatch. Maybe necessary to prevent memory leaks. Not sure, if lua garbage collection also collects warcraft objects...
---------------------------------------------------------------------------------------------------------------------------------------------------------]]
do
    ---@type real | nil
    CurSpeedupMult = nil       -- multiplier for timer speed
    --Help data structures for recycling tables for tasks (TimerQueueElements) in TimerQueues.
    local recycleStack = {}    --Used tables are stored here to prevent garbage collection, up to MAX_STACK_SIZE
    local stackSize = 0        --Current number of tables stored in recycleStack
    local MAX_STACK_SIZE = 128 --Max number of tables that can be stored in recycleStack. Set this to a value > 0 to activate table recycling.
    ---@class TimerQueueElement
    ---@field [integer] any arguments to be passed to callback
    TimerQueueElement = {
        next = nil ---@type TimerQueueElement next TimerQueueElement to expire after this one
        ,
        expiration_time = 0. ---@type number the elapsed time expected to be in the global game timer when this timer expires
        ,
        callback = function() end ---@type function callback to be executed
        ,
        n = 0 ---@type integer number of arguments passed
        ,
        enabled = true ---@type boolean defines whether the callback shall be executed on timeout or not.
        ,
        id = 0 ---@type integer unique id of this TimerQueueElement
        --static
        ,
        nextId = 0 ---@type integer ever increasing counter that shows the unique id of the next TimerQueueElement being created.
        ,
        storage = setmetatable({}, { __mode = 'v' }) ---@type table<integer, TimerQueueElement> saves all TimerQueueElements by their unique id. Weak values to not interfere with garbage collection.
    }
    TimerQueueElement.__index = TimerQueueElement
    TimerQueueElement.__name = 'TimerQueueElement'
    local fillTable
    ---Recursive help function that fills a table with the specified arguments from index to maxIndex.
    ---@param whichTable table table to be filled
    ---@param index integer current index to be filled with firstParam
    ---@param maxIndex integer maximum index up to which to continue recursively
    ---@param firstParam any first param is mentioned explicitly to simplify the recursive call below
    ---@param ... any second and subsequent params
    fillTable = function(whichTable, index, maxIndex, firstParam, ...)
        whichTable[index] = firstParam
        if index < maxIndex then
            fillTable(whichTable, index + 1, maxIndex, ...)
        end
    end
    ---Creates a new TimerQueueElement, which points to itself.
    ---@param expiration_time? number
    ---@param callback? function
    ---@param ... any arguments for callback
    ---@return TimerQueueElement
    function TimerQueueElement.create(expiration_time, callback, ...)
        local new
        if stackSize == 0 then
            new = setmetatable(
                {
                    expiration_time = expiration_time,
                    callback = callback,
                    id = TimerQueueElement.nextId,
                    n =
                        select('#', ...),
                    ...
                },
                TimerQueueElement)
        else
            new = setmetatable(recycleStack[stackSize], TimerQueueElement)
            recycleStack[stackSize] = nil
            stackSize = stackSize - 1
            new.expiration_time, new.callback, new.id, new.n = expiration_time, callback,
                TimerQueueElement.nextId, select('#', ...)
            fillTable(new, 1, new.n, ...) --recursive fillTable is around 20 percent faster than a for-loop based on new[i] = select(i, ...)
        end
        new.next = new
        TimerQueueElement.nextId = TimerQueueElement.nextId + 1
        TimerQueueElement.storage[new.id] = new
        return new
    end
    ---Empties a TimerQueueElement and puts it to the recycleStack.
    ---@param timerQueueElement TimerQueueElement
    local function recycleTimerQueueElement(timerQueueElement)
        --remove TimerQueueElement from storage and remove metatable
        TimerQueueElement.storage[timerQueueElement.id] = nil
        setmetatable(timerQueueElement, nil)
        --If table recycling is activated and there is space on the recycleStack, push the TimerQueueElement back onto it.
        if stackSize < MAX_STACK_SIZE then
            --empty table before putting it back
            for i = 1, timerQueueElement.n do
                timerQueueElement[i] = nil
            end
            timerQueueElement.next, timerQueueElement.callback, timerQueueElement.n, timerQueueElement.expiration_time, timerQueueElement.enabled, timerQueueElement.id =
                nil, nil, nil, nil, nil, nil
            --push on stack
            stackSize = stackSize + 1
            recycleStack[stackSize] = timerQueueElement
        end
        --Else: Do nothing. TimerQueueElement will automatically be garbage collected.
    end
    ---@class TimerQueue
    TimerQueue = {
        timer = nil ---@type timer the single timer this system is based on (one per instance of course)
        ,
        is_ticking = false ---@type boolean
        ,
        queue = TimerQueueElement.create() -- queue of waiting callbacks to be executed in the future
        ,
        n = 0 ---@type integer number of elements in the queue
        ,
        on_expire = function() end ---@type function callback to be executed upon timer expiration (defined further below).
        ,
        debugMode = false ---@type boolean setting this to true will print error messages, when the input function couldn't be executed properly. Set this to false before releasing your map.
        ,
        pause_time = 0 ---@type real the time when timer was paused, or 0 for non paused timer
    }
    TimerQueue.__index = TimerQueue
    TimerQueue.__name = 'TimerQueue'
    --Creates a timer on first access of the static TimerQueue:callDelayed method. Avoids timer creation inside the Lua root.
    setmetatable(TimerQueue,
        {
            __index = function(t, k)
                if k == 'timer' then t[k] = CreateTimer() end; return rawget(t, k)
            end
        })
    local unpack, max, timerStart, timerGetElapsed, pauseTimer, try = table.unpack, math.max, TimerStart, TimerGetElapsed,
        PauseTimer, Debug and Debug.try
    ---@param timerQueue TimerQueue
    local function on_expire(timerQueue)
        while timerQueue.n > 0 do
            local topOfQueue = timerQueue.queue.next
            -- LogWrite("before removed element. " .. TimerQueue:tostring())
            if topOfQueue.expiration_time > GetElapsedGameTime() then break end
            -- pop the next element
            timerQueue.queue.next = topOfQueue.next
            timerQueue.n = timerQueue.n - 1
            -- LogWrite("after removed element. " .. TimerQueue:tostring())
            if topOfQueue.enabled then
                if try then
                    try(topOfQueue.callback, unpack(topOfQueue, 1, topOfQueue.n))
                else
                    local errorStatus, errorMessage = pcall(topOfQueue.callback, unpack(topOfQueue, 1, topOfQueue.n))
                    if not errorStatus then
                        LogWrite("ERROR during TimerQueue callback: " .. errorMessage .. "|r")
                    end
                end
            end
            recycleTimerQueueElement(topOfQueue)
        end
        local timer = timerQueue.timer
        if timerQueue.n > 0 then
            timerQueue.is_ticking = true
            pauseTimer(timer)
            timerStart(timer, math.max(timerQueue.queue.next.expiration_time - GetElapsedGameTime(), 0), false,
                function()
                    on_expire(timerQueue)
                end)
        else
            -- These two functions below may not be necessary
            timerQueue.is_ticking = false
            timerStart(timer, 0, false, nil) --don't put in on_expire as handlerFunc, because it can still expire and reduce n to a value < 0.
            pauseTimer(timer)
        end
    end
    TimerQueue.on_expire = function() on_expire(TimerQueue) end
    ---@return TimerQueue
    function TimerQueue.create()
        local new = {}
        setmetatable(new, TimerQueue)
        new.timer = CreateTimer()
        new.queue = TimerQueueElement.create()
        new.on_expire = function()
            on_expire(new)
        end
        return new
    end
    function TimerQueue:Debug()
        LogWriteNoFlush("####TimerQueue Debug trace####")
        LogWriteNoFlush("timer: " .. tostring(self.timer))
        LogWriteNoFlush("Time elapsed on timer: " .. tostring(timerGetElapsed(self.timer)))
        LogWriteNoFlush("Time elapsed on game: " .. tostring(GetElapsedGameTime()))
        LogWriteNoFlush("Timer count: " .. self.n)
        LogWriteNoFlush("is first.next == first: " .. tostring(self.queue.next == self.queue))
        LogWriteNoFlush("is_ticking: " .. tostring(self.is_ticking))
        LogWriteNoFlush("Timer pause_time: " .. tostring(self.pause_time))
        LogWriteNoFlush("next id: " .. self.queue.next.id)
        LogWriteNoFlush("next nextId: " .. self.queue.next.nextId)
        LogWriteNoFlush("next expiration_time: " .. self.queue.next.expiration_time)
        LogWriteNoFlush("next enabled: " .. tostring(self.queue.next.enabled))
        LogWrite(TimerQueue:tostring())
        if self.n > 0 then
            local time_left = self.queue.next.expiration_time - GetElapsedGameTime()
            if time_left <= 0 then
                self.on_expire()
            else
                self.is_ticking = true
                timerStart(self.timer, time_left, false, function()
                    self.on_expire()
                end)
                if self.pause_time ~= 0 then
                    self:pause()
                end
            end
        end
    end
    ---Calls a function (or callable table) after the specified timeout (in seconds) with all specified arguments (...). Does not delay the following lines of codes.
    ---@param timeout number
    ---@param callback function|table if table, must be callable
    ---@param ... any arguments of the callback function
    ---@return integer callbackId usually discarded. Can be saved to local var to use in :disableCallback() or :enableCallback() later.
    function TimerQueue:callDelayed(timeout, callback, ...)
        timeout = math.max(timeout, 0.)
        if CurSpeedupMult ~= nil and CurSpeedupMult > 0 then
            timeout = timeout / CurSpeedupMult
        end
        local queue = self.queue
        -- LogWrite("before add element. " .. TimerQueue:tostring())
        self.n = self.n + 1
        local expiration_time = timeout + GetElapsedGameTime()
        local new = TimerQueueElement.create(expiration_time, callback, ...)
        local next_elem = queue.next
        local prev_elem = queue
        while next_elem ~= queue and expiration_time >= next_elem.expiration_time do
            prev_elem = next_elem
            next_elem = next_elem.next
        end
        -- prev_elem now points to the last element with smaller expiration time than our timer
        -- next_elem now points to the first element with larger expiration time than our timer
        new.next = next_elem
        prev_elem.next = new
        -- LogWrite("after add element. " .. TimerQueue:tostring())
        -- if the new callback is the next to expire, restart timer with new timeout
        if prev_elem == queue then --New callback is the next to expire
            self.is_ticking = true
            pauseTimer(self.timer)
            timerStart(self.timer, timeout, false, function()
                self.on_expire()
            end)
            if self.pause_time ~= 0 then
                self:pause()
            end
        end
        return new.id
    end
    ---Calls the specified callback with the specified argumets (...) every <timeout> seconds, until the specified stop-condition holds.
    ---The stop-condition must be a function returning a boolean. It is checked after every callback execution. All arguments (...) are also passed to the stop-conditon (you can still ignore them).
    ---Resetting the TimerQueue will stop all periodic executions, even if the reset happened within the periodic callback.
    ---Doesn't return a TimerQueue-Element, so disabling is only possible by either meeting the stop-condition or resetting the queue.
    ---@param timeout number time between calls
    ---@param stopCondition? fun(...):boolean callback will stop to repeat, when this condition holds. You can pass nil to skip the condition (i.e. the periodic execution will run forever).
    ---@param callback fun(...) the callback to be executed
    ---@param ... any arguments for the callback
    function TimerQueue:callPeriodically(timeout, stopCondition, callback, ...)
        local func
        func = function(...)
            local queue = self
                .queue    --memorize queue element to check later, whether TimerQueue:reset has been called in the meantime.
            callback(...) --execute callback first
            --re-queue, if stopCondition doesn't hold and the TimerQueue has not been reset during the callback (checked via queue == self.queue)
            if queue == self.queue and not (stopCondition and stopCondition(...)) then
                self:callDelayed(timeout, func, ...)
            end
        end
        self:callDelayed(timeout, func, ...)
    end
    ---Recycles all elements of a given TimerQueue, up to the limit given by MAX_STACK_SIZE
    ---@param timerQueue TimerQueue
    local function recycleQueueElements(timerQueue)
        --Recycle all TimerQueueElements in the queue, but not the root element (which is used for call-by-reference checks in :callPeriodically)
        local current, next = timerQueue.queue.next, nil
        while current ~= timerQueue.queue and stackSize < MAX_STACK_SIZE do --stop recycling early, if MAX_STACK_SIZE is reached. Remaining TimerQueueElements will be garbage collected. Weak values in TimerQueueElement.storage makes sure no reference is left.
            next = current
                .next                                                       --need to save current.next here, as it gets nilled during recycleTimerQueueElement
            recycleTimerQueueElement(current)
            current = next
        end
        TimerQueueElement.storage[timerQueue.queue.id] = nil
    end
    ---Removes all queued calls from the Timer Queue, so any remaining actions will not be executed.
    ---Using <TimerQueue>:callDelayed afterwards will still work.
    function TimerQueue:reset()
        recycleQueueElements(self)
        --Reset timer and create new queue to replace the old
        self.is_ticking = false
        timerStart(self.timer, 0., false, nil) --don't put in on_expire as handlerFunc. callback can still expire after pausing and resuming the empty queue, which would set n to a value < 0.
        pauseTimer(self.timer)
        self.n = 0
        self.queue = TimerQueueElement.create()
    end
    ---Pauses the TimerQueue at its current point in time, preventing all queued callbacks from being executed, until the queue is resumed.
    ---Using <TimerQueue>:callDelayed on a paused queue will correctly add the new callback to the queue, but time will start ticking only after the queue is being resumed.
    function TimerQueue:pause()
        self.is_ticking = false
        self.pause_time = GetElapsedGameTime()
        pauseTimer(self.timer)
    end
    ---Returns true, if the timer queue is paused, and false otherwise.
    ---@return boolean
    function TimerQueue:isPaused()
        return self.pause_time ~= 0
    end
    ---Resumes a TimerQueue that was paused previously. Has no effect on running TimerQueues.
    function TimerQueue:resume()
        if self.pause_time ~= 0 then
            local time_diff = GetElapsedGameTime() - self.pause_time
            self.pause_time = 0
            -- add time_diff to all timers expiration time
            cur = self.queue
            while cur.next ~= self.queue do
                cur = cur.next
                cur.expiration_time = cur.expiration_time + time_diff
            end
            ResumeTimer(self.timer)
        end
    end
    ---Destroys the timer object behind the TimerQueue. The Lua object will be automatically garbage collected once you ensure that there is no more reference to it.
    function TimerQueue:destroy()
        pauseTimer(self.timer) --https://www.hiveworkshop.com/threads/issues-with-timer-functions.309433/ suggests that non-paused destroyed timers can still execute their callback
        DestroyTimer(self.timer)
        recycleQueueElements(self)
        self.queue = nil
        setmetatable(self, nil) --prevents consequences on the TimerQueue class, if further methods (like :destroy again) are used on the destroyed TimerQueue.
    end
    ---Returns a list of queued callbacks within the TimerQueue. For debugging purposes.
    ---@return string
    function TimerQueue:tostring()
        local current, result, i = self.queue.next, {}, 0
        local args = {}
        while current ~= self.queue do
            i = i + 1
            for j = 1, current.n do
                args[j] = tostring(current[j])
            end
            result[i] = '(i=' ..
                i ..
                ',expiration_time=' ..
                current.expiration_time ..
                ',enabled = ' ..
                tostring(current.enabled) ..
                ',f=' .. tostring(current.callback) .. ',args={' .. table.concat(args, ',', 1, current.n) .. '})'
            current = current.next
        end
        return '{n = ' .. self.n .. ',queue=(' .. table.concat(result, ',', 1, i) .. ')}'
    end
    ---Disables a callback that is currently queued in the TimerQueue.
    ---The callback will still sit in the queue until timeout, but resolve without any effect.
    ---This method is similiar to resetting the whole TimerQueue, just limited to a single callback.
    ---@param callbackId integer the callbackId returned by TimerQueue:callDelayed
    function TimerQueue:disableCallback(callbackId)
        if TimerQueueElement.storage[callbackId] then
            TimerQueueElement.storage[callbackId].enabled = false
        end
    end
    ---Re-enables a callback that was previously disabled by TimerQueue:disableCallback.
    ---@param callbackId integer the callbackId returned by TimerQueue:callDelayed
    function TimerQueue:enableCallback(callbackId)
        if TimerQueueElement.storage[callbackId] then
            TimerQueueElement.storage[callbackId].enabled = true
        end
    end
end
if Debug and Debug.endFile then Debug.endFile() end
If this fixes the issue (which I assume it won't as I didn't really change anything) or if the debug fixup works, maybe we can understand more.
Otherwise I will try your check next time.

Thanks :)
 
I already started debugging the issue. I added a command to dump some debug data about the queue. After the issue happened, I used it twice and got these results
It's interesting that next id was identical in both cases. Could it be that the last callback running before the stop was also identical? Maybe you could record that one in your log, too? Just to check, if some erroneous function call is causing the issue. Debug Utils should generally throw an error message in that case, but there are rare instances where it can't (because even pcall and xpcall can break if you know how to).

Looking forward to the outcome :)
 
Top