• 🏆 Texturing Contest #33 is OPEN! Contestants must re-texture a SD unit model found in-game (Warcraft 3 Classic), recreating the unit into a peaceful NPC version. 🔗Click here to enter!
  • It's time for the first HD Modeling Contest of 2024. Join the theme discussion for Hive's HD Modeling Contest #6! Click here to post your idea!

Using game events as a clock source instead of timers?

Status
Not open for further replies.
Level 19
Joined
Jan 3, 2022
Messages
320

The essence of this thread, an abstract: April 2022​

  1. The game runs at 50 ticks per second when at 100% speed.
  2. Timers are correctly affected by set game speed. (TriggerSleepAction aka Wait is not)
  3. Timers are calculated as part of the game simulation loop and the game speed correctly affects any past and current timer
  4. Unit attack counter vs 20ms timer counter: they diverged! I don't know if this means that the timer runs more often (more than once per game tick every now and then) -or- that the unit skips attacks (<1 per tick)
    1. If timers at 20ms don't provide a perfect "once per game turn" clock, then this discussion is valid
    2. If it's impossible to make the unit attack "once per game turn" or find a similar event source for a clock, then this discussion ends and timers win
  5. It is practically useless to make timers <20ms (0.020) or any odd value that isn't a multiple of 20ms (40, 60, 80ms...)
    1. Testing required: at which point in game turn are timers executed? How are they executed if <20ms or odd values?


It strikes me as weird that everywhere in the game API, GUI and code, the only option to handle time is by specifying seconds. Timers are based on seconds, TriggerSleepAction in seconds, PolledWait wants seconds too. I've tried to understand how they work and documented them so far:

Timers: apparently they're as precise as the OS scheduler allows them to be. They're correctly affected by game time
TriggerSleepAction: at best 100ms accuracy, NOT affected by game time (was it changed in Reforged?)
PolledWait: although based on Timers, it keeps waiting until the timer expires using TriggerSleepAction. Thanks to multiple sleeps it achieves worse accuracy and precision than TriggerSleepAction

I arrived here because I'm trying to figure out the root cause of desyncs of a map that I transpiled from Jass to Lua, 1:1. Here's a quote from game's Desync.txt log (%userprofile%\documents)
<Exception.Summary:>
Network desync on turn 00706
<:Exception.Summary>
Here from <COMPUTER_NAME>_mmDDYY_HHMMSS_Desync.log:
[Desync - 1918987876 - Turn(00000706) = 1347594696]
The game obviously has turns. Each new simulation step is a new turn. So why aren't we tracking time and delays in terms of game's ticks? Is it possible to find out when a new turn has started and emit events, finally using these events as a clock source? Like in the real world, you can have different sources of time: your wall clock or the incredibly precise atomic clocks.

What if the desyncs in Lua are caused by timers' imprecisions? Is it plausible or do timers work differently? (I will open a thread about the map's desyncs elsewhere. to be updated with a link)

In any case, if we avoid "wall clock-based" timers and timeouts then our only and coalesced source of time will be the perfectly synchronized and deterministic game simulation itself. What if we start two 1s timers as it is. Will they always tick one after another, in the order they were started? Idk. Two timers, 2s and 1s? Idk, to be tested. Though by taking a different approach we can avoid "seconds" and this question altogether.

Event source for Game ticks: Unit Attacks​

This seemed like a perfect candidate. We make a unit attack ultra-fast, hopefully it attacks every single game tick. With "Unit Attacked" event we then achieve an event that fires every game tick. My research:
(2008): 45-46 attacks/s, by @Dagguh
(2017): WC3 runs at an internal frame rate of 1 / 0.03 (~33.3...) frames per game second, by @Dr Super Good
(2017): 45 attacks/s, also Dr Super Good
(2021): Also 45 attacks/s

It seems unlikely that the units would be able to attack more than once per game tick? Therefore I think the game runs at 45 TPS at 1.0x speed.
To achieve this attack speed you need:
  • Combat - Attack # - Animation Backswing Point = 0.0
  • Combat - Attack # - Animation Damage Point = 0.0
  • Combat - Attack # - Cooldown Time = 0.0
  • (The test hero also had an astronomical agility value, TBD)
