Skip to content

Fix backpack/curios duplication, performance overhaul, extended mod compat#173

Open
laforetbrut wants to merge 12 commits into
mlus-asuka:1.21.1-devfrom
Team-Arcadia:Arcadia-Fix
Open

Fix backpack/curios duplication, performance overhaul, extended mod compat#173
laforetbrut wants to merge 12 commits into
mlus-asuka:1.21.1-devfrom
Team-Arcadia:Arcadia-Fix

Conversation

@laforetbrut
Copy link
Copy Markdown

Hey @mlus-asuka,

I've been running PlayerSync on our network (Arcadia Echoes of Power modpack, ~30 concurrent players) and ran into a couple of nasty issues that turned into a fairly large patch. I've cleaned everything up and battle-tested it on production for a few weeks — opening it as a PR in case any of it is worth upstreaming.

⚠️ Heads-up before reviewing

This branch removes Cobblemon integration and the chat-sync feature entirely. Both were running synchronous JDBC on the main thread (or close to it) and were the largest source of TPS issues on our network. I didn't want to silently drop features other users might rely on, so I'd rather flag this clearly than have it surprise anyone.

A few options on how to handle that on your side:

  • Cherry-pick what you want into a new branch off 1.21.1-dev (e.g. 1.21.1-perf). Keep Cobblemon / chat-sync on the existing line for users who depend on them.
  • Use that new branch as a base to forward-port the same fixes to your other version branches (1.20.1, 1.20.4, etc.) — most of the perf and anti-dup logic is independent of the MC version.
  • At worst, create a dedicated Arcadia branch that lives alongside 1.21.1-dev — explicitly tuned for the Arcadia Echoes of Power modpack (no Cobblemon, no chat-sync, perf-first). That keeps your mainline clean and gives modpacks with a similar setup something they can target directly. I'd be happy to maintain that branch and forward upstream-able fixes back to 1.21.1-dev over time.

What it fixes

Backpack duplication (root cause)

BackpackStorage.setBackpackContents() upstream is actually a shallow merge, not a replace, when the UUID already exists. Stale sub-tags from a previous session survived every restore — players ended up with their old backpack contents alongside the new ones on rejoin. The fix is one line: call removeBackpackContents(uuid) before setBackpackContents so the replacement is clean.

Curios cosmetic stacks were silently wiped

snapshotCuriosData / applyCuriosFromData only iterated getStacks() — every cosmetic curio (getCosmeticStacks()) was lost on every server transfer. Now both banks are captured and restored under a cos:slotType:index key. Old-format rows without the cos: prefix still parse unchanged so existing DBs upgrade cleanly.

Several race conditions

  • pendingLogoutSaves.put() now happens before the async dispatch, with a manually completed CompletableFuture. The old ordering let a fast reconnect observe null while the save was already queued.
  • executeBatchTransaction now returns per-statement affected-row counts. writeSnapshotToDB calls SyncLogger.guardBlocked(...) when the core UPDATE silently no-ops because another server has claimed last_server. That was completely invisible before.
  • onPlayerDeath is now EventPriority.LOW with an event.isCanceled() guard — defends against Revive Me / Corail Tombstone cancelling the death.
  • RejectedExecutionException from the executor during server stop is caught and the future is drained, so a rejoin doesn't hang for 15 s.

Sophisticated Storage thread-safety

SS contents are now snapshotted on the main thread (snapshotSSData returns a frozen .copy() of each entry) and persisted from the background. Previously ItemContentsStorage was read directly from a background thread against a non-thread-safe HashMap.

closeContainer was too aggressive

The class-name heuristic (menu.getClass().getName().contains("curio") || "accessor" || …) could force-close unrelated mod menus mid-transaction. Replaced with a strict slot-reference scan: only menus whose slots actually point at the disconnecting player's inventory or ender-chest are closed.

What's removed (please read)

Cobblemon integration

The 6 mixins were running synchronous JDBC on the Cobblemon save thread (often the main server thread) and building SQL with raw UUID concatenation. The full PC NBT was serialized to SNBT via toString() on every save — multi-MB strings injected into a SQL string. Removed entirely. Existing cobblemon tables in the DB are left untouched for backward compat — the table just becomes orphaned, no data destroyed. If you want Cobblemon support back, I'd rewrite it in a separate addon-jar pattern so the perf cost only hits servers that actually use it.

