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
// ---------------------------------------------------------------
// 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 {
// recording
fileHandle_t recordFile;
qboolean recording;
char *lastConfigstrings[MAX_CONFIGSTRINGS]; // for delta detection
svdEntityState_t prevEntities[MAX_GENTITIES]; // previous frame for delta
// playback
fileHandle_t playFile;
@ -74,6 +86,7 @@ typedef struct {
int nextFrameTime; // serverTime of next frame to read
qboolean endOfDemo;
qboolean needConfigstrings; // apply saved configstrings on first frame
svdEntityState_t playPrevEntities[MAX_GENTITIES]; // previous frame for delta read
} svDemo_t;
static svDemo_t demo;
@ -150,37 +163,84 @@ static void SVD_WriteHeader( fileHandle_t f ) {
// ---------------------------------------------------------------
static void SVD_WriteFrame( fileHandle_t f ) {
int i, count;
int i;
sharedEntity_t *ent;
short numChanges;
msg_t msg;
byte msgBuf[MAX_GENTITIES * 64]; // delta-compressed entities are small
int msgLen;
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++ ) {
qboolean active;
ent = SV_GentityNum( i );
if ( ent->r.linked || ent->s.eType != 0 ) {
count++;
}
}
SVD_WriteShort( f, (short)count );
active = ( ent->r.linked || ent->s.eType != 0 );
// write each active entity
for ( i = 0; i < sv.num_entities; i++ ) {
ent = SV_GentityNum( i );
if ( !ent->r.linked && ent->s.eType == 0 ) {
continue;
if ( active ) {
entityState_t baseline;
entityState_t *from;
if ( demo.prevEntities[i].active ) {
from = &demo.prevEntities[i].es;
} else {
Com_Memset( &baseline, 0, sizeof(baseline) );
baseline.number = i;
from = &baseline;
}
MSG_WriteDeltaEntity( &msg, from, &ent->s, qtrue );
// write PVS fields only if changed from previous frame
{
qboolean pvsChanged = !demo.prevEntities[i].active
|| ent->r.svFlags != demo.prevEntities[i].svFlags
|| ent->r.linked != demo.prevEntities[i].linked
|| !VectorCompare( ent->r.currentOrigin, demo.prevEntities[i].currentOrigin )
|| !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] );
}
}
SVD_WriteShort( f, (short)i );
FS_Write( &ent->s, sizeof(entityState_t), f );
SVD_WriteInt( f, ent->r.svFlags );
SVD_WriteInt( f, ent->r.linked );
FS_Write( ent->r.currentOrigin, 12, f );
FS_Write( ent->r.absmin, 12, f );
FS_Write( ent->r.absmax, 12, f );
// 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
numChanges = 0;
@ -247,6 +307,9 @@ void SVD_Record_f( void ) {
Com_Printf( "Recording server demo to %s\n", name );
demo.recording = qtrue;
// clear delta state for fresh recording
Com_Memset( demo.prevEntities, 0, sizeof(demo.prevEntities) );
SVD_WriteHeader( demo.recordFile );
}
@ -361,8 +424,11 @@ static void SVD_ApplyConfigstrings( void ) {
static qboolean SVD_ReadFrame( fileHandle_t f ) {
int serverTime;
short numEnts, numChanges;
int i;
int i, entNum, msgLen;
sharedEntity_t *ent;
msg_t msg;
byte msgBuf[MAX_GENTITIES * 64];
entityState_t newEs;
serverTime = SVD_ReadInt( f );
if ( serverTime == -1 ) {
@ -370,54 +436,100 @@ static qboolean SVD_ReadFrame( fileHandle_t f ) {
}
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++ ) {
if ( i == demo.spectatorClientNum ) {
continue;
}
ent = SV_GentityNum( i );
ent->r.linked = qfalse;
if ( ent->r.linked ) {
SV_UnlinkEntity( ent );
}
ent->s.eType = 0;
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 es;
int svFlags, linked;
vec3_t currentOrigin, absmin, absmax;
{
entityState_t baseline;
entityState_t *from;
Com_Memset( &newEs, 0, sizeof(newEs) );
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 );
FS_Read( &es, sizeof(entityState_t), f );
svFlags = SVD_ReadInt( f );
linked = SVD_ReadInt( f );
FS_Read( currentOrigin, 12, f );
FS_Read( absmin, 12, f );
FS_Read( absmax, 12, f );
if ( newEs.number == MAX_GENTITIES - 1 ) {
// entity was removed
demo.playPrevEntities[entNum].active = qfalse;
continue;
}
// 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
if ( entNum == demo.spectatorClientNum ) {
continue;
}
// apply to server entity
ent = SV_GentityNum( entNum );
ent->s = es;
ent->s = newEs;
ent->s.number = entNum;
ent->r.svFlags = svFlags;
ent->r.linked = linked;
VectorCopy( currentOrigin, ent->r.currentOrigin );
VectorCopy( absmin, ent->r.absmin );
VectorCopy( absmax, ent->r.absmax );
ent->r.svFlags = demo.playPrevEntities[entNum].svFlags;
ent->r.linked = demo.playPrevEntities[entNum].linked;
VectorCopy( demo.playPrevEntities[entNum].currentOrigin, ent->r.currentOrigin );
VectorCopy( demo.playPrevEntities[entNum].absmin, ent->r.absmin );
VectorCopy( demo.playPrevEntities[entNum].absmax, ent->r.absmax );
// link into the world so PVS can find this entity
if ( linked ) {
if ( ent->r.linked ) {
SV_LinkEntity( ent );
}
// update num_entities high water mark
if ( entNum + 1 > sv.num_entities ) {
sv.num_entities = entNum + 1;
}