|
|
|
/*
|
|
|
|
cl_demo.c - demo record & playback
|
|
|
|
Copyright (C) 2007 Uncle Mike
|
|
|
|
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
|
|
it under the terms of the GNU General Public License as published by
|
|
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
|
|
(at your option) any later version.
|
|
|
|
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
GNU General Public License for more details.
|
|
|
|
*/
|
|
|
|
|
|
|
|
#include "common.h"
|
|
|
|
#include "client.h"
|
|
|
|
#include "net_encode.h"
|
|
|
|
|
|
|
|
#define dem_unknown 0 // unknown command
|
|
|
|
#define dem_norewind 1 // startup message
|
|
|
|
#define dem_read 2 // it's a normal network packet
|
|
|
|
#define dem_jumptime 3 // move the demostart time value forward by this amount
|
|
|
|
#define dem_userdata 4 // userdata from the client.dll
|
|
|
|
#define dem_usercmd 5 // read usercmd_t
|
|
|
|
#define dem_stop 6 // end of time
|
|
|
|
#define dem_lastcmd dem_stop
|
|
|
|
|
|
|
|
#define DEMO_STARTUP 0 // this lump contains startup info needed to spawn into the server
|
|
|
|
#define DEMO_NORMAL 1 // this lump contains playback info of messages, etc., needed during playback.
|
|
|
|
|
|
|
|
// Demo flags
|
|
|
|
#define FDEMO_TITLE 0x01 // Show title
|
|
|
|
#define FDEMO_PLAY 0x04 // Playing cd track
|
|
|
|
#define FDEMO_FADE_IN_SLOW 0x08 // Fade in (slow)
|
|
|
|
#define FDEMO_FADE_IN_FAST 0x10 // Fade in (fast)
|
|
|
|
#define FDEMO_FADE_OUT_SLOW 0x20 // Fade out (slow)
|
|
|
|
#define FDEMO_FADE_OUT_FAST 0x40 // Fade out (fast)
|
|
|
|
|
|
|
|
#define IDEMOHEADER (('M'<<24)+('E'<<16)+('D'<<8)+'I') // little-endian "IDEM"
|
|
|
|
#define DEMO_PROTOCOL 3
|
|
|
|
|
|
|
|
const char *demo_cmd[dem_lastcmd+1] =
|
|
|
|
{
|
|
|
|
"dem_unknown",
|
|
|
|
"dem_norewind",
|
|
|
|
"dem_read",
|
|
|
|
"dem_jumptime",
|
|
|
|
"dem_userdata",
|
|
|
|
"dem_usercmd",
|
|
|
|
"dem_stop",
|
|
|
|
};
|
|
|
|
|
|
|
|
#pragma pack( push, 1 )
|
|
|
|
typedef struct
|
|
|
|
{
|
|
|
|
int id; // should be IDEM
|
|
|
|
int dem_protocol; // should be DEMO_PROTOCOL
|
|
|
|
int net_protocol; // should be PROTOCOL_VERSION
|
|
|
|
double host_fps; // fps for demo playing
|
|
|
|
char mapname[64]; // name of map
|
|
|
|
char comment[64]; // comment for demo
|
|
|
|
char gamedir[64]; // name of game directory (FS_Gamedir())
|
|
|
|
int directory_offset; // offset of Entry Directory.
|
|
|
|
} demoheader_t;
|
|
|
|
#pragma pack( pop )
|
|
|
|
|
|
|
|
typedef struct
|
|
|
|
{
|
|
|
|
int entrytype; // DEMO_STARTUP or DEMO_NORMAL
|
|
|
|
float playback_time; // time of track
|
|
|
|
int playback_frames; // # of frames in track
|
|
|
|
int offset; // file offset of track data
|
|
|
|
int length; // length of track
|
|
|
|
int flags; // FX-flags
|
|
|
|
char description[64]; // entry description
|
|
|
|
} demoentry_t;
|
|
|
|
|
|
|
|
typedef struct
|
|
|
|
{
|
|
|
|
demoentry_t *entries; // track entry info
|
|
|
|
int numentries; // number of tracks
|
|
|
|
} demodirectory_t;
|
|
|
|
|
|
|
|
// add angles
|
|
|
|
typedef struct
|
|
|
|
{
|
|
|
|
float starttime;
|
|
|
|
vec3_t viewangles;
|
|
|
|
} demoangle_t;
|
|
|
|
|
|
|
|
// private demo states
|
|
|
|
struct
|
|
|
|
{
|
|
|
|
demoheader_t header;
|
|
|
|
demoentry_t *entry;
|
|
|
|
demodirectory_t directory;
|
|
|
|
int framecount;
|
|
|
|
float starttime;
|
|
|
|
float realstarttime;
|
|
|
|
float timestamp;
|
|
|
|
float lasttime;
|
|
|
|
int entryIndex;
|
|
|
|
|
|
|
|
// interpolation stuff
|
|
|
|
demoangle_t cmds[ANGLE_BACKUP];
|
|
|
|
int angle_position;
|
|
|
|
} demo;
|
|
|
|
|
|
|
|
/*
|
|
|
|
====================
|
|
|
|
CL_StartupDemoHeader
|
|
|
|
|
|
|
|
spooling demo header in case
|
|
|
|
we record a demo on this level
|
|
|
|
====================
|
|
|
|
*/
|
|
|
|
void CL_StartupDemoHeader( void )
|
|
|
|
{
|
|
|
|
if( cls.demoheader )
|
|
|
|
{
|
|
|
|
FS_Close( cls.demoheader );
|
|
|
|
}
|
|
|
|
|
|
|
|
// Note: this is replacing tmpfile()
|
|
|
|
cls.demoheader = FS_Open( "demoheader.tmp", "w+b", true );
|
|
|
|
|
|
|
|
if( !cls.demoheader )
|
|
|
|
{
|
|
|
|
Con_DPrintf( S_ERROR "couldn't open temporary header file.\n" );
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
Con_Printf( "Spooling demo header.\n" );
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
====================
|
|
|
|
CL_CloseDemoHeader
|
|
|
|
|
|
|
|
close demoheader file on engine shutdown
|
|
|
|
====================
|
|
|
|
*/
|
|
|
|
void CL_CloseDemoHeader( void )
|
|
|
|
{
|
|
|
|
if( !cls.demoheader )
|
|
|
|
return;
|
|
|
|
|
|
|
|
FS_Close( cls.demoheader );
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
====================
|
|
|
|
CL_GetDemoRecordClock
|
|
|
|
|
|
|
|
write time while demo is recording
|
|
|
|
====================
|
|
|
|
*/
|
|
|
|
float CL_GetDemoRecordClock( void )
|
|
|
|
{
|
|
|
|
return cl.mtime[0];
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
====================
|
|
|
|
CL_GetDemoPlaybackClock
|
|
|
|
|
|
|
|
overwrite host.realtime
|
|
|
|
====================
|
|
|
|
*/
|
|
|
|
float CL_GetDemoPlaybackClock( void )
|
|
|
|
{
|
|
|
|
return host.realtime + host.frametime;
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
====================
|
|
|
|
CL_GetDemoFramerate
|
|
|
|
|
|
|
|
overwrite host.frametime
|
|
|
|
====================
|
|
|
|
*/
|
|
|
|
double CL_GetDemoFramerate( void )
|
|
|
|
{
|
|
|
|
if( cls.timedemo )
|
|
|
|
return 0.0;
|
|
|
|
return bound( MIN_FPS, demo.header.host_fps, MAX_FPS );
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
====================
|
|
|
|
CL_WriteDemoCmdHeader
|
|
|
|
|
|
|
|
Writes the demo command header and time-delta
|
|
|
|
====================
|
|
|
|
*/
|
|
|
|
void CL_WriteDemoCmdHeader( byte cmd, file_t *file )
|
|
|
|
{
|
|
|
|
float dt;
|
|
|
|
|
|
|
|
Assert( cmd >= 1 && cmd <= dem_lastcmd );
|
|
|
|
if( !file ) return;
|
|
|
|
|
|
|
|
// command
|
|
|
|
FS_Write( file, &cmd, sizeof( byte ));
|
|
|
|
|
|
|
|
// time offset
|
|
|
|
dt = (float)(CL_GetDemoRecordClock() - demo.starttime);
|
|
|
|
FS_Write( file, &dt, sizeof( float ));
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
====================
|
|
|
|
CL_WriteDemoJumpTime
|
|
|
|
|
|
|
|
Update level time on a next level
|
|
|
|
====================
|
|
|
|
*/
|
|
|
|
void CL_WriteDemoJumpTime( void )
|
|
|
|
{
|
|
|
|
if( cls.demowaiting || !cls.demofile )
|
|
|
|
return;
|
|
|
|
|
|
|
|
demo.starttime = CL_GetDemoRecordClock(); // setup the demo starttime
|
|
|
|
|
|
|
|
// demo playback should read this as an incoming message.
|
|
|
|
// write the client's realtime value out so we can synchronize the reads.
|
|
|
|
CL_WriteDemoCmdHeader( dem_jumptime, cls.demofile );
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
====================
|
|
|
|
CL_WriteDemoUserCmd
|
|
|
|
|
|
|
|
Writes the current user cmd
|
|
|
|
====================
|
|
|
|
*/
|
|
|
|
void CL_WriteDemoUserCmd( int cmdnumber )
|
|
|
|
{
|
|
|
|
sizebuf_t buf;
|
|
|
|
word bytes;
|
|
|
|
byte data[1024];
|
|
|
|
|
|
|
|
if( !cls.demorecording || !cls.demofile )
|
|
|
|
return;
|
|
|
|
|
|
|
|
CL_WriteDemoCmdHeader( dem_usercmd, cls.demofile );
|
|
|
|
|
|
|
|
FS_Write( cls.demofile, &cls.netchan.outgoing_sequence, sizeof( int ));
|
|
|
|
FS_Write( cls.demofile, &cmdnumber, sizeof( int ));
|
|
|
|
|
|
|
|
// write usercmd_t
|
|
|
|
MSG_Init( &buf, "UserCmd", data, sizeof( data ));
|
|
|
|
CL_WriteUsercmd( &buf, -1, cmdnumber ); // always no delta
|
|
|
|
|
|
|
|
bytes = MSG_GetNumBytesWritten( &buf );
|
|
|
|
|
|
|
|
FS_Write( cls.demofile, &bytes, sizeof( word ));
|
|
|
|
FS_Write( cls.demofile, data, bytes );
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
====================
|
|
|
|
CL_WriteDemoSequence
|
|
|
|
|
|
|
|
Save state of cls.netchan sequences
|
|
|
|
so that we can play the demo correctly.
|
|
|
|
====================
|
|
|
|
*/
|
|
|
|
void CL_WriteDemoSequence( file_t *file )
|
|
|
|
{
|
|
|
|
Assert( file != NULL );
|
|
|
|
|
|
|
|
FS_Write( file, &cls.netchan.incoming_sequence, sizeof( int ));
|
|
|
|
FS_Write( file, &cls.netchan.incoming_acknowledged, sizeof( int ));
|
|
|
|
FS_Write( file, &cls.netchan.incoming_reliable_acknowledged, sizeof( int ));
|
|
|
|
FS_Write( file, &cls.netchan.incoming_reliable_sequence, sizeof( int ));
|
|
|
|
FS_Write( file, &cls.netchan.outgoing_sequence, sizeof( int ));
|
|
|
|
FS_Write( file, &cls.netchan.reliable_sequence, sizeof( int ));
|
|
|
|
FS_Write( file, &cls.netchan.last_reliable_sequence, sizeof( int ));
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
====================
|
|
|
|
CL_WriteDemoMessage
|
|
|
|
|
|
|
|
Dumps the current net message, prefixed by the length
|
|
|
|
====================
|
|
|
|
*/
|
|
|
|
void CL_WriteDemoMessage( qboolean startup, int start, sizebuf_t *msg )
|
|
|
|
{
|
|
|
|
file_t *file = startup ? cls.demoheader : cls.demofile;
|
|
|
|
int swlen;
|
|
|
|
byte c;
|
|
|
|
|
|
|
|
if( !file ) return;
|
|
|
|
|
|
|
|
// past the start but not recording a demo.
|
|
|
|
if( !startup && !cls.demorecording )
|
|
|
|
return;
|
|
|
|
|
|
|
|
swlen = MSG_GetNumBytesWritten( msg ) - start;
|
|
|
|
if( swlen <= 0 ) return;
|
|
|
|
|
|
|
|
if( !startup ) demo.framecount++;
|
|
|
|
|
|
|
|
// demo playback should read this as an incoming message.
|
|
|
|
c = (cls.state != ca_active) ? dem_norewind : dem_read;
|
|
|
|
|
|
|
|
CL_WriteDemoCmdHeader( c, file );
|
|
|
|
CL_WriteDemoSequence( file );
|
|
|
|
|
|
|
|
// write the length out.
|
|
|
|
FS_Write( file, &swlen, sizeof( int ));
|
|
|
|
|
|
|
|
// output the buffer. Skip the network packet stuff.
|
|
|
|
FS_Write( file, MSG_GetData( msg ) + start, swlen );
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
====================
|
|
|
|
CL_WriteDemoUserMessage
|
|
|
|
|
|
|
|
Dumps the user message (demoaction)
|
|
|
|
====================
|
|
|
|
*/
|
|
|
|
void CL_WriteDemoUserMessage( const byte *buffer, size_t size )
|
|
|
|
{
|
|
|
|
if( !cls.demorecording || cls.demowaiting )
|
|
|
|
return;
|
|
|
|
|
|
|
|
if( !cls.demofile || !buffer || size <= 0 )
|
|
|
|
return;
|
|
|
|
|
|
|
|
CL_WriteDemoCmdHeader( dem_userdata, cls.demofile );
|
|
|
|
|
|
|
|
// write the length out.
|
|
|
|
FS_Write( cls.demofile, &size, sizeof( int ));
|
|
|
|
|
|
|
|
// output the buffer.
|
|
|
|
FS_Write( cls.demofile, buffer, size );
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
====================
|
|
|
|
CL_WriteDemoHeader
|
|
|
|
|
|
|
|
Write demo header
|
|
|
|
====================
|
|
|
|
*/
|
|
|
|
void CL_WriteDemoHeader( const char *name )
|
|
|
|
{
|
|
|
|
int copysize;
|
|
|
|
int savepos;
|
|
|
|
int curpos;
|
|
|
|
|
|
|
|
Con_Printf( "recording to %s.\n", name );
|
|
|
|
cls.demofile = FS_Open( name, "wb", false );
|
|
|
|
cls.demotime = 0.0;
|
|
|
|
|
|
|
|
if( !cls.demofile )
|
|
|
|
{
|
|
|
|
Con_Printf( S_ERROR "couldn't open %s.\n", name );
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
cls.demorecording = true;
|
|
|
|
cls.demowaiting = true; // don't start saving messages until a non-delta compressed message is received
|
|
|
|
|
|
|
|
memset( &demo.header, 0, sizeof( demo.header ));
|
|
|
|
|
|
|
|
demo.header.id = IDEMOHEADER;
|
|
|
|
demo.header.dem_protocol = DEMO_PROTOCOL;
|
|
|
|
demo.header.net_protocol = cls.legacymode ? PROTOCOL_LEGACY_VERSION : PROTOCOL_VERSION;
|
|
|
|
demo.header.host_fps = bound( MIN_FPS, host_maxfps->value, MAX_FPS );
|
|
|
|
Q_strncpy( demo.header.mapname, clgame.mapname, sizeof( demo.header.mapname ));
|
|
|
|
Q_strncpy( demo.header.comment, clgame.maptitle, sizeof( demo.header.comment ));
|
|
|
|
Q_strncpy( demo.header.gamedir, FS_Gamedir(), sizeof( demo.header.gamedir ));
|
|
|
|
|
|
|
|
// write header
|
|
|
|
FS_Write( cls.demofile, &demo.header, sizeof( demo.header ));
|
|
|
|
|
|
|
|
demo.directory.numentries = 2;
|
|
|
|
demo.directory.entries = Mem_Calloc( cls.mempool, sizeof( demoentry_t ) * demo.directory.numentries );
|
|
|
|
|
|
|
|
// DIRECTORY ENTRY # 0
|
|
|
|
demo.entry = &demo.directory.entries[0]; // only one here.
|
|
|
|
demo.entry->entrytype = DEMO_STARTUP;
|
|
|
|
demo.entry->playback_time = 0.0f; // startup takes 0 time.
|
|
|
|
demo.entry->offset = FS_Tell( cls.demofile ); // position for this chunk.
|
|
|
|
|
|
|
|
// finish off the startup info.
|
|
|
|
CL_WriteDemoCmdHeader( dem_stop, cls.demoheader );
|
|
|
|
|
|
|
|
// now copy the stuff we cached from the server.
|
|
|
|
copysize = savepos = FS_Tell( cls.demoheader );
|
|
|
|
|
|
|
|
FS_Seek( cls.demoheader, 0, SEEK_SET );
|
|
|
|
|
|
|
|
FS_FileCopy( cls.demofile, cls.demoheader, copysize );
|
|
|
|
|
|
|
|
// jump back to end, in case we record another demo for this session.
|
|
|
|
FS_Seek( cls.demoheader, savepos, SEEK_SET );
|
|
|
|
|
|
|
|
demo.starttime = CL_GetDemoRecordClock(); // setup the demo starttime
|
|
|
|
demo.realstarttime = demo.starttime;
|
|
|
|
demo.framecount = 0;
|
|
|
|
cls.td_startframe = host.framecount;
|
|
|
|
cls.td_lastframe = -1; // get a new message this frame
|
|
|
|
|
|
|
|
// now move on to entry # 1, the first data chunk.
|
|
|
|
curpos = FS_Tell( cls.demofile );
|
|
|
|
demo.entry->length = curpos - demo.entry->offset;
|
|
|
|
|
|
|
|
// now we are writing the first real lump.
|
|
|
|
demo.entry = &demo.directory.entries[1]; // first real data lump
|
|
|
|
demo.entry->entrytype = DEMO_NORMAL;
|
|
|
|
demo.entry->playback_time = 0.0f; // startup takes 0 time.
|
|
|
|
|
|
|
|
demo.entry->offset = FS_Tell( cls.demofile );
|
|
|
|
|
|
|
|
// demo playback should read this as an incoming message.
|
|
|
|
// write the client's realtime value out so we can synchronize the reads.
|
|
|
|
CL_WriteDemoCmdHeader( dem_jumptime, cls.demofile );
|
|
|
|
|
|
|
|
if( clgame.hInstance ) clgame.dllFuncs.pfnReset();
|
|
|
|
|
|
|
|
Cbuf_InsertText( "fullupdate\n" );
|
|
|
|
Cbuf_Execute();
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
=================
|
|
|
|
CL_StopRecord
|
|
|
|
|
|
|
|
finish recording demo
|
|
|
|
=================
|
|
|
|
*/
|
|
|
|
void CL_StopRecord( void )
|
|
|
|
{
|
|
|
|
int i, curpos;
|
|
|
|
float stoptime;
|
|
|
|
int frames;
|
|
|
|
|
|
|
|
if( !cls.demorecording ) return;
|
|
|
|
|
|
|
|
// demo playback should read this as an incoming message.
|
|
|
|
CL_WriteDemoCmdHeader( dem_stop, cls.demofile );
|
|
|
|
|
|
|
|
stoptime = CL_GetDemoRecordClock();
|
|
|
|
if( clgame.hInstance ) clgame.dllFuncs.pfnReset();
|
|
|
|
|
|
|
|
curpos = FS_Tell( cls.demofile );
|
|
|
|
demo.entry->length = curpos - demo.entry->offset;
|
|
|
|
demo.entry->playback_time = stoptime - demo.realstarttime;
|
|
|
|
demo.entry->playback_frames = demo.framecount;
|
|
|
|
|
|
|
|
// Now write out the directory and free it and touch up the demo header.
|
|
|
|
FS_Write( cls.demofile, &demo.directory.numentries, sizeof( int ));
|
|
|
|
|
|
|
|
for( i = 0; i < demo.directory.numentries; i++ )
|
|
|
|
FS_Write( cls.demofile, &demo.directory.entries[i], sizeof( demoentry_t ));
|
|
|
|
|
|
|
|
Mem_Free( demo.directory.entries );
|
|
|
|
demo.directory.numentries = 0;
|
|
|
|
|
|
|
|
demo.header.directory_offset = curpos;
|
|
|
|
FS_Seek( cls.demofile, 0, SEEK_SET );
|
|
|
|
FS_Write( cls.demofile, &demo.header, sizeof( demo.header ));
|
|
|
|
|
|
|
|
FS_Close( cls.demofile );
|
|
|
|
cls.demofile = NULL;
|
|
|
|
cls.demorecording = false;
|
|
|
|
cls.demoname[0] = '\0';
|
|
|
|
cls.td_lastframe = host.framecount;
|
|
|
|
gameui.globals->demoname[0] = '\0';
|
|
|
|
demo.header.host_fps = 0.0;
|
|
|
|
|
|
|
|
frames = cls.td_lastframe - cls.td_startframe;
|
|
|
|
Con_Printf( "Completed demo\nRecording time: %02d:%02d, frames %i\n", (int)(cls.demotime / 60.0f), (int)fmod(cls.demotime, 60.0f), frames );
|
|
|
|
cls.demotime = 0.0;
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
=================
|
|
|
|
CL_DrawDemoRecording
|
|
|
|
=================
|
|
|
|
*/
|
|
|
|
void CL_DrawDemoRecording( void )
|
|
|
|
{
|
|
|
|
char string[64];
|
|
|
|
rgba_t color = { 255, 255, 255, 255 };
|
|
|
|
int pos;
|
|
|
|
int len;
|
|
|
|
|
|
|
|
if(!( host_developer.value && cls.demorecording ))
|
|
|
|
return;
|
|
|
|
|
|
|
|
pos = FS_Tell( cls.demofile );
|
|
|
|
Q_snprintf( string, sizeof( string ), "^1RECORDING:^7 %s: %s time: %02d:%02d", cls.demoname,
|
|
|
|
Q_memprint( pos ), (int)(cls.demotime / 60.0f ), (int)fmod( cls.demotime, 60.0f ));
|
|
|
|
|
|
|
|
Con_DrawStringLen( string, &len, NULL );
|
|
|
|
Con_DrawString(( refState.width - len ) >> 1, refState.height >> 4, string, color );
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
=======================================================================
|
|
|
|
|
|
|
|
CLIENT SIDE DEMO PLAYBACK
|
|
|
|
|
|
|
|
=======================================================================
|
|
|
|
*/
|
|
|
|
/*
|
|
|
|
=================
|
|
|
|
CL_ReadDemoCmdHeader
|
|
|
|
|
|
|
|
read the demo command
|
|
|
|
=================
|
|
|
|
*/
|
|
|
|
qboolean CL_ReadDemoCmdHeader( byte *cmd, float *dt )
|
|
|
|
{
|
|
|
|
// read the command
|
|
|
|
// HACKHACK: skip NOPs
|
|
|
|
do
|
|
|
|
{
|
|
|
|
FS_Read( cls.demofile, cmd, sizeof( byte ));
|
|
|
|
} while( *cmd == dem_unknown );
|
|
|
|
|
|
|
|
if( *cmd > dem_lastcmd )
|
|
|
|
{
|
|
|
|
Con_Printf( S_ERROR "Demo cmd %d > %d, file offset = %d\n", *cmd, dem_lastcmd, (int)FS_Tell( cls.demofile ));
|
|
|
|
CL_DemoCompleted();
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
// read the timestamp
|
|
|
|
FS_Read( cls.demofile, dt, sizeof( float ));
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
=================
|
|
|
|
CL_ReadDemoUserCmd
|
|
|
|
|
|
|
|
read the demo usercmd for predicting
|
|
|
|
and smooth movement during playback the demo
|
|
|
|
=================
|
|
|
|
*/
|
|
|
|
void CL_ReadDemoUserCmd( qboolean discard )
|
|
|
|
{
|
|
|
|
byte data[1024];
|
|
|
|
int cmdnumber;
|
|
|
|
int outgoing_sequence;
|
|
|
|
runcmd_t *pcmd;
|
|
|
|
word bytes;
|
|
|
|
|
|
|
|
FS_Read( cls.demofile, &outgoing_sequence, sizeof( int ));
|
|
|
|
FS_Read( cls.demofile, &cmdnumber, sizeof( int ));
|
|
|
|
FS_Read( cls.demofile, &bytes, sizeof( short ));
|
|
|
|
FS_Read( cls.demofile, data, bytes );
|
|
|
|
|
|
|
|
if( !discard )
|
|
|
|
{
|
|
|
|
usercmd_t nullcmd;
|
|
|
|
sizebuf_t buf;
|
|
|
|
demoangle_t *a;
|
|
|
|
|
|
|
|
memset( &nullcmd, 0, sizeof( nullcmd ));
|
|
|
|
MSG_Init( &buf, "UserCmd", data, sizeof( data ));
|
|
|
|
|
|
|
|
pcmd = &cl.commands[cmdnumber & CL_UPDATE_MASK];
|
|
|
|
pcmd->processedfuncs = false;
|
|
|
|
pcmd->senttime = 0.0f;
|
|
|
|
pcmd->receivedtime = 0.1f;
|
|
|
|
pcmd->frame_lerp = 0.1f;
|
|
|
|
pcmd->heldback = false;
|
|
|
|
pcmd->sendsize = 1;
|
|
|
|
|
|
|
|
// always delta'ing from null
|
|
|
|
cl.cmd = &pcmd->cmd;
|
|
|
|
|
|
|
|
MSG_ReadDeltaUsercmd( &buf, &nullcmd, cl.cmd );
|
|
|
|
|
|
|
|
// make sure what interp info contain angles from different frames
|
|
|
|
// or lerping will stop working
|
|
|
|
if( demo.lasttime != demo.timestamp )
|
|
|
|
{
|
|
|
|
// select entry into circular buffer
|
|
|
|
demo.angle_position = (demo.angle_position + 1) & ANGLE_MASK;
|
|
|
|
a = &demo.cmds[demo.angle_position];
|
|
|
|
|
|
|
|
// record update
|
|
|
|
a->starttime = demo.timestamp;
|
|
|
|
VectorCopy( cl.cmd->viewangles, a->viewangles );
|
|
|
|
demo.lasttime = demo.timestamp;
|
|
|
|
}
|
|
|
|
|
|
|
|
// NOTE: we need to have the current outgoing sequence correct
|
|
|
|
// so we can do prediction correctly during playback
|
|
|
|
cls.netchan.outgoing_sequence = outgoing_sequence;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
=================
|
|
|
|
CL_ReadDemoSequence
|
|
|
|
|
|
|
|
read netchan sequences
|
|
|
|
=================
|
|
|
|
*/
|
|
|
|
void CL_ReadDemoSequence( qboolean discard )
|
|
|
|
{
|
|
|
|
int incoming_sequence;
|
|
|
|
int incoming_acknowledged;
|
|
|
|
int incoming_reliable_acknowledged;
|
|
|
|
int incoming_reliable_sequence;
|
|
|
|
int outgoing_sequence;
|
|
|
|
int reliable_sequence;
|
|
|
|
int last_reliable_sequence;
|
|
|
|
|
|
|
|
FS_Read( cls.demofile, &incoming_sequence, sizeof( int ));
|
|
|
|
FS_Read( cls.demofile, &incoming_acknowledged, sizeof( int ));
|
|
|
|
FS_Read( cls.demofile, &incoming_reliable_acknowledged, sizeof( int ));
|
|
|
|
FS_Read( cls.demofile, &incoming_reliable_sequence, sizeof( int ));
|
|
|
|
FS_Read( cls.demofile, &outgoing_sequence, sizeof( int ));
|
|
|
|
FS_Read( cls.demofile, &reliable_sequence, sizeof( int ));
|
|
|
|
FS_Read( cls.demofile, &last_reliable_sequence, sizeof( int ));
|
|
|
|
|
|
|
|
if( discard ) return;
|
|
|
|
|
|
|
|
cls.netchan.incoming_sequence = incoming_sequence;
|
|
|
|
cls.netchan.incoming_acknowledged = incoming_acknowledged;
|
|
|
|
cls.netchan.incoming_reliable_acknowledged = incoming_reliable_acknowledged;
|
|
|
|
cls.netchan.incoming_reliable_sequence = incoming_reliable_sequence;
|
|
|
|
cls.netchan.outgoing_sequence = outgoing_sequence;
|
|
|
|
cls.netchan.reliable_sequence = reliable_sequence;
|
|
|
|
cls.netchan.last_reliable_sequence = last_reliable_sequence;
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
=================
|
|
|
|
CL_DemoStartPlayback
|
|
|
|
=================
|
|
|
|
*/
|
|
|
|
void CL_DemoStartPlayback( int mode )
|
|
|
|
{
|
|
|
|
if( cls.changedemo )
|
|
|
|
{
|
|
|
|
S_StopAllSounds( true );
|
|
|
|
SCR_BeginLoadingPlaque( false );
|
|
|
|
|
|
|
|
CL_ClearState ();
|
|
|
|
CL_InitEdicts (); // re-arrange edicts
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
// NOTE: at this point demo is still valid
|
|
|
|
CL_Disconnect();
|
|
|
|
Host_ShutdownServer();
|
|
|
|
|
|
|
|
Con_FastClose();
|
|
|
|
UI_SetActiveMenu( false );
|
|
|
|
}
|
|
|
|
|
|
|
|
cls.demoplayback = mode;
|
|
|
|
cls.state = ca_connected;
|
|
|
|
cl.background = (cls.demonum != -1) ? true : false;
|
|
|
|
cls.spectator = false;
|
|
|
|
cls.signon = 0;
|
|
|
|
|
|
|
|
demo.starttime = CL_GetDemoPlaybackClock(); // for determining whether to read another message
|
|
|
|
|
|
|
|
Netchan_Setup( NS_CLIENT, &cls.netchan, net_from, Cvar_VariableInteger( "net_qport" ), NULL, CL_GetFragmentSize );
|
|
|
|
|
|
|
|
memset( demo.cmds, 0, sizeof( demo.cmds ));
|
|
|
|
demo.angle_position = 1;
|
|
|
|
demo.framecount = 0;
|
|
|
|
cls.lastoutgoingcommand = -1;
|
|
|
|
cls.nextcmdtime = host.realtime;
|
|
|
|
cl.last_command_ack = -1;
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
=================
|
|
|
|
CL_DemoAborted
|
|
|
|
=================
|
|
|
|
*/
|
|
|
|
void CL_DemoAborted( void )
|
|
|
|
{
|
|
|
|
if( cls.demofile )
|
|
|
|
FS_Close( cls.demofile );
|
|
|
|
cls.demoplayback = false;
|
|
|
|
cls.changedemo = false;
|
|
|
|
cls.timedemo = false;
|
|
|
|
demo.framecount = 0;
|
|
|
|
cls.demofile = NULL;
|
|
|
|
cls.demonum = -1;
|
|
|
|
|
|
|
|
Cvar_SetValue( "v_dark", 0.0f );
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
=================
|
|
|
|
CL_DemoCompleted
|
|
|
|
=================
|
|
|
|
*/
|
|
|
|
void CL_DemoCompleted( void )
|
|
|
|
{
|
|
|
|
if( cls.demonum != -1 )
|
|
|
|
cls.changedemo = true;
|
|
|
|
|
|
|
|
CL_StopPlayback();
|
|
|
|
|
|
|
|
if( !CL_NextDemo() && !cls.changedemo )
|
|
|
|
UI_SetActiveMenu( true );
|
|
|
|
|
|
|
|
Cvar_SetValue( "v_dark", 0.0f );
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
=================
|
|
|
|
CL_DemoMoveToNextSection
|
|
|
|
|
|
|
|
returns true on success, false on failure
|
|
|
|
g-cont. probably captain obvious mode is ON
|
|
|
|
=================
|
|
|
|
*/
|
|
|
|
qboolean CL_DemoMoveToNextSection( void )
|
|
|
|
{
|
|
|
|
if( ++demo.entryIndex >= demo.directory.numentries )
|
|
|
|
{
|
|
|
|
// done
|
|
|
|
CL_DemoCompleted();
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
// switch to next section, we got a dem_stop
|
|
|
|
demo.entry = &demo.directory.entries[demo.entryIndex];
|
|
|
|
|
|
|
|
// ready to continue reading, reset clock.
|
|
|
|
FS_Seek( cls.demofile, demo.entry->offset, SEEK_SET );
|
|
|
|
|
|
|
|
// time is now relative to this chunk's clock.
|
|
|
|
demo.starttime = CL_GetDemoPlaybackClock();
|
|
|
|
demo.framecount = 0;
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
qboolean CL_ReadRawNetworkData( byte *buffer, size_t *length )
|
|
|
|
{
|
|
|
|
int msglen = 0;
|
|
|
|
|
|
|
|
Assert( buffer != NULL );
|
|
|
|
Assert( length != NULL );
|
|
|
|
|
|
|
|
*length = 0; // assume we fail
|
|
|
|
FS_Read( cls.demofile, &msglen, sizeof( int ));
|
|
|
|
|
|
|
|
if( msglen < 0 )
|
|
|
|
{
|
|
|
|
Con_Reportf( S_ERROR "Demo message length < 0\n" );
|
|
|
|
CL_DemoCompleted();
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if( msglen > MAX_INIT_MSG )
|
|
|
|
{
|
|
|
|
Con_Reportf( S_ERROR "Demo message %i > %i\n", msglen, MAX_INIT_MSG );
|
|
|
|
CL_DemoCompleted();
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if( msglen > 0 )
|
|
|
|
{
|
|
|
|
if( FS_Read( cls.demofile, buffer, msglen ) != msglen )
|
|
|
|
{
|
|
|
|
Con_Reportf( S_ERROR "Error reading demo message data\n" );
|
|
|
|
CL_DemoCompleted();
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
cls.netchan.last_received = host.realtime;
|
|
|
|
cls.netchan.total_received += msglen;
|
|
|
|
*length = msglen;
|
|
|
|
|
|
|
|
if( cls.state != ca_active )
|
|
|
|
Cbuf_Execute();
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
=================
|
|
|
|
CL_DemoReadMessageQuake
|
|
|
|
|
|
|
|
reads demo data and write it to client
|
|
|
|
=================
|
|
|
|
*/
|
|
|
|
qboolean CL_DemoReadMessageQuake( byte *buffer, size_t *length )
|
|
|
|
{
|
|
|
|
vec3_t viewangles;
|
|
|
|
int msglen = 0;
|
|
|
|
demoangle_t *a;
|
|
|
|
|
|
|
|
*length = 0; // assume we fail
|
|
|
|
|
|
|
|
// decide if it is time to grab the next message
|
|
|
|
if( cls.signon == SIGNONS ) // allways grab until fully connected
|
|
|
|
{
|
|
|
|
if( cls.timedemo )
|
|
|
|
{
|
|
|
|
if( host.framecount == cls.td_lastframe )
|
|
|
|
return false; // already read this frame's message
|
|
|
|
|
|
|
|
cls.td_lastframe = host.framecount;
|
|
|
|
|
|
|
|
// if this is the second frame, grab the real td_starttime
|
|
|
|
// so the bogus time on the first frame doesn't count
|
|
|
|
if( host.framecount == cls.td_startframe + 1 )
|
|
|
|
cls.td_starttime = host.realtime;
|
|
|
|
}
|
|
|
|
else if( cl.time <= cl.mtime[0] )
|
|
|
|
{
|
|
|
|
// don't need another message yet
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// get the next message
|
|
|
|
FS_Read( cls.demofile, &msglen, sizeof( int ));
|
|
|
|
FS_Read( cls.demofile, &viewangles[0], sizeof( float ));
|
|
|
|
FS_Read( cls.demofile, &viewangles[1], sizeof( float ));
|
|
|
|
FS_Read( cls.demofile, &viewangles[2], sizeof( float ));
|
|
|
|
cls.netchan.incoming_sequence++;
|
|
|
|
demo.timestamp = cl.mtime[0];
|
|
|
|
cl.skip_interp = false;
|
|
|
|
|
|
|
|
// make sure what interp info contain angles from different frames
|
|
|
|
// or lerping will stop working
|
|
|
|
if( demo.lasttime != demo.timestamp )
|
|
|
|
{
|
|
|
|
// select entry into circular buffer
|
|
|
|
demo.angle_position = (demo.angle_position + 1) & ANGLE_MASK;
|
|
|
|
a = &demo.cmds[demo.angle_position];
|
|
|
|
|
|
|
|
// record update
|
|
|
|
a->starttime = demo.timestamp;
|
|
|
|
VectorCopy( viewangles, a->viewangles );
|
|
|
|
demo.lasttime = demo.timestamp;
|
|
|
|
}
|
|
|
|
|
|
|
|
if( msglen < 0 )
|
|
|
|
{
|
|
|
|
Con_Reportf( S_ERROR "Demo message length < 0\n" );
|
|
|
|
CL_DemoCompleted();
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if( msglen > MAX_INIT_MSG )
|
|
|
|
{
|
|
|
|
Con_Reportf( S_ERROR "Demo message %i > %i\n", msglen, MAX_INIT_MSG );
|
|
|
|
CL_DemoCompleted();
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if( msglen > 0 )
|
|
|
|
{
|
|
|
|
if( FS_Read( cls.demofile, buffer, msglen ) != msglen )
|
|
|
|
{
|
|
|
|
Con_Reportf( S_ERROR "Error reading demo message data\n" );
|
|
|
|
CL_DemoCompleted();
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
cls.netchan.last_received = host.realtime;
|
|
|
|
cls.netchan.total_received += msglen;
|
|
|
|
*length = msglen;
|
|
|
|
|
|
|
|
if( cls.state != ca_active )
|
|
|
|
Cbuf_Execute();
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
=================
|
|
|
|
CL_DemoReadMessage
|
|
|
|
|
|
|
|
reads demo data and write it to client
|
|
|
|
=================
|
|
|
|
*/
|
|
|
|
qboolean CL_DemoReadMessage( byte *buffer, size_t *length )
|
|
|
|
{
|
|
|
|
size_t curpos = 0, lastpos = 0;
|
|
|
|
float fElapsedTime = 0.0f;
|
|
|
|
qboolean swallowmessages = true;
|
|
|
|
static int tdlastdemoframe = 0;
|
|
|
|
byte *userbuf = NULL;
|
|
|
|
size_t size = 0;
|
|
|
|
byte cmd;
|
|
|
|
|
|
|
|
if( !cls.demofile )
|
|
|
|
{
|
|
|
|
CL_DemoCompleted();
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if(( !cl.background && ( cl.paused || cls.key_dest != key_game )) || cls.key_dest == key_console )
|
|
|
|
{
|
|
|
|
demo.starttime += host.frametime;
|
|
|
|
return false; // paused
|
|
|
|
}
|
|
|
|
|
|
|
|
if( cls.demoplayback == DEMO_QUAKE1 )
|
|
|
|
return CL_DemoReadMessageQuake( buffer, length );
|
|
|
|
|
|
|
|
do
|
|
|
|
{
|
|
|
|
qboolean bSkipMessage = false;
|
|
|
|
|
|
|
|
if( !cls.demofile ) break;
|
|
|
|
curpos = FS_Tell( cls.demofile );
|
|
|
|
|
|
|
|
if( !CL_ReadDemoCmdHeader( &cmd, &demo.timestamp ))
|
|
|
|
return false;
|
|
|
|
|
|
|
|
fElapsedTime = CL_GetDemoPlaybackClock() - demo.starttime;
|
|
|
|
if( !cls.timedemo ) bSkipMessage = ((demo.timestamp - cl_serverframetime()) >= fElapsedTime) ? true : false;
|
|
|
|
if( cls.changelevel ) demo.framecount = 1;
|
|
|
|
|
|
|
|
// changelevel issues
|
|
|
|
if( demo.framecount <= 2 && ( fElapsedTime - demo.timestamp ) > host.frametime )
|
|
|
|
demo.starttime = CL_GetDemoPlaybackClock();
|
|
|
|
|
|
|
|
// not ready for a message yet, put it back on the file.
|
|
|
|
if( cmd != dem_norewind && cmd != dem_stop && bSkipMessage )
|
|
|
|
{
|
|
|
|
// never skip first message
|
|
|
|
if( demo.framecount != 0 )
|
|
|
|
{
|
|
|
|
FS_Seek( cls.demofile, curpos, SEEK_SET );
|
|
|
|
return false; // not time yet.
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// we already have the usercmd_t for this frame
|
|
|
|
// don't read next usercmd_t so predicting will work properly
|
|
|
|
if( cmd == dem_usercmd && lastpos != 0 && demo.framecount != 0 )
|
|
|
|
{
|
|
|
|
FS_Seek( cls.demofile, lastpos, SEEK_SET );
|
|
|
|
return false; // not time yet.
|
|
|
|
}
|
|
|
|
|
|
|
|
// COMMAND HANDLERS
|
|
|
|
switch( cmd )
|
|
|
|
{
|
|
|
|
case dem_jumptime:
|
|
|
|
demo.starttime = CL_GetDemoPlaybackClock();
|
|
|
|
return false; // time is changed, skip frame
|
|
|
|
case dem_stop:
|
|
|
|
CL_DemoMoveToNextSection();
|
|
|
|
return false; // header is ended, skip frame
|
|
|
|
case dem_userdata:
|
|
|
|
FS_Read( cls.demofile, &size, sizeof( int ));
|
|
|
|
userbuf = Mem_Malloc( cls.mempool, size );
|
|
|
|
FS_Read( cls.demofile, userbuf, size );
|
|
|
|
|
|
|
|
if( clgame.hInstance )
|
|
|
|
clgame.dllFuncs.pfnDemo_ReadBuffer( size, userbuf );
|
|
|
|
Mem_Free( userbuf );
|
|
|
|
userbuf = NULL;
|
|
|
|
break;
|
|
|
|
case dem_usercmd:
|
|
|
|
CL_ReadDemoUserCmd( false );
|
|
|
|
lastpos = FS_Tell( cls.demofile );
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
swallowmessages = false;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
} while( swallowmessages );
|
|
|
|
|
|
|
|
// If we are playing back a timedemo, and we've already passed on a
|
|
|
|
// frame update for this host_frame tag, then we'll just skip this message.
|
|
|
|
if( cls.timedemo && ( tdlastdemoframe == host.framecount ))
|
|
|
|
{
|
|
|
|
FS_Seek( cls.demofile, FS_Tell ( cls.demofile ) - 5, SEEK_SET );
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
tdlastdemoframe = host.framecount;
|
|
|
|
|
|
|
|
if( !cls.demofile )
|
|
|
|
return false;
|
|
|
|
|
|
|
|
// if not on "LOADING" section, check a few things
|
|
|
|
if( demo.entryIndex )
|
|
|
|
{
|
|
|
|
// We are now on the second frame of a new section,
|
|
|
|
// if so, reset start time (unless in a timedemo)
|
|
|
|
if( demo.framecount == 1 && !cls.timedemo )
|
|
|
|
{
|
|
|
|
// cheat by moving the relative start time forward.
|
|
|
|
demo.starttime = CL_GetDemoPlaybackClock();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
demo.framecount++;
|
|
|
|
CL_ReadDemoSequence( false );
|
|
|
|
|
|
|
|
return CL_ReadRawNetworkData( buffer, length );
|
|
|
|
}
|
|
|
|
|
|
|
|
void CL_DemoFindInterpolatedViewAngles( float t, float *frac, demoangle_t **prev, demoangle_t **next )
|
|
|
|
{
|
|
|
|
int i, i0, i1, imod;
|
|
|
|
float at;
|
|
|
|
|
|
|
|
if( cls.timedemo ) return;
|
|
|
|
|
|
|
|
imod = demo.angle_position - 1;
|
|
|
|
i0 = (imod + 1) & ANGLE_MASK;
|
|
|
|
i1 = (imod + 0) & ANGLE_MASK;
|
|
|
|
|
|
|
|
if( demo.cmds[i0].starttime >= t )
|
|
|
|
{
|
|
|
|
for( i = 0; i < ANGLE_BACKUP - 2; i++ )
|
|
|
|
{
|
|
|
|
at = demo.cmds[imod & ANGLE_MASK].starttime;
|
|
|
|
if( at == 0.0f ) break;
|
|
|
|
|
|
|
|
if( at < t )
|
|
|
|
{
|
|
|
|
i0 = (imod + 1) & ANGLE_MASK;
|
|
|
|
i1 = (imod + 0) & ANGLE_MASK;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
imod--;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
*next = &demo.cmds[i0];
|
|
|
|
*prev = &demo.cmds[i1];
|
|
|
|
|
|
|
|
// avoid division by zero (probably this should never happens)
|
|
|
|
if((*prev)->starttime == (*next)->starttime )
|
|
|
|
{
|
|
|
|
*prev = *next;
|
|
|
|
*frac = 0.0f;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// time spans the two entries
|
|
|
|
*frac = ( t - (*prev)->starttime ) / ((*next)->starttime - (*prev)->starttime );
|
|
|
|
*frac = bound( 0.0f, *frac, 1.0f );
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
==============
|
|
|
|
CL_DemoInterpolateAngles
|
|
|
|
|
|
|
|
We can predict or inpolate player movement with standed client code
|
|
|
|
but viewangles interpolate here
|
|
|
|
==============
|
|
|
|
*/
|
|
|
|
void CL_DemoInterpolateAngles( void )
|
|
|
|
{
|
|
|
|
demoangle_t *prev = NULL, *next = NULL;
|
|
|
|
float frac = 0.0f;
|
|
|
|
float curtime;
|
|
|
|
|
|
|
|
if( cls.demoplayback == DEMO_QUAKE1 )
|
|
|
|
{
|
|
|
|
// manually select next & prev states
|
|
|
|
next = &demo.cmds[(demo.angle_position - 0) & ANGLE_MASK];
|
|
|
|
prev = &demo.cmds[(demo.angle_position - 1) & ANGLE_MASK];
|
|
|
|
if( cl.skip_interp ) *prev = *next; // camera was teleported
|
|
|
|
frac = cl.lerpFrac;
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
curtime = (CL_GetDemoPlaybackClock() - demo.starttime) - host.frametime;
|
|
|
|
if( curtime > demo.timestamp )
|
|
|
|
curtime = demo.timestamp; // don't run too far
|
|
|
|
|
|
|
|
CL_DemoFindInterpolatedViewAngles( curtime, &frac, &prev, &next );
|
|
|
|
}
|
|
|
|
|
|
|
|
if( prev && next )
|
|
|
|
{
|
|
|
|
vec4_t q, q1, q2;
|
|
|
|
|
|
|
|
AngleQuaternion( next->viewangles, q1, false );
|
|
|
|
AngleQuaternion( prev->viewangles, q2, false );
|
|
|
|
QuaternionSlerp( q2, q1, frac, q );
|
|
|
|
QuaternionAngle( q, cl.viewangles );
|
|
|
|
}
|
|
|
|
else if( cl.cmd != NULL )
|
|
|
|
VectorCopy( cl.cmd->viewangles, cl.viewangles );
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
==============
|
|
|
|
CL_FinishTimeDemo
|
|
|
|
|
|
|
|
show stats
|
|
|
|
==============
|
|
|
|
*/
|
|
|
|
void CL_FinishTimeDemo( void )
|
|
|
|
{
|
|
|
|
int frames;
|
|
|
|
double time;
|
|
|
|
|
|
|
|
cls.timedemo = false;
|
|
|
|
|
|
|
|
// the first frame didn't count
|
|
|
|
frames = (host.framecount - cls.td_startframe) - 1;
|
|
|
|
time = host.realtime - cls.td_starttime;
|
|
|
|
if( !time ) time = 1.0;
|
|
|
|
|
|
|
|
Con_Printf( "%i frames %5.3f seconds %5.3f fps\n", frames, time, frames / time );
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
==============
|
|
|
|
CL_StopPlayback
|
|
|
|
|
|
|
|
Called when a demo file runs out, or the user starts a game
|
|
|
|
==============
|
|
|
|
*/
|
|
|
|
void CL_StopPlayback( void )
|
|
|
|
{
|
|
|
|
if( !cls.demoplayback ) return;
|
|
|
|
|
|
|
|
// release demofile
|
|
|
|
FS_Close( cls.demofile );
|
|
|
|
cls.demoplayback = false;
|
|
|
|
demo.framecount = 0;
|
|
|
|
cls.demofile = NULL;
|
|
|
|
|
|
|
|
cls.olddemonum = Q_max( -1, cls.demonum - 1 );
|
|
|
|
if( demo.directory.entries != NULL )
|
|
|
|
Mem_Free( demo.directory.entries );
|
|
|
|
cls.td_lastframe = host.framecount;
|
|
|
|
demo.directory.numentries = 0;
|
|
|
|
demo.directory.entries = NULL;
|
|
|
|
demo.header.host_fps = 0.0;
|
|
|
|
demo.entry = NULL;
|
|
|
|
|
|
|
|
cls.demoname[0] = '\0'; // clear demoname too
|
|
|
|
gameui.globals->demoname[0] = '\0';
|
|
|
|
|
|
|
|
if( cls.timedemo )
|
|
|
|
CL_FinishTimeDemo();
|
|
|
|
|
|
|
|
if( cls.changedemo )
|
|
|
|
{
|
|
|
|
S_StopAllSounds( true );
|
|
|
|
S_StopBackgroundTrack();
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
// let game known about demo state
|
|
|
|
Cvar_FullSet( "cl_background", "0", FCVAR_READ_ONLY );
|
|
|
|
cls.state = ca_disconnected;
|
|
|
|
memset( &cls.serveradr, 0, sizeof( cls.serveradr ) );
|
|
|
|
cls.set_lastdemo = false;
|
|
|
|
S_StopBackgroundTrack();
|
|
|
|
cls.connect_time = 0;
|
|
|
|
cls.demonum = -1;
|
|
|
|
cls.signon = 0;
|
|
|
|
|
|
|
|
// and finally clear the state
|
|
|
|
CL_ClearState ();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
==================
|
|
|
|
CL_GetDemoComment
|
|
|
|
==================
|
|
|
|
*/
|
|
|
|
int GAME_EXPORT CL_GetDemoComment( const char *demoname, char *comment )
|
|
|
|
{
|
|
|
|
file_t *demfile;
|
|
|
|
demoheader_t demohdr;
|
|
|
|
demodirectory_t directory;
|
|
|
|
demoentry_t entry;
|
|
|
|
float playtime = 0.0f;
|
|
|
|
int i;
|
|
|
|
|
|
|
|
if( !comment ) return false;
|
|
|
|
|
|
|
|
demfile = FS_Open( demoname, "rb", false );
|
|
|
|
if( !demfile )
|
|
|
|
{
|
|
|
|
comment[0] = '\0';
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
// read in the m_DemoHeader
|
|
|
|
FS_Read( demfile, &demohdr, sizeof( demoheader_t ));
|
|
|
|
|
|
|
|
if( demohdr.id != IDEMOHEADER )
|
|
|
|
{
|
|
|
|
FS_Close( demfile );
|
|
|
|
Q_strncpy( comment, "<corrupted>", MAX_STRING );
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if(( demohdr.net_protocol != PROTOCOL_VERSION &&
|
|
|
|
demohdr.net_protocol != PROTOCOL_LEGACY_VERSION ) ||
|
|
|
|
demohdr.dem_protocol != DEMO_PROTOCOL )
|
|
|
|
{
|
|
|
|
FS_Close( demfile );
|
|
|
|
Q_strncpy( comment, "<invalid protocol>", MAX_STRING );
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
// now read in the directory structure.
|
|
|
|
FS_Seek( demfile, demohdr.directory_offset, SEEK_SET );
|
|
|
|
FS_Read( demfile, &directory.numentries, sizeof( int ));
|
|
|
|
|
|
|
|
if( directory.numentries < 1 || directory.numentries > 1024 )
|
|
|
|
{
|
|
|
|
FS_Close( demfile );
|
|
|
|
Q_strncpy( comment, "<corrupted>", MAX_STRING );
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
for( i = 0; i < directory.numentries; i++ )
|
|
|
|
{
|
|
|
|
FS_Read( demfile, &entry, sizeof( demoentry_t ));
|
|
|
|
playtime += entry.playback_time;
|
|
|
|
}
|
|
|
|
|
|
|
|
// split comment to sections
|
|
|
|
Q_strncpy( comment, demohdr.mapname, CS_SIZE );
|
|
|
|
Q_strncpy( comment + CS_SIZE, demohdr.comment, CS_SIZE );
|
|
|
|
Q_snprintf( comment + CS_SIZE * 2, CS_TIME, "%g sec", playtime );
|
|
|
|
|
|
|
|
// all done
|
|
|
|
FS_Close( demfile );
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
==================
|
|
|
|
CL_NextDemo
|
|
|
|
|
|
|
|
Called when a demo finishes
|
|
|
|
==================
|
|
|
|
*/
|
|
|
|
qboolean CL_NextDemo( void )
|
|
|
|
{
|
|
|
|
char str[MAX_QPATH];
|
|
|
|
|
|
|
|
if( cls.demonum == -1 )
|
|
|
|
return false; // don't play demos
|
|
|
|
S_StopAllSounds( true );
|
|
|
|
|
|
|
|
if( !cls.demos[cls.demonum][0] || cls.demonum == MAX_DEMOS )
|
|
|
|
{
|
|
|
|
cls.demonum = 0;
|
|
|
|
if( !cls.demos[cls.demonum][0] )
|
|
|
|
{
|
|
|
|
Con_Printf( "no demos listed with startdemos\n" );
|
|
|
|
cls.demonum = -1;
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
Q_snprintf( str, MAX_STRING, "playdemo %s\n", cls.demos[cls.demonum] );
|
|
|
|
Cbuf_InsertText( str );
|
|
|
|
cls.demonum++;
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
==================
|
|
|
|
CL_CheckStartupDemos
|
|
|
|
|
|
|
|
queue demos loop after movie playing
|
|
|
|
==================
|
|
|
|
*/
|
|
|
|
void CL_CheckStartupDemos( void )
|
|
|
|
{
|
|
|
|
if( !cls.demos_pending )
|
|
|
|
return; // no demos in loop
|
|
|
|
|
|
|
|
if( cls.movienum != -1 )
|
|
|
|
return; // wait until movies finished
|
|
|
|
|
|
|
|
if( GameState->nextstate != STATE_RUNFRAME || cls.demoplayback )
|
|
|
|
{
|
|
|
|
// commandline override
|
|
|
|
cls.demos_pending = false;
|
|
|
|
cls.demonum = -1;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// run demos loop in background mode
|
|
|
|
Cvar_SetValue( "v_dark", 1.0f );
|
|
|
|
cls.demos_pending = false;
|
|
|
|
cls.demonum = 0;
|
|
|
|
CL_NextDemo ();
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
==================
|
|
|
|
CL_DemoGetName
|
|
|
|
==================
|
|
|
|
*/
|
|
|
|
static void CL_DemoGetName( int lastnum, char *filename )
|
|
|
|
{
|
|
|
|
if( lastnum < 0 || lastnum > 9999 )
|
|
|
|
{
|
|
|
|
// bound
|
|
|
|
Q_strcpy( filename, "demo9999" );
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
Q_sprintf( filename, "demo%04d", lastnum );
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
====================
|
|
|
|
CL_Record_f
|
|
|
|
|
|
|
|
record <demoname>
|
|
|
|
Begins recording a demo from the current position
|
|
|
|
====================
|
|
|
|
*/
|
|
|
|
void CL_Record_f( void )
|
|
|
|
{
|
|
|
|
string demoname, demopath;
|
|
|
|
const char *name;
|
|
|
|
int n;
|
|
|
|
|
|
|
|
if( Cmd_Argc() == 1 )
|
|
|
|
{
|
|
|
|
name = "new";
|
|
|
|
}
|
|
|
|
else if( Cmd_Argc() == 2 )
|
|
|
|
{
|
|
|
|
name = Cmd_Argv( 1 );
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
Con_Printf( S_USAGE "record <demoname>\n" );
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if( cls.demorecording )
|
|
|
|
{
|
|
|
|
Con_Printf( "Already recording.\n");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if( cls.demoplayback )
|
|
|
|
{
|
|
|
|
Con_Printf( "Can't record during demo playback.\n");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if( !cls.demoheader || cls.state != ca_active )
|
|
|
|
{
|
|
|
|
Con_Printf( "You must be in a level to record.\n");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if( !Q_stricmp( name, "new" ))
|
|
|
|
{
|
|
|
|
// scan for a free filename
|
|
|
|
for( n = 0; n < 10000; n++ )
|
|
|
|
{
|
|
|
|
CL_DemoGetName( n, demoname );
|
|
|
|
if( !FS_FileExists( va( "%s.dem", demoname ), true ))
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
if( n == 10000 )
|
|
|
|
{
|
|
|
|
Con_Printf( S_ERROR "no free slots for demo recording\n" );
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else Q_strncpy( demoname, name, sizeof( demoname ));
|
|
|
|
|
|
|
|
// open the demo file
|
|
|
|
Q_sprintf( demopath, "%s.dem", demoname );
|
|
|
|
|
|
|
|
// make sure that old demo is removed
|
|
|
|
if( FS_FileExists( demopath, false ))
|
|
|
|
FS_Delete( demopath );
|
|
|
|
|
|
|
|
Q_strncpy( cls.demoname, demoname, sizeof( cls.demoname ));
|
|
|
|
Q_strncpy( gameui.globals->demoname, demoname, sizeof( gameui.globals->demoname ));
|
|
|
|
|
|
|
|
CL_WriteDemoHeader( demopath );
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
====================
|
|
|
|
CL_PlayDemo_f
|
|
|
|
|
|
|
|
playdemo <demoname>
|
|
|
|
====================
|
|
|
|
*/
|
|
|
|
void CL_PlayDemo_f( void )
|
|
|
|
{
|
|
|
|
char filename[MAX_QPATH];
|
|
|
|
char demoname[MAX_QPATH];
|
|
|
|
int i, ident;
|
|
|
|
|
|
|
|
if( Cmd_Argc() < 2 )
|
|
|
|
{
|
|
|
|
Con_Printf( S_USAGE "%s <demoname>\n", Cmd_Argv( 0 ));
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if( cls.demoplayback )
|
|
|
|
{
|
|
|
|
CL_StopPlayback();
|
|
|
|
}
|
|
|
|
|
|
|
|
if( cls.demorecording )
|
|
|
|
{
|
|
|
|
Con_Printf( "Can't playback during demo record.\n");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
Q_strncpy( demoname, Cmd_Argv( 1 ), sizeof( demoname ));
|
|
|
|
COM_StripExtension( demoname );
|
|
|
|
Q_snprintf( filename, sizeof( filename ), "%s.dem", demoname );
|
|
|
|
|
|
|
|
// hidden parameter
|
|
|
|
if( Cmd_Argc() > 2 )
|
|
|
|
cls.set_lastdemo = Q_atoi( Cmd_Argv( 2 ));
|
|
|
|
|
|
|
|
// member last demo
|
|
|
|
if( cls.set_lastdemo )
|
|
|
|
Cvar_Set( "lastdemo", demoname );
|
|
|
|
|
|
|
|
if( !FS_FileExists( filename, true ))
|
|
|
|
{
|
|
|
|
Con_Printf( S_ERROR "couldn't open %s\n", filename );
|
|
|
|
CL_DemoAborted();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
cls.demofile = FS_Open( filename, "rb", true );
|
|
|
|
Q_strncpy( cls.demoname, demoname, sizeof( cls.demoname ));
|
|
|
|
Q_strncpy( gameui.globals->demoname, demoname, sizeof( gameui.globals->demoname ));
|
|
|
|
|
|
|
|
FS_Read( cls.demofile, &ident, sizeof( int ));
|
|
|
|
FS_Seek( cls.demofile, 0, SEEK_SET ); // rewind back to start
|
|
|
|
cls.forcetrack = 0;
|
|
|
|
|
|
|
|
// check for quake demos
|
|
|
|
if( ident != IDEMOHEADER )
|
|
|
|
{
|
|
|
|
int c, neg = false;
|
|
|
|
|
|
|
|
demo.header.host_fps = host_maxfps->value;
|
|
|
|
|
|
|
|
while(( c = FS_Getc( cls.demofile )) != '\n' )
|
|
|
|
{
|
|
|
|
if( c == '-' ) neg = true;
|
|
|
|
else cls.forcetrack = cls.forcetrack * 10 + (c - '0');
|
|
|
|
}
|
|
|
|
|
|
|
|
if( neg ) cls.forcetrack = -cls.forcetrack;
|
|
|
|
CL_DemoStartPlayback( DEMO_QUAKE1 );
|
|
|
|
return; // quake demo is started
|
|
|
|
}
|
|
|
|
|
|
|
|
// read in the demo header
|
|
|
|
FS_Read( cls.demofile, &demo.header, sizeof( demoheader_t ));
|
|
|
|
|
|
|
|
if( demo.header.id != IDEMOHEADER )
|
|
|
|
{
|
|
|
|
Con_Printf( S_ERROR "%s is not a demo file\n", demoname );
|
|
|
|
CL_DemoAborted();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if( demo.header.dem_protocol != DEMO_PROTOCOL )
|
|
|
|
{
|
|
|
|
Con_Printf( S_ERROR "playdemo: demo protocol outdated (%i should be %i)\n", demo.header.dem_protocol, DEMO_PROTOCOL );
|
|
|
|
CL_DemoAborted();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if( demo.header.net_protocol != PROTOCOL_VERSION &&
|
|
|
|
demo.header.net_protocol != PROTOCOL_LEGACY_VERSION )
|
|
|
|
{
|
|
|
|
Con_Printf( S_ERROR "playdemo: net protocol outdated (%i should be %i)\n", demo.header.net_protocol, PROTOCOL_VERSION );
|
|
|
|
CL_DemoAborted();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// now read in the directory structure.
|
|
|
|
FS_Seek( cls.demofile, demo.header.directory_offset, SEEK_SET );
|
|
|
|
FS_Read( cls.demofile, &demo.directory.numentries, sizeof( int ));
|
|
|
|
|
|
|
|
if( demo.directory.numentries < 1 || demo.directory.numentries > 1024 )
|
|
|
|
{
|
|
|
|
Con_Printf( S_ERROR "demo had bogus # of directory entries: %i\n", demo.directory.numentries );
|
|
|
|
CL_DemoAborted();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// allocate demo entries
|
|
|
|
demo.directory.entries = Mem_Malloc( cls.mempool, sizeof( demoentry_t ) * demo.directory.numentries );
|
|
|
|
|
|
|
|
for( i = 0; i < demo.directory.numentries; i++ )
|
|
|
|
{
|
|
|
|
FS_Read( cls.demofile, &demo.directory.entries[i], sizeof( demoentry_t ));
|
|
|
|
}
|
|
|
|
|
|
|
|
demo.entryIndex = 0;
|
|
|
|
demo.entry = &demo.directory.entries[demo.entryIndex];
|
|
|
|
|
|
|
|
FS_Seek( cls.demofile, demo.entry->offset, SEEK_SET );
|
|
|
|
|
|
|
|
CL_DemoStartPlayback( DEMO_XASH3D );
|
|
|
|
|
|
|
|
// g-cont. is this need?
|
|
|
|
Q_strncpy( cls.servername, demoname, sizeof( cls.servername ));
|
|
|
|
cls.legacymode = demo.header.net_protocol == PROTOCOL_LEGACY_VERSION;
|
|
|
|
|
|
|
|
// begin a playback demo
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
====================
|
|
|
|
CL_TimeDemo_f
|
|
|
|
|
|
|
|
timedemo <demoname>
|
|
|
|
====================
|
|
|
|
*/
|
|
|
|
void CL_TimeDemo_f( void )
|
|
|
|
{
|
|
|
|
CL_PlayDemo_f ();
|
|
|
|
|
|
|
|
// cls.td_starttime will be grabbed at the second frame of the demo, so
|
|
|
|
// all the loading time doesn't get counted
|
|
|
|
cls.timedemo = true;
|
|
|
|
cls.td_starttime = host.realtime;
|
|
|
|
cls.td_startframe = host.framecount;
|
|
|
|
cls.td_lastframe = -1; // get a new message this frame
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
==================
|
|
|
|
CL_StartDemos_f
|
|
|
|
==================
|
|
|
|
*/
|
|
|
|
void CL_StartDemos_f( void )
|
|
|
|
{
|
|
|
|
int i, c;
|
|
|
|
|
|
|
|
if( cls.key_dest != key_menu )
|
|
|
|
{
|
|
|
|
Con_Printf( "'startdemos' is not valid from the console\n" );
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
c = Cmd_Argc() - 1;
|
|
|
|
if( c > MAX_DEMOS )
|
|
|
|
{
|
|
|
|
Con_DPrintf( S_WARN "Host_StartDemos: max %i demos in demoloop\n", MAX_DEMOS );
|
|
|
|
c = MAX_DEMOS;
|
|
|
|
}
|
|
|
|
|
|
|
|
Con_Printf( "%i demo%s in loop\n", c, (c > 1) ? "s" : "" );
|
|
|
|
|
|
|
|
for( i = 1; i < c + 1; i++ )
|
|
|
|
Q_strncpy( cls.demos[i-1], Cmd_Argv( i ), sizeof( cls.demos[0] ));
|
|
|
|
cls.demos_pending = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
==================
|
|
|
|
CL_Demos_f
|
|
|
|
|
|
|
|
Return to looping demos
|
|
|
|
==================
|
|
|
|
*/
|
|
|
|
void CL_Demos_f( void )
|
|
|
|
{
|
|
|
|
if( cls.key_dest != key_menu )
|
|
|
|
{
|
|
|
|
Con_Printf( "'demos' is not valid from the console\n" );
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// demos loop are not running
|
|
|
|
if( cls.olddemonum == -1 )
|
|
|
|
return;
|
|
|
|
|
|
|
|
cls.demonum = cls.olddemonum;
|
|
|
|
|
|
|
|
// run demos loop in background mode
|
|
|
|
if( !SV_Active() && !cls.demoplayback )
|
|
|
|
CL_NextDemo ();
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
====================
|
|
|
|
CL_Stop_f
|
|
|
|
|
|
|
|
stop any client activity
|
|
|
|
====================
|
|
|
|
*/
|
|
|
|
void CL_Stop_f( void )
|
|
|
|
{
|
|
|
|
// stop all
|
|
|
|
CL_StopRecord();
|
|
|
|
CL_StopPlayback();
|
|
|
|
SCR_StopCinematic();
|
|
|
|
|
|
|
|
// stop background track that was runned from the console
|
|
|
|
if( !SV_Active( ))
|
|
|
|
{
|
|
|
|
S_StopBackgroundTrack();
|
|
|
|
}
|
|
|
|
}
|