Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions libs/gw2_analytics/src/gw2_analytics/buff_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions libs/gw2_analytics/src/gw2_analytics/down_contribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,15 +207,24 @@ 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
if health.time_ms >= down.time_ms:
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
Expand Down
28 changes: 28 additions & 0 deletions libs/gw2_analytics/tests/test_buff_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions libs/gw2_analytics/tests/test_down_contribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
33 changes: 33 additions & 0 deletions libs/gw2_evtc_parser/src/gw2_evtc_parser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2445,13 +2445,34 @@ 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")
is_evtc_2025 = _build_version_from_build_str(build_str) >= 2025_00_00
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)

Expand All @@ -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
Expand All @@ -2482,6 +2505,16 @@ 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
Expand Down
38 changes: 34 additions & 4 deletions libs/gw2_evtc_parser/tests/test_parser_awareness.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@
from gw2_evtc_parser import scan_agent_awareness

_CBTEVENT_FMT = struct.Struct("<QQQiiIIHHHBBBBBBBBIIBB")
_LEGACY_STRUCT = struct.Struct("<QQQii4xI7xbbbbbb11x")
_AGENT_NAME_SIZE = 72


def _event(time_ms: int, src_agent: int, dst_agent: int) -> 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
)


Expand Down Expand Up @@ -79,3 +82,30 @@ 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)
Loading