Chat sync

319 LoC of socket/thread code guarded by a config flag that defaulted to false. Orphaned config keys (sync_chat, IsChatServer, ChatServerIP, ChatServerPort) are silently ignored by NeoForge ModConfig on upgrade — no crash. Same recommendation: if you want it back, separate addon.

What's added

Dedicated logging system (SyncLogger)

A separate diagnostic log written to logs/playersync/sync.log with categories: SAVE, RESTORE, DUPE_RISK, DATA_LOSS, RACE, GUARD, SAVE_FAIL, RESTORE_FAIL, SAVE_SKIP, PERF_SLOW, EVENT. Rotates at 10 MB, keeps 5 files. Truly async — a dedicated low-priority daemon scheduler flushes every 500 ms, so no log call ever touches I/O on the caller thread. This made the duplication root-cause analysis possible — every silently-blocked write (last_server guard mismatch) now shows up as a GUARD entry.

Admin commands (/playersync)

Full /playersync command tree with 14 admin subcommands: pool status, force-save / force-restore / force-unlock a UUID, list pending logout saves, inspect a player's inventory in DB, dump current config, toggle verbose logging at runtime, etc. Plus 20+ new config keys to fine-tune behaviour without recompiling. (These shipped in our Phase 8 commit — kept verbatim in this branch.)

Extended mod compatibility

  • Curios — functional + cosmetic stacks
  • Sophisticated Storage — shulker / barrel / chest contents carried as items
  • Sophisticated Core — explicit dep handling
  • Refined Storage — disk contents linked via inventory items
  • Accessories — full slot coverage (used by The Aether)
  • The Aether — Accessories + AETHER_PLAYER attachment
  • Cosmetic Armor Reworked — the 4 cosmetic slots
  • Apotheosis — DataComponents + WORLD_TIER / RADIAL_MINING_MODE attachments
  • Apothic Enchanting / Apothic Attributes / Apothic Spawners
  • Revive Me, Corpse / Gravestone (+ Curios-Compat)

Any mod using NeoForge AttachmentTypes is now picked up automatically via reflection on AttachmentHolder.serializeAttachments / deserializeAttachments (resolved once in a static initializer and cached as Method references). That covers Ars Nouveau, Iron's Spellbooks, Pehkui, Spice of Life: Onion, etc. without any explicit handler.

Configurable table prefix

New table_prefix config option (defaults to empty for backward compat). Lets admins share a single MySQL database with other mods (LuckPerms, custom mods) without table-name collisions. Routes through a new Tables helper so every SQL string in the codebase resolves at runtime.

HikariCP retune

Pool size 25 → 15, connectionTimeout 30 s → 10 s, idleTimeout 600 s → 300 s, leakDetectionThreshold 10 s → 25 s (covers the worst-case doPlayerJoin poll without log spam).

Defensive guards

  • onServerStarting early-returns on integrated (single-player) servers — no more noisy MySQL attempts when someone loads the mod in a dev client.
  • alterColumnIfNeeded helper queries INFORMATION_SCHEMA.COLUMNS before any ALTER TABLE … MODIFY COLUMN so we don't re-run an ALTER that forces an MDL lock + table rebuild on every boot.

Performance results

