From 330cc30ae7c58c3b49f29672bcc69d4474f99b7b Mon Sep 17 00:00:00 2001 From: serge_shubin Date: Mon, 23 Mar 2026 05:22:41 +0800 Subject: [PATCH] Optional LZ4 compression for demo files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-frame entity and playerState blocks are compressed with LZ4 when svdemo_compress is set (default: 1). The block format writes [original_size][compressed_size][data] — compressed_size=0 means uncompressed. Playback auto-detects based on header flags. Demo format bumped to version 2 with SVDEMO_FLAG_COMPRESSED flag. Version 1 (uncompressed) demos are no longer compatible. Uses the lz4.c/lz4.h library already in the server code directory. Co-Authored-By: Claude Opus 4.6 (1M context) --- code/quake3.vcxproj | 2 + code/server/sv_init.c | 3 +- code/server/sv_netdemo.c | 96 ++++++++++++++++++++++++++++++++-------- 3 files changed, 81 insertions(+), 20 deletions(-) diff --git a/code/quake3.vcxproj b/code/quake3.vcxproj index 25d6f18..f850f17 100644 --- a/code/quake3.vcxproj +++ b/code/quake3.vcxproj @@ -997,6 +997,8 @@ + + Disabled true diff --git a/code/server/sv_init.c b/code/server/sv_init.c index 2271f6f..6d6d9ab 100644 --- a/code/server/sv_init.c +++ b/code/server/sv_init.c @@ -621,8 +621,9 @@ void SV_Init (void) { sv_lanForceRate = Cvar_Get ("sv_lanForceRate", "1", CVAR_ARCHIVE ); sv_strictAuth = Cvar_Get ("sv_strictAuth", "1", CVAR_ARCHIVE ); - // server-side demo auto-recording + // server-side demo settings Cvar_Get ("svdemo_autorecord", "0", CVAR_ARCHIVE); + Cvar_Get ("svdemo_compress", "1", CVAR_ARCHIVE); // initialize bot cvars so they are listed and can be set before loading the botlib SV_BotInitCvars(); diff --git a/code/server/sv_netdemo.c b/code/server/sv_netdemo.c index 6ddae76..75067ac 100644 --- a/code/server/sv_netdemo.c +++ b/code/server/sv_netdemo.c @@ -10,6 +10,7 @@ snapshot pipeline delivers them to a spectator client. */ #include "server.h" +#include "lz4.h" // --------------------------------------------------------------- // File format @@ -50,9 +51,12 @@ snapshot pipeline delivers them to a spectator client. // #define SVDEMO_MAGIC (('S') | ('V' << 8) | ('D' << 16) | ('M' << 24)) -#define SVDEMO_VERSION 1 +#define SVDEMO_VERSION 2 // v2: optional LZ4 compression #define SVDEMO_MAX_MAPNAME 64 +// header flags +#define SVDEMO_FLAG_COMPRESSED 1 // per-frame data is LZ4 compressed + // --------------------------------------------------------------- // State // --------------------------------------------------------------- @@ -78,6 +82,7 @@ typedef struct { // recording fileHandle_t recordFile; qboolean recording; + qboolean compressed; // LZ4 compression enabled char *lastConfigstrings[MAX_CONFIGSTRINGS]; // for delta detection svdEntityState_t prevEntities[MAX_GENTITIES]; // previous frame for delta svdPlayerState_t prevPlayers[MAX_CLIENTS]; // previous frame player states @@ -123,6 +128,58 @@ static short SVD_ReadShort( fileHandle_t f ) { return v; } +// --------------------------------------------------------------- +// LZ4 block I/O: writes [uncompressed_size][compressed_size][data] +// --------------------------------------------------------------- + +static void SVD_WriteBlock( fileHandle_t f, const byte *data, int len ) { + if ( demo.compressed && len > 0 ) { + int bound = LZ4_compressBound( len ); + static byte compBuf[MAX_GENTITIES * 300]; + int compLen; + + compLen = LZ4_compress_default( (const char *)data, (char *)compBuf, len, bound ); + if ( compLen > 0 ) { + SVD_WriteInt( f, len ); // original size + SVD_WriteInt( f, compLen ); // compressed size + FS_Write( compBuf, compLen, f ); + return; + } + // fall through to uncompressed on failure + } + SVD_WriteInt( f, len ); // original size + SVD_WriteInt( f, 0 ); // 0 = not compressed + FS_Write( data, len, f ); +} + +static int SVD_ReadBlock( fileHandle_t f, byte *buf, int bufSize ) { + int origLen, compLen; + + origLen = SVD_ReadInt( f ); + compLen = SVD_ReadInt( f ); + + if ( origLen <= 0 || origLen > bufSize ) { + return -1; + } + + if ( compLen > 0 ) { + // compressed + static byte compBuf[MAX_GENTITIES * 300]; + if ( compLen > (int)sizeof(compBuf) ) { + return -1; + } + FS_Read( compBuf, compLen, f ); + if ( LZ4_decompress_safe( (const char *)compBuf, (char *)buf, compLen, bufSize ) != origLen ) { + return -1; + } + } else { + // uncompressed + FS_Read( buf, origLen, f ); + } + + return origLen; +} + // --------------------------------------------------------------- // Write header // --------------------------------------------------------------- @@ -133,6 +190,7 @@ static void SVD_WriteHeader( fileHandle_t f ) { SVD_WriteInt( f, SVDEMO_MAGIC ); SVD_WriteInt( f, SVDEMO_VERSION ); + SVD_WriteInt( f, demo.compressed ? SVDEMO_FLAG_COMPRESSED : 0 ); SVD_WriteInt( f, sv_maxclients->integer ); SVD_WriteInt( f, sv_fps->integer ); @@ -176,7 +234,6 @@ static void SVD_WriteFrame( fileHandle_t f ) { short numChanges; msg_t msg; static byte msgBuf[MAX_GENTITIES * 300]; // worst case: all entities full write from baseline - int msgLen; SVD_WriteInt( f, svs.time ); SVD_WriteShort( f, (short)sv.num_entities ); @@ -245,10 +302,8 @@ static void SVD_WriteFrame( fileHandle_t f ) { // end of entities marker MSG_WriteBits( &msg, (MAX_GENTITIES - 1), GENTITYNUM_BITS ); - // write compressed entity message to file - msgLen = msg.cursize; - SVD_WriteInt( f, msgLen ); - FS_Write( msg.data, msgLen, f ); + // write entity message to file (optionally LZ4 compressed) + SVD_WriteBlock( f, msg.data, msg.cursize ); // write player states (delta compressed) { @@ -282,9 +337,7 @@ static void SVD_WriteFrame( fileHandle_t f ) { MSG_WriteBits( &psmsg, MAX_CLIENTS - 1, 6 ); MSG_WriteBits( &psmsg, 0, 1 ); - msgLen = psmsg.cursize; - SVD_WriteInt( f, msgLen ); - FS_Write( psmsg.data, msgLen, f ); + SVD_WriteBlock( f, psmsg.data, psmsg.cursize ); } // configstring changes @@ -346,8 +399,10 @@ static qboolean SVD_StartRecording( const char *demoname ) { return qfalse; } - Com_Printf( "Recording server demo to %s\n", path ); + Com_Printf( "Recording server demo to %s%s\n", path, + Cvar_VariableIntegerValue("svdemo_compress") ? " (LZ4)" : "" ); demo.recording = qtrue; + demo.compressed = Cvar_VariableIntegerValue("svdemo_compress") ? qtrue : qfalse; // clear delta state for fresh recording Com_Memset( demo.prevEntities, 0, sizeof(demo.prevEntities) ); @@ -467,6 +522,11 @@ static qboolean SVD_ReadHeader( fileHandle_t f ) { return qfalse; } + { + int flags = SVD_ReadInt( f ); + demo.compressed = ( flags & SVDEMO_FLAG_COMPRESSED ) ? qtrue : qfalse; + } + demo.playMaxClients = SVD_ReadInt( f ); demo.playFps = SVD_ReadInt( f ); FS_Read( demo.playMapName, SVDEMO_MAX_MAPNAME, f ); @@ -523,7 +583,7 @@ static void SVD_ApplyConfigstrings( void ) { static qboolean SVD_ReadFrame( fileHandle_t f ) { int serverTime; short numEnts, numChanges; - int i, entNum, msgLen; + int i, entNum, blockLen; sharedEntity_t *ent; msg_t msg; static byte msgBuf[MAX_GENTITIES * 300]; @@ -539,14 +599,13 @@ static qboolean SVD_ReadFrame( fileHandle_t f ) { // time jumps that break client timeouts and heartbeats. numEnts = SVD_ReadShort( f ); - // read compressed message - msgLen = SVD_ReadInt( f ); - if ( msgLen <= 0 || msgLen > (int)sizeof(msgBuf) ) { + // read entity message (optionally LZ4 compressed) + blockLen = SVD_ReadBlock( f, msgBuf, sizeof(msgBuf) ); + if ( blockLen <= 0 ) { return qfalse; } - FS_Read( msgBuf, msgLen, f ); MSG_Init( &msg, msgBuf, sizeof(msgBuf) ); - msg.cursize = msgLen; + msg.cursize = blockLen; // clear all non-spectator entities for ( i = 0; i < sv.num_entities; i++ ) { @@ -647,9 +706,8 @@ static qboolean SVD_ReadFrame( fileHandle_t f ) { static byte psBuf[MAX_CLIENTS * 600]; // worst case: full playerState from baseline int psMsgLen; - psMsgLen = SVD_ReadInt( f ); - if ( psMsgLen > 0 && psMsgLen <= (int)sizeof(psBuf) ) { - FS_Read( psBuf, psMsgLen, f ); + psMsgLen = SVD_ReadBlock( f, psBuf, sizeof(psBuf) ); + if ( psMsgLen > 0 ) { MSG_Init( &psmsg, psBuf, sizeof(psBuf) ); psmsg.cursize = psMsgLen;