How Triggevent is Different (2024)

Triggevent is a comprehensive addon for FFXIV that provides cooldown and multi-target DoT tracking, easy triggers, a titan jail plugin, and more.

Project maintained by xpdota Hosted on GitHub Pages — Theme by mattgraham

Now that we’ve talked about the issues with existing solutions, let’s talk more abouthow Triggevent is different.

What is the ‘Job’ of a Trigger?

What this all really boils down to is: what is the responsibility of the trigger, and what is the responsibility of the framework in whichthe trigger is made? Triggevent takes the approach that the former should be “only the parts that are absolutely unique to that trigger”, andthat all the boilerplate should be external.

Cactbot is very good at this - the vast majority of it is abstracted. The trigger definition doesn’t need to care about the exact format of the logline, nor how to provide user customization of the TTS/text, nor how to display it on the screen. Triggevent takes it a step further. For example,you’ll notice many Cactbot triggers define the source as a name of a boss. This requires auto-translation to support other languages, and isn’tperfect - for example, often times, a boss and several fake actors will share the same name. Triggevent, with its fully parsed objects, provides alot more than what is just on the log line. NPC and NPC name IDs, action effect parsing, type conversion, injection of data from other sources(e.g. OverlayPlugin or Telesto), and much more. You can even get details that come from in-game data files, like ability recast times.

Let’s look at some of the abilities that Triggevent has.


By using the ‘ModifiableCallout’ class to build callouts, and annotating the class with @CalloutRepo, the callouts automatically show up in thePlugins > Callouts tab, allowing the user to enable/disable them, modify the TTS, and change the on-screen text.

@CalloutRepo("Dragonsong's Reprise")public class Dragonsong {private final ModifiableCallout<HeadMarkerEvent> p1_firstCleaveMarker = new ModifiableCallout<>("Quad Marker (1st set)", "Marker, First Set");private final ModifiableCallout<HeadMarkerEvent> p1_secondCleaveMarker = new ModifiableCallout<>("Quad Marker (2nd set)", "Second Set");private final ModifiableCallout<AbilityCastStart> p1_holiestOfHoly = ModifiableCallout.durationBasedCall("Holiest of Holy", "Raidwide");private final ModifiableCallout<AbilityCastStart> p1_emptyDimension = ModifiableCallout.durationBasedCall("Empty Dimension", "In");private final ModifiableCallout<AbilityCastStart> p1_fullDimension = ModifiableCallout.durationBasedCall("Empty Dimension", "Out");private final ModifiableCallout<AbilityCastStart> p1_heavensblaze = ModifiableCallout.durationBasedCall("Heavensblaze", "Stack on {}");private final ModifiableCallout<AbilityCastStart> p1_holiestHallowing = ModifiableCallout.durationBasedCall("Holiest Hallowing", "Interrupt {event.source}");private final ModifiableCallout<BuffApplied> p1_brightwing = ModifiableCallout.durationBasedCall("Brightwing", "Pair Cleaves"); ...}

This shows up on the UI like this:

How Triggevent is Different (1)

However, sometimes, that’s not enough. In the event that a plugin legitimately needs more customization (e.g. a priority list), it can defineits own customization UI:

How Triggevent is Different (2)

Simple Concise Triggers

Triggers with very simple conditions can be defined in as few as two lines:

@NpcCastCallout({0x796C, 0x7984})private final ModifiableCallout<AbilityCastStart> hellsNebula = ModifiableCallout.durationBasedCall("Hells' Nebula", "1 HP");

This will trigger when an NPC starts casting 0x796C or 0x7984, while still providing the user with the ability to customizethe callout.

Game Data Files

In fact, let’s look at the example of cooldown tracking. Let’s say we want a simple CD tracker that also shows the buff’s active duration. You’dthink that you’d need to record the ability ID, cooldown, buff ID, and duration, right? Nope. The cooldown is in the data files for actions(with the exception being when a trait lowers the cooldown), the buff ID is in the 21-line, and the duration is in the 26-line. This data is alleasily accessible in Triggevent. Even the icons are accessible. This means that for most cooldowns, you would need only the ability ID, andeverything else can be derived!