Measured on production (Arcadia Echoes of Power modpack, 30 active players, heavy modset — Apotheosis, Sophisticated*, RS, Aether, Curios, Iron's Spellbooks, Ars Nouveau, etc.):

Metric Before After
Main-thread CPU share owed to PlayerSync ~22 % 0.2 – 1 % (peaks at 1 % during join / leave)
Save MSPT spikes up to multi-second none observed
Logout save behaviour blocked main thread until commit fully async, returns immediately

The remaining ~1 % during joins is mostly the doPlayerJoin poll loop (waiting on the previous server's save to land). I think there's still room to push that further — replacing the Thread.sleep(500) loop with a ScheduledExecutorService exponential backoff would be the natural next step — but I've reached the limit of what I can confidently change without risking regressions. I'd rather stop on something tested than push experimental changes.

README & repo hygiene

  • README restored to its original structure (English only) — the Mod Support section is just extended to list everything PlayerSync now covers. Development Setup section untouched.
  • TEST_PROCEDURE_v2.1.5.html removed from the tree — it was a local QA artefact that shouldn't have been pushed. Added a .gitignore entry for TEST_PROCEDURE_*.html and test-procedures/ to keep it out.

Backward compatibility

Designed as a drop-in upgrade:

  • No destructive DDL — CREATE TABLE IF NOT EXISTS, no DROP, no ALTER COLUMN that changes existing types.
  • Removed config keys are silently ignored by NeoForge ModConfig.
  • Cobblemon tables in existing DBs are kept intact.
  • Curios rows in the old format (no cos: prefix) still load correctly.

How it was tested

  • Production: 30 concurrent players for several weeks on the Arcadia network.
  • Cross-server transfer: backpack + curios + cosmetic armor + Aether accessories all preserved end-to-end, repeated dozens of times.
  • Death / respawn / ReviveMe fallen state — no phantom effects, no XP duplication.
  • Server crash mid-save — last_server guard correctly blocks the stale write from the dead node.
  • Mass reconnect after a planned restart — no main-thread spikes.

Thanks for the original mod — it's a huge part of what makes our network actually work, and the design (atomic transactions, last_server guard, async writes) made all of this much easier to extend than it would otherwise have been. Happy to address any review feedback, split this into smaller PRs if it's easier to review, or rework anything you'd prefer done differently.

@mlus-asuka
Copy link
Copy Markdown
Owner

good job. but i don't think cobblemon integration and chat sync influence server so much. it won't work if cobblemon is not installed and chat sync can be disable in config too

@laforetbrut
Copy link
Copy Markdown
Author

Great! I'll leave you with this work and let you decide what you do with it :)

…e dup)

LivingDeathEvent fires BEFORE items drop, so the death-save's snapshot
captured the full pre-death inventory. With a Corpse/Gravestone mod the
items also live in the corpse — if the death-save committed AFTER the
logout-save (the two async tasks could interleave), the DB ended up
with the pre-death inventory and the player rejoined with everything
PLUS a corpse holding the same items = duplication.

Cliff-jump-then-disconnect was the easiest repro: the death event fires
on impact, the player drops out before respawn, both async saves race.

Fix:
- New writeNonItemSnapshotToDB() writes only xp / effects / score /
  food / health / advancements (+ last_server guard). The death-save
  now uses it instead of the full writeSnapshotToDB.
- Backpack / SS / RS2 main-thread snapshots are no longer captured on
  the death path (saves a few ms of NBT serialization too).
- Item-bearing tables (player_data.inventory, armor, enderchest,
  left_hand, cursors, mod_player_data, backpack_data) are now written
  exclusively by the logout-save or shutdown-save, whose snapshots are
  taken post-drop (empty for a dead player whose items went to a corpse).

Net effect: zero race possible between death-save and logout-save at the
item level — they write disjoint columns. Non-item progression earned
between the last auto-save and death is still preserved (the safety net
the death-save was designed to provide).

Backward compat: no schema change. Older DBs keep working.
5 surgical optimisations targeting the per-join hot path. Each one was
verified to be invariant-preserving (no schema change, no behaviour
change visible to mods or players).

A4 — Cache the SS clear-storage reflection
   Resolving Method/Field reflectively on every restored SS item meant
   33 inventory + 27 enderchest slots × N reflective walks per join.
   The lookup is now done once per concrete class (lazy, synchronised
   on the class object) and reused for all subsequent items.

A5 — isPlayerOnline O(n) -> O(1)
   Replaced the per-player UUID.toString allocation + linear scan over
   getPlayers() with the PlayerList's existing UUID-keyed lookup. Saves
   one allocation per call and goes from O(players) to O(1).

A8 — Fuse the two SELECT player_data queries in doPlayerJoin
   The existence check (SELECT uuid) and the full row read (SELECT *)
   touched the same row back-to-back. Merged into one SELECT * where
   rs.next() == false drives the new-player init path. Saves one MySQL
   round-trip on every existing-player join.

A7 — TTL + sweep on connectCheckCache
   ConcurrentHashMap entries were inserted by PlayerNegotiationEvent
   and removed by PlayerLoggedInEvent; if the handshake aborted between
   the two (client closes the window mid-login) the entry leaked.
   Wrapped in a record with an insertion timestamp, stale entries are
   purged every 600 ticks from the existing server tick handler, and
   any cached value older than 60 s is treated as a miss (forces the
   safe fallback DB path). Closes a slow memory leak.

A3 — Hash-check advancements before write+reload
   onDataPackSyncEvent did Files.write + playeradvancements.reload()
   on the main thread on every single join, even when the DB content
   was identical to what we last applied. reload() walks every
   criterion of every advancement (5-50 ms on a heavy datapack).
   Now we CRC32 the DB payload and skip both calls if it matches the
   last-applied hash for this player. First apply per JVM session
   still writes (no false skip).

Skipped this pass (next iteration):
- A6 (replace busy-poll with ScheduledExecutorService backoff) —
  touches cross-server claim handshake; needs more validation before
  changing live race-resolution logic.
- A2 (NBT deserialise on background thread) — registryAccess thread
  safety + DataComponents creation needs explicit sign-off before
  moving off main.

Estimated single-join main-thread cost reduction: 15-70 ms depending
on datapack size and SS item count. No schema change, no config change,
no API change — drop-in upgrade.
…eMe + corpse mod)

