@@ -10400,6 +10400,113 @@ void tracker_recalculate_progress(Tracker *t, const AppSettings *settings) {
1040010400}
1040110401
1040210402
10403+ // Defined in main.cpp. Shared so the host-side Hermes path can update the
10404+ // per-player and merged snapshot caches directly without re-reading disk.
10405+ extern size_t serialize_template_data(TemplateData *td, char *buffer);
10406+ extern bool merge_coop_progress(const char *buffer, TemplateData *target);
10407+
10408+
10409+ // Apply a single Hermes event to (a) the source player's snapshot and (b) the
10410+ // merged snapshot, then restore the currently-displayed view into
10411+ // t->template_data based on the dropdown selection. Returns true if any
10412+ // snapshot actually changed.
10413+ //
10414+ // Rationale: the host sees Hermes events from every player in the lobby (LAN
10415+ // games route everyone's stats through the host's world). Applying those events
10416+ // directly to t->template_data made the currently-visible view drift regardless
10417+ // of which player the dropdown had selected. The snapshots are authoritative
10418+ // per-player state; template_data is just a display buffer rebuilt from them.
10419+ static bool hermes_apply_event_to_coop_snapshots(
10420+ Tracker *t, const AppSettings *settings,
10421+ int player_idx, const char *event_type, const cJSON *data,
10422+ char *workbuf, size_t workbuf_size) {
10423+ if (!t || !t->template_data || !settings || !workbuf) return false;
10424+
10425+ bool is_stat = (strcmp(event_type, "stat") == 0);
10426+ bool is_adv = (strcmp(event_type, "advancement") == 0);
10427+ if (!is_stat && !is_adv) return false;
10428+
10429+ bool any_changed = false;
10430+
10431+ // 1. Per-player snapshot: single-player semantics (highest-wins for stats).
10432+ if (player_idx >= 0 && player_idx < MAX_COOP_PLAYERS &&
10433+ t->coop_player_snapshots[player_idx] &&
10434+ t->coop_player_snapshot_sizes[player_idx] >= sizeof(TemplateData)) {
10435+ if (merge_coop_progress(t->coop_player_snapshots[player_idx], t->template_data)) {
10436+ bool changed = false;
10437+ if (is_stat) {
10438+ changed = hermes_apply_stat_event(t, data, false);
10439+ } else {
10440+ changed = hermes_apply_advancement_event(t, data);
10441+ }
10442+ if (changed) {
10443+ tracker_recalculate_progress(t, settings);
10444+ size_t new_size = serialize_template_data(t->template_data, workbuf);
10445+ if (new_size > 0 && new_size <= workbuf_size) {
10446+ char *new_buf = (char *) realloc(t->coop_player_snapshots[player_idx], new_size);
10447+ if (new_buf) {
10448+ memcpy(new_buf, workbuf, new_size);
10449+ t->coop_player_snapshots[player_idx] = new_buf;
10450+ t->coop_player_snapshot_sizes[player_idx] = new_size;
10451+ any_changed = true;
10452+ }
10453+ }
10454+ }
10455+ }
10456+ }
10457+
10458+ // 2. Merged snapshot: coop_stat_merge-aware. Relies on the per-UUID delta
10459+ // cache (seeded from disk during tracker_update_coop_merged) so repeat
10460+ // events from the same player advance the merged total correctly.
10461+ if (t->coop_merged_snapshot && t->coop_merged_snapshot_size >= sizeof(TemplateData)) {
10462+ if (merge_coop_progress(t->coop_merged_snapshot, t->template_data)) {
10463+ bool changed = false;
10464+ const char *ev_uuid = (player_idx >= 0 && player_idx < settings->coop_player_count)
10465+ ? settings->coop_players[player_idx].uuid
10466+ : nullptr;
10467+ if (is_stat) {
10468+ if (settings->coop_stat_merge == COOP_STAT_CUMULATIVE) {
10469+ changed = hermes_apply_stat_event_cumulative(t, data, ev_uuid, false);
10470+ } else {
10471+ bool c1 = hermes_apply_stat_event(t, data, true);
10472+ bool c2 = hermes_apply_stat_event_cumulative(t, data, ev_uuid, true);
10473+ changed = c1 || c2;
10474+ }
10475+ } else {
10476+ changed = hermes_apply_advancement_event(t, data);
10477+ }
10478+ if (changed) {
10479+ tracker_recalculate_progress(t, settings);
10480+ size_t new_size = serialize_template_data(t->template_data, workbuf);
10481+ if (new_size > 0 && new_size <= workbuf_size) {
10482+ char *new_buf = (char *) realloc(t->coop_merged_snapshot, new_size);
10483+ if (new_buf) {
10484+ memcpy(new_buf, workbuf, new_size);
10485+ t->coop_merged_snapshot = new_buf;
10486+ t->coop_merged_snapshot_size = new_size;
10487+ any_changed = true;
10488+ }
10489+ }
10490+ }
10491+ }
10492+ }
10493+
10494+ // 3. Restore the currently-displayed view so the UI reflects the update.
10495+ int sel = t->selected_coop_player_idx;
10496+ const char *restore_buf = nullptr;
10497+ if (sel >= 0 && sel < MAX_COOP_PLAYERS && t->coop_player_snapshots[sel]) {
10498+ restore_buf = t->coop_player_snapshots[sel];
10499+ } else if (t->coop_merged_snapshot) {
10500+ restore_buf = t->coop_merged_snapshot;
10501+ }
10502+ if (restore_buf) {
10503+ merge_coop_progress(restore_buf, t->template_data);
10504+ }
10505+
10506+ return any_changed;
10507+ }
10508+
10509+
1040310510void tracker_poll_hermes_log(Tracker *t, const AppSettings *settings) {
1040410511 if (!settings->using_hermes || !t->hermes_active || !t->hermes_play_log)
1040510512 return;
@@ -10408,6 +10515,11 @@ void tracker_poll_hermes_log(Tracker *t, const AppSettings *settings) {
1040810515 return;
1040910516
1041010517 bool any_changed = false;
10518+ bool snapshots_changed = false;
10519+ // Lazily allocated scratch buffer for snapshot re-serialization. Sized to
10520+ // match the broadcast buffer used on the file-merge path.
10521+ char *workbuf = nullptr;
10522+ const size_t workbuf_size = 4 * 1024 * 1024;
1041110523
1041210524 while (true) {
1041310525 long start_offset = ftell(t->hermes_play_log);
@@ -10462,12 +10574,15 @@ void tracker_poll_hermes_log(Tracker *t, const AppSettings *settings) {
1046210574
1046310575 // --- Player identity filter ---
1046410576 // Hermes events include a "player" object with "name" and "uuid".
10465- // In coop HOST mode: accept events from any player in the lobby roster.
10577+ // In coop HOST mode: accept events from any player in the lobby roster,
10578+ // and remember which roster slot they came from so the event can be
10579+ // applied to that player's snapshot specifically.
1046610580 // In singleplayer/receiver: accept only the local player's events.
1046710581 // Match by UUID first (authoritative, case-insensitive hex), then
1046810582 // fall back to case-insensitive username (Hermes "name" may differ
1046910583 // in case; legacy stats files are fully lowercase).
1047010584 const char *event_player_uuid = nullptr; // Used for stat cache key
10585+ int matched_player_idx = -1; // Roster index of the source player (coop host)
1047110586 cJSON *player_json = cJSON_GetObjectItem(data, "player");
1047210587 if (cJSON_IsObject(player_json)) {
1047310588 cJSON *uuid_json = cJSON_GetObjectItem(player_json, "uuid");
@@ -10485,12 +10600,14 @@ void tracker_poll_hermes_log(Tracker *t, const AppSettings *settings) {
1048510600 strcasecmp(ev_uuid, rp->uuid) == 0) {
1048610601 player_match = true;
1048710602 event_player_uuid = ev_uuid;
10603+ matched_player_idx = p;
1048810604 break;
1048910605 }
1049010606 if (!player_match && ev_name && rp->username[0] != '\0' &&
1049110607 strcasecmp(ev_name, rp->username) == 0) {
1049210608 player_match = true;
1049310609 event_player_uuid = ev_uuid; // may be null, that's OK
10610+ matched_player_idx = p;
1049410611 break;
1049510612 }
1049610613 }
@@ -10513,9 +10630,27 @@ void tracker_poll_hermes_log(Tracker *t, const AppSettings *settings) {
1051310630 // If no player object in event, allow it through (older Hermes versions)
1051410631
1051510632 // --- Dispatch event ---
10516- if (strcmp(type, "stat") == 0) {
10517- bool is_coop_host = (settings->network_mode == NETWORK_HOST &&
10518- t->hermes_coop_stat_cache);
10633+ bool is_coop_host = (settings->network_mode == NETWORK_HOST &&
10634+ t->hermes_coop_stat_cache);
10635+ bool have_snapshots = (matched_player_idx >= 0 &&
10636+ matched_player_idx < MAX_COOP_PLAYERS &&
10637+ t->coop_player_snapshots[matched_player_idx] &&
10638+ t->coop_merged_snapshot);
10639+
10640+ if (is_coop_host && have_snapshots &&
10641+ (strcmp(type, "stat") == 0 || strcmp(type, "advancement") == 0)) {
10642+ // Per-player isolation path: update the source player's snapshot
10643+ // and the merged snapshot, then restore the selected view.
10644+ if (!workbuf) {
10645+ workbuf = (char *) malloc(workbuf_size);
10646+ }
10647+ if (workbuf &&
10648+ hermes_apply_event_to_coop_snapshots(t, settings, matched_player_idx,
10649+ type, data, workbuf, workbuf_size)) {
10650+ any_changed = true;
10651+ snapshots_changed = true;
10652+ }
10653+ } else if (strcmp(type, "stat") == 0) {
1051910654 if (is_coop_host && settings->coop_stat_merge == COOP_STAT_CUMULATIVE) {
1052010655 // CUMULATIVE mode: track per-player deltas for everything
1052110656 if (hermes_apply_stat_event_cumulative(t, data, event_player_uuid))
@@ -10543,9 +10678,22 @@ void tracker_poll_hermes_log(Tracker *t, const AppSettings *settings) {
1054310678 }
1054410679
1054510680 if (any_changed) {
10546- tracker_recalculate_progress(t, settings);
10681+ // The snapshot path already calls tracker_recalculate_progress inside
10682+ // hermes_apply_event_to_coop_snapshots (per snapshot) and restores the
10683+ // displayed view. Only recalc here for the legacy direct-apply path.
10684+ if (!snapshots_changed) {
10685+ tracker_recalculate_progress(t, settings);
10686+ }
1054710687 t->hermes_wants_ipc_flush = true;
10688+ // If we updated coop snapshots, broadcast the new merged view to
10689+ // receivers. The main loop's g_coop_broadcast_needed handler takes
10690+ // care of serialization + coop_net_broadcast.
10691+ if (snapshots_changed) {
10692+ SDL_SetAtomicInt(&g_coop_broadcast_needed, 1);
10693+ }
1054810694 }
10695+
10696+ if (workbuf) free(workbuf);
1054910697}
1055010698
1055110699// =============================================================================
0 commit comments