From be7eadd86a07a7a69e0f2998a647f1dcdeb288fb Mon Sep 17 00:00:00 2001 From: Roland Salardon Date: Tue, 11 Aug 2026 02:28:53 +0200 Subject: [PATCH 1/4] fix(down-contribution): open pre-down window before first HealthUpdate Elite Insights' IsDownedBeforeNext90 treats a hit delivered before the target's first HealthUpdate as hitting at unknown HP (<=90%), so the pre-down window must open right after the last rally even without a health reading. Parity regression on 20260213-213832: 503 vs 267. Signed-off-by: Roland Salardon --- .../src/gw2_analytics/down_contribution.py | 9 ++++ .../tests/test_down_contribution.py | 41 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/libs/gw2_analytics/src/gw2_analytics/down_contribution.py b/libs/gw2_analytics/src/gw2_analytics/down_contribution.py index 3d97e6ca..191f8e4f 100644 --- a/libs/gw2_analytics/src/gw2_analytics/down_contribution.py +++ b/libs/gw2_analytics/src/gw2_analytics/down_contribution.py @@ -207,6 +207,7 @@ def _pre_down_windows( ), default=-1, ) + saw_above_90 = False for health in health_by_target.get(down.source_agent_id, []): if health.time_ms <= last_up: continue @@ -214,8 +215,16 @@ def _pre_down_windows( break if health.health_percent > 90.0: start = None + saw_above_90 = True elif start is None: start = health.time_ms + # Elite Insights' IsDownedBeforeNext90 treats a hit delivered before + # the target's first HealthUpdate as hitting at unknown HP (<=90%), + # so the pre-down window opens right after the last rally even when + # no HealthUpdate has been seen yet. Without this, parity with EI is + # off by the damage dealt before the first recorded HealthUpdate. + if not saw_above_90: + start = last_up + 1 if start is not None: windows[down.source_agent_id].append((start, down.time_ms)) return windows diff --git a/libs/gw2_analytics/tests/test_down_contribution.py b/libs/gw2_analytics/tests/test_down_contribution.py index dcc1f2aa..a7aded84 100644 --- a/libs/gw2_analytics/tests/test_down_contribution.py +++ b/libs/gw2_analytics/tests/test_down_contribution.py @@ -391,6 +391,47 @@ def contribution(death_time_ms: int) -> int: assert contribution(1_001) == 500 +def test_pre_down_damage_before_first_health_update_counts() -> None: + """EI treats HP as unknown (<=90%) before the first HealthUpdate. + + Parity regression on ``20260213-213832``: creative.1094 (agent 2980) + lands 236 dmg at t=35, before the target's first HealthUpdate at t=88. + EI's ``IsDownedBeforeNext90`` counts it because the target will down + before its next >90% recovery -- even though the hit precedes every + recorded health reading. Before the fix we opened the pre-down window + at the first <=90% HealthUpdate and reported 267 instead of the EI + value of 503. + """ + rows = DownContributionAggregator().aggregate( + [ + _damage(source=1, target=2, damage=236, time_ms=35), + _damage(source=1, target=2, damage=267, time_ms=260), + DamageEvent( + time_ms=6_318, + source_agent_id=1, + target_agent_id=2, + skill_id=71892, + damage=201, + against_downed=True, + ), + ], + [_down(agent=2, time_ms=2_747)], + [_death(agent=2, time_ms=8_681)], + duration_s=900.0, + health_events=[ + HealthUpdateEvent( + time_ms=88, source_agent_id=2, target_agent_id=0, skill_id=0, health_percent=13.46 + ), + HealthUpdateEvent( + time_ms=380, source_agent_id=2, target_agent_id=0, skill_id=0, health_percent=6.0 + ), + ], + ) + row = rows[0] + assert row.down_contribution_damage == 503 # 236 + 267 == EI value + assert row.against_downed_damage == 201 + + def test_against_downed_counts_a_landed_hit_that_dealt_no_damage() -> None: """The counter asks whether the hit landed, not whether it hurt. From e1233ef6e3d6d84731049db1efd50d5298c5a8be Mon Sep 17 00:00:00 2001 From: Roland Salardon Date: Tue, 11 Aug 2026 02:28:53 +0200 Subject: [PATCH 2/4] fix(parser): exclude HealthUpdate/StackActive statechanges from awareness scan These statechange kinds can reference a stale agent id after that agent despawned (druid heal on merged minion raw id, swiftness/protection stack ticks on despawned minion ids), overcounting boon uptime on every combined-player minion. EI truncates awareness at despawn+10. Signed-off-by: Roland Salardon --- .../src/gw2_evtc_parser/parser.py | 31 ++++++++++++++++ .../tests/test_parser_awareness.py | 35 ++++++++++++++++--- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py b/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py index 36b367b2..77e19b80 100644 --- a/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py +++ b/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py @@ -2445,6 +2445,24 @@ def scan_agent_awareness(source: BinaryIO | bytes) -> dict[int, tuple[int, int]] Records with ``time == 0`` are header-ish metadata rather than combat activity and are skipped, matching the parser's own down-duration scan. + + Two arcdps statechange kinds are excluded because they reference a + stale agent id after that agent has despawned, contaminating the + last-aware bound. On the corpus these are the only kinds that + produce phantom mentions of despawned combined-player raw ids: + + - ``HealthUpdate`` (byte 8): a heal on a minion whose raw id was + reused/merged can be logged against the *old* raw id long after + it despawned (druid healing instance 5342 at t=103267 while the + alive raw agent is 3534, not the dead 3515). + - ``StackActive`` (byte 27): a buff-stack activation tick on a + despawned minion's stale id (swiftness tick on 3514 at t=24012 + after 3514 despawned at 16799, protection tick on 4182 at + t=141972 after 4182 despawned at 129403). + + Without these exclusions every combined-player minion overcounts + boon uptime: the awareness bound runs past the despawn, so the buff + simulation accrues uptime that EI correctly truncates at despawn+10. """ data = _read_all(source) build_str = data[BUILD_OFFSET : BUILD_OFFSET + 8].decode("ascii", errors="replace") @@ -2452,6 +2470,9 @@ def scan_agent_awareness(source: BinaryIO | bytes) -> dict[int, tuple[int, int]] unpack = ( _EVENT_STRUCT_EVENTS_2025.unpack_from if is_evtc_2025 else _EVENT_STRUCT_EVENTS.unpack_from ) + # ponytail: is_statechange tuple index differs between the 2025 and legacy + # structs (16 vs 7). The exclusion set is the same either way. + _statechange_index = 16 if is_evtc_2025 else 7 cursor = _compute_post_skills_offset(data, is_evtc_2025=is_evtc_2025) end = len(data) @@ -2465,6 +2486,8 @@ def scan_agent_awareness(source: BinaryIO | bytes) -> dict[int, tuple[int, int]] continue if origin is None or time_ms < origin: origin = time_ms + if unpacked[_statechange_index] in _AWARENESS_EXCLUDED_STATECHANGES: + continue for agent_id in (src_agent, dst_agent): if not agent_id: continue @@ -2482,6 +2505,14 @@ def scan_agent_awareness(source: BinaryIO | bytes) -> dict[int, tuple[int, int]] } +#: arcdps statechange kinds excluded from :func:`scan_agent_awareness`. +#: See that function's docstring for the contamination rationale. +_AWARENESS_EXCLUDED_STATECHANGES: Final[frozenset[int]] = frozenset({ + 8, # HealthUpdate — stale mentions of despawned merged-player raw ids + 27, # StackActive — buff-stack ticks on despawned minion stale ids +}) + + #: arcdps buff id for Regeneration. The only buff Elite Insights routes #: through its regeneration-specific stacking logic. _REGENERATION_BUFF_ID: Final[int] = 718 diff --git a/libs/gw2_evtc_parser/tests/test_parser_awareness.py b/libs/gw2_evtc_parser/tests/test_parser_awareness.py index ebc04623..50acc1f8 100644 --- a/libs/gw2_evtc_parser/tests/test_parser_awareness.py +++ b/libs/gw2_evtc_parser/tests/test_parser_awareness.py @@ -19,13 +19,16 @@ from gw2_evtc_parser import scan_agent_awareness _CBTEVENT_FMT = struct.Struct(" bytes: - """One 64-byte cbtevent carrying only a timestamp and the two actors.""" - return _CBTEVENT_FMT.pack( - time_ms, src_agent, dst_agent, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 +def _event(time_ms: int, src_agent: int, dst_agent: int, is_statechange: int = 0) -> bytes: + """One 64-byte cbtevent carrying timestamp, actors, and statechange.""" + # is_statechange is tuple index 7 in the legacy unpack struct: + # (time, src, dst, val, buff_dmg, skill, is_nondamage, is_statechange, ...) + return _LEGACY_STRUCT.pack( + time_ms, src_agent, dst_agent, 0, 0, 0, 0, is_statechange, 0, 0, 0, 0 ) @@ -79,3 +82,27 @@ def test_scan_agent_awareness_ignores_absent_agent_ids() -> None: assert 0 not in awareness assert awareness == {10: (0, 0)} + + +def test_scan_agent_awareness_excludes_healthupdate_and_stackactive() -> None: + """HealthUpdate (8) and StackActive (27) carry stale agent mentions post-despawn.""" + raw = _evtc([ + _event(1_000, 10, 0, is_statechange=0), + _event(2_000, 10, 0, is_statechange=8), # HealthUpdate — skipped + _event(3_000, 10, 0, is_statechange=27), # StackActive — skipped + _event(4_000, 10, 0, is_statechange=0), + ]) + awareness = scan_agent_awareness(raw) + + # 2_000 and 3_000 events are skipped, so span runs 1_000 to 4_000 (relative: 0 to 3_000) + assert awareness[10] == (0, 3_000) + + # If all non-first events are skipped statechanges, the agent only stays aware at 1_000 + raw_only_skipped = _evtc([ + _event(1_000, 10, 0, is_statechange=0), + _event(5_000, 10, 0, is_statechange=8), + _event(6_000, 10, 0, is_statechange=27), + ]) + awareness_skipped = scan_agent_awareness(raw_only_skipped) + assert awareness_skipped[10] == (0, 0) + From 49ffdf215a4b9fd768d0bf83222bb5cdd26467f1 Mon Sep 17 00:00:00 2001 From: Roland Salardon Date: Tue, 11 Aug 2026 12:21:21 +0200 Subject: [PATCH 3/4] fix(buff-state): honor active initial regen stacks Signed-off-by: Roland Salardon --- .../src/gw2_analytics/buff_state.py | 5 ++++ libs/gw2_analytics/tests/test_buff_state.py | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/libs/gw2_analytics/src/gw2_analytics/buff_state.py b/libs/gw2_analytics/src/gw2_analytics/buff_state.py index e2200e8c..7e35ca85 100644 --- a/libs/gw2_analytics/src/gw2_analytics/buff_state.py +++ b/libs/gw2_analytics/src/gw2_analytics/buff_state.py @@ -506,6 +506,11 @@ def _process_buff_apply(self, event: BuffApplyEvent) -> None: del target_tracker.expirations[_capacity_for(buff_name) :] del target_tracker.stack_ids[_capacity_for(buff_name) :] del target_tracker.healing_scores[_capacity_for(buff_name) :] + if event.added_active and event.stack_id in target_tracker.stack_ids: + index = target_tracker.stack_ids.index(event.stack_id) + target_tracker.expirations.insert(0, target_tracker.expirations.pop(index)) + target_tracker.stack_ids.insert(0, target_tracker.stack_ids.pop(index)) + target_tracker.healing_scores.insert(0, target_tracker.healing_scores.pop(index)) def _process_buff_extension(self, event: BuffExtensionEvent) -> None: buff_name = _get_buff_name(event.skill_id) diff --git a/libs/gw2_analytics/tests/test_buff_state.py b/libs/gw2_analytics/tests/test_buff_state.py index cc904ba8..7120cb28 100644 --- a/libs/gw2_analytics/tests/test_buff_state.py +++ b/libs/gw2_analytics/tests/test_buff_state.py @@ -450,6 +450,34 @@ def apply(time_ms: int, duration_ms: int, stack_id: int) -> BoonApplyEvent: assert tracker.compute_player_uptimes(7, 3_000)["regeneration"] == 100.0 +def test_initial_regeneration_active_stack_starts_at_front() -> None: + tracker = BuffStateTracker() + + tracker.process( + BuffApplyEvent( + time_ms=0, + source_agent_id=1, + target_agent_id=7, + skill_id=718, + duration_ms=1_000, + stack_id=11, + ) + ) + tracker.process( + BuffApplyEvent( + time_ms=0, + source_agent_id=1, + target_agent_id=7, + skill_id=718, + duration_ms=5_000, + stack_id=22, + added_active=True, + ) + ) + + assert tracker.compute_player_uptimes(7, 3_000)["regeneration"] == 100.0 + + def _regen_apply(time_ms: int, duration_ms: int, stack_id: int) -> BoonApplyEvent: return BoonApplyEvent( time_ms=time_ms, From be19f25dc05dc49fc31ed94588968901fab26b3c Mon Sep 17 00:00:00 2001 From: Roland Salardon Date: Tue, 11 Aug 2026 12:23:53 +0200 Subject: [PATCH 4/4] style(parser): format awareness exclusions Signed-off-by: Roland Salardon --- .../src/gw2_evtc_parser/parser.py | 10 ++++--- .../tests/test_parser_awareness.py | 27 ++++++++++--------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py b/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py index 77e19b80..8d083fb5 100644 --- a/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py +++ b/libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py @@ -2507,10 +2507,12 @@ def scan_agent_awareness(source: BinaryIO | bytes) -> dict[int, tuple[int, int]] #: arcdps statechange kinds excluded from :func:`scan_agent_awareness`. #: See that function's docstring for the contamination rationale. -_AWARENESS_EXCLUDED_STATECHANGES: Final[frozenset[int]] = frozenset({ - 8, # HealthUpdate — stale mentions of despawned merged-player raw ids - 27, # StackActive — buff-stack ticks on despawned minion stale ids -}) +_AWARENESS_EXCLUDED_STATECHANGES: Final[frozenset[int]] = frozenset( + { + 8, # HealthUpdate — stale mentions of despawned merged-player raw ids + 27, # StackActive — buff-stack ticks on despawned minion stale ids + } +) #: arcdps buff id for Regeneration. The only buff Elite Insights routes diff --git a/libs/gw2_evtc_parser/tests/test_parser_awareness.py b/libs/gw2_evtc_parser/tests/test_parser_awareness.py index 50acc1f8..dacffe80 100644 --- a/libs/gw2_evtc_parser/tests/test_parser_awareness.py +++ b/libs/gw2_evtc_parser/tests/test_parser_awareness.py @@ -86,23 +86,26 @@ def test_scan_agent_awareness_ignores_absent_agent_ids() -> None: def test_scan_agent_awareness_excludes_healthupdate_and_stackactive() -> None: """HealthUpdate (8) and StackActive (27) carry stale agent mentions post-despawn.""" - raw = _evtc([ - _event(1_000, 10, 0, is_statechange=0), - _event(2_000, 10, 0, is_statechange=8), # HealthUpdate — skipped - _event(3_000, 10, 0, is_statechange=27), # StackActive — skipped - _event(4_000, 10, 0, is_statechange=0), - ]) + raw = _evtc( + [ + _event(1_000, 10, 0, is_statechange=0), + _event(2_000, 10, 0, is_statechange=8), # HealthUpdate — skipped + _event(3_000, 10, 0, is_statechange=27), # StackActive — skipped + _event(4_000, 10, 0, is_statechange=0), + ] + ) awareness = scan_agent_awareness(raw) # 2_000 and 3_000 events are skipped, so span runs 1_000 to 4_000 (relative: 0 to 3_000) assert awareness[10] == (0, 3_000) # If all non-first events are skipped statechanges, the agent only stays aware at 1_000 - raw_only_skipped = _evtc([ - _event(1_000, 10, 0, is_statechange=0), - _event(5_000, 10, 0, is_statechange=8), - _event(6_000, 10, 0, is_statechange=27), - ]) + raw_only_skipped = _evtc( + [ + _event(1_000, 10, 0, is_statechange=0), + _event(5_000, 10, 0, is_statechange=8), + _event(6_000, 10, 0, is_statechange=27), + ] + ) awareness_skipped = scan_agent_awareness(raw_only_skipped) assert awareness_skipped[10] == (0, 0) -