Warcraft III Trigger Event Dispatch And Response Context
OpenRealm routes Warcraft III gameplay events through GAMEEVENT in
games/warcraft-3/game/g_events.c. Map JASS registers LPEVENT handlers;
authoritative gameplay publishes an event only when the corresponding state
transition actually happens. Tutorial/campaign behavior must use this generic
bridge rather than map-specific checks.
Player save/load game events
TriggerRegisterGameEvent registers EVENT_GAME_SAVE (259) and
EVENT_GAME_LOADED (256) using the normal bounded event registry. Other game
IDs remain unsupported and return null event handles. The global registrations
have no unit/player subject, respect disabled triggers, and survive a game
snapshot alongside the other registered events.
Normal ESC-menu Save Game / Quick Save and the server's save command issue
EVENT_GAME_SAVE before writing the full snapshot, allowing map handlers
such as the Rexxar zone-backup scripts to copy their existing zone files.
The session loader issues EVENT_GAME_LOADED after a successful named
full-game restore; this lets campaign scripts restore a saved slot's zone
backups. A scripted zone restore also dispatches this event after applying its
snapshot, with MAP_RELOADED temporarily set so campaign return handlers run
without the named-save directory-copy handler. These callbacks run event
conditions and queued actions through the existing JASS scheduler while the
save-name context is valid. The event is not issued after a failed restore.
The save is aborted if pre-save event execution reports a JASS runtime error or
the event queue cannot accept the event.
GetSaveBasicFilename returns the logical save basename (stripping a .sav
or .w3z suffix) during these callbacks, not an OS path. Outside the callback
it is empty. Actions that yield and run later do not retain this transient
filename; asynchronous save-handler completion is not supported yet.
JASS-initiated scripted zone saves and loads use deferred VM-safe/session
operations. The internal source save does not synthesize EVENT_GAME_SAVE;
the successful scripted restore dispatches EVENT_GAME_LOADED with
MAP_RELOADED as described above. See
save/load.
Event response payload
GAMEEVENT carries the response state that must survive until the JASS action
coroutine runs:
| Field | Meaning |
|---|---|
edict |
primary/triggering unit or widget |
source |
secondary unit/widget: attacker, order target, manipulated item, etc. |
value |
event-specific scalar such as spell/research rawcode |
point / has_point |
spell point target when the event carries one |
responseTo |
exact targeted registration for region/range/game-state events |
jass_calltriggerevent() copies those fields into JASSCONTEXT; sleeping JASS
coroutines retain the same response context. The save/JASS snapshot serializers
persist the queue/context fields introduced by the event bridge.
Retail event IDs
The retail common.j used by current campaign data assigns player sell events
to 272/273/274 and unit sell events to 289/290/291 (SELL, CHANGE_OWNER,
SELL_ITEM). The corresponding spell lifecycle events follow those values
through 279 and 296. Keep the EVENTTYPE values and the declarations in
game/common.txt aligned with those IDs: ConvertPlayerUnitEvent and
ConvertUnitEvent preserve the numeric value, so a stale producer enum can
silently publish an event that no retail JASS registration matches. The
OrcX01 armory regression in t_items.c exercises the player sell-item value.
Explicit trigger execution
Event dispatch evaluates conditions, then queues action coroutines for the JASS
scheduler. A direct TriggerExecute() call instead starts each action and runs
it immediately until it finishes or reaches its first wait. A waiting action
remains scheduled to resume after the caller continues.
ConditionalTriggerExecute() therefore exposes action writes before the
action's first wait to its caller; if the action has no wait, all of its writes
are visible when the native returns.
Enabled state only gates registered events. TriggerEvaluate() evaluates the
trigger's conditions regardless of whether DisableTrigger() was called, and
TriggerExecute() runs its actions explicitly. The event/timer bridge checks
trigger->disabled before evaluating a registered callback. This matches
Blizzard.j's QueuedTriggerAddBJ: its TriggerExecuteBJ helper evaluates the
conditions and then explicitly executes the action, so maps can queue a trigger
that they created disabled. ConditionalTriggerExecute() also uses explicit
evaluation; a condition that needs to reject a disabled trigger can query
IsTriggerEnabled() itself.
UndeadX04 relies on this contract for the opening Base quest: the map disables
Dragonhawks Die Q during initialization, then queues it after gameplay starts.
Its dialogue action discovers the Base quest. Rejecting disabled triggers in
TriggerEvaluate() caused the Blizzard.j queue helper to discard that entry.
The regression wc3_jass.disabled_triggers_allow_explicit_execution_but_ignore_events
checks that explicit disabled-trigger execution succeeds while disabled timer
events remain suppressed and enabled timer events still fire.
Nested immediate actions must preserve the active parent coroutine. In
particular, if the parent calls TriggerSleepAction() after a nested
TriggerExecute(), it must still yield and resume at that point. jass_resume()
restores the enclosing coroutine, player/unit response context, and loop index
when the nested action returns.
This distinction fixed Orc03's zeppelin victory gate. Its ForGroup callback
executes a counter trigger for each zeppelin, then immediately checks the
counter. Queuing those TriggerExecute() actions made the check run too early,
so the victory trigger was skipped even when both zeppelins were home. The
regression is covered by
wc3_jass_map.orc03_home_counter_actions_complete_before_victory_gate_check and
wc3_jass_map.nested_trigger_execute_preserves_parent_coroutine.
Selection
Interactive selection uses G_SelectEntity() / G_DeselectEntity() and emits
EVENT_PLAYER_UNIT_SELECTED / DESELECTED plus the unit-specific equivalent
only on an actual membership transition. GAMEEVENT does not currently carry a
separate selecting-player field; response code that needs that distinction must
not infer support from the event payload.
SelectUnit / ClearSelection are local-presentation natives. A synchronized
call applies to every connected local client view, not to unused map-player
slots. When JASS is executing under a local-player context, only that connected
client view is changed. Disconnected placeholder clients must never acquire
selection bits or generate selection events.
Attacked events
An attack event is emitted when an actual unit attack swing starts, not when an attack order is merely accepted:
Response context is:
The attacked unit is GAMEEVENT.edict; the attacker is GAMEEVENT.source.
Destructable attacks do not synthesize unit-attacked events.
Spell effect events
The current unified spell pipeline supports the high-confidence starts the effect transition:
validation succeeds
-> mana/cooldown commit where applicable
-> EVENT_PLAYER_UNIT_SPELL_EFFECT
-> EVENT_UNIT_SPELL_EFFECT
-> spell gameplay callback
Response context is:
GetSpellAbilityUnit() = caster
GetSpellAbilityId() = ability rawcode
GetSpellTargetUnit() = entity target when the current spell target is a unit
GetSpellTargetX/Y() = point target coordinates when the current target is a point
GetSpellTargetLoc() = location for a point target
GetSpellTargetItem() / GetSpellTargetDestructable() safely classify the
secondary target handle if future spell target modes carry those widget types.
OpenRealm does not yet expose a confident ability-instance handle for
GetSpellAbility().
Do not fake the rest of the lifecycle. SPELL_CHANNEL, SPELL_CAST,
SPELL_FINISH, and SPELL_ENDCAST remain unsupported until the spell state
machine has distinct authoritative transitions for them; event diagnostics
continue to report registrations for those gaps.
Orders, items, Hero skills, regions and range
The bridge also provides:
- accepted immediate, point, and target order events with
GetIssuedOrderId(), point and target response natives; - successful item pickup/use context via
GetManipulatingUnit()andGetManipulatedItem(); final-charge perishable uses retain the consumed item handle through queued event dispatch and sleeping trigger-action context so the response remains valid while JASS is handling that use; - successful Hero skill learning via
GetLearningUnit(),GetLearnedSkill()andGetLearnedSkillLevel(); - region enter/leave with
GetTriggeringRegion()and entering/leaving unit; TriggerRegisterUnitInRangewith the watched unit as trigger unit and the entering/other unit as filter source.
Authored boolexpr filters run with the event-specific filter unit before trigger conditions/actions are scheduled.
Diagnostics
wc3_event_debug is opt-in and must not affect gameplay:
wc3_event_debug 1 registrations, known gaps, per-map summary
wc3_event_debug 2 plus event queue, filter rejection and dispatch detail
Summary counters have distinct meanings:
registered: map registrations of that event type;queued:GAMEEVENTs published by gameplay;matched: a registration was reached and its event filter passed;dispatched:jass_calltriggerevent/ direct timer trigger queued work after trigger conditions;filtered: authored registration filter rejected the event.
EVENT_GAME_TIMER_EXPIRED is special: G_RunTimers() invokes its registrations
directly instead of creating a GAMEEVENT, so queued can remain zero while
matched/dispatched increase. This is working behavior, not a missing timer
producer.
A summary reports REGISTERED_NEVER_OBSERVED only when an event was registered
but neither a queued gameplay event nor a direct matched registration was seen.
That status is evidence to investigate, not proof of a bug: the player may
simply not have performed the relevant action.
Human01 diagnostic findings (2026-09-04)
A wc3_event_debug 2 Human01 run established these reusable facts:
- region enter, unit-in-range, game-state, death, accepted order, Hero-skill and selection producers were active;
- script-driven selection incorrectly generated transitions for disconnected players; fixed by limiting local selection presentation to connected views;
- Human01 registers both player-unit and unit-specific attacked events while no attacked producer existed; fixed at attack-swing start;
- Human01 registers
EVENT_PLAYER_UNIT_SPELL_EFFECT; the unified spell pipeline had no lifecycle producer, soSPELL_EFFECTis now emitted immediately before the gameplay effect callback; - zero observed pickup/Hero-level/leave-region events in that run were not treated as implementation failures because those actions were not proven to have occurred;
- timer summaries were previously false positives because direct timer trigger
execution bypassed the queued-event counter;
matchednow records them.
Verification
Focused coverage lives in:
games/warcraft-3/game/tests/t_jass_map.c— Orc03's synchronousForGroup/ConditionalTriggerExecutecounter gate, direct trigger waits, and nested parent-coroutine restoration;games/warcraft-3/game/tests/t_api.c— selection locality, attacked response, order/item/Hero/region/range response context, spell response context;games/warcraft-3/game/tests/t_avatar.c— a runtime-added spell can cast through its stock order and publishes the two spell-effect event families;games/warcraft-3/game/tests/t_game.c— direct timer expiry is counted as matched/dispatched diagnostics and save/load timer behavior remains intact.
After building, useful targeted runs are:
make test-wc3-engine WC3_PATTERN=wc3_jass_map.orc03_home_counter_actions_complete_before_victory_gate_check
make test-wc3-engine WC3_PATTERN='wc3_api.*'
make test-wc3-engine WC3_PATTERN='wc3_spell.*'
make test-wc3-engine WC3_PATTERN='wc3_save.round_trip_jass_timers'
JASS interactive dialog choices
DialogDisplay creates a player-scoped modal svc_window without pausing the
simulation. A validated click publishes events targeted at the registered
dialog and button, with the authoritative player ID and one-based clicked
handle IDs. This response context is copied to a coroutine, so
GetClickedButton / GetClickedDialog continue to work after a trigger wait.
See JASS choice dialogs for lifecycle and limitations.
Trigger destruction
DestroyTrigger retires every live event_t registration owned by the trigger
and clears queued responseTo references before those event slots can be reused.
It disables the map-lifetime trigger object instead of freeing it, so an action
can destroy its own trigger safely. DisableTrigger leaves registrations intact
so EnableTrigger can resume them.
Scripted zone save lifecycle
The JASS SaveGame native used by zone-transition wrappers is a deferred
internal save, not a player-selected named game save. It does not synthesize
EVENT_GAME_SAVE or GetSaveBasicFilename context. Normal player save
entry points continue dispatching EVENT_GAME_SAVE before their own snapshot.
A following ChangeLevel is queued only when that deferred internal save
succeeds at the end-of-frame VM-safe boundary. The cross-map LoadGame uses
the session loader and dispatches EVENT_GAME_LOADED after restoring the
destination snapshot, with MAP_RELOADED temporarily set.