diff --git a/Minecraft.Client/MinecraftServer.cpp b/Minecraft.Client/MinecraftServer.cpp index 53f513a9..67b9b399 100644 --- a/Minecraft.Client/MinecraftServer.cpp +++ b/Minecraft.Client/MinecraftServer.cpp @@ -34,6 +34,9 @@ #ifdef _WINDOWS64 #include "Windows64\Network\WinsockNetLayer.h" #endif +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) +#include "..\Minecraft.Server\ServerLogger.h" +#endif #include #ifdef SPLIT_SAVES #include "..\Minecraft.World\ConsoleSaveFileSplit.h" @@ -1881,11 +1884,23 @@ void MinecraftServer::run(int64_t seed, void *lpParameter) QueryPerformanceCounter(&qwTime); #endif +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + LARGE_INTEGER asTicksPerSec, asT0, asT1; + QueryPerformanceFrequency(&asTicksPerSec); + double asSecsPerTick = 1.0 / (double)asTicksPerSec.QuadPart; + QueryPerformanceCounter(&asT0); + LARGE_INTEGER asAfterPlayers, asAfterLevels, asAfterRules, asAfterFlush; +#endif + if (players != nullptr) { players->saveAll(nullptr); } +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + QueryPerformanceCounter(&asAfterPlayers); +#endif + for (unsigned int j = 0; j < levels.length; j++) { if( s_bServerHalted ) break; @@ -1901,6 +1916,11 @@ void MinecraftServer::run(int64_t seed, void *lpParameter) PIXEndNamedEvent(); #endif } + +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + QueryPerformanceCounter(&asAfterLevels); +#endif + if (!s_bServerHalted) { #if defined(_XBOX_ONE) || defined(__ORBIS__) @@ -1912,7 +1932,24 @@ void MinecraftServer::run(int64_t seed, void *lpParameter) PIXBeginNamedEvent(0, "Save to disc"); #endif + +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + QueryPerformanceCounter(&asAfterRules); +#endif + levels[0]->saveToDisc(Minecraft::GetInstance()->progressRenderer, true); + +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + QueryPerformanceCounter(&asAfterFlush); + ServerRuntime::LogInfof("world-io", + "autosave breakdown: players=%.0fms levels=%.0fms rules=%.0fms flush=%.0fms total=%.0fms", + (asAfterPlayers.QuadPart - asT0.QuadPart) * asSecsPerTick * 1000.0, + (asAfterLevels.QuadPart - asAfterPlayers.QuadPart) * asSecsPerTick * 1000.0, + (asAfterRules.QuadPart - asAfterLevels.QuadPart) * asSecsPerTick * 1000.0, + (asAfterFlush.QuadPart - asAfterRules.QuadPart) * asSecsPerTick * 1000.0, + (asAfterFlush.QuadPart - asT0.QuadPart) * asSecsPerTick * 1000.0); +#endif + #if defined(_XBOX_ONE) || defined(__ORBIS__) PIXEndNamedEvent(); #endif diff --git a/Minecraft.Client/ServerLevel.cpp b/Minecraft.Client/ServerLevel.cpp index 80a12c99..5bc347e7 100644 --- a/Minecraft.Client/ServerLevel.cpp +++ b/Minecraft.Client/ServerLevel.cpp @@ -977,8 +977,11 @@ void ServerLevel::save(bool force, ProgressListener *progressListener, bool bAut if (progressListener != nullptr) progressListener->progressStage(IDS_PROGRESS_SAVING_CHUNKS); -#if defined(_XBOX_ONE) || defined(__ORBIS__) - // Our autosave is a minimal save. All the chunks are saves by the constant save process +#if defined(_XBOX_ONE) || defined(__ORBIS__) || (defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)) + // Autosave is a minimal save. Chunks are saved continuously by the + // per-tick trickle save process (ServerChunkCache::tick), so we only + // need to flush entity data here. The full chunkSource->save() would + // redundantly re-save all dirty chunks and block the main thread. if(bAutosave) { chunkSource->saveAllEntities(); diff --git a/Minecraft.Server/Windows64/ServerMain.cpp b/Minecraft.Server/Windows64/ServerMain.cpp index c5ae1346..b298f78d 100644 --- a/Minecraft.Server/Windows64/ServerMain.cpp +++ b/Minecraft.Server/Windows64/ServerMain.cpp @@ -16,6 +16,7 @@ #include "..\Security\IdentityTokenManager.h" #include "..\WorldManager.h" #include "..\Console\ServerCli.h" +#include "..\..\Minecraft.World\ConsoleSaveFileOriginal.h" #include "Tesselator.h" #include "Windows64/4JLibs/inc/4J_Render.h" #include "Windows64/GameConfig/Minecraft.spa.h" @@ -330,6 +331,7 @@ static void TickCoreSystems() g_NetworkManager.DoWork(); ProfileManager.Tick(); StorageManager.Tick(); + ConsoleSaveFileOriginal::CommitPendingAsyncSave(); } /** diff --git a/Minecraft.World/ConsoleSaveFileOriginal.cpp b/Minecraft.World/ConsoleSaveFileOriginal.cpp index 4207aba4..cbab89e6 100644 --- a/Minecraft.World/ConsoleSaveFileOriginal.cpp +++ b/Minecraft.World/ConsoleSaveFileOriginal.cpp @@ -12,6 +12,27 @@ #include "..\Minecraft.Client\Common\GameRules\LevelGenerationOptions.h" #include "..\Minecraft.World\net.minecraft.world.level.chunk.storage.h" +#ifdef _WINDOWS64 +#include +#include +#include +extern bool g_Win64DedicatedServer; +static std::atomic s_asyncSaveInFlight{false}; + +// Pending async save: background thread fills this, main thread commits it. +struct PendingAsyncSave +{ + ConsoleSaveFile *self; + PBYTE thumbData; + DWORD thumbSize; + BYTE textMetadata[88]; + int textMetadataBytes; + bool ready; +}; +static std::mutex s_pendingSaveMutex; +static PendingAsyncSave s_pendingSave = {}; +#endif + #ifdef _XBOX #define RESERVE_ALLOCATION MEM_RESERVE | MEM_LARGE_PAGES @@ -673,6 +694,138 @@ void ConsoleSaveFileOriginal::Flush(bool autosave, bool updateThumbnail ) unsigned int fileSize = header.GetFileSize(); +#ifdef _WINDOWS64 + // --- Dedicated server async flush path --- + // Snapshot pvSaveMem while holding the lock (fast memcpy), then release + // the lock immediately so the main thread can continue ticking. Compression + // and disk write happen on a detached background thread. + if (g_Win64DedicatedServer) + { + // If a previous async save is still compressing, fall through to the + // synchronous path to avoid queuing unbounded background work. + if (s_asyncSaveInFlight.load(std::memory_order_acquire)) + { + app.DebugPrintf("Async save: previous still in flight, falling back to sync\n"); + goto sync_flush; + } + + // Snapshot: copy the entire save buffer so we can release the lock + QueryPerformanceCounter(&qwTime); + byte *snapshot = new (std::nothrow) byte[fileSize]; + if (snapshot == nullptr) + { + app.DebugPrintf("Async save: failed to allocate %u byte snapshot, falling back to sync\n", fileSize); + goto sync_flush; + } + memcpy(snapshot, pvSaveMem, fileSize); + QueryPerformanceCounter(&qwNewTime); + qwDeltaTime.QuadPart = qwNewTime.QuadPart - qwTime.QuadPart; + fElapsedTime = fSecsPerTick * static_cast(qwDeltaTime.QuadPart); + app.DebugPrintf("Async save: snapshot %u bytes in %.3f sec\n", fileSize, fElapsedTime); + + // Gather metadata while still on the main thread + PBYTE pbThumbnailData = nullptr; + DWORD dwThumbnailDataSize = 0; + app.GetSaveThumbnail(&pbThumbnailData, &dwThumbnailDataSize); + + BYTE bTextMetadata[88]; + ZeroMemory(bTextMetadata, 88); + int64_t seed = 0; + bool hasSeed = false; + if (MinecraftServer::getInstance() != nullptr && MinecraftServer::getInstance()->levels[0] != nullptr) + { + seed = MinecraftServer::getInstance()->levels[0]->getLevelData()->getSeed(); + hasSeed = true; + } + int iTextMetadataBytes = app.CreateImageTextData(bTextMetadata, seed, hasSeed, + app.GetGameHostOption(eGameHostOption_All), Minecraft::GetInstance()->getCurrentTexturePackId()); + + INT saveOrCheckpointId = 0; + StorageManager.GetSaveUniqueNumber(&saveOrCheckpointId); + TelemetryManager->RecordLevelSaveOrCheckpoint(ProfileManager.GetPrimaryPad(), saveOrCheckpointId, fileSize); + + // Release the lock -- main thread and chunk trickle saves can resume + ReleaseSaveAccess(); + + // Compress and write on a background thread. + // Pack metadata into a heap struct so the lambda captures a single + // owning pointer instead of stack arrays that go out of scope. + struct AsyncSaveContext + { + byte *snapshot; + unsigned int fileSize; + ConsoleSaveFile *self; + PBYTE thumbData; + DWORD thumbSize; + BYTE textMetadata[88]; + int textMetadataBytes; + }; + + auto *ctx = new AsyncSaveContext(); + ctx->snapshot = snapshot; + ctx->fileSize = fileSize; + ctx->self = this; + ctx->thumbData = pbThumbnailData; + ctx->thumbSize = dwThumbnailDataSize; + memcpy(ctx->textMetadata, bTextMetadata, 88); + ctx->textMetadataBytes = iTextMetadataBytes; + + s_asyncSaveInFlight.store(true, std::memory_order_release); + + std::thread([ctx]() + { + unsigned int compLength = ctx->fileSize + 8; + byte *compData = static_cast(StorageManager.AllocateSaveData(compLength)); + if (compData == nullptr) + { + // Pre-calculate compressed size + compLength = 0; + Compression::getCompression()->Compress(nullptr, &compLength, ctx->snapshot, ctx->fileSize); + compLength += 8; + compData = static_cast(StorageManager.AllocateSaveData(compLength)); + } + + if (compData != nullptr) + { + Compression::getCompression()->Compress(compData + 8, &compLength, ctx->snapshot, ctx->fileSize); + + ZeroMemory(compData, 8); + int saveVer = 0; + memcpy(compData, &saveVer, sizeof(int)); + unsigned int fs = ctx->fileSize; + memcpy(compData + 4, &fs, sizeof(int)); + + app.DebugPrintf("Async save: compressed %u -> %u bytes\n", ctx->fileSize, compLength); + + // Queue for the main thread to commit via StorageManager + // (StorageManager requires main-thread calls + Tick() to flush) + { + std::lock_guard lock(s_pendingSaveMutex); + s_pendingSave.self = ctx->self; + s_pendingSave.thumbData = ctx->thumbData; + s_pendingSave.thumbSize = ctx->thumbSize; + memcpy(s_pendingSave.textMetadata, ctx->textMetadata, 88); + s_pendingSave.textMetadataBytes = ctx->textMetadataBytes; + s_pendingSave.ready = true; + } + } + else + { + app.DebugPrintf("Async save: failed to allocate compression buffer\n"); + s_asyncSaveInFlight.store(false, std::memory_order_release); + } + + delete[] ctx->snapshot; + delete ctx; + }).detach(); + + return; + } +sync_flush: +#endif + + // --- Original synchronous flush path (game client / non-server) --- + // Assume that the compression will make it smaller so initially attempt to allocate the current file size // We add 4 bytes to the start so that we can signal compressed data // And another 4 bytes to store the decompressed data size @@ -1130,3 +1283,21 @@ void *ConsoleSaveFileOriginal::getWritePointer(FileEntry *file) { return static_cast(pvSaveMem) + file->currentFilePointer;; } + +#ifdef _WINDOWS64 +void ConsoleSaveFileOriginal::CommitPendingAsyncSave() +{ + std::lock_guard lock(s_pendingSaveMutex); + if (!s_pendingSave.ready) + return; + + StorageManager.SetSaveImages( + s_pendingSave.thumbData, s_pendingSave.thumbSize, + nullptr, 0, s_pendingSave.textMetadata, s_pendingSave.textMetadataBytes); + StorageManager.SaveSaveData( + &ConsoleSaveFileOriginal::SaveSaveDataCallback, s_pendingSave.self); + + s_pendingSave.ready = false; + s_asyncSaveInFlight.store(false, std::memory_order_release); +} +#endif diff --git a/Minecraft.World/ConsoleSaveFileOriginal.h b/Minecraft.World/ConsoleSaveFileOriginal.h index 9c91fafc..315d49ca 100644 --- a/Minecraft.World/ConsoleSaveFileOriginal.h +++ b/Minecraft.World/ConsoleSaveFileOriginal.h @@ -77,6 +77,11 @@ public: virtual void LockSaveAccess(); virtual void ReleaseSaveAccess(); +#ifdef _WINDOWS64 + // Called from the main thread to commit a completed async save to StorageManager. + static void CommitPendingAsyncSave(); +#endif + virtual ESavePlatform getSavePlatform(); virtual bool isSaveEndianDifferent(); virtual void setLocalPlatform(); diff --git a/README.md b/README.md index 754fc1a9..9c3954b3 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,13 @@ This project is based on source code of Minecraft Legacy Console Edition v1.6.05 ## Latest: +### Async Autosave (Dedicated Server) + +- Autosave no longer freezes the server. Previously, every autosave compressed the entire world save file with zlib synchronously on the main thread, blocking all game ticks for 2-6 seconds depending on world size +- The save buffer is now snapshotted (memcpy) while holding the lock (~18ms), then compression runs on a background thread. The compressed data is committed back to StorageManager on the next main-thread tick +- Additionally, autosave on the dedicated server now only flushes entity data instead of re-saving all dirty chunks, matching the Xbox/Orbis behavior. Chunks are already saved continuously by the per-tick trickle save process +- Autosave timing breakdown is now logged to `server.log` for diagnostics (e.g. `autosave breakdown: players=0ms levels=0ms rules=0ms flush=18ms total=18ms`) + ### Dedicated Server Entity Tracking Optimization - Eliminated unnecessary O(players^2 * entities) split-screen system-mate checks in the entity tracker on dedicated servers. The `EntityTracker::tick()`, `TrackedEntity::isVisible()`, and `TrackedEntity::broadcast()` functions all contained loops that called `IsSameSystem()` to support console split-screen couch co-op visibility expansion. On dedicated servers, all players are remote, so `IsSameSystem()` always returns false and these loops did nothing but waste CPU every tick