Delta-compress netdemo entity states

Use MSG_WriteDeltaEntity/MSG_ReadDeltaEntity for entity state
serialization. Only changed fields are written per frame. PVS
fields (svFlags, linked, origin, absmin, absmax) also use a
1-bit change flag to skip when unchanged.

Reduces a 10-second demo from ~1400KB to ~52KB (27x smaller).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
serge_shubin 2026-03-23 04:36:02 +08:00
parent a8044aad8b
commit de9863da57

View file

@ -57,11 +57,23 @@ snapshot pipeline delivers them to a spectator client.
// State // State
// --------------------------------------------------------------- // ---------------------------------------------------------------
// per-entity data stored for delta compression (entityState + PVS fields)
typedef struct {
entityState_t es;
int svFlags;
qboolean linked;
vec3_t currentOrigin;
vec3_t absmin;
vec3_t absmax;
qboolean active; // was this entity present last frame?
} svdEntityState_t;
typedef struct { typedef struct {
// recording // recording
fileHandle_t recordFile; fileHandle_t recordFile;
qboolean recording; qboolean recording;
char *lastConfigstrings[MAX_CONFIGSTRINGS]; // for delta detection char *lastConfigstrings[MAX_CONFIGSTRINGS]; // for delta detection
svdEntityState_t prevEntities[MAX_GENTITIES]; // previous frame for delta
// playback // playback
fileHandle_t playFile; fileHandle_t playFile;
@ -74,6 +86,7 @@ typedef struct {
int nextFrameTime; // serverTime of next frame to read int nextFrameTime; // serverTime of next frame to read
qboolean endOfDemo; qboolean endOfDemo;
qboolean needConfigstrings; // apply saved configstrings on first frame qboolean needConfigstrings; // apply saved configstrings on first frame
svdEntityState_t playPrevEntities[MAX_GENTITIES]; // previous frame for delta read
} svDemo_t; } svDemo_t;
static svDemo_t demo; static svDemo_t demo;
@ -150,38 +163,85 @@ static void SVD_WriteHeader( fileHandle_t f ) {
// --------------------------------------------------------------- // ---------------------------------------------------------------
static void SVD_WriteFrame( fileHandle_t f ) { static void SVD_WriteFrame( fileHandle_t f ) {
int i, count; int i;
sharedEntity_t *ent; sharedEntity_t *ent;
short numChanges; short numChanges;
msg_t msg;
byte msgBuf[MAX_GENTITIES * 64]; // delta-compressed entities are small
int msgLen;
SVD_WriteInt( f, svs.time ); SVD_WriteInt( f, svs.time );
SVD_WriteShort( f, (short)sv.num_entities );
// delta-compress all entities into a message buffer
MSG_Init( &msg, msgBuf, sizeof(msgBuf) );
// count active entities
count = 0;
for ( i = 0; i < sv.num_entities; i++ ) { for ( i = 0; i < sv.num_entities; i++ ) {
qboolean active;
ent = SV_GentityNum( i ); ent = SV_GentityNum( i );
if ( ent->r.linked || ent->s.eType != 0 ) { active = ( ent->r.linked || ent->s.eType != 0 );
count++;
}
}
SVD_WriteShort( f, (short)count );
// write each active entity if ( active ) {
for ( i = 0; i < sv.num_entities; i++ ) { entityState_t baseline;
ent = SV_GentityNum( i ); entityState_t *from;
if ( !ent->r.linked && ent->s.eType == 0 ) { if ( demo.prevEntities[i].active ) {
continue; from = &demo.prevEntities[i].es;
} } else {
Com_Memset( &baseline, 0, sizeof(baseline) );
baseline.number = i;
from = &baseline;
}
MSG_WriteDeltaEntity( &msg, from, &ent->s, qtrue );
SVD_WriteShort( f, (short)i ); // write PVS fields only if changed from previous frame
FS_Write( &ent->s, sizeof(entityState_t), f ); {
SVD_WriteInt( f, ent->r.svFlags ); qboolean pvsChanged = !demo.prevEntities[i].active
SVD_WriteInt( f, ent->r.linked ); || ent->r.svFlags != demo.prevEntities[i].svFlags
FS_Write( ent->r.currentOrigin, 12, f ); || ent->r.linked != demo.prevEntities[i].linked
FS_Write( ent->r.absmin, 12, f ); || !VectorCompare( ent->r.currentOrigin, demo.prevEntities[i].currentOrigin )
FS_Write( ent->r.absmax, 12, f ); || !VectorCompare( ent->r.absmin, demo.prevEntities[i].absmin )
|| !VectorCompare( ent->r.absmax, demo.prevEntities[i].absmax );
MSG_WriteBits( &msg, pvsChanged, 1 );
if ( pvsChanged ) {
MSG_WriteLong( &msg, ent->r.svFlags );
MSG_WriteLong( &msg, ent->r.linked );
MSG_WriteFloat( &msg, ent->r.currentOrigin[0] );
MSG_WriteFloat( &msg, ent->r.currentOrigin[1] );
MSG_WriteFloat( &msg, ent->r.currentOrigin[2] );
MSG_WriteFloat( &msg, ent->r.absmin[0] );
MSG_WriteFloat( &msg, ent->r.absmin[1] );
MSG_WriteFloat( &msg, ent->r.absmin[2] );
MSG_WriteFloat( &msg, ent->r.absmax[0] );
MSG_WriteFloat( &msg, ent->r.absmax[1] );
MSG_WriteFloat( &msg, ent->r.absmax[2] );
}
}
// update prev state
demo.prevEntities[i].es = ent->s;
demo.prevEntities[i].svFlags = ent->r.svFlags;
demo.prevEntities[i].linked = ent->r.linked;
VectorCopy( ent->r.currentOrigin, demo.prevEntities[i].currentOrigin );
VectorCopy( ent->r.absmin, demo.prevEntities[i].absmin );
VectorCopy( ent->r.absmax, demo.prevEntities[i].absmax );
demo.prevEntities[i].active = qtrue;
} else if ( demo.prevEntities[i].active ) {
// entity was removed — write a remove marker
MSG_WriteDeltaEntity( &msg, &demo.prevEntities[i].es, NULL, qtrue );
demo.prevEntities[i].active = qfalse;
}
// else: entity was inactive and still inactive — write nothing
} }
// end of entities marker
MSG_WriteBits( &msg, (MAX_GENTITIES - 1), GENTITYNUM_BITS );
// write compressed message to file
msgLen = msg.cursize;
SVD_WriteInt( f, msgLen );
FS_Write( msg.data, msgLen, f );
// configstring changes // configstring changes
numChanges = 0; numChanges = 0;
for ( i = 0; i < MAX_CONFIGSTRINGS; i++ ) { for ( i = 0; i < MAX_CONFIGSTRINGS; i++ ) {
@ -247,6 +307,9 @@ void SVD_Record_f( void ) {
Com_Printf( "Recording server demo to %s\n", name ); Com_Printf( "Recording server demo to %s\n", name );
demo.recording = qtrue; demo.recording = qtrue;
// clear delta state for fresh recording
Com_Memset( demo.prevEntities, 0, sizeof(demo.prevEntities) );
SVD_WriteHeader( demo.recordFile ); SVD_WriteHeader( demo.recordFile );
} }
@ -361,8 +424,11 @@ static void SVD_ApplyConfigstrings( void ) {
static qboolean SVD_ReadFrame( fileHandle_t f ) { static qboolean SVD_ReadFrame( fileHandle_t f ) {
int serverTime; int serverTime;
short numEnts, numChanges; short numEnts, numChanges;
int i; int i, entNum, msgLen;
sharedEntity_t *ent; sharedEntity_t *ent;
msg_t msg;
byte msgBuf[MAX_GENTITIES * 64];
entityState_t newEs;
serverTime = SVD_ReadInt( f ); serverTime = SVD_ReadInt( f );
if ( serverTime == -1 ) { if ( serverTime == -1 ) {
@ -370,54 +436,100 @@ static qboolean SVD_ReadFrame( fileHandle_t f ) {
} }
svs.time = serverTime; svs.time = serverTime;
numEnts = SVD_ReadShort( f );
// clear all entities except the spectator slot // read compressed message
msgLen = SVD_ReadInt( f );
if ( msgLen <= 0 || msgLen > (int)sizeof(msgBuf) ) {
return qfalse;
}
FS_Read( msgBuf, msgLen, f );
MSG_Init( &msg, msgBuf, sizeof(msgBuf) );
msg.cursize = msgLen;
// clear all non-spectator entities
for ( i = 0; i < sv.num_entities; i++ ) { for ( i = 0; i < sv.num_entities; i++ ) {
if ( i == demo.spectatorClientNum ) { if ( i == demo.spectatorClientNum ) {
continue; continue;
} }
ent = SV_GentityNum( i ); ent = SV_GentityNum( i );
ent->r.linked = qfalse; if ( ent->r.linked ) {
SV_UnlinkEntity( ent );
}
ent->s.eType = 0; ent->s.eType = 0;
ent->s.number = i; ent->s.number = i;
} }
numEnts = SVD_ReadShort( f ); // parse delta-compressed entities
while ( 1 ) {
entNum = MSG_ReadBits( &msg, GENTITYNUM_BITS );
if ( entNum == MAX_GENTITIES - 1 ) {
break; // end of entities marker
}
if ( msg.readcount > msg.cursize ) {
break;
}
for ( i = 0; i < numEnts; i++ ) { {
short entNum; entityState_t baseline;
entityState_t es; entityState_t *from;
int svFlags, linked; Com_Memset( &newEs, 0, sizeof(newEs) );
vec3_t currentOrigin, absmin, absmax; if ( demo.playPrevEntities[entNum].active ) {
from = &demo.playPrevEntities[entNum].es;
} else {
Com_Memset( &baseline, 0, sizeof(baseline) );
baseline.number = entNum;
from = &baseline;
}
MSG_ReadDeltaEntity( &msg, from, &newEs, entNum );
}
entNum = SVD_ReadShort( f ); if ( newEs.number == MAX_GENTITIES - 1 ) {
FS_Read( &es, sizeof(entityState_t), f ); // entity was removed
svFlags = SVD_ReadInt( f ); demo.playPrevEntities[entNum].active = qfalse;
linked = SVD_ReadInt( f ); continue;
FS_Read( currentOrigin, 12, f ); }
FS_Read( absmin, 12, f );
FS_Read( absmax, 12, f ); // read PVS fields
{
qboolean pvsChanged = MSG_ReadBits( &msg, 1 );
if ( pvsChanged ) {
demo.playPrevEntities[entNum].svFlags = MSG_ReadLong( &msg );
demo.playPrevEntities[entNum].linked = MSG_ReadLong( &msg );
demo.playPrevEntities[entNum].currentOrigin[0] = MSG_ReadFloat( &msg );
demo.playPrevEntities[entNum].currentOrigin[1] = MSG_ReadFloat( &msg );
demo.playPrevEntities[entNum].currentOrigin[2] = MSG_ReadFloat( &msg );
demo.playPrevEntities[entNum].absmin[0] = MSG_ReadFloat( &msg );
demo.playPrevEntities[entNum].absmin[1] = MSG_ReadFloat( &msg );
demo.playPrevEntities[entNum].absmin[2] = MSG_ReadFloat( &msg );
demo.playPrevEntities[entNum].absmax[0] = MSG_ReadFloat( &msg );
demo.playPrevEntities[entNum].absmax[1] = MSG_ReadFloat( &msg );
demo.playPrevEntities[entNum].absmax[2] = MSG_ReadFloat( &msg );
}
}
demo.playPrevEntities[entNum].es = newEs;
demo.playPrevEntities[entNum].active = qtrue;
// skip the spectator's slot // skip the spectator's slot
if ( entNum == demo.spectatorClientNum ) { if ( entNum == demo.spectatorClientNum ) {
continue; continue;
} }
// apply to server entity
ent = SV_GentityNum( entNum ); ent = SV_GentityNum( entNum );
ent->s = es; ent->s = newEs;
ent->s.number = entNum; ent->s.number = entNum;
ent->r.svFlags = svFlags; ent->r.svFlags = demo.playPrevEntities[entNum].svFlags;
ent->r.linked = linked; ent->r.linked = demo.playPrevEntities[entNum].linked;
VectorCopy( currentOrigin, ent->r.currentOrigin ); VectorCopy( demo.playPrevEntities[entNum].currentOrigin, ent->r.currentOrigin );
VectorCopy( absmin, ent->r.absmin ); VectorCopy( demo.playPrevEntities[entNum].absmin, ent->r.absmin );
VectorCopy( absmax, ent->r.absmax ); VectorCopy( demo.playPrevEntities[entNum].absmax, ent->r.absmax );
// link into the world so PVS can find this entity if ( ent->r.linked ) {
if ( linked ) {
SV_LinkEntity( ent ); SV_LinkEntity( ent );
} }
// update num_entities high water mark
if ( entNum + 1 > sv.num_entities ) { if ( entNum + 1 > sv.num_entities ) {
sv.num_entities = entNum + 1; sv.num_entities = entNum + 1;
} }