State Repositories

Let’s take an example of a mechanic that places different buffs on different players (or places one buff but only on certain players), and thenlater those buffs come back into play when the boss starts casting a particular ability.

Normally, you would need one trigger to collect the buffs and store them somewhere. The second trigger would then reference wherever they werestored, and figure out what should be called.

In Triggevent, the former becomes unnecessary, because you can just query various state objects (buffs, castbars, etc):

if (getBuffs().statusesOnTarget(getState().getPlayer()).stream().anyMatch(buff -> buff.getBuff().getId() == 0xB11)) { s.accept(thordan2_trio1_inLightning.getModified(donut));}else{ s.accept(thordan2_trio1_in.getModified(donut));}

This example is for Wrath of the Heavens, when the dynamo starts casting. If you have lightning, it will call “in with lightning”, and if you don’t, it just calls “in”.

Normally, you’d need a separate trigger to collect these buffs, but Triggevent makes this approach unnecessary.

Sequential Triggers

Many mechanics consist of complex sequences of events. These can be a mess to make triggers for, because there’s no way to read them thatprovides a nice overview of the actual flow of the mechanic and logic of the trigger. Here’s the full Wrath of the Heavens example:

 @AutoFeed private final SequentialTrigger<BaseEvent> thordan2_trio1 = new SequentialTrigger<>(30_000, BaseEvent.class, // Start this sequential trigger on the actual Wrath of the Heavens cast event -> event instanceof AbilityUsedEvent aue && aue.getAbility().getId() == 0x6B89, (e1, s) -> { // The first thing that happens is the two tethers List<TetherEvent> tethers = s.waitEvents(2, TetherEvent.class, tether -> tether.getId() == 5); // next, the blue marker HeadMarkerEvent blueMark = s.waitEvent(HeadMarkerEvent.class); // Give a different callout depending on whether the player is blue marker, tether, or nothing if (blueMark.getTarget().isThePlayer()) { s.accept(thordan2_trio1_blueMark.getModified(blueMark)); } else { Optional<TetherEvent> tetherOnPlayer = // A neat part of TetherEvent is that we have a method to test if *either* target matches // some condition, so we don't have to worry about whether the player is the first or // second target. .filter(tether -> tether.eitherTargetMatches(XivCombatant::isThePlayer)) .findAny(); if (tetherOnPlayer.isPresent()) {G s.accept(thordan2_trio1_tether.getModified(tetherOnPlayer.get())); } else { s.accept(thordan2_trio1_neither.getModified()); } } // Green marker HeadMarkerEvent greenMark = s.waitEvent(HeadMarkerEvent.class); // Only call if green is on the player if (greenMark.getTarget().isThePlayer()) { s.accept(thordan2_trio1_greenMark.getModified(greenMark)); } // Call out the 'spread' AbilityCastStart spread = s.waitEvent(AbilityCastStart.class, acs -> acs.getAbility().getId() == 0x63CA); s.accept(thordan2_trio1_protean.getModified(spread)); // Now, call out the dynamo, BUT modify the call based on whether the player has lightning or not. AbilityCastStart donut = s.waitEvent(AbilityCastStart.class, acs -> acs.getAbility().getId() == 0x62DA); if (getBuffs().statusesOnTarget(getState().getPlayer()).stream().anyMatch(buff -> buff.getBuff().getId() == 0xB11)) { s.accept(thordan2_trio1_inLightning.getModified(donut)); } else { s.accept(thordan2_trio1_in.getModified(donut)); } } );

That’s it. That’s the entirety of Wrath of the Heavens, minus twisters and liquid heaven. See the comments in the code for more detail.

After hitting the start point (in this case, the literal ‘Wrath of the Heavens’ cast), the trigger executes its code.When it hits a ‘s.waitEvent’ call, think of it like await - it will ‘pause’ the code until it sees that event.

In addition, because the sequential trigger need not change any external state, you don’t have to worry about cleanup behavior. Everything isjust a local variable that ceases to exist once the sequential trigger has finished executing.

Updating Existing Calls

While this is doable manually, a neat feature of Sequential Triggers is the ability to ‘update’ a call. If you are calling out multiple things in sequence, youcan do it in several ways depending on the circ*mstances:

  • Just do multiple calls (each will have their own TTS and on-screen text)
  • Make use of the dynamic nature of on-screen text to update the call as you go (example: meteor drops in DSR). This does NOT result in multiple TTS calls.
  • Update an existing call, resulting in a new TTS call, but updates the text on screen in-place.

The third can be done manually like so. This trigger calls out when you swap the red/blue tether in DSR eye phase:

private CalloutEvent previousRedBlueCall;@HandleEventspublic void doRedBlueTethers(EventContext ctx, BuffApplied ba) {if (ba.getTarget().isThePlayer()) {CalloutEvent call;long id = ba.getBuff().getId();if (id == 0xAD7) {call = redTether.getModified(ba);}else if (id == 0xAD8) {call = blueTether.getModified(ba);}else {return;}if (previousRedBlueCall != null) {// This is the secret sauce - we tell the new call that it should completely replace the old onecall.setReplaces(previousRedBlueCall);}ctx.accept(call);previousRedBlueCall = call;}}

However, sequential triggers have an even more slick way of handling this, as we see in the Skyblind trigger:

private final ModifiableCallout<BuffApplied> p1_puddleBait = ModifiableCallout.<BuffApplied>durationBasedCall("Puddle (Place)", "Puddle on you").autoIcon();private final ModifiableCallout<BuffRemoved> p1_puddleBaitAfter = new ModifiableCallout<BuffRemoved>("Puddle (Move)", "Move").autoIcon();@AutoFeedprivate final SequentialTrigger<BaseEvent> p1_puddleBaitSeq = new SequentialTrigger<>(10_000, BaseEvent.class,e -> e instanceof BuffApplied ba && ba.getBuff().getId() == 0xA65 && ba.getTarget().isThePlayer(),(e1, s) -> {s.updateCall(p1_puddleBait.getModified((BuffApplied) e1));BuffRemoved removed = s.waitEvent(BuffRemoved.class, br -> br.getBuff().getId() == 0xA65 && br.getTarget().isThePlayer());s.updateCall(p1_puddleBaitAfter.getModified(removed));});

Here, we initially set it to the “Puddle on you” call - but once the debuff expires, we change the call to “Move”. The on-screen text updates in place,but we get a new TTS.


Sequential Trigger Templates can be created and used to cover common scenarios. For example, if you have a mechanic where you want one call to happen atthe beginning of a cast, then another call at the end (e.g. a “bait then move” mechanic), there is a template for that:

@AutoFeedprivate final SequentialTrigger<BaseEvent> darkDomeSq = SqtTemplates.beginningAndEndingOfCast(acs -> acs.abilityIdMatches(30859),darkDomeBait, darkDomeMove2);

This calls out the “bait” then “move” mechanic (Dark Dome) in P6S.

What about mechanics where the same ability is used multiple times in one fight, necessitating different calls each time?

@AutoFeedprivate final SequentialTrigger<BaseEvent> highConcept1 = SqtTemplates.multiInvocation(60_000,AbilityCastStart.class, acs -> acs.abilityIdMatches(31148),this::hc1,this::hc2);

Here, we can split High Concept 1 and High Concept 2 into separate methods. The first time in a given pull we see ability 31148 being cast, it will call the hc1 method. The second time, hc2. You can specify as many of these as needed.

Similarly, what about mechanics where the actual castbar or buff duration is very long, and we want to call out whenthe duration falls below a certain point?

Here is Shockwave, the knockback in P2S:

@AutoFeedprivate final SequentialTrigger<BaseEvent> shockwaveSq = SqtTemplates.callWhenDurationIs(AbilityCastStart.class, acs -> acs.abilityIdMatches(0x682F),shockwave,Duration.ofSeconds(6));

Since knockback immunity lasts 6 seconds, we want to wait until there are 6 seconds remaining on the cast before telling the user touse knockback immunity.

Log Analysis

One of the main advantages is Triggevent is the ‘Events’ UI is incredibly powerful. This gives you a very good amount of information and tells you what eachlog line actually means, in human-readable form. One of the difficulties of making triggers currently is that looking through a log file could be a littleeasier. So, there’s a great visualization of everything going on:

How Triggevent is Different (3)

You can also use the ‘Combatants’ tab and the ‘Map’ tab to see a detailed view of the state of combatants and what all is going on:

How Triggevent is Different (4)

How Triggevent is Different (5)

We’ve already seen that we can use various data sources for replays, such as ACT log files, Triggevent’s own save format, or fflogs. But the same is true oflive runs as well. In its current form, combatants and party data come from ACT lines, OverlayPlugin, and Telesto. Available data sources are merged togetherseamlessly behind the scenes. Neither the trigger maker nor the user has to worry about it. For example, combatant positions come from ACT log lines andOverlayPlugin, with the most recent being preferred. HP, on the other hand, will use ACT lines and OverlayPlugin data, but HP from 37/38/39 lines takespriority due to it being the most up to date (as it comes from network data rather than memory).

In the future, the resilience of the application against game updates could be massively improved by implementing a Dalamud plugin that feeds it similarcombat data, roughly equivalent to ACT lines. Since it all gets abstracted away, a trigger or overlay would have no idea that there’s no ACT under the hood!This means that, on the rare occasion such as 6.15 where Dalamud plugins are updated faster than the ACT parsing plugin, your overlays and triggers would beup and running a lot sooner.

You got me - almost. Most of what I’ve talked about so far has been regarding triggers written in code. Triggernometry, however, lets you make themwith a visual editor. Triggevent currently has ‘Easy Triggers’, and I plan to significantly expand this functionality.

How Triggevent is Different (6)

Right now, easy triggers are fairly basic, but they demonstrate the concept. Compared to Triggernometry, it tries to round off some of the edges and help youavoid some pitfalls. It gives you a UI to pick ability/status IDs, and tries to never even give you the option of doing something invalid.

But it doesn’t stop there. The biggest strength of Easy Triggers is the fact that in many cases, one can be automatically created from an event:

How Triggevent is Different (7)

That’s the true strength - doing the whole thing for you!

Is Triggevent perfect in any way? No. Is it complete? Absolutely not, I add new features left and right. This was supposed to be a proof of concept, but I realized that there were some serious UX needs that weren’t being filled by anything,despite a decent amount of competition in the space.Do I think everyone should immediately drop everything else and switch to it? No.But if there’s one takeaway that I want you to get from this, it’s simple: now that we’ve seen what is possible, let’s do better. I would love nothing more than to have another program come along and do everything better.

How Triggevent is Different (2024)


Top Articles
Latest Posts
Article information

Author: Clemencia Bogisich Ret

Last Updated:

Views: 6524

Rating: 5 / 5 (80 voted)

Reviews: 95% of readers found this page helpful

Author information

Name: Clemencia Bogisich Ret

Birthday: 2001-07-17

Address: Suite 794 53887 Geri Spring, West Cristentown, KY 54855

Phone: +5934435460663

Job: Central Hospitality Director

Hobby: Yoga, Electronics, Rafting, Lockpicking, Inline skating, Puzzles, scrapbook

Introduction: My name is Clemencia Bogisich Ret, I am a super, outstanding, graceful, friendly, vast, comfortable, agreeable person who loves writing and wants to share my knowledge and understanding with you.