Together with os.clock()/os.date() in Lua it was very easy to carry out tests and record attacks per second values. It's also useful to determine what the game speed settings actually do:
Menu NameGame constantAttacks/sSpeed10 seconds is:
High (default)MAP_SPEED_NORMAL45-461.0x10s
MediumMAP_SPEED_SLOW36-370.8x12.5s
SlowMAP_SPEED_SLOWEST27-280.6x16.667s
It's normal for these values to oscillate a little bit. Judging by the numbers, 45 ticks per second is indeed the maximum value, not 60. Btw higher speeds aren't allowed by the game.

Thoughts?​

This brings me back to the timer discussion: why use timers and seconds when it's possible to connect with game's simulation ticks and rely on them for all calculations and time tracking? Wouldn't it be more accurate and precise, with guaranteed determinism? I haven't yet tested if regular timers and TriggerActionSleep provide all these guarantees and I want to hear your thoughts. Instead of creating/starting new timers, you would add your Action to an event queue, delayed by X gameticks in time.

Test map:​

Worker training: records wall clock time spent on training the human worker. Set to 10s in WE
OnAttack: Hero and Arrow tower are tracked and written to multiboard. First value is attackCount, second value is attacks/second

Change game speed: -gs <number>
Currently only 0, 1, 2 exist in the game

TriggerSleepAction benchmark: -sleep <seconds>
PolledWait benchmark: -polledwait <seconds>
Wait based on a single Timer (unlike PolledWait): -timerwait <seconds>

PS: Special thanks to @Eikonium for the In-game Lua console. It makes testing and writing documentation much easier.
 

Attachments

  • Attack and Gamespeed v1.0.w3x
    22.5 KB · Views: 15
  • Attack and Gamespeed v1.1.w3x
    24.3 KB · Views: 12
Last edited:

Dr Super Good

Spell Reviewer
Level 63
Joined
Jan 18, 2005
Messages
27,195
You can use triggers to set unit attack speed faster. With that approach (not available at the time I made that post) you can hit 50 attacks per second.

Various tests show that various aspects of WC3 update at different rates. For example unit rotation updates every 0.03 seconds, while attack cooldown seem to update every 0.02 seconds.

I suspect the timers are ordered within some update period so that from perspective of the timers they are completely accurate but from the game perspective they are running within a single update, possibly multiple times. Since no actual time is being used and instead only simulated time is, they should be completely deterministic.
 
@Flux haven't you played around much with making attack cooldowns smaller? Like here: Sleight of Fist v1.22

1646044246338.png
 
Level 19
Joined
Jan 3, 2022
Messages
320
First things first. Uncertainty means we need data!
Warcraft 3 timers precision and accuracy test - data table

I created all the timers that my map uses and added 22ms timer, because that's the attack rate. Doesn't match it perfectly, I didn't expect much either.

Now onto the worst: the timers 100ms, 50ms, 10ms, 1ms are perfect multiples of each other. In an ideal world, their ticks would be the multiples of each other too, but they are not. Faster timers end up accumulating values faster than their slower counterparts. I left the game running for 2000s, didn't touch it, fullscreen.
The 10ms timer is ~13% behind 1ms; 100ms is 0.1% behind 50ms. The hero also gets very slowly ahead of the 22ms timer.

I don't know how to interpret this result. Dr. you're right that they can run multiple times, but is the difference simply the accumulated rounding error where the game keeps track of a timer's delta t to run it multiple times per frame when needed?

If the game runs at 50 ticks per second, then "SLOWER" is 40, "SLOWEST" is 30. That makes sense unlike the odd number of 45 attacks per second I got.
@IcemanBo thanks, I will check out this ability hack later.

New map version added to first post.
 
Level 20
Joined
Jul 10, 2009
Messages
477
Could you test, what results you get with timer intervals that have an exact representation in the binary float format?
For instance, a 1./32. timer instead of a 0.03 timer.
Just to check, how much of the discrepancy comes from that. Should be a tiny amount, but I don't know how much adds up over time, especially because addition is not an exact operation on floats anyway (at least, when the mantissa already has full length).
 
Last edited:
Level 19
Joined
Jan 3, 2022
Messages
320
Spot on:

Timer
Ticks
Expected (multiplied by 2):
Diff (formula,division):
1s
3125
3125
1
500ms
6251
6250
0,999840025595905
250ms
12502
12500
0,999840025595905
125ms
25004
25000
0,999840025595905
62,5ms
50009
50000
0,999820032394169
31,25ms
100018
100000
0,999820032394169
15,625ms
200036
200000
0,999820032394169
7,8125ms
400072
400000
0,999820032394169
3,90625ms
800144
800000
0,999820032394169
1,953125ms
1600288
1600000
0,999820032394169
0,9765625ms
3200576
3200000
0,999820032394169