When ReviveMe / HardcoreRevival / CorailTombstone cancel LivingDeathEvent
to show their revive interface, NeoForge skipped PlayerSync's LOW-priority
onPlayerDeath handler entirely (receiveCanceled defaulted to false) — so
the mod had no record of the canceled death.

The player kept the full inventory in the downed state. On disconnect,
onPlayerLogout took its normal snapshot and wrote the inventory to DB.
The revive timer then finalized the death post-disconnect, items dropped,
the corpse/gravestone mod created a body holding the same inventory. On
reconnect: player respawned with the full restored inventory AND the
corpse held a second copy — full duplication.

Fix:
- onPlayerDeath now @SubscribeEvent(priority=LOW, receiveCanceled=true).
  The isCanceled() branch records the player in deathCanceledRecently
  (ConcurrentHashMap uuid -> timestamp, 2-min TTL).
- onPlayerLogout consults deathCanceledRecently before the normal save
  path. When the keepInventory game rule is OFF, calls the new
  handleReviveCanceledLogout which persists progression (xp/effects/
  score/food/health/advancements) but explicitly clears the item-dropping
  columns (inventory/armor/left_hand/cursors) in DB so the corpse becomes
  the single source of truth.
- New writeReviveLogoutClearItemsToDB bypasses refuse_empty_inventory_write
  (the empty write is intentional here) and sets online=0 +
  logout_started_at=NULL atomically.
- Tracking auto-clears on PlayerRespawnEvent and removePlayerLock.
- lastWrittenSnapshotHash.remove(uuid) in the BG task so a pending
  auto-save cannot resurrect the cleared inventory via the hash-skip.

KeepInventory=ON falls through to the normal save path — items stay on
the player, no corpse forms, no dup risk; clearing the DB would destroy
the player's items in that case.

The existing death-save (non-item) path remains unchanged; this fix only
adds a new branch in onPlayerLogout for the canceled-death case.
The r1 fix used @SubscribeEvent(receiveCanceled = true) to catch canceled
LivingDeathEvent firings, but NeoForge bus 8.x silently ignores this
annotation flag at dispatch time: SubscribeEventListener.invoke
unconditionally skips canceled events for ICancellableEvent, and the
constructor reads subInfo.priority() but never subInfo.receiveCanceled().
Only EventBus.addListener(priority, receiveCanceled, ...) respects the
flag via passNotGenericFilter. So r1 never received any canceled deaths
— the tracking map stayed empty and the bug persisted.

r2 fixes the detection with two independent signals:

1. Programmatic LivingDeathEvent listener registered in
   VanillaSync.register() via NeoForge.EVENT_BUS.addListener with
   priority=LOWEST and receiveCanceled=true. This DOES dispatch canceled
   events. Method onCanceledLivingDeath records the player in
   deathCanceledRecently and logs [revive-track].

