From 7654bd9684b667c0e755205846fbd62948bd1098 Mon Sep 17 00:00:00 2001 From: havoc Date: Sun, 21 Jan 2007 20:27:36 +0000 Subject: [PATCH] implemented individual file downloads on darkplaces servers deferred loading of files until signon 2 (necessary for download system) made loading of files operate in two passes, first for existing files and then again for files that need to be downloaded added cl_joinbeforedownloadsfinish cvar to cause the game to begin after the map is loaded, before other downloads finish (not entirely sure this is a good idea, but it's cool) added sv_allowdownloads cvar to allow enabling/disabling of downloads, and sv_allowdownloads_inarchive and sv_allowdownloads_archive cvars to allow server administrators to enable downloading files from archives or even whole archives if so desired (however the client does not currently request archives) git-svn-id: svn://svn.icculus.org/twilight/trunk/darkplaces@6715 d7cf8633-e32d-0410-b094-e92efae38249 --- cl_input.c | 13 ++ cl_main.c | 5 + cl_parse.c | 415 +++++++++++++++++++++++++++++++++++++++++++++------- cl_screen.c | 16 +- client.h | 25 ++++ common.c | 2 +- fs.c | 74 ++++++++++ fs.h | 2 + host.c | 11 ++ protocol.h | 4 +- server.h | 6 + snd_main.c | 9 +- sv_main.c | 168 ++++++++++++++++++++- sv_user.c | 54 ++++++- todo | 8 + 15 files changed, 749 insertions(+), 63 deletions(-) diff --git a/cl_input.c b/cl_input.c index 9ac1fa0c..53701d4a 100644 --- a/cl_input.c +++ b/cl_input.c @@ -1549,6 +1549,19 @@ void CL_SendMove(void) // PROTOCOL_DARKPLACES7 = 71 bytes per packet } + if (cls.protocol != PROTOCOL_QUAKEWORLD) + { + // acknowledge any recently received data blocks + for (i = 0;i < CL_MAX_DOWNLOADACKS && (cls.dp_downloadack[i].start || cls.dp_downloadack[i].size);i++) + { + MSG_WriteByte(&buf, clc_ackdownloaddata); + MSG_WriteLong(&buf, cls.dp_downloadack[i].start); + MSG_WriteShort(&buf, cls.dp_downloadack[i].size); + cls.dp_downloadack[i].start = 0; + cls.dp_downloadack[i].size = 0; + } + } + // send the reliable message (forwarded commands) if there is one NetConn_SendUnreliableMessage(cls.netcon, &buf, cls.protocol); diff --git a/cl_main.c b/cl_main.c index 0bfb1545..95301a97 100644 --- a/cl_main.c +++ b/cl_main.c @@ -332,6 +332,8 @@ void CL_Disconnect(void) cl.worldmodel = NULL; + CL_Parse_ErrorCleanUp(); + if (cls.demoplayback) CL_StopPlayback(); else if (cls.netcon) @@ -1290,6 +1292,9 @@ static void CL_RelinkStaticEntities(void) for (i = 0, e = cl.static_entities;i < cl.num_static_entities && r_refdef.numentities < r_refdef.maxentities;i++, e++) { e->render.flags = 0; + // if the model was not loaded when the static entity was created we + // need to re-fetch the model pointer + e->render.model = cl.model_precache[e->state_baseline.modelindex]; // transparent stuff can't be lit during the opaque stage if (e->render.effects & (EF_ADDITIVE | EF_NODEPTHTEST) || e->render.alpha < 1) e->render.flags |= RENDER_TRANSPARENT; diff --git a/cl_parse.c b/cl_parse.c index 3cc5820a..cea947f1 100644 --- a/cl_parse.c +++ b/cl_parse.c @@ -83,7 +83,7 @@ char *svc_strings[128] = "", // 47 "", // 48 "", // 49 - "svc_unusedlh1", // 50 // + "svc_downloaddata", // 50 // [int] start [short] size [variable length] data "svc_updatestatubyte", // 51 // [byte] stat [byte] value "svc_effect", // 52 // [vector] org [byte] modelindex [byte] startframe [byte] framecount [byte] framerate "svc_effect2", // 53 // [vector] org [short] modelindex [short] startframe [byte] framecount [byte] framerate @@ -164,6 +164,8 @@ cvar_t cl_sound_ric1 = {0, "cl_sound_ric1", "weapons/ric1.wav", "sound to play w cvar_t cl_sound_ric2 = {0, "cl_sound_ric2", "weapons/ric2.wav", "sound to play with 5% chance during TE_SPIKE/TE_SUPERSPIKE (empty cvar disables sound)"}; cvar_t cl_sound_ric3 = {0, "cl_sound_ric3", "weapons/ric3.wav", "sound to play with 10% chance during TE_SPIKE/TE_SUPERSPIKE (empty cvar disables sound)"}; cvar_t cl_sound_r_exp3 = {0, "cl_sound_r_exp3", "weapons/r_exp3.wav", "sound to play during TE_EXPLOSION and related effects (empty cvar disables sound)"}; +cvar_t cl_serverextension_download = {0, "cl_serverextension_download", "0", "indicates whether the server supports the download command"}; +cvar_t cl_joinbeforedownloadsfinish = {0, "cl_joinbeforedownloadsfinish", "1", "if non-zero the game will begin after the map is loaded before other downloads finish"}; static qboolean QW_CL_CheckOrDownloadFile(const char *filename); static void QW_CL_RequestNextDownload(void); @@ -264,7 +266,7 @@ so the server doesn't disconnect. */ static unsigned char olddata[NET_MAXMESSAGE]; -void CL_KeepaliveMessage (void) +void CL_KeepaliveMessage (qboolean readmessages) { float time; static double nextmsg = -1; @@ -276,18 +278,21 @@ void CL_KeepaliveMessage (void) if (sv.active || !cls.netcon || cls.protocol == PROTOCOL_QUAKEWORLD) return; -// read messages from server, should just be nops - oldreadcount = msg_readcount; - oldbadread = msg_badread; - old = net_message; - memcpy(olddata, net_message.data, net_message.cursize); - - NetConn_ClientFrame(); - - msg_readcount = oldreadcount; - msg_badread = oldbadread; - net_message = old; - memcpy(net_message.data, olddata, net_message.cursize); + if (readmessages) + { + // read messages from server, should just be nops + oldreadcount = msg_readcount; + oldbadread = msg_badread; + old = net_message; + memcpy(olddata, net_message.data, net_message.cursize); + + NetConn_ClientFrame(); + + msg_readcount = oldreadcount; + msg_badread = oldbadread; + net_message = old; + memcpy(net_message.data, olddata, net_message.cursize); + } if (cls.netcon && (time = Sys_DoubleTime()) >= nextmsg) { @@ -507,6 +512,9 @@ static void QW_CL_RequestNextDownload(void) Mem_Free(cls.qw_downloadmemory); cls.qw_downloadmemory = NULL; } + + // done loading + cl.loadfinished = true; break; case dl_sound: if (cls.qw_downloadnumber == 0) @@ -600,6 +608,7 @@ static void QW_CL_ParseDownload(void) // read the fragment out of the packet MSG_ReadBytes(size, cls.qw_downloadmemory + cls.qw_downloadmemorycursize); cls.qw_downloadmemorycursize += size; + cls.qw_downloadspeedcount += size; cls.qw_downloadpercent = percent; @@ -880,6 +889,301 @@ static void CL_UpdateItemsAndWeapon(void) cl.activeweapon = cl.stats[STAT_ACTIVEWEAPON]; } +void CL_BeginDownloads(qboolean aborteddownload) +{ + // quakeworld works differently + if (cls.protocol == PROTOCOL_QUAKEWORLD) + return; + + // TODO: this would be a good place to do curl downloads + + if (cl.loadmodel_current < cl.loadmodel_total) + { + // loading models + + for (;cl.loadmodel_current < cl.loadmodel_total;cl.loadmodel_current++) + { + if (cl.model_precache[cl.loadmodel_current] && cl.model_precache[cl.loadmodel_current]->Draw) + continue; + if (cls.signon < SIGNONS) + CL_KeepaliveMessage(true); + cl.model_precache[cl.loadmodel_current] = Mod_ForName(cl.model_name[cl.loadmodel_current], false, false, cl.loadmodel_current == 1); + if (cl.model_precache[cl.loadmodel_current] && cl.model_precache[cl.loadmodel_current]->Draw && cl.loadmodel_current == 1) + { + // we now have the worldmodel so we can set up the game world + cl.entities[0].render.model = cl.worldmodel = cl.model_precache[1]; + CL_UpdateRenderEntity(&cl.entities[0].render); + R_Modules_NewMap(); + // check memory integrity + Mem_CheckSentinelsGlobal(); + if (!cl.loadfinished && cl_joinbeforedownloadsfinish.integer) + { + cl.loadfinished = true; + // now issue the spawn to move on to signon 3 like normal + if (cls.netcon) + Cmd_ForwardStringToServer("spawn"); + } + } + } + + // finished loading models + } + + if (cl.loadsound_current < cl.loadsound_total) + { + // loading sounds + + for (;cl.loadsound_current < cl.loadsound_total;cl.loadsound_current++) + { + if (cl.sound_precache[cl.loadsound_current] && S_IsSoundPrecached(cl.sound_precache[cl.loadsound_current])) + continue; + if (cls.signon < SIGNONS) + CL_KeepaliveMessage(true); + // Don't lock the sfx here, S_ServerSounds already did that + cl.sound_precache[cl.loadsound_current] = S_PrecacheSound(cl.sound_name[cl.loadsound_current], false, false); + } + + // finished loading sounds + } + + // note: the reason these loops skip already-loaded things is that it + // enables this command to be issued during the game if desired + + if (cl.downloadmodel_current < cl.loadmodel_total) + { + // loading models + + for (;cl.downloadmodel_current < cl.loadmodel_total;cl.downloadmodel_current++) + { + if (aborteddownload) + { + if (cl.downloadmodel_current == 1) + { + // the worldmodel failed, but we need to set up anyway + cl.entities[0].render.model = cl.worldmodel = cl.model_precache[1]; + CL_UpdateRenderEntity(&cl.entities[0].render); + R_Modules_NewMap(); + // check memory integrity + Mem_CheckSentinelsGlobal(); + if (!cl.loadfinished && cl_joinbeforedownloadsfinish.integer) + { + cl.loadfinished = true; + // now issue the spawn to move on to signon 3 like normal + if (cls.netcon) + Cmd_ForwardStringToServer("spawn"); + } + } + aborteddownload = false; + continue; + } + if (cl.model_precache[cl.downloadmodel_current] && cl.model_precache[cl.downloadmodel_current]->Draw) + continue; + if (cls.signon < SIGNONS) + CL_KeepaliveMessage(true); + if (!FS_FileExists(cl.model_name[cl.downloadmodel_current])) + { + if (cl.downloadmodel_current == 1) + Con_Printf("Map %s not found\n", cl.model_name[cl.downloadmodel_current]); + else + Con_Printf("Model %s not found\n", cl.model_name[cl.downloadmodel_current]); + // regarding the * check: don't try to download submodels + if (cl_serverextension_download.integer && cls.netcon && cl.model_name[cl.downloadmodel_current][0] != '*') + { + Cmd_ForwardStringToServer(va("download %s", cl.model_name[cl.downloadmodel_current])); + // we'll try loading again when the download finishes + return; + } + } + cl.model_precache[cl.downloadmodel_current] = Mod_ForName(cl.model_name[cl.downloadmodel_current], false, false, cl.downloadmodel_current == 1); + if (cl.downloadmodel_current == 1) + { + // we now have the worldmodel so we can set up the game world + cl.entities[0].render.model = cl.worldmodel = cl.model_precache[1]; + CL_UpdateRenderEntity(&cl.entities[0].render); + R_Modules_NewMap(); + // check memory integrity + Mem_CheckSentinelsGlobal(); + if (!cl.loadfinished && cl_joinbeforedownloadsfinish.integer) + { + cl.loadfinished = true; + // now issue the spawn to move on to signon 3 like normal + if (cls.netcon) + Cmd_ForwardStringToServer("spawn"); + } + } + } + + // finished loading models + } + + if (cl.downloadsound_current < cl.loadsound_total) + { + // loading sounds + + for (;cl.downloadsound_current < cl.loadsound_total;cl.downloadsound_current++) + { + char soundname[MAX_QPATH]; + if (aborteddownload) + { + aborteddownload = false; + continue; + } + if (cl.sound_precache[cl.downloadsound_current] && S_IsSoundPrecached(cl.sound_precache[cl.downloadsound_current])) + continue; + if (cls.signon < SIGNONS) + CL_KeepaliveMessage(true); + dpsnprintf(soundname, sizeof(soundname), "sound/%s", cl.sound_name[cl.downloadsound_current]); + if (!FS_FileExists(soundname) && !FS_FileExists(cl.sound_name[cl.downloadsound_current])) + { + Con_Printf("Sound %s not found\n", soundname); + if (cl_serverextension_download.integer && cls.netcon) + { + Cmd_ForwardStringToServer(va("download %s", soundname)); + // we'll try loading again when the download finishes + return; + } + } + // Don't lock the sfx here, S_ServerSounds already did that + cl.sound_precache[cl.downloadsound_current] = S_PrecacheSound(cl.sound_name[cl.downloadsound_current], false, false); + } + + // finished loading sounds + } + + if (!cl.loadfinished) + { + cl.loadfinished = true; + + // check memory integrity + Mem_CheckSentinelsGlobal(); + + // now issue the spawn to move on to signon 3 like normal + if (cls.netcon) + Cmd_ForwardStringToServer("spawn"); + } +} + +void CL_BeginDownloads_f(void) +{ + CL_BeginDownloads(false); +} + +extern void FS_Rescan_f(void); +void CL_StopDownload(int size, int crc) +{ + if (cls.qw_downloadmemory && cls.qw_downloadmemorycursize == size && CRC_Block(cls.qw_downloadmemory, size) == crc) + { + // finished file + // save to disk only if we don't already have it + // (this is mainly for playing back demos) + if (!FS_FileExists(cls.qw_downloadname)) + { + const char *extension; + + Con_Printf("Downloaded \"%s\" (%i bytes, %i CRC)\n", cls.qw_downloadname, size, crc); + + FS_WriteFile(cls.qw_downloadname, cls.qw_downloadmemory, cls.qw_downloadmemorycursize); + + extension = FS_FileExtension(cls.qw_downloadname); + if (!strcasecmp(extension, "pak") || !strcasecmp(extension, "pk3")) + FS_Rescan_f(); + } + } + + if (cls.qw_downloadmemory) + Mem_Free(cls.qw_downloadmemory); + cls.qw_downloadmemory = NULL; + cls.qw_downloadname[0] = 0; + cls.qw_downloadmemorymaxsize = 0; + cls.qw_downloadmemorycursize = 0; + cls.qw_downloadpercent = 0; +} + +void CL_ParseDownload(void) +{ + int i, start, size; + unsigned char data[65536]; + start = MSG_ReadLong(); + size = (unsigned short)MSG_ReadShort(); + + // record the start/size information to ack in the next input packet + for (i = 0;i < CL_MAX_DOWNLOADACKS;i++) + { + if (!cls.dp_downloadack[i].start && !cls.dp_downloadack[i].size) + { + cls.dp_downloadack[i].start = start; + cls.dp_downloadack[i].size = size; + break; + } + } + + MSG_ReadBytes(size, data); + + if (!cls.qw_downloadname[0]) + { + if (size > 0) + Con_Printf("CL_ParseDownload: received %i bytes with no download active\n", size); + return; + } + + if (start + size > cls.qw_downloadmemorymaxsize) + Host_Error("corrupt download message\n"); + + // only advance cursize if the data is at the expected position + // (gaps are unacceptable) + memcpy(cls.qw_downloadmemory + start, data, size); + cls.qw_downloadmemorycursize = start + size; + cls.qw_downloadpercent = (int)floor((start+size) * 100.0 / cls.qw_downloadmemorymaxsize); + cls.qw_downloadpercent = bound(0, cls.qw_downloadpercent, 100); + cls.qw_downloadspeedcount += size; +} + +void CL_DownloadBegin_f(void) +{ + int size = atoi(Cmd_Argv(1)); + + if (size < 0 || size > 1<<30 || FS_CheckNastyPath(Cmd_Argv(2), false)) + { + Con_Printf("cl_downloadbegin: received bogus information\n"); + CL_StopDownload(0, 0); + return; + } + + if (cls.qw_downloadname[0]) + Con_Printf("Download of %s aborted\n", cls.qw_downloadname); + + CL_StopDownload(0, 0); + + // we're really beginning a download now, so initialize stuff + strlcpy(cls.qw_downloadname, Cmd_Argv(2), sizeof(cls.qw_downloadname)); + cls.qw_downloadmemorymaxsize = size; + cls.qw_downloadmemory = Mem_Alloc(cls.permanentmempool, cls.qw_downloadmemorymaxsize); + cls.qw_downloadnumber++; + + Cmd_ForwardStringToServer("sv_startdownload"); +} + +void CL_StopDownload_f(void) +{ + if (cls.qw_downloadname[0]) + { + Con_Printf("Download of %s aborted\n", cls.qw_downloadname); + CL_StopDownload(0, 0); + } + CL_BeginDownloads(true); +} + +void CL_DownloadFinished_f(void) +{ + if (Cmd_Argc() < 3) + { + Con_Printf("Malformed cl_downloadfinished command\n"); + return; + } + CL_StopDownload(atoi(Cmd_Argv(1)), atoi(Cmd_Argv(2))); + CL_BeginDownloads(false); +} + /* ===================== CL_SignonReply @@ -899,6 +1203,8 @@ static void CL_SignonReply (void) MSG_WriteByte (&cls.netcon->message, clc_stringcmd); MSG_WriteString (&cls.netcon->message, "prespawn"); } + else // playing a demo... make sure loading occurs as soon as possible + CL_BeginDownloads(false); break; case 2: @@ -929,8 +1235,13 @@ static void CL_SignonReply (void) MSG_WriteByte (&cls.netcon->message, clc_stringcmd); MSG_WriteString (&cls.netcon->message, va("rate %i", cl_rate.integer)); - MSG_WriteByte (&cls.netcon->message, clc_stringcmd); - MSG_WriteString (&cls.netcon->message, "spawn"); + // LordHavoc: changed to begin a loading stage and issue this when done + //MSG_WriteByte (&cls.netcon->message, clc_stringcmd); + //MSG_WriteString (&cls.netcon->message, "spawn"); + + // execute cl_begindownloads next frame after this message is sent + // (so that the server can see the player name while downloading) + Cbuf_AddText("\ncl_begindownloads\n"); } break; @@ -969,6 +1280,9 @@ void CL_ParseServerInfo (void) // check memory integrity Mem_CheckSentinelsGlobal(); + // clear cl_serverextension cvars + Cvar_SetValueQuick(&cl_serverextension_download, 0); + // // wipe the client_state_t struct // @@ -1038,6 +1352,8 @@ void CL_ParseServerInfo (void) MSG_WriteString(&cls.netcon->message, va("soundlist %i %i", cl.qw_servercount, 0)); } + cl.loadfinished = false; + cls.state = ca_connected; cls.signon = 1; @@ -1121,33 +1437,13 @@ void CL_ParseServerInfo (void) cl.sfx_r_exp3 = S_PrecacheSound(cl_sound_r_exp3.string, false, true); // now we try to load everything that is new - - // world model - CL_KeepaliveMessage (); - cl.model_precache[1] = Mod_ForName(cl.model_name[1], false, false, true); - if (cl.model_precache[1]->Draw == NULL) - Con_Printf("Map %s not found\n", cl.model_name[1]); - - // normal models - for (i=2 ; iDraw == NULL) - Con_Printf("Model %s not found\n", cl.model_name[i]); - } - - // sounds - for (i=1 ; irender.matrix, ent->state_baseline.origin[0], ent->state_baseline.origin[1], ent->state_baseline.origin[2], ent->state_baseline.angles[0], ent->state_baseline.angles[1], ent->state_baseline.angles[2], 1); CL_UpdateRenderEntity(&ent->render); - // This is definitely cheating... - if (ent->render.model == NULL) - cl.num_static_entities--; + // This is definitely a cheesy way to conserve resources... + //if (ent->render.model == NULL) + // cl.num_static_entities--; } /* @@ -2189,6 +2485,9 @@ void CL_ParseServerMessage(void) cl.last_received_message = realtime; + if (cls.netcon && cls.signon < SIGNONS) + CL_KeepaliveMessage(false); + // // if recording demos, copy the message out // @@ -2229,7 +2528,7 @@ void CL_ParseServerMessage(void) cl.qw_num_nails = 0; // fade weapon view kick - cl.qw_weaponkick = min(cl.qw_weaponkick + 10 * (cl.time - cl.oldtime), 0); + cl.qw_weaponkick = min(cl.qw_weaponkick + 10 * bound(0, cl.time - cl.oldtime, 0.1), 0); while (1) { @@ -2960,11 +3259,15 @@ void CL_ParseServerMessage(void) case svc_csqcentities: CSQC_ReadEntities(); break; + case svc_downloaddata: + CL_ParseDownload(); + break; } } } - CL_UpdateItemsAndWeapon(); + if (cls.signon == SIGNONS) + CL_UpdateItemsAndWeapon(); EntityFrameQuake_ISeeDeadEntities(); @@ -2982,12 +3285,7 @@ void CL_Parse_DumpPacket(void) void CL_Parse_ErrorCleanUp(void) { - if (cls.qw_downloadmemory) - { - Mem_Free(cls.qw_downloadmemory); - cls.qw_downloadmemory = NULL; - } - cls.qw_downloadpercent = 0; + CL_StopDownload(0, 0); QW_CL_StopUpload(); } @@ -3007,10 +3305,19 @@ void CL_Parse_Init(void) Cvar_RegisterVariable(&cl_sound_ric3); Cvar_RegisterVariable(&cl_sound_r_exp3); + Cvar_RegisterVariable(&cl_joinbeforedownloadsfinish); + + // server extension cvars set by commands issued from the server during connect + Cvar_RegisterVariable(&cl_serverextension_download); + Cmd_AddCommand("nextul", QW_CL_NextUpload, "sends next fragment of current upload buffer (screenshot for example)"); Cmd_AddCommand("stopul", QW_CL_StopUpload, "aborts current upload (screenshot for example)"); Cmd_AddCommand("skins", QW_CL_Skins_f, "downloads missing qw skins from server"); Cmd_AddCommand("changing", QW_CL_Changing_f, "sent by qw servers to tell client to wait for level change"); + Cmd_AddCommand("cl_begindownloads", CL_BeginDownloads_f, "used internally by darkplaces client while connecting (causes loading of models and sounds or triggers downloads for missing ones)"); + Cmd_AddCommand("cl_downloadbegin", CL_DownloadBegin_f, "(networking) informs client of download file information, client replies with sv_startsoundload to begin the transfer"); + Cmd_AddCommand("stopdownload", CL_StopDownload_f, "terminates a download"); + Cmd_AddCommand("cl_downloadfinished", CL_DownloadFinished_f, "signals that a download has finished and provides the client with file size and crc to check its integrity"); } void CL_Parse_Shutdown(void) diff --git a/cl_screen.c b/cl_screen.c index 895f491a..2da15b32 100644 --- a/cl_screen.c +++ b/cl_screen.c @@ -316,8 +316,22 @@ static int SCR_DrawQWDownload(int offset) float size = 8; char temp[256]; if (!cls.qw_downloadname[0]) + { + cls.qw_downloadspeedrate = 0; + cls.qw_downloadspeedtime = realtime; + cls.qw_downloadspeedcount = 0; return 0; - dpsnprintf(temp, sizeof(temp), "Downloading %s ... %3i%%\n", cls.qw_downloadname, cls.qw_downloadpercent); + } + if (realtime >= cls.qw_downloadspeedtime + 1) + { + cls.qw_downloadspeedrate = cls.qw_downloadspeedcount; + cls.qw_downloadspeedtime = realtime; + cls.qw_downloadspeedcount = 0; + } + if (cls.protocol == PROTOCOL_QUAKEWORLD) + dpsnprintf(temp, sizeof(temp), "Downloading %s %3i%% (%i) at %i bytes/s\n", cls.qw_downloadname, cls.qw_downloadpercent, cls.qw_downloadmemorycursize, cls.qw_downloadspeedrate); + else + dpsnprintf(temp, sizeof(temp), "Downloading %s %3i%% (%i/%i) at %i bytes/s\n", cls.qw_downloadname, cls.qw_downloadpercent, cls.qw_downloadmemorycursize, cls.qw_downloadmemorymaxsize, cls.qw_downloadspeedrate); len = (int)strlen(temp); x = (vid_conwidth.integer - len*size) / 2; y = vid_conheight.integer - size - offset; diff --git a/client.h b/client.h index e725a11f..f6772bd2 100644 --- a/client.h +++ b/client.h @@ -434,6 +434,14 @@ typedef struct capturevideostate_s } capturevideostate_t; +#define CL_MAX_DOWNLOADACKS 4 + +typedef struct cl_downloadack_s +{ + int start, size; +} +cl_downloadack_t; + // // the client_static_t structure is persistent through an arbitrary number // of server connections @@ -490,6 +498,10 @@ typedef struct client_static_s // network connection netconn_t *netcon; + // download information + // (note: qw_download variables are also used) + cl_downloadack_t dp_downloadack[CL_MAX_DOWNLOADACKS]; + // quakeworld stuff below // value of "qport" cvar at time of connection @@ -506,6 +518,10 @@ typedef struct client_static_s int qw_downloadnumber; int qw_downloadpercent; qw_downloadtype_t qw_downloadtype; + // transfer rate display + double qw_downloadspeedtime; + int qw_downloadspeedcount; + int qw_downloadspeedrate; // current file upload buffer (for uploading screenshots to server) unsigned char *qw_uploaddata; @@ -864,6 +880,15 @@ typedef struct client_state_s int free_particle; + // cl_serverextension_download feature + int loadmodel_current; + int downloadmodel_current; + int loadmodel_total; + int loadsound_current; + int downloadsound_current; + int loadsound_total; + qboolean loadfinished; + // quakeworld stuff // local copy of the server infostring diff --git a/common.c b/common.c index 035c1a6f..e3c95da4 100644 --- a/common.c +++ b/common.c @@ -27,7 +27,7 @@ Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. #include "quakedef.h" -cvar_t registered = {0, "registered","0", "indicates if this is running registered quake (whether gfx/qpop.lmp was found)"}; +cvar_t registered = {0, "registered","0", "indicates if this is running registered quake (whether gfx/pop.lmp was found)"}; cvar_t cmdline = {0, "cmdline","0", "contains commandline the engine was launched with"}; char com_token[MAX_INPUTLINE]; diff --git a/fs.c b/fs.c index 1a35d45d..016255ef 100644 --- a/fs.c +++ b/fs.c @@ -1047,6 +1047,26 @@ const char *FS_FileExtension (const char *in) } +/* +============ +FS_FileWithoutPath +============ +*/ +const char *FS_FileWithoutPath (const char *in) +{ + const char *separator, *backslash, *colon; + + separator = strrchr(in, '/'); + backslash = strrchr(in, '\\'); + if (!separator || separator < backslash) + separator = backslash; + colon = strrchr(in, ':'); + if (!separator || separator < colon) + separator = colon; + return separator ? separator + 1 : in; +} + + /* ================ FS_ClearSearchPath @@ -2601,3 +2621,57 @@ const char *FS_WhichPack(const char *filename) else return 0; } + +/* +==================== +FS_IsRegisteredQuakePack + +Look for a proof of purchase file file in the requested package + +If it is found, this file should NOT be downloaded. +==================== +*/ +qboolean FS_IsRegisteredQuakePack(const char *name) +{ + searchpath_t *search; + pack_t *pak; + + // search through the path, one element at a time + for (search = fs_searchpaths;search;search = search->next) + { + if (search->pack && !strcasecmp(FS_FileWithoutPath(search->filename), name)) + { + int (*strcmp_funct) (const char* str1, const char* str2); + int left, right, middle; + + pak = search->pack; + strcmp_funct = pak->ignorecase ? strcasecmp : strcmp; + + // Look for the file (binary search) + left = 0; + right = pak->numfiles - 1; + while (left <= right) + { + int diff; + + middle = (left + right) / 2; + diff = !strcmp_funct (pak->files[middle].name, "gfx/pop.lmp"); + + // Found it + if (!diff) + return true; + + // If we're too far in the list + if (diff > 0) + right = middle - 1; + else + left = middle + 1; + } + + // we found the requested pack but it is not registered quake + return false; + } + } + + return false; +} diff --git a/fs.h b/fs.h index a256b719..d1c87d3c 100644 --- a/fs.h +++ b/fs.h @@ -66,9 +66,11 @@ int FS_Seek (qfile_t* file, fs_offset_t offset, int whence); fs_offset_t FS_Tell (qfile_t* file); fs_offset_t FS_FileSize (qfile_t* file); void FS_Purge (qfile_t* file); +const char *FS_FileWithoutPath (const char *in); const char *FS_FileExtension (const char *in); int FS_CheckNastyPath (const char *path, qboolean isgamedir); qboolean FS_ChangeGameDir(const char *string); +qboolean FS_IsRegisteredQuakePack(const char *name); typedef struct fssearch_s { diff --git a/host.c b/host.c index f72a7e09..2495e58c 100644 --- a/host.c +++ b/host.c @@ -454,6 +454,17 @@ void SV_DropClient(qboolean crash) prog->globals.server->self = saveSelf; } + // if a download is active, close it + if (host_client->download_file) + { + Con_DPrintf("Download of %s aborted when %s dropped\n", host_client->download_name, host_client->name); + FS_Close(host_client->download_file); + host_client->download_file = NULL; + host_client->download_name[0] = 0; + host_client->download_expectedposition = 0; + host_client->download_started = false; + } + // remove leaving player from scoreboard //host_client->edict->fields.server->netname = PRVM_SetEngineString(host_client->name); //if ((val = PRVM_GETEDICTFIELDVALUE(host_client->edict, eval_clientcolors))) diff --git a/protocol.h b/protocol.h index dc22ba79..350bdb60 100644 --- a/protocol.h +++ b/protocol.h @@ -225,7 +225,7 @@ void Protocol_Names(char *buffer, size_t buffersize); #define svc_skybox 37 // [string] skyname // LordHavoc: my svc_ range, 50-59 -#define svc_unusedlh1 50 // +#define svc_downloaddata 50 // [int] start [short] size #define svc_updatestatubyte 51 // [byte] stat [byte] value #define svc_effect 52 // [vector] org [byte] modelindex [byte] startframe [byte] framecount [byte] framerate #define svc_effect2 53 // [vector] org [short] modelindex [short] startframe [byte] framecount [byte] framerate @@ -248,7 +248,7 @@ void Protocol_Names(char *buffer, size_t buffersize); // LordHavoc: my clc_ range, 50-59 #define clc_ackframe 50 // [int] framenumber -#define clc_unusedlh1 51 +#define clc_ackdownloaddata 51 // [int] start [short] size (note: exact echo of latest values received in svc_downloaddata, packet-loss handling is in the server) #define clc_unusedlh2 52 #define clc_unusedlh3 53 #define clc_unusedlh4 54 diff --git a/server.h b/server.h index 41947092..10047f67 100644 --- a/server.h +++ b/server.h @@ -184,6 +184,12 @@ typedef struct client_s entityframe_database_t *entitydatabase; entityframe4_database_t *entitydatabase4; entityframe5_database_t *entitydatabase5; + + // information on an active download if any + qfile_t *download_file; + int download_expectedposition; // next position the client should ack + qboolean download_started; + char download_name[MAX_QPATH]; } client_t; diff --git a/snd_main.c b/snd_main.c index b00852da..2c32953a 100644 --- a/snd_main.c +++ b/snd_main.c @@ -919,6 +919,9 @@ void S_ServerSounds (char serversound [][MAX_QPATH], unsigned int numsounds) sfx = S_FindName (serversound[i]); if (sfx != NULL) { + // clear the FILEMISSING flag so that S_LoadSound will try again on a + // previously missing file + sfx->flags &= ~ SFXFLAG_FILEMISSING; S_LockSfx (sfx); sfx->flags |= SFXFLAG_SERVERSOUND; } @@ -950,13 +953,13 @@ sfx_t *S_PrecacheSound (const char *name, qboolean complain, qboolean lock) sfx = S_FindName (name); + if (sfx == NULL) + return NULL; + // clear the FILEMISSING flag so that S_LoadSound will try again on a // previously missing file sfx->flags &= ~ SFXFLAG_FILEMISSING; - if (sfx == NULL) - return NULL; - if (lock) S_LockSfx (sfx); diff --git a/sv_main.c b/sv_main.c index 2b90a6e3..00242d60 100644 --- a/sv_main.c +++ b/sv_main.c @@ -36,6 +36,9 @@ void EntityFrameCSQC_WriteFrame (sizebuf_t *msg, int numstates, const entity_sta cvar_t sv_protocolname = {0, "sv_protocolname", "DP7", "selects network protocol to host for (values include QUAKE, QUAKEDP, NEHAHRAMOVIE, DP1 and up)"}; cvar_t sv_ratelimitlocalplayer = {0, "sv_ratelimitlocalplayer", "0", "whether to apply rate limiting to the local player in a listen server (only useful for testing)"}; cvar_t sv_maxrate = {CVAR_SAVE | CVAR_NOTIFY, "sv_maxrate", "10000", "upper limit on client rate cvar, should reflect your network connection quality"}; +cvar_t sv_allowdownloads = {0, "sv_allowdownloads", "1", "whether to allow clients to download files from the server (does not affect http downloads)"}; +cvar_t sv_allowdownloads_inarchive = {0, "sv_allowdownloads_inarchive", "0", "whether to allow downloads from archives (pak/pk3)"}; +cvar_t sv_allowdownloads_archive = {0, "sv_allowdownloads_archive", "0", "whether to allow downloads of archives (pak/pk3)"}; extern cvar_t sv_random_seed; @@ -73,6 +76,8 @@ mempool_t *sv_mempool = NULL; extern void SV_Phys_Init (void); extern void SV_World_Init (void); static void SV_SaveEntFile_f(void); +static void SV_StartDownload_f(void); +static void SV_Download_f(void); /* =============== @@ -89,6 +94,8 @@ void SV_Init (void) Cvar_RegisterVariable (&csqc_progcrc); Cmd_AddCommand("sv_saveentfile", SV_SaveEntFile_f, "save map entities to .ent file (to allow external editing)"); + Cmd_AddCommand_WithClientCommand("sv_startdownload", NULL, SV_StartDownload_f, "begins sending a file to the client (network protocol use only)"); + Cmd_AddCommand_WithClientCommand("download", NULL, SV_Download_f, "downloads a specified file from the server"); Cvar_RegisterVariable (&sv_maxvelocity); Cvar_RegisterVariable (&sv_gravity); Cvar_RegisterVariable (&sv_friction); @@ -124,6 +131,9 @@ void SV_Init (void) Cvar_RegisterVariable (&sv_protocolname); Cvar_RegisterVariable (&sv_ratelimitlocalplayer); Cvar_RegisterVariable (&sv_maxrate); + Cvar_RegisterVariable (&sv_allowdownloads); + Cvar_RegisterVariable (&sv_allowdownloads_inarchive); + Cvar_RegisterVariable (&sv_allowdownloads_archive); Cvar_RegisterVariable (&sv_progs); SV_VM_Init(); @@ -366,6 +376,12 @@ void SV_SendServerinfo (client_t *client) } } + if (sv_allowdownloads.integer) + { + MSG_WriteByte (&client->netconnection->message, svc_stufftext); + MSG_WriteString (&client->netconnection->message, "cl_serverextension_download 1"); + } + MSG_WriteByte (&client->netconnection->message, svc_serverinfo); MSG_WriteLong (&client->netconnection->message, Protocol_NumberForEnum(sv.protocol)); MSG_WriteByte (&client->netconnection->message, svs.maxclients); @@ -1215,7 +1231,7 @@ SV_SendClientDatagram static unsigned char sv_sendclientdatagram_buf[NET_MAXMESSAGE]; // FIXME? void SV_SendClientDatagram (client_t *client) { - int rate, maxrate, maxsize, maxsize2; + int rate, maxrate, maxsize, maxsize2, downloadsize; sizebuf_t msg; int stats[MAX_CL_STATS]; @@ -1248,6 +1264,11 @@ void SV_SendClientDatagram (client_t *client) maxsize2 = 1400; } + // while downloading, limit entity updates to half the packet + // (any leftover space will be used for downloading) + if (host_client->download_file) + maxsize /= 2; + msg.data = sv_sendclientdatagram_buf; msg.maxsize = maxsize; msg.cursize = 0; @@ -1275,10 +1296,36 @@ void SV_SendClientDatagram (client_t *client) { // the player isn't totally in the game yet // send small keepalive messages if too much time has passed + msg.maxsize = maxsize2; client->keepalivetime = realtime + 5; MSG_WriteChar (&msg, svc_nop); } + msg.maxsize = maxsize2; + + // if a download is active, see if there is room to fit some download data + // in this packet + downloadsize = maxsize * 2 - msg.cursize - 7; + if (host_client->download_file && host_client->download_started && downloadsize > 0) + { + fs_offset_t downloadstart; + unsigned char data[1400]; + downloadstart = FS_Tell(host_client->download_file); + downloadsize = min(downloadsize, (int)sizeof(data)); + downloadsize = FS_Read(host_client->download_file, data, downloadsize); + // note this sends empty messages if at the end of the file, which is + // necessary to keep the packet loss logic working + // (the last blocks may be lost and need to be re-sent, and that will + // only occur if the client acks the empty end messages, revealing + // a gap in the download progress, causing the last blocks to be + // sent again) + MSG_WriteChar (&msg, svc_downloaddata); + MSG_WriteLong (&msg, downloadstart); + MSG_WriteShort (&msg, downloadsize); + if (downloadsize > 0) + SZ_Write (&msg, data, downloadsize); + } + // send the datagram NetConn_SendUnreliableMessage (client->netconnection, &msg, sv.protocol); } @@ -1416,6 +1463,125 @@ void SV_SendClientMessages (void) SV_CleanupEnts(); } +void SV_StartDownload_f(void) +{ + if (host_client->download_file) + host_client->download_started = true; +} + +void SV_Download_f(void) +{ + const char *whichpack, *whichpack2, *extension; + + if (Cmd_Argc() != 2) + { + SV_ClientPrintf("usage: download \n"); + return; + } + + if (FS_CheckNastyPath(Cmd_Argv(1), false)) + { + SV_ClientPrintf("Download rejected: nasty filename \"%s\"\n", Cmd_Argv(1)); + return; + } + + if (host_client->download_file) + { + // at this point we'll assume the previous download should be aborted + Con_DPrintf("Download of %s aborted by %s starting a new download\n", host_client->download_name, host_client->name); + Host_ClientCommands("\nstopdownload\n"); + + // close the file and reset variables + FS_Close(host_client->download_file); + host_client->download_file = NULL; + host_client->download_name[0] = 0; + host_client->download_expectedposition = 0; + host_client->download_started = false; + } + + if (!sv_allowdownloads.integer) + { + SV_ClientPrintf("Downloads are disabled on this server\n"); + Host_ClientCommands("\nstopdownload\n"); + return; + } + + strlcpy(host_client->download_name, Cmd_Argv(1), sizeof(host_client->download_name)); + + // host_client is asking to download a specified file + if (developer.integer >= 100) + Con_Printf("Download request for %s by %s\n", host_client->download_name, host_client->name); + + if (!FS_FileExists(host_client->download_name)) + { + SV_ClientPrintf("Download rejected: server does not have the file \"%s\"\nYou may need to separately download or purchase the data archives for this game/mod to get this file\n", host_client->download_name); + Host_ClientCommands("\nstopdownload\n"); + return; + } + + // check if the user is trying to download part of registered Quake(r) + whichpack = FS_WhichPack(host_client->download_name); + whichpack2 = FS_WhichPack("gfx/pop.lmp"); + if ((whichpack && whichpack2 && !strcasecmp(whichpack, whichpack2)) || FS_IsRegisteredQuakePack(host_client->download_name)) + { + SV_ClientPrintf("Download rejected: file \"%s\" is part of registered Quake(r)\nYou must purchase Quake(r) from id Software or a retailer to get this file\nPlease go to http://www.idsoftware.com/games/quake/quake/index.php?game_section=buy\n", host_client->download_name); + Host_ClientCommands("\nstopdownload\n"); + return; + } + + // check if the server has forbidden archive downloads entirely + if (!sv_allowdownloads_inarchive.integer) + { + whichpack = FS_WhichPack(host_client->download_name); + if (whichpack) + { + SV_ClientPrintf("Download rejected: file \"%s\" is in an archive (\"%s\")\nYou must separately download or purchase the data archives for this game/mod to get this file\n", host_client->download_name, whichpack); + Host_ClientCommands("\nstopdownload\n"); + return; + } + } + + if (!sv_allowdownloads_archive.integer) + { + extension = FS_FileExtension(host_client->download_name); + if (!strcasecmp(extension, "pak") || !strcasecmp(extension, "pk3")) + { + SV_ClientPrintf("Download rejected: file \"%s\" is an archive\nYou must separately download or purchase the data archives for this game/mod to get this file\n", host_client->download_name); + Host_ClientCommands("\nstopdownload\n"); + return; + } + } + + host_client->download_file = FS_Open(host_client->download_name, "rb", true, false); + if (!host_client->download_file) + { + SV_ClientPrintf("Download rejected: server could not open the file \"%s\"\n", host_client->download_name); + Host_ClientCommands("\nstopdownload\n"); + return; + } + + if (FS_FileSize(host_client->download_file) > 1<<30) + { + SV_ClientPrintf("Download rejected: file \"%s\" is very large\n", host_client->download_name); + Host_ClientCommands("\nstopdownload\n"); + FS_Close(host_client->download_file); + host_client->download_file = NULL; + return; + } + + Con_DPrintf("Downloading %s to %s\n", host_client->download_name, host_client->name); + + Host_ClientCommands("\ncl_downloadbegin %i %s\n", (int)FS_FileSize(host_client->download_file), host_client->download_name); + + host_client->download_expectedposition = 0; + host_client->download_started = false; + + // the rest of the download process is handled in SV_SendClientDatagram + // and other code dealing with svc_downloaddata and clc_ackdownloaddata + // + // no svc_downloaddata messages will be sent until sv_startdownload is + // sent by the client +} /* ============================================================================== diff --git a/sv_user.c b/sv_user.c index eeb7f10f..5e87f67b 100644 --- a/sv_user.c +++ b/sv_user.c @@ -646,7 +646,7 @@ SV_ReadClientMessage extern void SV_SendServerinfo(client_t *client); void SV_ReadClientMessage(void) { - int cmd, num; + int cmd, num, start; char *s; //MSG_BeginReading (); @@ -710,6 +710,58 @@ void SV_ReadClientMessage(void) SV_DropClient (false); break; + case clc_ackdownloaddata: + start = MSG_ReadLong(); + num = MSG_ReadShort(); + if (host_client->download_file && host_client->download_started) + { + if (host_client->download_expectedposition == start) + { + int size = (int)FS_FileSize(host_client->download_file); + // a data block was successfully received by the client, + // update the expected position on the next data block + host_client->download_expectedposition = start + num; + // if this was the last data block of the file, it's done + if (host_client->download_expectedposition >= FS_FileSize(host_client->download_file)) + { + // tell the client that the download finished + // we need to calculate the crc now + // + // note: at this point the OS probably has the file + // entirely in memory, so this is a faster operation + // now than it was when the download started. + // + // it is also preferable to do this at the end of the + // download rather than the start because it reduces + // potential for Denial Of Service attacks against the + // server. + int crc; + unsigned char *temp; + FS_Seek(host_client->download_file, 0, SEEK_SET); + temp = Mem_Alloc(tempmempool, size); + FS_Read(host_client->download_file, temp, size); + crc = CRC_Block(temp, size); + Mem_Free(temp); + // calculated crc, send the file info to the client + // (so that it can verify the data) + Host_ClientCommands(va("\ncl_downloadfinished %i %i %s\n", size, crc, host_client->download_name)); + Con_DPrintf("Download of %s by %s has finished\n", host_client->download_name, host_client->name); + FS_Close(host_client->download_file); + host_client->download_file = NULL; + host_client->download_name[0] = 0; + host_client->download_expectedposition = 0; + host_client->download_started = false; + } + } + else + { + // a data block was lost, reset to the expected position + // and resume sending from there + FS_Seek(host_client->download_file, host_client->download_expectedposition, SEEK_SET); + } + } + break; + case clc_ackframe: if (msg_badread) Con_Printf("SV_ReadClientMessage: badread at %s:%i\n", __FILE__, __LINE__); num = MSG_ReadLong(); diff --git a/todo b/todo index d3a7dbec..b7b88bb6 100644 --- a/todo +++ b/todo @@ -36,6 +36,7 @@ 0 bug darkplaces client: GAME_NEHAHRA: make sure cutscenes and movies work, got a report of seeing a black screen (NightFright) 0 bug darkplaces client: hipnotic: health is one character to the right on the sbar, covering up the key icons (M`Shacron) 0 bug darkplaces client: it has been reported that sometimes level changes on quakeworld servers don't load a map, this may be related to downloading? (Baker) +0 bug darkplaces client: on crctf proquake servers the scoreboard does not contain exactly matching player names (READY is sometimes appended), the ping report and status parsing should ignore text after the player name 0 bug darkplaces client: svc_effect should post a warning and do nothing if given a framerate below 1 (Willis) 0 bug darkplaces console: commandline history won't scroll back past a blank line - the blank line should not be entered into history (Elric) 0 bug darkplaces console: when cursoring up and down through command history, shorter lines sometimes contain some text from the previous line @@ -47,6 +48,8 @@ 0 bug darkplaces loader: occasional crash due to memory corruption when doing "deathmatch 1;map start" during demo loop (Willis) 0 bug darkplaces loader: q3bsp deluxemap detection can fail on some files, thinking they have deluxemaps even though they don't? (jimmmy) 0 bug darkplaces loader: q3bsp lightgrid loading seems to be ignoring the "gridsize" key of worldspawn, but how? +0 bug darkplaces loading: when gamedir (or -game) contains a directory which listdirectory() fails on, do a Host_Error with an appropriate message, rather than running with a non-existent directory +0 bug darkplaces model loader: a q1 mdl file with a _1.tga but no _0.tga crashes at load (daemon) 0 bug darkplaces physics: GAME_TAOV: Vigil's movement isn't working properly, the qc uses MOVETYPE_STEP and clears FL_ONGROUND every frame and moves using velocity, this is causing a landing sound every frame and causing the player to slide down minor slopes very quickly, this did not occur in Quake, and seems that it must be related to a velocity_z check or FL_ONGROUND check in the MOVETYPE_STEP physics code (RenegadeC, xaGe) 0 bug darkplaces physics: in Prydon Gate the func_door2 entities are stuck in eachother, causing a continuous spew of warnings and causing one of them to be teleported slightly upward which looks bad (FrikaC) 0 bug darkplaces readme: commandline options are slightly out of date, update them (Baker) @@ -64,6 +67,7 @@ 0 bug darkplaces server: if sv_fixedframeratesingleplayer is 0 and cl_maxfps is something like 10, the server still runs every frame, consuming massive amounts of cpu and resulting in very small frametime values 0 bug darkplaces server: in X-Men: Ravages of Apocalypse the weapon part in x1m3 fails to follow the platform it is on, it is probably spawning inside the ceiling and for some reason not associating with the platform as its groundentity? (qwerasdf) 0 bug darkplaces server: in X-Men: Ravages of Apocalypse the weapon part in x2m4 falls out of the level, along with a few other items in the same secret (qwerasdf) +0 bug darkplaces server: stats[TOTAL_MONSTERS] should be networked as a stat 0 bug darkplaces sound: remove playing sounds when their owner entity has been removed by network code, this would mean that Nexuiz could have rocket/electro noise again (Qantoursic) 0 bug darkplaces wgl client: during video mode setup, sometimes another application's window becomes permanently top most, not darkplaces' fullscreen window, why? (tZork) 0 bug darkplaces wgl client: hardware gamma is being retried every frame for unknown reasons, this is SEVERELY impacting framerates on win2k/xp (Jago) @@ -79,6 +83,7 @@ 0 bug dpmod: go through http://www.inside3d.com/qip/q1/bugsmap.htm and fix map bugs by using replacement .ent files (Lardarse) 0 bug dpmod: identify what could cause huge angles values (1187488512.0000) on a dog entity, may be related to anglemod, FacingIdeal, ai_run, or dog_run2 (Zombie13) 0 bug dpmod: impulse 102 isn't removing the bots +0 bug dpmod: in dpmod_qcphysics_casings the two main particles have the same mass, realistically speaking the rear one should have more mass than the front one 0 bug dpmod: monsters don't properly chase you around corners, this is not an engine bug because they work fine in id1 but not dpmod, the knight at the start of e2m2 makes for good testing by shooting him and then hiding behind the crates (StrangerThanYou) 0 bug dpmod: monsters falling out of level? 0 bug dpmod: monsters shouldn't constantly do sightsounds on a slain player, it's annoying and silly @@ -130,6 +135,8 @@ 0 feature darkplaces client: add .loc file support and say macros 0 feature darkplaces client: add .mvd demo support 0 feature darkplaces client: add .qwd demo support +0 feature darkplaces client: add BX_WAL_SUPPORT to extensions and document it, the feature has been in for a long time, also update wiki.quakesrc.org accordingly +0 feature darkplaces client: add DP_GFX_EFFECTINFO_TXT to extensions and document it, the feature has been in for a long time, also update wiki.quakesrc.org accordingly 0 feature darkplaces client: add a cl_showspeed cvar to display a hud overlay of your current velocity, speed as length of velocity, and speed along forward vector (Spike) 0 feature darkplaces client: add a cvar to make the renderer use a different entity for pvs than for viewing, this might be useful for a third person camera that should only see what the player sees (Urre) 0 feature darkplaces client: add an alias to control whether the nexuiz logo.dpv splash video plays at startup and whether the menu opens, this way the config can change it before it happens, for instance to disable it, or to set up a similar thing in other games (green) @@ -169,6 +176,7 @@ 0 feature darkplaces renderer: add rtlight "avelocity" parameter to make lights that spin, useful with cubemaps (romi) 0 feature darkplaces renderer: make showfps display GL renderer string and CPU - figure out how to detect this from the OS 0 feature darkplaces renderer: save r_shadow_glsl* cvars (and possibly a few others) to config because they are useful user settings (SavageX) +0 feature darkplaces renderer: support gl_picmip -1, -2, etc like Twilight does 0 feature darkplaces renderer: support tcgen in q3 shaders (ob3lisk) 0 feature darkplaces server: DP_SV_FINDPVS 0 feature darkplaces server: add .maxspeed field to control player movement speed in engine code, call it QW_SV_MAXSPEED (Carni) -- 2.39.2