PS: The old custom trigger code of "timersBenchmark" was replaced with this to generate these new timers
Lua:
-- line 60
    local timeouts = {
        --60, 10, 5, 2, 1, .250, .100, .050, .030, .022, .010, .001
        1, 1/(2^1), 1/(2^2), 1/(2^3), 1/(2^4), 1/(2^5), 1/(2^6), 1/(2^7), 1/(2^8), 1/(2^9), 1/(2^10),
    }
 

Attachments

  • wc3-timersv12.png
    wc3-timersv12.png
    61.3 KB · Views: 22
Level 20
Joined
Jul 10, 2009
Messages
477
Interesting.
Backwards calculation for the expected amount, taking the 1/(2^10) one as granted, yields the following numbers:

TimerTicksExpected (divided by 2):Diff (formula,division):
1s31253125,56251,00018
500ms62516251,1251,0000199968005119
250ms1250212502,251,0000199968005119
125ms2500425004,51,0000199968005119
62,5ms50009500091
31,25ms1000181000181
15,625ms2000362000361
7,8125ms4000724000721
3,90625ms8001448001441
1,953125ms160028816002881
0,9765625ms320057632005761

Which actually looks quite good.
The longer interval timers can't hit their expected amounts, because we are measuring integers.
But numbers suggest that the actual ticks would match the expected amount for the long interval timers, if you ran the test for 16 times as long :p
 
Level 19
Joined
Jan 3, 2022
Messages
320
Hello I'm back and reached to goal of one attack per turn.
First of all, Sleight of Fist's comments are confusing. These two abilities simply transform the Pandaren hero from the normal form (regular attack speed) to jumping form (maximum attack speed) and back. I didn't try it because it seemed pointless - but maybe the hero transformation really resets attack cooldown.

There's this old useful API function called BlzSetUnitAttackCooldown(unit, real cooldown, weaponIndex). Guess what? Setting cd = 0.0 stops unit from attacking entirely (haha, guessed wrong!)
  • BlzGetUnitAttackCooldown with all relevant speed stats maxed returns: 0.1000000014901161 and achieves 45-46 atk/s as above
  • If you set cd to a very low number, e.g. BlzSetUnitAttackCooldown(unit, 1/(2^10), 0): 0.0009765625 and achieves 50 atk/s!
Let's compare game speed multipliers:
Ticks per second (wall time)
At 0.8x
At 0.6x
(hypothetical) 60 (16.67ms)
48 (20.83ms)
36 (27.78ms)
(real) 50 (20ms)
40 (25ms)
30 (33.33ms)
(hypothetical) 45 (22.22ms)
36 (27.78ms)
27 (37.037ms)
Makes sense now, perfectly even numbers. 20ms time per game turn.

Finally the attack cooldown is indeed more like a speed crank than a raw, time-based cooldown. This had been said in some other explanation thread. Someone else may continue where I leave off, but a cooldown of 1.0 gave me 5 attacks/s (50/(1.0*10?)):
Lua:
BlzSetUnitAttackCooldown(unit, 1/(2^10), 0)
-- cooldown = -1 -> 0 a/s. see below
-- cooldown = 0 -> 0 a/s. he sometimes made an animation as if he was going to attack
-- cooldown = 1 -> 5 a/s
-- cooldown = 0.01 -> 50 a/s
-- cooldown = 0.001 -> 50 a/s
-- cooldown = 1/(2^10) -> 50 a/s
Does it mean running a timer at 20ms guarantees one tick per turn? In theory it should if everything is done correctly. I left the map running for 1023s:
Trigger count:At startEnd
Unit attacks27251116
Timer 20ms30151263
delta29147
You can see the difference kept increasing. Either A: the unit was skipping attacks or B: the timer had been accumulating rounding errors. 0.020 cannot be exactly represented in base-2, so if anything touches the mantissa beyond +-0.020... At the same time SetCd=1 shows erratic animation movement: I'm not 100% sure about the unit/attack mechanics either (maybe changing RNG state?)

A-a-anyway, I'm going to stick to 20ms timers from now on and derive multiples of that by using a single timer (to avoid different, unsynchronized rounding errors).
PS: The map leaks. Does GC not keep up anymore? The multiboard is updated on each timer expire
 

Attachments

  • Attack and Gamespeed v1.3.w3x
    25 KB · Views: 11
Last edited:
Level 19
Joined
Jan 3, 2022
Messages
320
I don't remember if I've seen Handling Timer Imprecision by @_Guhun_ before, but he talked about the same thing. Namely timers that are not a power of 2 are imprecise.
But after looking at timer numbers carefully, even the power-of-2 timers are imprecise. They do not accumulate rounding errors (good!) but they don't seem to hit every game tick. This means you'll either overshoot or undershoot the 20ms game tick frequency: 20ms<(21*0,9765625ms) or 20ms>(20*0,9765625ms)

In my recent work High speed triggers or a slow TriggerRegisterTimerEventPeriodic() I had to find out why Triggers were only limited to 0.01s. Luckily, the test map here doesn't have this problem else I'd have never noticed!
But the result is great, there's a reliable game source. The in-game time. It ticks at 200 times per second (each 5ms or 4x times per game tick). That's how often changes in time=GetTimeOfDay() are detected by zero-timers.

I hope this is good enough to track the game turns that show up in desync logs (look at the top of the thread, it's been 5 months :goblin_jawdrop:)
[Desync - 1918987876 - Turn(00000706) = 1347594696]
It raises an interesting question too: if it ticks 4 times per tick, this means internally timers are evaluated 4 times per tick too? So if the fastest 0.0s timer is called 10000 timers per second, it's actually 4x2500 times. Don't forget the rounding errors. I wonder what happens in between the four updates?
 

Bribe

Code Moderator
Level 50
Joined
Sep 26, 2009
Messages
9,464
I'm quite interested in this line of thought, as I'm trying to establish how I should handle my attack-launch timers (given that I may be able to round +/- to get to the nearest game tick, and subsequently potentially optimize by minimizing the amount of running timers to "merge" launches that are expected to hit at the same time.

So what this is saying is that there is basically going to be a series of split frames of 1/45 seconds, and nothing attack-related will happen in between those frames.

One way to put this to the test is to have 5 or 6 footmen be ordered to attack via triggers (each new order separated by 0.001 seconds) and see if the damage event runs separated by that 0.001 spacing, or if they all get lumped together in order to fit the 0.022222 criteria.
 
Level 39
Joined
Feb 27, 2007
Messages
5,008
and subsequently potentially optimize by minimizing the amount of running timers to "merge" launches that are expected to hit at the same time.
Wouldn't any motion of the target unit (in any 3d direction) change the timing anyway, or does wc3 fix the travel time once the projectile has launched and instead alters the projectile velocity to match?
 
Level 19
Joined
Jan 3, 2022
Messages
320
@Bribe You gotta read my threads like ongoing diaries: incrementally. Because ultimately, this is an going work (like in most cases with me):
If you set cd to a very low number, e.g. BlzSetUnitAttackCooldown(unit, 1/(2^10), 0): 0.0009765625 and achieves 50 atk/s!
50 ATK/s is possible.
The attack delay is a great idea, the footmen are almost ready for this on the test map. I suggest the script is modified to:
1. Move them radially around the hero, with distance and angle facing correctly set.
2. Initially frozen
3. Unfreeze each unit after 0.001*n delay.
4. Since they're all equal, you can disregard their slow attack, cd and swing animations.
 

Bribe

Code Moderator
Level 50
Joined
Sep 26, 2009
Messages
9,464
Here's the code I used to test:

Lua:
do
    local oldBliz = InitBlizzard
    InitBlizzard = function()
        oldBliz()

        local struct = CreateUnit(Player(0), FourCC('hgra'), 0, 0, 0)
       
        local units = {}
        for i = 1, 7 do
            units[i] = CreateUnit(Player(0), FourCC('hfoo'), 0, 0, 0)
            SetUnitFacingToFaceUnitTimed(units[i], struct, 0)
        end
       
        local tracker = CreateTimer()
        TimerStart(tracker, 3600, false)

        local lastTime

        local trig = CreateTrigger()
        TriggerRegisterUnitEvent(trig, struct, EVENT_UNIT_DAMAGED)
        TriggerAddAction(trig, function()
            local time = TimerGetElapsed(tracker)
            if lastTime then
                print(time - lastTime)
            else
                print("=====================\n"..time)
            end
            lastTime = time
            IssueImmediateOrder(GetEventDamageSource(), "stop")
        end)
       
        local t = CreateTimer()
        TimerStart(CreateTimer(), 2, true, function()
            lastTime = nil
            SetWidgetLife(struct, 999999)
            local co
            co = coroutine.create(function()
                for i = 1, 7 do
                    TimerStart(t, 0.001, false, function()
                        IssueTargetOrder(units[i], "attack", struct)
                        coroutine.resume(co)
                    end)
                    coroutine.yield(co)
                end
            end)
            coroutine.resume(co)
        end)
    end
end

Output:

1666382483832.png


Edit: I've run a bunch of tests on this, and it seems the 0.03 second offset just applies to unit orders. It seems internally the orders just won't always execute immediately - some will get lumped together, others will be separated by some multiple of 0.03.

However, where this 0.03 thing tapers off is for something like the attack event to the damage point event. Once the attack event deploys, for example, it is exactly 0.5 seconds (the backswing point for the footmen) until the damage event runs. And 0.03 is not a perfect divisor of 0.5, so the game is clearly breaking away from any standards for certain things that need to be precisely timed.

Here's the code I ended up with:

Lua:
do
    local oldBliz = InitBlizzard
    InitBlizzard = function()
        oldBliz()

        --local wc3fps = 30.3
        --local wc3tick = 1/wc3fps --game ticks for unit-ordered-to-attack events will use this value to sync with the damage point.

        --round second to nearest wc3 tick:
        local function sec2tick(sec)
            --local tick = sec // wc3tick * wc3tick
            --if tick * .5 > sec then
            --    return tick + wc3tick
            --end
            --return tick
            return sec
        end

        local unitCount = 16 --the max number of lines that can be displayed on screen simultaneously.

        local struct = CreateUnit(Player(0), FourCC('hgra'), 0, 0, 0)
        
        local units,unitData,unitDelta,clockDelta = {},{},{},{}
        for i = 1, unitCount do
            units[i] = CreateUnit(Player(0), FourCC('hfoo'), 0, 0, 0)
            IssueTargetOrder(units[i], "attack", struct)
            PauseUnit(units[i], true)
            unitData[units[i]] = i
        end
        
        local tracker = CreateTimer()
        TimerStart(tracker, sec2tick(3600))

        --local first

        local attTrig = CreateTrigger()
        TriggerRegisterUnitEvent(attTrig, struct, EVENT_UNIT_ATTACKED)
        TriggerAddAction(attTrig, function()
            unitDelta[GetAttacker()] = TimerGetElapsed(tracker)
            clockDelta[GetAttacker()] = os.clock()
        end)

        local damTrig = CreateTrigger()
        TriggerRegisterUnitEvent(damTrig, struct, EVENT_UNIT_DAMAGED)
        TriggerAddAction(damTrig, function()
            local u = GetEventDamageSource()
            BlzSetEventDamage(0)
            PauseUnit(u, true)
            local index = unitData[u]
            local str = (index < 10) and ("0"..index) or index
            print(str..": ".. TimerGetElapsed(tracker) - unitDelta[u].."; ".. os.clock() - clockDelta[u])
        end)
        
        --local t = CreateTimer()
        TimerStart(CreateTimer(), sec2tick(2), true, function()
            --local co
            --co = coroutine.create(function()
                print("=====================\n")
                for i = 1, unitCount do
                    --TimerStart(t, 0, false, function()
                        PauseUnit(units[i], false)
                    --    coroutine.resume(co)
                    --end)
                    --coroutine.yield(co)
                end
            --end)
            --coroutine.resume(co)
        end)
    end
end

It's a crapshoot trying to figure out which unit is going to actually attack first if they all attack at the same time. I thought I had clocked the interval at 1/30.3 FPS (or rather 0.033001), so I tried to space out the orders so that unit 1 would attack, then 0.033 seconds later unit 2 would attack. It still had a few attack events fire out of sequence, so I was quite confused at that outcome. I'd probably have to space it out even more, like at 0.05 or perhaps even 0.1, to get a perfectly synced "one attack after the other" sequence. But that was not my goal.

Outside of massive battles with hundreds of units perfectly attacking each other at once (a la Footmen Frenzy), I don't see the point in consolidating moment-of-expiration events into each other. The chances of synchronized attacks are low, even when the units are already positioned to attack.
 
Last edited:
Status
Not open for further replies.
Top