2. Heuristic fallback at onPlayerLogout: if the player has at least one
   infinite-duration MobEffect AND health < 50% of max, treat as
   downed-state regardless of LivingDeathEvent cancellation. Catches
   revive mods that prevent death via LivingDamageEvent cancel or Mixin
   redirect (no LivingDeathEvent fires in that case). Beacons use
   duration=200 refreshed periodically (not isInfiniteDuration()=true),
   so they do not false-positive.

The existing @SubscribeEvent annotated onPlayerDeath is kept for
un-canceled deaths (the dispatcher delivers those normally) and the dead
isCanceled() check has been removed since it was unreachable.

Diagnostic logging at both the cancel-track and logout-detect points
([revive-track] / [revive-detect]) prints which signal fired (tracked
cancel vs heuristic), HP ratio, and keepInventory state — makes any
future regression debuggable from sync.log without code instrumentation.

keepInventory=ON still falls through to the normal save path; only
keepInventory=OFF triggers the DB inventory clear.
…lear

r2 used a programmatic LOWEST + receiveCanceled=true listener on
LivingDeathEvent. It only fires when LivingDeathEvent is actually canceled.
Some revive mods prevent death by canceling LivingDamageEvent earlier in
the pipeline or by Mixin-ing LivingEntity.die() / actuallyHurt() — in
those flows LivingDeathEvent never fires at all, our listener has
nothing to handle, and the tracking map stays empty. The heuristic
fallback (infinite-duration effect + HP < 50%) was also too restrictive:
not every revive mod applies infinite-duration effects, and not every
revive mod clamps HP below 50%.

r3 moves death detection to a @SubscribeEvent(priority = HIGHEST) hook
named onPlayerDeathAttempt. At HIGHEST priority no other handler has had
a chance to cancel the event, so isCanceled() is always false and the
dispatcher always delivers — we capture EVERY death attempt regardless
of whether it gets canceled later in the priority chain.

Adds @SubscribeEvent(priority = LOWEST) onPlayerHeal(LivingHealEvent)
which clears the tracking when the player is healed back to ≥80%
maxHealth. Covers the "revived and continued playing" case so a later
normal logout isn't wrongly treated as death-pending.

The onPlayerLogout heuristic (infinite-duration effect + HP < 50%) is
kept as a secondary safety net for revive mods that prevent death
without ever firing LivingDeathEvent.

Removes the no-longer-needed programmatic listener from register() and
the dead "if (event.isCanceled())" branch from onPlayerDeath LOW (the
dispatcher already filters canceled events for that handler — that
branch was always unreachable).

The onPlayerLogout fix path itself (clearing inventory / armor /
left_hand / cursors when keepInventory=OFF, atomic online=0 +
logout_started_at=NULL) is unchanged.

Logs: [death-track] on capture, [revive-detect] on logout fix-path
trigger — makes future regressions debuggable from sync.log.
… slots

r3 fixed the dup for the main inventory / armor / left_hand / cursors
columns of player_data, but the user still observed duplication on
Curios slots, Aether-Accessories slots, and Cosmetic Armor Reworked
slots — items stored in separate tables that writeReviveLogoutClearItemsToDB
was not touching.

Mod-specific item storage layout:
- Curios items → table `curios` column `curios_item`, keyed by player UUID
- Accessories slots (used by The Aether) → `mod_player_data` row with
  mod_id='accessories', data_value=serialized slot map
- Cosmetic Armor Reworked → `mod_player_data` row with mod_id='cosmeticarmor'

Each of these has a corpse-mod compat that catches dropped items into
the player's corpse on the post-disconnect death finalize. With the DB
copy intact, the rejoin restored the items AND the corpse held them =
the user's remaining "curios / aether / cosmetic armor" dup.

r4 extends the writeReviveLogoutClearItemsToDB batch to also UPDATE:
- `curios.curios_item` → '{}'
- `mod_player_data.data_value` where mod_id='accessories' → '{}'
- `mod_player_data.data_value` where mod_id='cosmeticarmor' → '{}'

The corresponding applyCuriosFromData / applyAccessoriesFromData /
applyCosmeticArmorFromData all short-circuit when data is null or
length() <= 2 — '{}' (length 2) triggers that skip-path, so the apply
functions clear the slots then return without restoring. Player rejoins
with empty curios / accessories / cosmetic slots; corpse holds the items
for retrieval.

