diff --git a/Documentation/extensions/sounds.lst.md b/Documentation/extensions/sounds.lst.md new file mode 100644 index 00000000..ad44d4c8 --- /dev/null +++ b/Documentation/extensions/sounds.lst.md @@ -0,0 +1,65 @@ +# sounds.lst.md + +Using sounds.lst located in scripts folder, modder can override some of the hardcoded sounds in temp entities and server physics. + +File format: +``` + +{ + + + +} + + +``` + +* Sounds can use any supported sound format (WAV or MP3). +* The path must be relative to the sounds/ folder in the game or base directory root, addon folder, or archive root. +* Groups can be empty or omitted from the file to load no sound. +* Groups can either list a set of files or specify a format string and a range. +* Anything after // will be considered a comment and ignored. +* Behavior is undefined if the group was listed multiple times. + +Currently supported groups are: +|Group name|Usage| +|----------|-----| +|`BouncePlayerShell`|Used for BOUNCE_SHELL tempentity hitsound| +|`BounceWeaponShell`|Used for BOUCNE_SHOTSHELL tempentity hitsound| +|`BounceConcrete`|Used for BOUNCE_CONCRETE tempentity hitsound| +|`BounceGlass`|Used for BOUCNE_GLASS| +|`BounceMetal`|Used for BOUNCE_METAL| +|`BounceFlesh`|Used for BOUNCE_FLESH| +|`BounceWood`|Used for BOUNCE_WOOD| +|`Ricochet`|Used for BOUNCE_SHRAP and ricochet tempentities| +|`Explode`|Used for tempentity explosions| +|`EntityWaterEnter`|Used for entity entering water| +|`EntityWaterExit`|Used for entity exiting water| +|`PlayerWaterEnter`|Used for player entering water| +|`PlayerWaterExit`|Used for player exiting water| + +## Example + +This example is based on defaults sounds used in Half-Life: + +``` +BouncePlayerShell "player/pl_shell%d.wav" 1 3 +BounceWeaponShell "weapons/sshell%d.wav" 1 3 +BounceConcrete "debris/concrete%d.wav" 1 3 +BounceGlass "debris/glass%d.wav" 1 4 +BounceMetal "debris/metal%d.wav" 1 6 +BounceFlesh "debris/flesh%d.wav" 1 7 +BounceWood "debris/wood%d.wav" 1 4 +Ricochet "weapons/ric%d.wav" 1 5 +Explode "weapons/explode%d.wav" 3 5 +EntityWaterEnter "player/pl_wade%d.wav" 1 4 +EntityWaterExit "player/pl_wade%d.wav" 1 4 +PlayerWaterEnter +{ + "player/pl_wade1.wav" +} +PlayerWaterExit +{ + "player/pl_wade2.wav" +} +``` diff --git a/engine/common/common.h b/engine/common/common.h index bcb3b984..773b2716 100644 --- a/engine/common/common.h +++ b/engine/common/common.h @@ -816,6 +816,34 @@ void NET_MasterClear( void ); void NET_MasterShutdown( void ); qboolean NET_GetMaster( netadr_t from, uint *challenge, double *last_heartbeat ); +// +// sounds.c +// +typedef enum soundlst_group_e +{ + BouncePlayerShell = 0, + BounceWeaponShell, + BounceConcrete, + BounceGlass, + BounceMetal, + BounceFlesh, + BounceWood, + Ricochet, + Explode, + PlayerWaterEnter, + PlayerWaterExit, + EntityWaterEnter, + EntityWaterExit, + + SoundList_Groups // must be last +} soundlst_group_t; + +int SoundList_Count( soundlst_group_t group ); +const char *SoundList_GetRandom( soundlst_group_t group ); +const char *SoundList_Get( soundlst_group_t group, int idx ); +void SoundList_Init( void ); +void SoundList_Shutdown( void ); + #ifdef REF_DLL #error "common.h in ref_dll" #endif diff --git a/engine/common/host.c b/engine/common/host.c index f990f1cc..65ae4267 100644 --- a/engine/common/host.c +++ b/engine/common/host.c @@ -1310,6 +1310,7 @@ int EXPORT Host_Main( int argc, char **argv, const char *progname, int bChangeGa HTTP_Init(); ID_Init(); + SoundList_Init(); if( Host_IsDedicated() ) { @@ -1412,6 +1413,7 @@ void EXPORT Host_Shutdown( void ) SV_ShutdownFilter(); CL_Shutdown(); + SoundList_Shutdown(); Mod_Shutdown(); NET_Shutdown(); HTTP_Shutdown(); diff --git a/engine/common/sounds.c b/engine/common/sounds.c new file mode 100644 index 00000000..80e410b2 --- /dev/null +++ b/engine/common/sounds.c @@ -0,0 +1,362 @@ +/* +sounds.c - sounds.lst parser +Copyright (C) 2024 Alibek Omarov + +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" + +enum soundlst_type_e +{ + SoundList_None, + SoundList_Range, + SoundList_List +}; + +static const char *soundlst_groups[SoundList_Groups] = +{ + "BouncePlayerShell", + "BounceWeaponShell", + "BounceConcrete", + "BounceGlass", + "BounceMetal", + "BounceFlesh", + "BounceWood", + "Ricochet", + "Explode", + "PlayerWaterEnter", + "PlayerWaterExit", + "EntityWaterEnter", + "EntityWaterExit", +}; + +typedef struct soundlst_s +{ + enum soundlst_type_e type; + char *snd; + int min; // the string length if type is group + int max; // the string count if type is group +} soundlst_t; + +soundlst_t soundlst[SoundList_Groups]; + +static void SoundList_Print_f( void ); +static void SoundList_Free( soundlst_t *lst ) +{ + if( lst->snd ) + Mem_Free( lst->snd ); + + memset( lst, 0, sizeof( *lst )); +} + +void SoundList_Shutdown( void ) +{ + int i; + + for( i = 0; i < SoundList_Groups; i++ ) + SoundList_Free( &soundlst[i] ); +} + +int SoundList_Count( enum soundlst_group_e group ) +{ + soundlst_t *lst = &soundlst[group]; + + switch( lst->type ) + { + case SoundList_Range: + return lst->max - lst->min + 1; + case SoundList_List: + return lst->max; + } + + return 0; +} + +const char *SoundList_Get( enum soundlst_group_e group, int i ) +{ + static string temp; + soundlst_t *lst = &soundlst[group]; + + if( i < 0 || i >= SoundList_Count( group )) + return NULL; + + switch( lst->type ) + { + case SoundList_Range: + Q_snprintf( temp, sizeof( temp ), lst->snd, lst->min + i ); + return temp; + case SoundList_List: + return &lst->snd[i * lst->min]; + } + + return NULL; +} + +const char *SoundList_GetRandom( enum soundlst_group_e group ) +{ + int count = SoundList_Count( group ); + int idx = COM_RandomLong( 0, count - 1 ); + + Con_Printf( "%s: %s %d %d\n", __func__, soundlst_groups[group], count, idx ); + + return SoundList_Get( group, idx ); +} + +static qboolean SoundList_ParseGroup( soundlst_t *lst, char **file ) +{ + string token; + int count = 0, slen = 0, i; + char *p; + + p = *file; + + while(( p = COM_ParseFile( p, token, sizeof( token )))) + { + int len; + + if( !Q_strcmp( token, "}" )) + break; + + if( !Q_strcmp( token, "{" )) + { + Con_Printf( "%s: expected '}' but got '{' during group list parse\n", __func__ ); + return false; + } + else if( !COM_CheckStringEmpty( token )) + { + Con_Printf( "%s: expected '}' but got EOF during group list parse\n", __func__ ); + return false; + } + + len = Q_strlen( token ) + 1; + if( slen < len ) + slen = len; + + count++; + } + + if( count == 0 ) // deactivate group + { + lst->type = SoundList_None; + lst->snd = NULL; + lst->min = lst->max = 0; + return true; + } + + lst->type = SoundList_List; + lst->min = slen; + lst->max = count; + lst->snd = Mem_Malloc( host.mempool, count * slen ); // allocate single buffer for the whole group + + for( i = 0; i < count; i++ ) + { + *file = COM_ParseFile( *file, token, sizeof( token )); + + Q_strncpy( &lst->snd[i * slen], token, slen ); + } + + return true; +} + +static qboolean SoundList_ParseRange( soundlst_t *lst, char **file ) +{ + string token, snd; + char *p; + int i = 0; + + lst->type = SoundList_Range; + *file = COM_ParseFile( *file, snd, sizeof( snd )); + + // validate format string, count all % characters + p = snd; + i = 0; + while(( p = Q_strchr( p, '%' ))) + { + // only decimal + if( p[1] != 'd' && p[1] != 'i' && p[1] != 'u' ) + { + Con_Printf( "%s: invalid range string %s, only decimal numbers are allowed", __func__, snd ); + return false; + } + + i++; + p++; + } + + // if more than one %, then it's an invalid format string + if( i != 1 ) + { + Con_Printf( "%s: invalid range string %s, only single specifier is allowed\n", __func__, snd ); + return false; + } + + *file = COM_ParseFile( *file, token, sizeof( token )); + if( !Q_isdigit( token )) + { + Con_Printf( "%s: %s must be a digit\n", __func__, token ); + return false; + } + lst->min = Q_atoi( token ); + + *file = COM_ParseFile( *file, token, sizeof( token )); + if( !Q_isdigit( token )) + { + Con_Printf( "%s: %s must be a digit\n", __func__, token ); + return false; + } + lst->max = Q_atoi( token ); + lst->snd = copystring( snd ); + + return true; +} + +static qboolean SoundList_Parse( char *file ) +{ + string token; + int i; + + while(( file = COM_ParseFile( file, token, sizeof( token )))) + { + soundlst_t *lst = NULL; + char *p; + + for( i = 0; i < SoundList_Groups; i++ ) + { + if( !Q_strcmp( token, soundlst_groups[i] )) + { + lst = &soundlst[i]; + break; + } + } + + if( !lst ) + { + Con_Printf( "%s: unexpected token %s, must be group name\n", __func__, token ); + goto cleanup; + } + + p = COM_ParseFile( file, token, sizeof( token )); + + // group is a range + if( !Q_strcmp( token, "{" )) + { + file = p; // advance pointer + + if( !SoundList_ParseGroup( lst, &file )) + goto cleanup; + + file = COM_ParseFile( file, token, sizeof( token )); + if( Q_strcmp( token, "}" )) + { + Con_Printf( "%s: unexpected token %s, must be }\n", __func__, token ); + goto cleanup; + } + } + else + { + // ranges are more simple, but need to rewind pointer for them + if( !SoundList_ParseRange( lst, &file )) + goto cleanup; + } + } + + if( host_developer.value >= 2 ) + SoundList_Print_f(); + + return true; + +cleanup: + SoundList_Shutdown(); + return false; +} + +static void SoundList_Print_f( void ) +{ + int i; + + for( i = 0; i < SoundList_Groups; i++ ) + { + soundlst_t *lst = &soundlst[i]; + + switch( lst->type ) + { + case SoundList_Range: + Con_Reportf( "%-16s\t" S_CYAN "Range" S_DEFAULT " %s [%d; %d]\n", + soundlst_groups[i], lst->snd, lst->min, lst->max ); + break; + case SoundList_List: + { + int j; + + Con_Reportf( "%-16s\t" S_MAGENTA "List" S_DEFAULT " [", soundlst_groups[i] ); + for( j = 0; j < lst->max; j++ ) + Con_Reportf( "%s%s", &lst->snd[j * lst->min], j + 1 == lst->max ? "" : ", " ); + Con_Reportf( "]\n" ); + break; + } + default: + Con_Reportf( "%-16s\t" S_RED "inactive" S_DEFAULT "\n", soundlst_groups[i] ); + break; + } + } +} + +// I wish we had #embed already +static const char default_sounds_lst[] = +"BouncePlayerShell \"player/pl_shell%d.wav\" 1 3\n" +"BounceWeaponShell \"weapons/sshell%d.wav\" 1 3\n" +"BounceConcrete \"debris/concrete%d.wav\" 1 3\n" +"BounceGlass \"debris/glass%d.wav\" 1 4\n" +"BounceMetal \"debris/metal%d.wav\" 1 6\n" +"BounceFlesh \"debris/flesh%d.wav\" 1 7\n" +"BounceWood \"debris/wood%d.wav\" 1 4\n" +"Ricochet \"weapons/ric%d.wav\" 1 5\n" +"Explode \"weapons/explode%d.wav\" 3 5\n" +"EntityWaterEnter \"player/pl_wade%d.wav\" 1 4\n" +"EntityWaterExit \"player/pl_wade%d.wav\" 1 4\n" +"PlayerWaterEnter\n" +"{\n" +" \"player/pl_wade1.wav\"\n" +"}\n" +"\n" +"PlayerWaterExit\n" +"{\n" +" \"player/pl_wade2.wav\"\n" +"}\n"; + +static void SoundList_Reload_f( void ) +{ + qboolean load_internal = false; + char *pfile = FS_LoadFile( "scripts/sounds.lst", NULL, false ); + + if( pfile ) + { + if( !SoundList_Parse( pfile )) + { + Con_Printf( S_ERROR "can't parse sounds.lst file, loading internal...\n" ); + load_internal = true; + } + + Mem_Free( pfile ); + } + else load_internal = true; + + if( load_internal ) + SoundList_Parse( (char *)default_sounds_lst ); +} + +void SoundList_Init( void ) +{ + Cmd_AddRestrictedCommand( "host_soundlist_print", SoundList_Print_f, "print current sound list" ); + Cmd_AddRestrictedCommand( "host_soundlist_reload", SoundList_Reload_f, "reload sound list" ); + + SoundList_Reload_f (); +}