Skip to content

Chain Semantics

Summary: Normative rules for how composed ability behavior — chains, procs, cascades — computes damage and bounds itself. One damage context per chain: the pre-exec SetByCaller seed, snapshot at trigger; every link re-runs the damage exec under its own identity. A three-value generation counter (gen 0 / 1 / 2) replaces the ad-hoc anti-chain mechanisms; three structural rails (depth cap, fan-out rule, one proc generation) contain proc storms. This page is the precision underneath the GDD §2.4b "Ability chains" section — the GDD carries the design statement, this page carries the rules that code and specs cite. Decision provenance: ImplementationDocs/AbilityChains.plan.md §2 (decisions 2–14, ruled 2026-07-09/10).


Enforcement status (read this first)

As of 2026-07-10 these rules are machine-enforced (AbilityChains Steps 2–3, merged 9c80cfc12, PIE-proven on the SunderingSlam chain):

  • Generation counter is native: Effect.Source.ChainGen1 / ChainGen2 spec tags, consumed by HandleIncomingDamage (which suppresses gen-2 from the proc event stream and marks gen-1 events Event.Hit.ChainGen1). The proc family stamps them in C++ (Echo Strike, Status Cascade, Ground Hazard); the old Cast<AEchoStrikeActor> marker and per-consumer filters are gone.
  • Seeds are pre-exec by construction: chain/proc payloads carry a UDamageSeedPayload snapshot of the triggering hit's pre-exec SetByCaller magnitudes; Echo Strike and Hemorrhage Cascade were re-seated onto it (see Shipped deviations — all fixed).
  • The structural rails are stamped and validated: Eternal.Chain.Stamp refuses a sheet that breaks them (linear only, depth ≤ 3, per-victim fan-out on the terminal link only) before any asset is created, and CheckChainBundleCompleteness errors on any modifier/kit grant set with a dangling Event.Chain.* connector.

Still hand-work: per-link feel (montage, cues, hazard GE choice) and Build Lab chain-DPS attribution (plan §3c, in progress).