INTENTIONALLY NOT cleared:
- mod_id='neoforge_attachments' — per-player progression / state for
  Aether (AETHER_PLAYER: portals, darts, flight timer, life shards),
  Apotheosis (WORLD_TIER), Apothic Attributes (AUX_DMG_TRACKER), Ars
  Nouveau / Iron's Spellbooks mana, etc. Not items. Must persist.
- enderchest — does not drop on vanilla death.
- backpack_data / sophisticatedstorage / refinedstorage — keyed by ITEM
  UUID, not player UUID. Backpack/shulker item drops into corpse with
  its UUID; on retrieval the data follows the item. No dup risk.

All clears run inside the same executeBatchTransaction as the core
player_data UPDATE, so they share the last_server guard — if a peer
claimed the player between our poll and our write, the entire batch
no-ops cleanly.
…ter legit reco)

A player reported losing their inventory on a normal disconnect/reconnect
10 minutes after dying — meaning r4 was firing handleReviveCanceledLogout
on a legitimate logout, clearing the DB inventory by mistake. Two false-
positive paths in r4:

(1) The heuristic "infinite-duration effect AND HP < 50%" was way too
    broad. Many modern mods apply long-lived/infinite effects in normal
    gameplay (Aether racial effects, Apotheosis affix buffs, Iron's
    Spellbooks mana auras, Ars Nouveau learned-spell markers, etc.). A
    player wearing one of those and disconnecting with sub-50% HP from
    combat hit the heuristic and lost their inventory.

(2) The 2-minute TTL on the canceled-death tracking was too short. If
    onPlayerHeal didn't fire (revive mod uses setHealth() directly
    instead of heal()), the tracking persisted but became inert after
    2 min — yet the heuristic kept catching the state, firing the fix
    on a player who'd been alive for minutes.

r5 hardens the detection by:

- REMOVING the heuristic entirely. Detection now relies SOLELY on the
  HIGHEST-priority LivingDeathEvent tracking — an explicit signal, never
  a behavioral guess.

- Tightening the logout HP check to "HP ≤ 1.0 absolute OR
  isDeadOrDying()". Revive mods typically clamp downed-state HP to
  exactly 1 (half a heart); a revived player at any HP > 1 (even 1.5 or
  2) is considered out of the death-pending state. No more "40% HP from
  combat triggers fix" false positives.

- Dropping the onPlayerHeal clear threshold from "≥80% maxHealth" to
  "HP > 1.0". Any non-trivial heal ends the tracking — covers
  partial-heal revives (e.g. heal to 5 HP) that the old threshold missed.

- Adding an explicit tracking-clear in the auto-save loop. Every 5
  minutes, for every player passing the eligibility check AND with
  HP > 1.0, deathCanceledRecently is dropped. Covers the setHealth-based
  revive case (no LivingHealEvent fires) within at most one auto-save
  cycle.

- Extending the TTL to 1 hour as a safety net for the rare path where
  none of the explicit clears fire (player remains in the revive
  interface for more than 5 min without any heal event).

Multi-layered defense: (a) HIGHEST hook captures every death event,
(b) onPlayerHeal clears on heal, (c) onPlayerRespawn clears on respawn,
(d) auto-save clears on eligibility + HP > 1, (e) removePlayerLock
clears on session end, (f) 1 h TTL backup, (g) strict HP check at
logout. The fix path now triggers ONLY when the player is genuinely
still in a downed state at the moment of disconnect.
r2-r5 fought the revive-disconnect dup with logout-side heuristics
(canceled-LivingDeathEvent tracking, HP thresholds, infinite-effect
signatures). Every variant either missed the downed state (dup came
back) or false-positived (inventory wrongly cleared, items lost). The
approach was wrong on two counts: wrong detection (heuristics) and wrong
location (logout side).

Root cause, found by decompiling revive_me-1.21.1-5.7.14.jar:
- Revive Me holds a downed player in a "fallen" state via
  invoker54.reviveme.common.capability.FallenData — a NeoForge
  AttachmentType registered as revive_me:fallen_data. The class exposes
  static FallenData.get(LivingEntity) and boolean isFallen().
