aboutsummaryrefslogtreecommitdiffhomepage
path: root/patches/server/0456-incremental-chunk-and-player-saving.patch
blob: 4e15ac9547da713a8f0846c438bbe502ab8c674c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Shane Freeder <theboyetronic@gmail.com>
Date: Sun, 9 Jun 2019 03:53:22 +0100
Subject: [PATCH] incremental chunk and player saving


diff --git a/src/main/java/com/destroystokyo/paper/PaperConfig.java b/src/main/java/com/destroystokyo/paper/PaperConfig.java
index ebbbffd209c6796bc608992e293035141a122d1f..4fb6b2153117f54a2b0ca940de4c0ee2fa85e20e 100644
--- a/src/main/java/com/destroystokyo/paper/PaperConfig.java
+++ b/src/main/java/com/destroystokyo/paper/PaperConfig.java
@@ -458,4 +458,14 @@ public class PaperConfig {
         set("settings.unsupported-settings.allow-tnt-duplication", null);
     }
 
+    public static int playerAutoSaveRate = -1;
+    public static int maxPlayerAutoSavePerTick = 10;
+    private static void playerAutoSaveRate() {
+        playerAutoSaveRate = getInt("settings.player-auto-save-rate", -1);
+        maxPlayerAutoSavePerTick = getInt("settings.max-player-auto-save-per-tick", -1);
+        if (maxPlayerAutoSavePerTick == -1) { // -1 Automatic / "Recommended"
+            // 10 should be safe for everyone unless you mass spamming player auto save
+            maxPlayerAutoSavePerTick = (playerAutoSaveRate == -1 || playerAutoSaveRate > 100) ? 10 : 20;
+        }
+    }
 }
diff --git a/src/main/java/com/destroystokyo/paper/PaperWorldConfig.java b/src/main/java/com/destroystokyo/paper/PaperWorldConfig.java
index 845f105457d659a4bd88d4a8ce91b20d6abb7865..bf0c6e1b2b1934bc3fa5c17c414b3730c5ac0833 100644
--- a/src/main/java/com/destroystokyo/paper/PaperWorldConfig.java
+++ b/src/main/java/com/destroystokyo/paper/PaperWorldConfig.java
@@ -62,6 +62,21 @@ public class PaperWorldConfig {
         log( "Keep Spawn Loaded Range: " + (keepLoadedRange/16));
     }
 
+    public int autoSavePeriod = -1;
+    private void autoSavePeriod() {
+        autoSavePeriod = getInt("auto-save-interval", -1);
+        if (autoSavePeriod > 0) {
+            log("Auto Save Interval: " +autoSavePeriod + " (" + (autoSavePeriod / 20) + "s)");
+        } else if (autoSavePeriod < 0) {
+            autoSavePeriod = net.minecraft.server.MinecraftServer.getServer().autosavePeriod;
+        }
+    }
+
+    public int maxAutoSaveChunksPerTick = 24;
+    private void maxAutoSaveChunksPerTick() {
+        maxAutoSaveChunksPerTick = getInt("max-auto-save-chunks-per-tick", 24);
+    }
+
     private boolean getBoolean(String path, boolean def) {
         config.addDefault("world-settings.default." + path, def);
         return config.getBoolean("world-settings." + worldName + "." + path, config.getBoolean("world-settings.default." + path));
diff --git a/src/main/java/net/minecraft/server/MinecraftServer.java b/src/main/java/net/minecraft/server/MinecraftServer.java
index 4351dd9c293d1db48c87c5df28bcb2b9a59583f7..faa6f999f620712b90df465e0dc68e4ff35f94af 100644
--- a/src/main/java/net/minecraft/server/MinecraftServer.java
+++ b/src/main/java/net/minecraft/server/MinecraftServer.java
@@ -901,7 +901,7 @@ public abstract class MinecraftServer extends ReentrantBlockableEventLoop<TickTa
 
         try {
             this.isSaving = true;
-            this.getPlayerList().saveAll();
+            this.getPlayerList().saveAll(); // Diff on change
             flag3 = this.saveAllChunks(suppressLogs, flush, force);
         } finally {
             this.isSaving = false;
@@ -1408,13 +1408,28 @@ public abstract class MinecraftServer extends ReentrantBlockableEventLoop<TickTa
             }
         }
 