Terminology

  • Chain — an authored sequence of linked abilities (Design/Chains/*.chain.md), stamped as N gameplay ability assets connected by Event.Chain.<ChainId>.<N> trigger tags. Linear only; no branching.
  • Link — one ability in a chain. Link 1 is the entry (an active skill or a proc trigger); each link's execution may trigger the next.
  • Trigger — the event that starts the chain (an input activation for actives, an on-hit proc for triggered chains). The trigger is not a link and does not count against depth.
  • Seed — the damage context the whole chain shares: the pre-exec per-damage-type SetByCaller magnitudes of the triggering hit (or wearer weapon damage for actives). See rule 1.
  • Generation (gen) — the proc-recursion counter, not the link index. Three values; see rule 3.
  • Terminal damage — gen-2 damage: never fires proc events, never triggers procs or chains.

The rules

1. One damage context: the pre-exec seed, snapshot at trigger (decision 2)

A chain has exactly one damage seed, captured when the chain triggers:

  • Triggered (proc) chains: the triggering hit's outgoing per-damage-type SetByCaller magnitudes — the values UExecCalc_Damage reads via Spec.GetSetByCallerMagnitude (ExecCalc_Damage.cpp:216,230) before the exec runs. Weapon base × attribute scaling, no Increased%, no crit, no mitigation.
  • Active chains: the wearer's weapon damage at activation — the same values link 1's own hit is seeded with. This is what the GDD text means by "weapon damage, scaled once by the weapon's attribute".

Weapon/attribute seeding happens only at link 0. No link re-seeds, and no link's seed is another link's output. Post-exec numbers (EventData.EventMagnitude, IncomingDamage) are never a seed: they already contain Increased%, conditionals, crit, and the previous victim's mitigation — seeding from them double-dips source-side scaling and leaks victim A's armor into victim B's damage.

Each link applies damage by running the seed (its declared share of it) through UExecCalc_Damage as a fresh base:

  • Damage type is per-link (decision 7): a link may re-type its output (same seed magnitude, new type identity). Re-typing selects which Increased% bucket, resistance, and conditional captures apply at that link's exec run — Sundering Slam's fissure is the seed re-typed to Corruption, scaled by Dread / +% Corruption, not by the slam's Physical modifiers.
  • Conditionals evaluate live at each application (decision 3): the exec re-reads target status (ConditionalDamageVs*) and source state at each link's run. Exception: a status effect snapshots the hit that applied it — see rule 4.
  • Crit rolls per link (decision 12): each link is its own hit; the exec rolls crit per run (ExecCalc_Damage.cpp:270). There is no whole-chain crit; crit is never part of the frozen seed.
  • Mitigation is per victim, per link: armor/resistances are the current target's, captured at that link's exec run.

Chain semantics live in the exec/kernel path (EternalDamageMath::ComputeHit), never in per-link ability code — Build Lab's EvaluateBuild shares the kernel, and per-link special cases would make its chain DPS attribution lie.

3. Generations: a three-value counter (decisions 4, 4a, 4b)

Generation counts proc-recursion hops, not chain links:

Gen What it is Fires damage events? May trigger on-hit procs? Spec marking
0 The wearer's own directly-executed hit: ordinary swings, and an active chain's link 1 Yes Yes (normal proc rules) none
1 Chain-link damage past gen 0, and single-link proc damage (Echo Strike's phantom, a fissure, a proc'd burst) Yes — the next link triggers off it, and gen-1 procs must see it Yes, once — the products are gen 2 Effect.Source.ChainGen1 dynamic asset tag
2 Anything triggered by gen-1 damage: proc products, cascade detonations No — suppressed from the proc event stream Never Effect.Source.ChainGen2 dynamic asset tag

Key consequences:

  • Links do not escalate generation among themselves. An active chain is gen 0 (link 1) then gen 1 (every later link); a proc-triggered chain is gen 1 at every link (the trigger already consumed the gen-0 hop). This is the trigger-kind numbering difference the format spike surfaced — it falls out of counting proc hops instead of link indices.
  • A gen-1 hit may proc once per proc (Echo Strike's phantom can proc Bleed), and each such product is gen 2 — terminal.
  • Cascade detonations are gen 2 (decision 4b): Hemorrhage Cascade's burst kills neighbors but never re-applies Bleed to them and never procs anything. The loop dies at the detonation; survivors need fresh gen-0 swings to restart it. (The alternative — detonations count as chain links that may proc once — was rejected; revisit only with a new ruling.)
  • DoT ticks are terminal, whatever applied them (decision 4a): applying a status consumes the proc allowance; the ticks inherit the applying hit's chain provenance but carry the terminal marking — they fire no proc events and trigger nothing. This preserves today's event-silent Bleed ticks exactly.

Mechanism (decision 4a; shipped): dynamic asset tags on the damage spec (AddDynamicAssetTag at spec creation). UEternalAttributeSet::HandleIncomingDamage derives the generation from the spec's asset tags (Effect.Source.OnHitProc counts as gen 2); the old hardcoded Cast<AEchoStrikeActor> marker is gone. A custom FGameplayEffectContext subclass was considered and rejected — a two-value counter doesn't justify the project's first context subclass.

4. Statuses snapshot; everything else is live (decision 3)

A status effect (Bleed, Ignite, Poison) snapshots the hit that applied it — its tick damage is fixed at application (UOnHitAbility::HitDamageMultiplier × the applying hit). Direct chain-link damage is the opposite: conditionals, target state, and crit are read live at each link's exec run (rule 2). The boundary is application: once a status exists, it no longer reads the world.

5. Structural rails: depth, width, recursion (decisions 6, 11, 4)

Three rails contain proc storms. All three are stamped/validated structurally (the chain stamp refuses violations before any asset exists) — same rail class, never optional, never designer-overridable:

  • Depth cap: 3 links (decision 6), trigger excluded. A fourth link means "two items that synergize", not one item. Depth caps length only.
  • Fan-out: link N+1 triggers ONCE per execution of link N (decision 11), with context target = the primary/first victim. Only the terminal link may declare per-victim fan-out ("a fissure under each enemy the burst hit"). Without this rail, an AOE link hitting 8 targets activates the next link 8 times — O(targets²) actor spawns and O(targets³) damage events at depth 3. Depth does not contain width; this does.
  • One proc generation (decision 4): rule 3's counter. Guard is structural (the stamp writes the tags), not per-class craftsmanship.

6. Blocked and parried triggers (decision 13)

  • A blocked hit still counts as a hit for chain/proc triggers, and chains seed from the PRE-block swing. This matches the retaliation-proc precedent (design 2026-07-02: "of Reprisal" seeds from the swing received, not the chip — EternalAttributeSet.cpp:366,420-424). A 100%-block shield does not zero the wearer's chain. Note: under rule 1 the seed is pre-exec anyway (block never touches it); this ruling mostly governs trigger-condition semantics.
  • A parried hit never triggers anything. The damage pipeline returns early before any event fires (ProcessParryDamage, EternalAttributeSet.cpp:358). This is codified behavior, not an incidental consequence of the early return.

The general trap behind this rule (Learnings/guaranteed-procs-have-a-zero-seed-case.md): any payload seeded post-mitigation makes every full-mitigation path a silent zero case. Chain seeds are pre-exec by rule 1, so chains are immune by construction — keep them that way.

7. Owner death mid-chain (decision 14 — confirmed by owner 2026-07-10)

  • In-flight links land. Echo Strike is the precedent: the phantom's spec is snapshotted at spawn (EchoStrikeAbility.cpp:72; one-pager: "the echo still lands if the owner dies mid-windup").
  • Source-side modifiers evaluate from the spec's captures — GAS captures source attributes at spec creation, so "live" conditionals degrade gracefully to captured-at-trigger when the source ASC is gone.
  • No link starts a new activation for a dead owner. In-flight ≠ pending: a link whose activation hasn't begun when the owner dies does not fire.

Worked trace: Persona 2 — Bleed Cascade

The acceptance test for the rules above. Build (Design/Kits/Persona2_BleedCascade.kit.md): sword and shield with Bleed Chance on Hit (weapon trait, GA_OnHit_Bleed), Echo Strike (Echo Mod: 20% on attack, a phantom repeats the strike), Hemorrhage Cascade (Echo Mod, GA_OnHit_HemorrhageCascade: Bleed applier that detonates at 5 stacks). Wearer swings at Enemy A standing in a pack.

# Application Gen What happens
1 Swing hits Enemy A 0 Weapon hit: spec seeded pre-exec from weapon base × attribute scaling (per-type SetByCallers). Exec runs under Physical identity — Increased Physical%, live conditionals vs A, A's armor, crit rolled for this hit. Damage lands; Event.Combat.DamageDealt fires with no generation tag.
2 Gen-0 procs roll All on-hit procs see the gen-0 event: weapon Bleed rolls its chance; Hemorrhage Cascade (also a Bleed applier) rolls; Echo Strike rolls its 20%. Assume Bleed applies and Echo Strike procs.
3 Bleed applies to A 1→terminal ticks The Bleed application is gen-1 proc product; its DoT spec snapshots the applying hit (rule 4) and carries the terminal marking — ticks fire no events, proc nothing (today's event-silent ticks, preserved). Stack count on A: 1.
4 Phantom strikes A 1 Echo Strike's phantom repeats the strike: same pre-exec seed as application #1 (rule 1 — NOT the post-exec landed number), spec tagged ChainGen1. Exec re-runs under the phantom's identity: live conditionals vs A now (A is Bleeding — ConditionalDamageVsBleeding applies where it didn't at #1), A's armor again, fresh crit roll. Damage event fires carrying ChainGen1.
5 Gen-1 procs roll — once Procs see the gen-1 event and may fire (rule 3): weapon Bleed can proc off the phantom. Echo Strike itself refuses gen-1 hits — a phantom never chains another phantom. Assume Bleed applies: stack count climbs.
6 Bleed applies to A (from phantom) 2 (terminal ticks) Gen-2 product of a gen-1 hit. Ticks terminal, as always. Stacks accumulate toward 5 across further swings/phantoms.
7 5th stack — Hemorrhage Cascade detonates 2 At threshold, Cascade strips all Bleed GEs from A and applies its burst scaled by consumed stacks (StatusCascadeAbility.cpp:29-53). The burst is second-generation damage (decision 4b): it kills A's neighbors, but it never re-applies Bleed to them, never procs, never fires a proc event (ChainGen2, event-suppressed).
8 Aftermath The pack is wounded or dead; no new stacks exist on survivors. The loop restarts only with fresh gen-0 swings. One payoff hit, then silence — exactly the GDD worked example's ending.

If any step of this trace reads ambiguously against the rules, the rules are not done — fix the rule, then the trace.


Shipped deviations

Code that predated these rules and violated them. All fixed as of 2026-07-10 — the table stays as provenance for why the fixes look the way they do:

Deviation Where Rule broken Fix
~~Echo Strike phantom seeded from post-exec EventData.EventMagnitude — the mitigated, Increased%-scaled, crit-rolled number — then re-ran the exec on it. Double-dipped Increased% and conditionals, re-mitigated, and would leak victim A's armor into victim B's damage on cross-target links.~~ EchoStrikeAbility.cpp Rule 1 FIXED 2026-07-10: re-seated onto the triggering hit's pre-exec SetByCaller magnitudes via UDamageSeedPayload. HitDamageMultiplier re-derived with the seed change — a multiplier tuned against post-exec magnitudes is a different balance decision on a pre-exec seed (Learnings/rederive-multipliers-when-seed-changes.md).
~~Hemorrhage Cascade's burst seeded from post-exec EventMagnitude × multiplier × stacks and applied to the primary target only.~~ StatusCascadeAbility.cpp Rule 1 FIXED 2026-07-10 (owner-ruled): burst re-seated onto the triggering hit's pre-exec seed total × multiplier × consumed stacks, and detonates onto everything within CascadeRadius (owner excluded). A seedless trigger detonates nothing and eats no stacks. Spec: ProjectEternal.Unit.Combat.StatusCascadeAbility.
~~Two incompatible anti-chain mechanisms: Effect.Source.OnHitProc suppressed the damage event entirely at source, while Echo Strike marked phantoms via a hardcoded Cast<AEchoStrikeActor> and filtered per-consumer. Neither supported 3-link chains (link damage must fire events).~~ EternalAttributeSet.cpp Rule 3 FIXED 2026-07-10: generation tags replaced both; the cast is gone. Effect.Source.OnHitProc survives as an authored alias for gen-2 (terminal) marking.

Single-link status procs (UOnHitAbility scaling tick damage from EventMagnitude) are not a deviation: rule 4 says a status snapshots the hit that applied it, and the applied-hit magnitude is the established ailment model the 0.2-class multipliers were tuned against. The line is: statuses snapshot their applying hit; chain links and exec re-runs seed pre-exec.


Source reference

Symbol / seam Location
Damage exec (per-link identity engine) Source/ProjectEternal/Private/AbilitySystem/ExecCalc/ExecCalc_Damage.cpp
Pure damage kernel (shared with Build Lab) Source/ProjectEternal/Public/AbilitySystem/EternalDamageMath.h
Damage pipeline, proc events, block/parry ordering Source/ProjectEternal/Private/AbilitySystem/EternalAttributeSet.cppHandleIncomingDamage
Proc family base (chance, conditions, snapshot multiplier) Source/ProjectEternal/Public/Abilities/OnHitAbility.h
Echo Strike (gen-1 precedent; seed re-seat target) Source/ProjectEternal/Private/Abilities/EchoStrikeAbility.cpp
Status cascade (detonation-boundary precedent) Source/ProjectEternal/Private/Abilities/StatusCascadeAbility.cpp
Chain sheets (authoring artifact) Design/Chains/*.chain.md (_TEMPLATE.chain.md, exemplar SunderingSlam.chain.md)
Decision record + roadmap ImplementationDocs/AbilityChains.plan.md §2–3

Recent Changes

Date Change Impact
2026-07-10 Page created (AbilityChains Step 1a/1b) Chain provenance, generation, and rail rules made citable; Persona 2 worked trace with generation numbers; shipped deviations catalogued (Echo Strike + Hemorrhage Cascade post-exec seeds, dual anti-chain mechanisms); decision 14 default recorded pending owner confirmation
2026-07-10 Steps 2–3 merged (9c80cfc12) — enforcement went live Generation tags native + consumed in HandleIncomingDamage; all three shipped deviations fixed (pre-exec re-seats via UDamageSeedPayload, cast marker removed); Eternal.Chain.Stamp + CheckChainBundleCompleteness make the rails structural; first live chain (SunderingSlam) PIE-proven