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/ChainGen2spec tags, consumed byHandleIncomingDamage(which suppresses gen-2 from the proc event stream and marks gen-1 eventsEvent.Hit.ChainGen1). The proc family stamps them in C++ (Echo Strike, Status Cascade, Ground Hazard); the oldCast<AEchoStrikeActor>marker and per-consumer filters are gone. - Seeds are pre-exec by construction: chain/proc payloads carry a
UDamageSeedPayloadsnapshot 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.Stamprefuses a sheet that breaks them (linear only, depth ≤ 3, per-victim fan-out on the terminal link only) before any asset is created, andCheckChainBundleCompletenesserrors on any modifier/kit grant set with a danglingEvent.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 byEvent.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_Damagereads viaSpec.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.
2. Every link re-runs the exec under its own identity (decisions 2, 3, 7, 12)¶
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.cpp → HandleIncomingDamage |
| 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 |
Related systems¶
- GDD §2.4b "Ability chains" — the design statement this page underpins (Notion GDD)
- Ability Kits — kit-side bundle validation (secondary consumer of Step 2b)
- Ability Authoring Tools — stamp/validator spine chains extend
- New Ability Archetype Recipe — where Step 2's atoms land
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 |