-        if (this.autosavePeriod > 0 && this.tickCount % this.autosavePeriod == 0) { // CraftBukkit
-            MinecraftServer.LOGGER.debug("Autosave started");
-            this.profiler.push("save");
-            this.saveEverything(true, false, false);
-            this.profiler.pop();
-            MinecraftServer.LOGGER.debug("Autosave finished");
+        // Paper start - incremental chunk and player saving
+        int playerSaveInterval = com.destroystokyo.paper.PaperConfig.playerAutoSaveRate;
+        if (playerSaveInterval < 0) {
+            playerSaveInterval = autosavePeriod;
         }
+        this.profiler.push("save");
+        final boolean fullSave = autosavePeriod > 0 && this.tickCount % autosavePeriod == 0;
+        try {
+            this.isSaving = true;
+            if (playerSaveInterval > 0) {
+                this.playerList.saveAll(playerSaveInterval);
+            }
+            for (ServerLevel level : this.getAllLevels()) {
+                if (level.paperConfig.autoSavePeriod > 0) {
+                    level.saveIncrementally(fullSave);
+                }
+            }
+        } finally {
+            this.isSaving = false;
+        }
+        this.profiler.pop();
+        // Paper end
         io.papermc.paper.util.CachedLists.reset(); // Paper
         // Paper start - move executeAll() into full server tick timing
         try (co.aikar.timings.Timing ignored = MinecraftTimings.processTasksTimer.startTiming()) {
diff --git a/src/main/java/net/minecraft/server/level/ChunkHolder.java b/src/main/java/net/minecraft/server/level/ChunkHolder.java
index 626bcbc6dd013260c3f8b38a1d14e7ba35dc1e01..9e96b0465717bfa761289c255fd8d2f1df1be3d8 100644
--- a/src/main/java/net/minecraft/server/level/ChunkHolder.java
+++ b/src/main/java/net/minecraft/server/level/ChunkHolder.java
@@ -84,6 +84,8 @@ public class ChunkHolder {
         this.playersInChunkTickRange = this.chunkMap.playerChunkTickRangeMap.getObjectsInRange(key);
     }
     // Paper end - optimise anyPlayerCloseEnoughForSpawning
+    long lastAutoSaveTime; // Paper - incremental autosave
+    long inactiveTimeStart; // Paper - incremental autosave
 
     public ChunkHolder(ChunkPos pos, int level, LevelHeightAccessor world, LevelLightEngine lightingProvider, ChunkHolder.LevelChangeListener levelUpdateListener, ChunkHolder.PlayerProvider playersWatchingChunkProvider) {
         this.futures = new AtomicReferenceArray(ChunkHolder.CHUNK_STATUSES.size());
@@ -473,7 +475,19 @@ public class ChunkHolder {
         boolean flag2 = playerchunk_state.isOrAfter(ChunkHolder.FullChunkStatus.BORDER);
         boolean flag3 = playerchunk_state1.isOrAfter(ChunkHolder.FullChunkStatus.BORDER);
 
+        boolean prevHasBeenLoaded = this.wasAccessibleSinceLastSave; // Paper
         this.wasAccessibleSinceLastSave |= flag3;
+        // Paper start - incremental autosave
+        if (this.wasAccessibleSinceLastSave & !prevHasBeenLoaded) {
+            long timeSinceAutoSave = this.inactiveTimeStart - this.lastAutoSaveTime;
+            if (timeSinceAutoSave < 0) {
+                // safest bet is to assume autosave is needed here
+                timeSinceAutoSave = this.chunkMap.level.paperConfig.autoSavePeriod;
+            }
+            this.lastAutoSaveTime = this.chunkMap.level.getGameTime() - timeSinceAutoSave;
+            this.chunkMap.autoSaveQueue.add(this);
+        }
+        // Paper end
         if (!flag2 && flag3) {
             int expectCreateCount = ++this.fullChunkCreateCount; // Paper
             this.fullChunkFuture = chunkStorage.prepareAccessibleChunk(this);
@@ -604,9 +618,33 @@ public class ChunkHolder {
     }
 
     public void refreshAccessibility() {
+        boolean prev = this.wasAccessibleSinceLastSave; // Paper
         this.wasAccessibleSinceLastSave = ChunkHolder.getFullChunkStatus(this.ticketLevel).isOrAfter(ChunkHolder.FullChunkStatus.BORDER);
+        // Paper start - incremental autosave
+        if (prev != this.wasAccessibleSinceLastSave) {
+            if (this.wasAccessibleSinceLastSave) {
+                long timeSinceAutoSave = this.inactiveTimeStart - this.lastAutoSaveTime;
+                if (timeSinceAutoSave < 0) {
+                    // safest bet is to assume autosave is needed here
+                    timeSinceAutoSave = this.chunkMap.level.paperConfig.autoSavePeriod;
+                }
+                this.lastAutoSaveTime = this.chunkMap.level.getGameTime() - timeSinceAutoSave;
+                this.chunkMap.autoSaveQueue.add(this);
+            } else {
+                this.inactiveTimeStart = this.chunkMap.level.getGameTime();
+                this.chunkMap.autoSaveQueue.remove(this);
+            }
+        }
+        // Paper end
     }
 
+    // Paper start - incremental autosave
+    public boolean setHasBeenLoaded() {
+        this.wasAccessibleSinceLastSave = getFullChunkStatus(this.ticketLevel).isOrAfter(ChunkHolder.FullChunkStatus.BORDER);
+        return this.wasAccessibleSinceLastSave;
+    }
+    // Paper end
+
     public void replaceProtoChunk(ImposterProtoChunk chunk) {
         for (int i = 0; i < this.futures.length(); ++i) {
             CompletableFuture<Either<ChunkAccess, ChunkHolder.ChunkLoadingFailure>> completablefuture = (CompletableFuture) this.futures.get(i);
diff --git a/src/main/java/net/minecraft/server/level/ChunkMap.java b/src/main/java/net/minecraft/server/level/ChunkMap.java
index ea61f5df9a0998272dd5da1332aa0fbb8704cd5e..beb930fdb2535e32692906c54b8654401706086e 100644
--- a/src/main/java/net/minecraft/server/level/ChunkMap.java
+++ b/src/main/java/net/minecraft/server/level/ChunkMap.java
@@ -101,6 +101,7 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.StructureMana
 import net.minecraft.world.level.storage.DimensionDataStorage;
 import net.minecraft.world.level.storage.LevelStorageSource;
 import net.minecraft.world.phys.Vec3;
+import it.unimi.dsi.fastutil.objects.ObjectRBTreeSet; // Paper
 import org.apache.commons.lang3.mutable.MutableBoolean;
 import org.apache.commons.lang3.mutable.MutableObject;
 import org.apache.logging.log4j.LogManager;
@@ -677,6 +678,64 @@ public class ChunkMap extends ChunkStorage implements ChunkHolder.PlayerProvider
 
     }
 
+    // Paper start - incremental autosave
+    final ObjectRBTreeSet<ChunkHolder> autoSaveQueue = new ObjectRBTreeSet<>((playerchunk1, playerchunk2) -> {
+        int timeCompare =  Long.compare(playerchunk1.lastAutoSaveTime, playerchunk2.lastAutoSaveTime);
+        if (timeCompare != 0) {
+            return timeCompare;
+        }
+
+        return Long.compare(MCUtil.getCoordinateKey(playerchunk1.pos), MCUtil.getCoordinateKey(playerchunk2.pos));
+    });
+
+    protected void saveIncrementally() {
+        int savedThisTick = 0;
+        // optimized since we search far less chunks to hit ones that need to be saved
+        List<ChunkHolder> reschedule = new java.util.ArrayList<>(this.level.paperConfig.maxAutoSaveChunksPerTick);
+        long currentTick = this.level.getGameTime();
+        long maxSaveTime = currentTick - this.level.paperConfig.autoSavePeriod;
+
+        for (Iterator<ChunkHolder> iterator = this.autoSaveQueue.iterator(); iterator.hasNext();) {
+            ChunkHolder playerchunk = iterator.next();
+            if (playerchunk.lastAutoSaveTime > maxSaveTime) {
+                break;
+            }
+
+            iterator.remove();
+
+            ChunkAccess ichunkaccess = playerchunk.getChunkToSave().getNow(null);
+            if (ichunkaccess instanceof LevelChunk) {
+                boolean shouldSave = ((LevelChunk)ichunkaccess).lastSaveTime <= maxSaveTime;
+
+                if (shouldSave && this.save(ichunkaccess) && this.level.entityManager.storeChunkSections(playerchunk.pos.toLong(), entity -> {})) {
+                    ++savedThisTick;
+
+                    if (!playerchunk.setHasBeenLoaded()) {
+                        // do not fall through to reschedule logic
+                        playerchunk.inactiveTimeStart = currentTick;
+                        if (savedThisTick >= this.level.paperConfig.maxAutoSaveChunksPerTick) {
+                            break;
+                        }
+                        continue;
+                    }
+                }
+            }
+
+            reschedule.add(playerchunk);
+
+            if (savedThisTick >= this.level.paperConfig.maxAutoSaveChunksPerTick) {
+                break;
+            }
+        }
+
+        for (int i = 0, len = reschedule.size(); i < len; ++i) {
+            ChunkHolder playerchunk = reschedule.get(i);
+            playerchunk.lastAutoSaveTime = this.level.getGameTime();
+            this.autoSaveQueue.add(playerchunk);
+        }
+    }
+    // Paper end
+
     protected void saveAllChunks(boolean flush) {
         if (flush) {
             List<ChunkHolder> list = (List) this.visibleChunkMap.values().stream().filter(ChunkHolder::wasAccessibleSinceLastSave).peek(ChunkHolder::refreshAccessibility).collect(Collectors.toList());
@@ -772,7 +831,7 @@ public class ChunkMap extends ChunkStorage implements ChunkHolder.PlayerProvider
         int l = 0;
         ObjectIterator objectiterator = this.visibleChunkMap.values().iterator();
 
-        while (l < 20 && shouldKeepTicking.getAsBoolean() && objectiterator.hasNext()) {
+        while (false && l < 20 && shouldKeepTicking.getAsBoolean() && objectiterator.hasNext()) { // Paper - incremental chunk and player saving
             if (this.saveChunkIfNeeded((ChunkHolder) objectiterator.next())) {
                 ++l;
             }
@@ -814,6 +873,7 @@ public class ChunkMap extends ChunkStorage implements ChunkHolder.PlayerProvider
 
                         this.level.unload(chunk);
                     }
+                    this.autoSaveQueue.remove(holder); // Paper
 
                     this.lightEngine.updateChunkStatus(ichunkaccess.getPos());
                     this.lightEngine.tryScheduleUpdate();
@@ -1211,6 +1271,7 @@ public class ChunkMap extends ChunkStorage implements ChunkHolder.PlayerProvider
             asyncSaveData, chunk);
 
         chunk.setUnsaved(false);
+        chunk.setLastSaved(this.level.getGameTime()); // Paper - track last saved time
     }
     // Paper end
 
@@ -1220,6 +1281,7 @@ public class ChunkMap extends ChunkStorage implements ChunkHolder.PlayerProvider
         if (!chunk.isUnsaved()) {
             return false;
         } else {
+            chunk.setLastSaved(this.level.getGameTime()); // Paper - track save time
             chunk.setUnsaved(false);
             ChunkPos chunkcoordintpair = chunk.getPos();
 
diff --git a/src/main/java/net/minecraft/server/level/ServerChunkCache.java b/src/main/java/net/minecraft/server/level/ServerChunkCache.java
index 76b56ea346d843aba482c52c4bfe877fdf0e9225..eb0c5ceb05f37bc653ea0cc91cc778866861688a 100644
--- a/src/main/java/net/minecraft/server/level/ServerChunkCache.java
+++ b/src/main/java/net/minecraft/server/level/ServerChunkCache.java
@@ -825,6 +825,15 @@ public class ServerChunkCache extends ChunkSource {
         } // Paper - Timings
     }
 
+    // Paper start - duplicate save, but call incremental
+    public void saveIncrementally() {
+        this.runDistanceManagerUpdates();
+        try (co.aikar.timings.Timing timed = level.timings.chunkSaveData.startTiming()) { // Paper - Timings
+            this.chunkMap.saveIncrementally();
+        } // Paper - Timings
+    }
+    // Paper end
+
     @Override
     public void close() throws IOException {
         // CraftBukkit start
diff --git a/src/main/java/net/minecraft/server/level/ServerLevel.java b/src/main/java/net/minecraft/server/level/ServerLevel.java
index 44bc8657d1436114db9ac5f4bca23d330eb49d79..f82ad531d356a663bb085f50eeffbe8746fc6cd3 100644
--- a/src/main/java/net/minecraft/server/level/ServerLevel.java
+++ b/src/main/java/net/minecraft/server/level/ServerLevel.java
@@ -1055,6 +1055,37 @@ public class ServerLevel extends Level implements WorldGenLevel {
         return !this.server.isUnderSpawnProtection(this, pos, player) && this.getWorldBorder().isWithinBounds(pos);
     }
 
+    // Paper start - derived from below
+    public void saveIncrementally(boolean doFull) {
+        ServerChunkCache chunkproviderserver = this.getChunkSource();
+
+        if (doFull) {
+            org.bukkit.Bukkit.getPluginManager().callEvent(new org.bukkit.event.world.WorldSaveEvent(getWorld()));
+        }
+
+        try (co.aikar.timings.Timing ignored = this.timings.worldSave.startTiming()) {
+            if (doFull) {
+                this.saveLevelData();
+            }
+
+            this.timings.worldSaveChunks.startTiming(); // Paper
+            if (!this.noSave()) chunkproviderserver.saveIncrementally();
+            this.timings.worldSaveChunks.stopTiming(); // Paper
+
+            // Copied from save()
+            // CraftBukkit start - moved from MinecraftServer.saveChunks
+            if (doFull) { // Paper
+                ServerLevel worldserver1 = this;
+
+                this.serverLevelData.setWorldBorder(worldserver1.getWorldBorder().createSettings());
+                this.serverLevelData.setCustomBossEvents(this.server.getCustomBossEvents().save());
+                this.convertable.saveDataTag(this.server.registryHolder, this.serverLevelData, this.server.getPlayerList().getSingleplayerData());
+            }
+            // CraftBukkit end
+        }
+    }
+    // Paper end
+
     public void save(@Nullable ProgressListener progressListener, boolean flush, boolean savingDisabled) {
         ServerChunkCache chunkproviderserver = this.getChunkSource();
 
diff --git a/src/main/java/net/minecraft/server/level/ServerPlayer.java b/src/main/java/net/minecraft/server/level/ServerPlayer.java
index 2f13055a39c26fe12d2c1094103186635e536166..4b83617a81db1749faaf49fc3ee77e44846dce1a 100644
--- a/src/main/java/net/minecraft/server/level/ServerPlayer.java
+++ b/src/main/java/net/minecraft/server/level/ServerPlayer.java
@@ -171,6 +171,7 @@ public class ServerPlayer extends Player {
     public final int getViewDistance() { return this.getLevel().getChunkSource().chunkMap.viewDistance - 1; } // Paper - placeholder
 
     private static final Logger LOGGER = LogManager.getLogger();
+    public long lastSave = MinecraftServer.currentTick; // Paper
     private static final int NEUTRAL_MOB_DEATH_NOTIFICATION_RADII_XZ = 32;
     private static final int NEUTRAL_MOB_DEATH_NOTIFICATION_RADII_Y = 10;
     public ServerGamePacketListenerImpl connection;
diff --git a/src/main/java/net/minecraft/server/players/PlayerList.java b/src/main/java/net/minecraft/server/players/PlayerList.java
index 9b7fcd8be912cd211b6226386bd3340076953442..a8048e3af5bccb4cabe1ed1bc774aac6b8486bec 100644
--- a/src/main/java/net/minecraft/server/players/PlayerList.java
+++ b/src/main/java/net/minecraft/server/players/PlayerList.java
@@ -556,6 +556,7 @@ public abstract class PlayerList {
     protected void save(ServerPlayer player) {
         if (!player.getBukkitEntity().isPersistent()) return; // CraftBukkit
         if (!player.didPlayerJoinEvent) return; // Paper - If we never fired PJE, we disconnected during login. Data has not changed, and additionally, our saved vehicle is not loaded! If we save now, we will lose our vehicle (CraftBukkit bug)
+        player.lastSave = MinecraftServer.currentTick; // Paper
         this.playerIo.save(player);
         ServerStatsCounter serverstatisticmanager = (ServerStatsCounter) player.getStats(); // CraftBukkit
 
@@ -1158,10 +1159,22 @@ public abstract class PlayerList {
     }
 
     public void saveAll() {
+        // Paper start - incremental player saving
+        this.saveAll(-1);
+    }
+
+    public void saveAll(int interval) {
         net.minecraft.server.MCUtil.ensureMain("Save Players" , () -> { // Paper - Ensure main
         MinecraftTimings.savePlayers.startTiming(); // Paper
+        int numSaved = 0;
+        long now = MinecraftServer.currentTick;
         for (int i = 0; i < this.players.size(); ++i) {
-            this.save(this.players.get(i));
+            ServerPlayer entityplayer = this.players.get(i);
+            if (interval == -1 || now - entityplayer.lastSave >= interval) {
+                this.save(entityplayer);
+                if (interval != -1 && ++numSaved <= com.destroystokyo.paper.PaperConfig.maxPlayerAutoSavePerTick) { break; }
+            }
+            // Paper end
         }
         MinecraftTimings.savePlayers.stopTiming(); // Paper
         return null; }); // Paper - ensure main
diff --git a/src/main/java/net/minecraft/world/level/chunk/ChunkAccess.java b/src/main/java/net/minecraft/world/level/chunk/ChunkAccess.java
index 96dc50a24821016e53dfeac344c0e61f110a5a53..5093e34dddbea0f0d5c26f25c257b85bdf841a47 100644
--- a/src/main/java/net/minecraft/world/level/chunk/ChunkAccess.java
+++ b/src/main/java/net/minecraft/world/level/chunk/ChunkAccess.java
@@ -457,6 +457,7 @@ public abstract class ChunkAccess implements BlockGetter, BiomeManager.NoiseBiom
     public LevelHeightAccessor getHeightAccessorForGeneration() {
         return this;
     }
+    public void setLastSaved(long ticks) {} // Paper
 
     // CraftBukkit start - decompile error
     public static record TicksToSave(SerializableTickContainer<Block> blocks, SerializableTickContainer<Fluid> fluids) {
diff --git a/src/main/java/net/minecraft/world/level/chunk/LevelChunk.java b/src/main/java/net/minecraft/world/level/chunk/LevelChunk.java
index 8c4cd4a82dcf5295a992c37914d3124d6615148a..93e9ef06f02e5dc0c1bd0e8d714f44b2fea24db0 100644
--- a/src/main/java/net/minecraft/world/level/chunk/LevelChunk.java
+++ b/src/main/java/net/minecraft/world/level/chunk/LevelChunk.java
@@ -87,6 +87,12 @@ public class LevelChunk extends ChunkAccess {
     private final Int2ObjectMap<GameEventDispatcher> gameEventDispatcherSections;
     private final LevelChunkTicks<Block> blockTicks;
     private final LevelChunkTicks<Fluid> fluidTicks;
+    // Paper start - track last save time
+    public long lastSaveTime;
+    public void setLastSaved(long ticks) {
+        this.lastSaveTime = ticks;
+    }
+    // Paper end
 
     public LevelChunk(Level world, ChunkPos pos) {
         this(world, pos, UpgradeData.EMPTY, new LevelChunkTicks<>(), new LevelChunkTicks<>(), 0L, (LevelChunkSection[]) null, (LevelChunk.PostLoadProcessor) null, (BlendingData) null);