- Revive Me pauses the fall timer on logout and resumes it on reconnect.
- The dup was a race in doPlayerJoin: the player reconnects fallen, the
  resumed timer finalizes the death, a corpse/gravestone mod captures
  the inventory into a corpse — and THEN doPlayerJoin's deferred
  (async + server.execute) apply restores the DB inventory, handing the
  player a second copy.

r6 fixes detection AND location:

- New isReviveMeFallen(player): reflectively calls
  FallenData.get(player).isFallen(). EXACT — the player is fallen iff
  Revive Me says so. No heuristic, no false positive. Soft dependency:
  returns false cleanly if revive_me isn't loaded.

- doPlayerJoin's apply block now skips the ENTIRE DB data apply when
  isReviveMeFallen(player) || player.isDeadOrDying(). The vanilla .dat
  inventory stays in place as the single source of truth during the
  transient fallen phase. The isDeadOrDying() guard covers the race
  where the death finalizes during the join's async delay.

- When the player leaves the fallen state, normal sync resumes: a
  successful revive is captured by the next auto-save / logout-save; a
  finalized death is captured by onPlayerRespawn (empty inventory → DB).

All r2-r5 logout-side machinery is removed: deathCanceledRecently map,
onPlayerDeathAttempt / onPlayerHeal hooks, handleReviveCanceledLogout,
writeReviveLogoutClearItemsToDB, the auto-save tracking-clear, the
onPlayerRespawn / removePlayerLock tracking-clears, the LivingHealEvent
import. onPlayerLogout is back to the pre-r2 baseline (normal save).

onPlayerDeath (LOW priority, non-item death-save) is unchanged.

Net result: no duplication, no item loss, no heuristic — the fix keys
off Revive Me's own authoritative state and steps aside while the mod
owns the player's transient downed phase.
…ogout

r6 fixed the dieOnDisconnect=false path (player reconnects still fallen,
join-side apply skipped). The dup came back because the common config is
dieOnDisconnect=true.

Decompiled revive_me-1.21.1-5.7.14 + corpse-neoforge-1.21.1-1.1.13:

- ReviveMe CapabilityEvents.onLogout (NORMAL priority): for a fallen
  player it calls pauseTimerOnLogout() + removeAllEffects(), and when the
  config dieOnDisconnect is true ALSO calls FallenData.forceDeath().
- forceDeath() deals lethal damage → LivingDeathEvent → LivingDropsEvent.
- Corpse mod DeathEvents.playerDeath(LivingDropsEvent) creates the corpse
  via DeathManager.addDeath(); corpsecurioscompat / cosmeticcorpsecompat
  pull curios + cosmetics into it.
- So with dieOnDisconnect=true the player is force-killed on disconnect.
  On reconnect they are no longer "fallen" — r6's join-side skip never
  triggers — and doPlayerJoin restores the DB inventory next to the
  corpse = duplication.
- ReviveMe.onLogout and PlayerSync.onPlayerLogout are both NORMAL
  priority → undefined order. Whenever PlayerSync ran first it saved the
  still-attached pre-death inventory.

r7 adds the logout-side guard that r6 was missing:

- onPlayerLogout now detects isReviveMeFallen(player) ||
  player.isDeadOrDying() — exact, via ReviveMe's FallenData. This is true
  whether PlayerSync's handler ran BEFORE ReviveMe's forceDeath (player
  still fallen) or AFTER it (player dead), so it is correct regardless of
  the undefined handler order.
- New handleFallenLogout + writeReviveLogoutClearItemsToDB clears every
  item-bearing DB column (inventory / armor / left_hand / cursors +
  curios + accessories + cosmeticarmor), persists non-item progression,
  sets online=0 — the corpse becomes the single source of truth.
- Gated on the keepInventory game rule being OFF: with it ON a death
  drops nothing and forms no corpse, so clearing the DB would destroy the
  player's items — the normal save path runs instead.
- r6's join-side apply-skip is kept for the dieOnDisconnect=false path
  (player reconnects still fallen → keeps the vanilla .dat inventory
  instead of the cleared DB row).

NeoForge attachments (neoforge_attachments) and enderchest are never
cleared — progression / non-dropping data.
@Piotr1628
Copy link
Copy Markdown

@laforetbrut would it be hard to implement Applied Energestics compat too? :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants