- Joined
- Jul 20, 2004
- Messages
- 2,760
Table of Contents
Blizzard’s game mechanics are extremely powerful, and it is commonly known that they improve their environment with every additional game they develop. From Warcraft II to Starcraft II, the featured map editors have greatly improved, increased in flexibility and adapted to fit to modders’ various needs. Experienced programmers might find the environment obnoxious and crude, but be it as it is, many people use the provided engine to create extraordinary maps and mods.
While most of the concepts explained here might be familiar to the old Warcraft III modders, I hope I might bring something new to the veterans, while sheading some light into a newcomer’s mind about how the scripting language (supported by the Graphical User Interface) can be put to good work into giving life to great ideas and projects. This is a beginner tutorial, so it’s fine if you don’t know much about Blizzard modding.
Note
This tutorial is relatively theoretical, so at the end of it you should end up understanding generic, fundamental concepts, but crucially important for developing your modding projects. The regular programmer might find some of my explanations quite silly, maybe even ridiculous, but keep in mind that it’s the essence of the concepts we are interested in, rather than the academic shell.
2. Triggers – what’s up with that?
Starcraft II is a multi-threaded environment. In simple words, actors (be them units, buildings, players themselves etc.) perform actions at the same time and relatively independent of one another.
Example
Consider two marines fighting each other on the battle-field, while SCVs collect minerals at the base, the barracks are busy building more units and the players independently scan the map and issue various orders. This is all very complex and it is clear that all these actors act independent of one another – the SCVs collecting the minerals have nothing to do with the marines shooting bullets at one another.
Again, we are talking about the basic actions of the actors rather than complex scenarios, such as an opponent marine attacking the SCVs, causing the latter to run away. Even in this case the SCVs run independently of the marine’s actions (though the player’s decision to set the SCVs on the run or the marine to attack them has reason behind it of course, but this is a different type of ‘action’ and is not the direct subject of interest for our study).
Nevertheless, the question remains how to tame such a wild environment? What can the modder do to ensure that everyone plays by the rules of your games, while constructing everything in a structured and clean manner? The answer is very simple: using triggers.
Triggers are fundamental objects in the programming environment with a dual purpose. It enables:
- The programmer to structure his/her code – container. While it is true that Starcraft II Editor enables programmers to write custom actions outside a trigger’s ‘box’, any piece code you write and want to be executed by the system must be ultimately placed or referenced into a trigger.
- The system to run programmable actions – actuator.
3. The Structure of a Trigger
Warcraft III JASS programmers might remember that all triggers had to follow the Event – Conditions – Actions schema, the trigger object actually being implemented like this in the background. This does not remain true in Starcraft II, and we will see that Blizzard actually differentiates between the GUI (conceptual) and Scripting (effective) structure of a trigger.
Keep in mind, though, that this difference is purely conceptual. The two perspectives are merely point-of-view related and are in the end analogous (though these abstract concepts will certainly detail some aspects regarding triggers and their functionality).
a) Conceptual Structure (GUI)
Conceptually speaking, the structure of a trigger is rather basic: Events – Conditions – Actions (with Local Variables to be discussed a bit later). The scenario is as follows:
(1) An event of interest for the Trigger occurs. By event of interest we understand the event registered for the specified Trigger.
(2) The Trigger checks whether the conditions are fulfilled. While the event is something intrinsically generic, conditions are circumstantial.
(3) The actions are executed in order, one by one! This is the actual code the environment is expected to execute!
b) Effective Structure (Script)
While the conceptual structure is really simple to understand, the underlying structure of a trigger is even simpler. Essentially, two steps are taken into consideration: creation & reaction of the thread.
- At creation, the body of the trigger is specified (by referencing a user-defined function). Events are also attached to the trigger via specific functions.
- Whenever the trigger reacts to the registered events, the system
- creates a new thread of execution (threads are special structures that allow the environment to run code in parallel – though we do not directly work with them, it’s important to understand what they are and where they are used)
- places the thread into a context (very important step to be discussed a bit later)
- executes the body function “inside the thread”
- destroys the thread, cleaning up resources
The trigger responds and generates a thread for every registered event, whenever it occurs. Let’s assume for instance that our trigger is set to respond to Unit - Any Unit dies. Every time a unit on the map dies, the system will generate a thread and execute the code for each of the units. If a nuke kills 100 units at the same time, 100 threads will be generated by the trigger, each executing the same target function. If any other event takes place (such as units taking damage, buildings producing units etc.), the trigger will simply ignore them.
In a second (more generic) example, Trigger T1 is sensitive to events 2, 5 and 6 (ignoring the other 3). It will generate Threads 1, 2 and 3 as a response to these events, independent one to another. The threads run the same code (depending on the moment in which the events occurred, in parallel or during separate periods of time).
So where are the conditions then, you might ask? Since the list of conditions is simply a check that determines whether the actions are executed or not, it is simple enough to integrate them into the action function in the form of a composed condition. If at least one of the conditions is evaluated as false, then it is enough to halt the execution of the ‘actions’.
Here is how the map editor translates an execution function from GUI to Script:
JASS:bool gt_Trigger1_Func (bool testConds, bool runActions) { // Place Variable Declarations & initializations // Conditions if (testConds && any_condition_false) return false; if (!runActions) return true; // Place actions here return true; }
Note
Even though at this point the functionality of the two parameters may not seem highly relevant, it is mandatory that the function takes them. Trigger functions must return false only if and only if any conditions fail.
4. Contexts
If you remember, I earlier mentioned the idea of context and how it is very important from the point of view of a trigger. Well, the context is very important when we talk about events actually, because they help us determine the actors involved in an event.
Let’s assume we make a trigger respond to an event of the form “Any Unit takes Damage”.
What information does this event give us standalone? A unit somewhere on the map has been inflicted damage. Very nice, but does that insignificant information help us? Not really, that doesn’t do much, especially since we’d like to operate with the actors or maybe some parameters of the action itself.
It would be nice for instance to be able to tell which specific unit got damaged, or what caused the damage. In many situations one would also like to know the amount of damage inflicted. This is where the context kicks in. Generically speaking, the context particularizes a generic event by taking into consideration all the actors and parameters relevant to the event.
Context functions help reference the context data. The system allows the programmer to choose from a high range of native functions as to ensure that for all contexts all relevant parameters and actors can be determined. The programmer has access to all functions in all contexts, but most of these functions are particular to certain contexts.
For example, assume a trigger responds to the generic event “Any unit dies”. While context functions such as (Triggering Unit) and (Killing Unit) make sense, a context function such as (Created Unit) is highly irrelevant (and in this context will probably return a null reference). You can learn which context function is appropriate for which event with experience and practice.
Note
The context is determined by the wording of the event. If you are not sure which context function to use, read the event and try to decide whether the context function makes sense. In the case of generic functions such as (Triggering Unit), decide to which unit is the event directly referring to. “A unit dies” for instance clearly refers to the (Dying Unit), not the Killer. To reference the dying unit, therefore, you will always use (Triggering Unit).
5. Resources
We begin now to understand what triggers are, how they work and in what context. But even though triggers are easy to control in an isolated box, the resources (items/actors/objects/etc.) they operate on are global (also referred to as shared resources), making the results of the actions of multiple triggers more difficult to control.
Let’s have another example in which we consider the event “Any unit dies”. We hypothetically assume that a Protoss Colossus kills four Zerglings with a single energy beam. Our trigger will generate four threads and run four separate instances of the action function. Due to the way contexts work, each thread refers to its own (Triggering Unit) and (Killing Unit). But because the singular Colossus killed all four Zerglings, the (Killing Unit) of all threads refers to the same unique unit.
We say that the Colossus is a resource shared by the generated threads. But wait! What if there was another trigger that reacted to the same event (Any unit dies)? In that case, even though the pair (Killing Unit, Triggering Unit) is unique for the threads generated by the first triggers, between the two triggers, the pairs in fact match one by one (the exact same number of threads being generated for the second trigger too).
So now this might actually sound really confusing to some people. The resources are global, yet the references are local?! Yep, that’s right! Think of it this way: from the trigger’s point of view, whenever an event occurs, only the elements directly related to the event are generally of interest. It is indeed possible to connect additional data for inter-thread sharing (via Banks), but this is beyond the purpose of today’s tutorial. Just keep in mind that threads may operate on common resources, and it becomes problematic when these threads attempt to manipulate properties of the objects (such as a unit’s health, energy, armor, etc.) when they affect the correct functionality of one-another.
6. Local and global variables
Variables are an inevitable concept any programmer must deal with (whether we talk about direct scripts or GUI-assisted code). Variables can be seen as labeled containers that can store information for later usage. You can either use them to:- Store programmer-specific values into them: numeric values, strings, Booleans (logical true/false values)
- Reference objects (such as units, structures, timers, triggers, players etc.)
The mechanics of local and global variables are identical. They work the same and can be used identically in the code (the system makes absolutely no difference when it comes to usage). The difference between them is quite sensible and there is a reason why local variables are incorporated in the body of a trigger.
a) Global variables are visible by any thread, anywhere. They are created at map initialization and specified globally in the GUI (just like triggers and composite actions), independent of thread instances. Can be seen as resources.
b) Local variables belong to threads. They are visible and can only be manipulated by their owner thread. Every time a trigger generates the context, it also creates and places the specified set of local variables into that context. Therefore, local variables are not shared and context-dependent.
Note
From the Scripting perspective, local variables are declared and initialized at function call.
Let’s now take an example to further understand the power of local variables and see why they are indispensable for what people formerly referred to as “Multi-Instance” abilities. The ability we are going to develop is going to enhance the standard Blink’s functionality. We are not interested now in how balanced or complex the ability is, but in the coding perspective.
Holy Blink – Teleports the caster to a nearby location. Upon arrival, heal all friendly units in a nearby radius by 5% of the units’ cumulated life amount (if for instance there are 5 zealots in the area with 24 life each, the spell heals each zealot by 5x24x5% = 6 life points).
Our trigger clearly reacts to the usage of the Blink ability, more specifically at the end of a successful cast. The generated thread will detect the units in the vicinity of the caster, compute the healing amount out of their cumulative life points, and then heal the units back. The resulting GUI code is presented below.
Because stalkers generally blink in groups, it is very likely that the trigger may generate more than a thread at a time. Therefore, the usage of global variables for performing intermediate computations is not viable – keep in mind that global variables are shared by multiple threads, but the operation of the spell is purely context-dependent.
We will therefore define three local variables.- uGroup – stores the units in the vicinity of the caster (the group needs to be iterated twice, once for computing the cumulative life amount and the second time for the healing itself).
- u – current unit while iterating through the Unit Group. All iterators (For structures) need an iterating variable – which is (most probably in all situations) context-dependent.
- TLife – accumulator into which life of units in group is added.
The functionality is simple: for each stalker blinking in the group, the trigger generates a separate thread, each with its own group of units, iterator, and accumulator. This does not exclude the possibility of a unit belonging to two or more such groups simultaneously (especially if the Stalkers blink very close to one another). That unit will, however, be treated by each of the threads and healed by the appropriate amount. Remember, even though the reference (variable) is local, the resource (unit) is still global!
7. Parallelism and scheduling
Alright, we now know that triggers may generate threads in parallel and independent to one-another. We also know that these threads operate on common resources such as units, structures, players, global variables etc. The question however remains how the system handles the simultaneous threads.
Experiments show that the environment can always actively run only one (trigger) thread at a time, independent of the hardware of capabilities of the host system (such as the processor’s number of virtual cores). This simplifies certain complex scenarios - such as those further explained in chapter 8. It should be noticed that even though only one thread runs at a time, multiple threads can coexist.
So what does the system do when threads are created by triggers? It adds the threads to a special queue (yep, you can think of threads as if they were waiting for their turn to run in a long line). When the system is thread-idle (executes no thread at the moment), it extracts the first element (head) from the queue and begins executing its code (Body).
How long does the thread execute, and when are the other threads given a chance to run too? There are three situations in which a thread will stop executing and making room for another thread.
(1) The executing thread reaches a Wait instruction. It relinquishes access to the processing unit and ‘falls asleep’ (goes into an idle state). Once the time period is was programmed to wait ends (whether it is 0.4, 5, 60 or whatever amount may come in mind), it is reawakened and placed back at the end of the queue.
(2) The executing thread has run uninterrupted for more than t0 seconds (t0 is a fixed period of time imposed by the system). The system interprets this as a timeout and kills the running thread. If it still had actions to perform, those actions will never take place. This is an abnormal situation and should be avoided!
(3) The thread finishes execution in time. It relinquishes access to the processing unit and ends its execution successfully. It is obviously not added back to the queue but instead removed by the system as it has done its work.
Understanding how the scheduler works is important when we will discuss next about the problems of code parallelism.
8. Races & Locks
We already talked about resources and how they are shared between threads. At most times it is possible to avoid the usage of such resources simply by smartly using the context set up by the trigger – though remember that even a local context operates with global objects. There are, however, situations in which the usage of global resources simply cannot be avoided!
Consider a group of triggers that operate on a common global unit group. The triggers can work in parallel on the unique group by performing all combinations of operations on it (adding and removing units to/from the group, iterating through the group, comparing it with other groups etc.).
Assume now that T1 performs operation O1 on the group while in parallel T2 performs operation O2 (we consider the two operations independent of one-another). If the order of the operations influences the final state of the unit group, it is said that we have a race. Think of the final state of the group as a destination, and the orders of the operations as alternative paths. The chosen path should not influence the destination.
To solve races, Starcraft II GUI environment features a special mechanism known as Critical Section. With the aid of a special lock variable, the system allows only one thread to execute a code section at a time. That way, global resources with potential race condition can be locked by an ‘owning’ thread, all the other threads attempting to access the same resource needing to wait for access on the resource.
Critical sections are blocked by using an additional Boolean global variable (referred to by Blizzard as lock). Whenever the variable’s value is false, a thread is currently accessing the critical section. When no threads are accessing the resource, the variable is left on true.
Note
It is up to the programmer to set up the critical sections. Threads that do not place resource access in a critical section will ignore the restriction imposed by the owning thread and freely access the resource. Use critical sections with care though – if used unnecessarily, they will generate useless code processing traffic (and too high processing traffic can generate lag) and can even lead to unpleasant situations (such as deadlocks).
Remember
Races may occur only if threads operate on the same data simultaneously. Due to the functionality of the scheduler (responsible with thread maintenance), only threads that contain Wait actions can induce race conditions –such threads having interrupted execution. To avoid race conditions, try to perform all the critical operations uninterrupted by Wait instructions. That way no Critical Section is needed to guard the resource as the thread waits.
Terminology
We say that blocks of Actions (instructions) that run uninterrupted are Atomic. The atomicity of a block is guaranteed because the scheduler never (normally) forces threads to halt their execution unwillingly. Instead, threads themselves may choose to willingly pause their execution via the Wait function call (generally while waiting after another thread).
9. Conclusions
I am sure many of you probably found this tutorial a bit too abstract for your taste. Don’t be disappointed if you haven’t grasped every single concept developed here though, or if you don’t understand everything in depth. Some of the problems will reveal themselves with practice, others you might never have to deal with.
Experiment with the concepts, learn to harness them, and if you have any questions do not hesitate to contact me and I will do my best to provide you a proper answer.
Frequently Asked Questions
1. From the effective structure of a trigger, am I to assume that the trigger is simply a thread generator?
Yep, in simple words, that’s pretty much it. We can also say that the trigger filters the various events and selectively generates the response in the form of a series of actions.
2. Can I register more than one event/trigger? When does the trigger react?
Yes you can. The trigger will react whenever any of the specified events occur. This makes very little sense if the events are very different, as the set of actions may not be universally suitable. Remember! The same piece code is executed for ALL events. Take extra care when registering multiple events on the same trigger.
3. Can I make a trigger execute only one thread at a time?
You can try to disable the thread every time it fires and then re-enable it at the end, but if the event occurs for multiple actors at the same time (like in the example of dying units and the nuke), you will have more than one thread generated by the trigger and placed into the scheduler. Remember races and guard the entire actions block by placing it into a Critical Section!
4. There are situations in which the same effect might be achieved either by turning highly generic event in a more particular one, or by using a condition instead to narrow down potential triggering actors and values. What’s the difference between the two?
This is a very interesting question. Triggers will have to create the thread instance and set up the context before it can check the conditions. If the event however fails, the thread is not created at all. Therefore, particularized events are more efficient than supplementary conditions. This is however very circumstantial, and in most situations it is impossible to set up an event that can incorporate in it all the necessary conditions.
5. Can I use local triggers?
Yes you can – we usually refer to the creating trigger as the parent, and the created trigger as the child. It is even possible to make the parent wait after its child before continuing its execution. It is up to you how you use this feature, though.
6. So do threads truly run in parallel?
No. As explained in chapter 7, only one thread will actually run at a time.
7. What’s a deadlock and how can I avoid it?
Deadlock situations occur when multiple threads wait one after another, without any chance of exiting the wait state. This usually occurs due to the insertion of critical sections – so as mentioned before, great care when you use them!
A simple example of a deadlock is when threads T1 (holding resource R1) and T2 (holding resource R2) attempt to get the other thread’s resource at the same time. T1 halts its execution while waiting for R2, but T2 is also frozen due to it waiting after R1. Because neither of the threads can relinquish its resource before entering the critical section, the two threads are indefinitely locked.
8. Is there a limit of the number of threads that may be scheduled at a time?
Yes. Experiments suggest that the current version imposes a limit of 1024 active threads at a time.
Attachments
-
Holy Blink.jpg115.2 KB · Views: 3,069
-
TriggerEffective.jpg43.6 KB · Views: 2,689
-
TriggerStructure.jpg26.3 KB · Views: 2,751
-
TriggerContext.jpg79.6 KB · Views: 3,051
-
TriggerConceptual.jpg33.7 KB · Views: 2,837
-
Race.jpg36.3 KB · Views: 2,792
-
Deadlock.jpg28.7 KB · Views: 2,694
-
Scheduler.jpg15.7 KB · Views: 3,008
Last edited: