From c5cf9255f41562b9b04daf956bb1509517ef2eb4 Mon Sep 17 00:00:00 2001 From: R1KO Date: Sun, 11 Oct 2026 01:42:40 +0300 Subject: [PATCH 01/11] Add the VIP_GetCurrentVersionInterface native and the VIP_OnClientStorageUpdate forward, and bump VIP_CORE_INTERFACE to 320. The forward fires from Storage_SetClientValue only when the value changes, so unchanged values are no longer marked for saving either. --- AGENTS.md | 1 + .../sourcemod/scripting/include/vip_core.inc | 30 ++++++++++++++++++- addons/sourcemod/scripting/vip/API.sp | 18 +++++++++++ addons/sourcemod/scripting/vip/Storage.sp | 15 ++++++++-- 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6ec33d7..62c40d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ - Third-party modules depend on it: never change existing native/forward signatures or semantics. Add new params with defaults or add new natives. Deprecate via `#pragma deprecated`, don't delete (see `docs/update_modules.md`). - New native: implement `Native_X` in API.sp, `RegNative(X)` in `AskPluginLoad2`, declare in `.inc`, add `MarkNativeAsOptional("VIP_X")` in `__pl_vip_core_SetNTVOptional`. - New forward: `static GlobalForward` field + `new GlobalForward(...)` in `API_SetupForwards` + `CreateForward_*` wrapper + `forward` declaration with doc comment in `.inc`. +- Any added native/forward: bump `VIP_CORE_INTERFACE` in `.inc` once per release (3.2 → 320) and note "Requires VIP_CORE_INTERFACE N" in its doc comment; modules check it via `VIP_GetCurrentVersionInterface()`. ## Database - Config name `vip_core` in databases.cfg → MySQL; otherwise falls back to local SQLite. Every query must work on BOTH. diff --git a/addons/sourcemod/scripting/include/vip_core.inc b/addons/sourcemod/scripting/include/vip_core.inc index 301e276..7e2b643 100644 --- a/addons/sourcemod/scripting/include/vip_core.inc +++ b/addons/sourcemod/scripting/include/vip_core.inc @@ -3,7 +3,8 @@ #endif #define _vip_core_included -#define VIP_CORE_INTERFACE 310 +// API version of this include; bumped whenever natives or forwards are added. See VIP_GetCurrentVersionInterface(). +#define VIP_CORE_INTERFACE 320 /* VIP feature data types */ enum VIP_ValueType @@ -156,6 +157,20 @@ forward void VIP_OnConfigsLoaded(); */ forward void VIP_OnClientStorageLoaded(int iClient); +/** + * Called when a value in the player's storage has changed (VIP_SetClientStorageValue, + * including toggle statuses saved by the core under "FeatureStatus-" keys). + * Not called when the value is set to what it already was. + * Requires VIP_CORE_INTERFACE 320 (see VIP_GetCurrentVersionInterface). + * + * @param iClient Client index. + * @param szKey Key. + * @param szValue New value. + * + * @noreturn +*/ +forward void VIP_OnClientStorageUpdate(int iClient, const char[] szKey, const char[] szValue); + /** * Called when a VIP feature has been registered. * @@ -480,6 +495,18 @@ native bool VIP_RemoveClientVIP(int iClient, bool bInDB, bool bNotify); */ native bool VIP_IsVIPLoaded(); +/** + * Gets the API version of the running core (its VIP_CORE_INTERFACE), so a module can tell + * which natives and forwards are available, e.g. VIP_GetCurrentVersionInterface() >= 320. + * Cores before 3.2 don't have this native: check it first with + * GetFeatureStatus(FeatureType_Native, "VIP_GetCurrentVersionInterface") == FeatureStatus_Available. + * + * @noparams + * + * @return API version of the running core. +*/ +native int VIP_GetCurrentVersionInterface(); + /** * Registers a new VIP feature. * @@ -741,6 +768,7 @@ public SharedPlugin __pl_vip_core= public void __pl_vip_core_SetNTVOptional() { MarkNativeAsOptional("VIP_IsVIPLoaded"); + MarkNativeAsOptional("VIP_GetCurrentVersionInterface"); MarkNativeAsOptional("VIP_GetDatabase"); MarkNativeAsOptional("VIP_GetDatabaseType"); MarkNativeAsOptional("VIP_RegisterFeature"); diff --git a/addons/sourcemod/scripting/vip/API.sp b/addons/sourcemod/scripting/vip/API.sp index 97e1e64..f95dfcf 100644 --- a/addons/sourcemod/scripting/vip/API.sp +++ b/addons/sourcemod/scripting/vip/API.sp @@ -12,6 +12,7 @@ static GlobalForward g_hGlobalForward_OnFeatureUnregistered; static GlobalForward g_hGlobalForward_OnClientDisconnect; static GlobalForward g_hGlobalForward_OnConfigsLoaded; static GlobalForward g_hGlobalForward_OnClientStorageLoaded; +static GlobalForward g_hGlobalForward_OnClientStorageUpdate; void API_SetupForwards() { @@ -30,6 +31,7 @@ void API_SetupForwards() g_hGlobalForward_OnClientDisconnect = new GlobalForward("VIP_OnClientDisconnect", ET_Ignore, Param_Cell, Param_Cell); g_hGlobalForward_OnConfigsLoaded = new GlobalForward("VIP_OnConfigsLoaded", ET_Ignore); g_hGlobalForward_OnClientStorageLoaded = new GlobalForward("VIP_OnClientStorageLoaded", ET_Ignore, Param_Cell); + g_hGlobalForward_OnClientStorageUpdate = new GlobalForward("VIP_OnClientStorageUpdate", ET_Ignore, Param_Cell, Param_String, Param_String); } // Global Forwards @@ -55,6 +57,16 @@ void CreateForward_OnClientStorageLoaded(int iClient) Call_Finish(); } +void CreateForward_OnClientStorageUpdate(int iClient, const char[] szKey, const char[] szValue) +{ + DBG_API("CreateForward_OnClientStorageUpdate(%N (%d), '%s', '%s')", iClient, iClient, szKey, szValue); + Call_StartForward(g_hGlobalForward_OnClientStorageUpdate); + Call_PushCell(iClient); + Call_PushString(szKey); + Call_PushString(szValue); + Call_Finish(); +} + bool CreateForward_OnClientPreLoad(int iClient) { DBG_API("g_hGlobalForward_OnClientPreLoad(%N (%d), %b)", iClient, iClient, g_ePlayer[iClient].bVIP); @@ -233,6 +245,7 @@ public APLRes AskPluginLoad2(Handle myself, bool bLate, char[] szError, int err_ { // Global RegNative(IsVIPLoaded); + RegNative(GetCurrentVersionInterface); RegNative(GetDatabase); RegNative(GetDatabaseType); @@ -832,6 +845,11 @@ public int Native_IsVIPLoaded(Handle hPlugin, int iNumParams) return (g_bStarted && g_hDatabase); } +public int Native_GetCurrentVersionInterface(Handle hPlugin, int iNumParams) +{ + return VIP_CORE_INTERFACE; +} + public int Native_RegisterFeature(Handle hPlugin, int iNumParams) { char szFeature[FEATURE_NAME_LENGTH]; diff --git a/addons/sourcemod/scripting/vip/Storage.sp b/addons/sourcemod/scripting/vip/Storage.sp index 51de1c1..04b5f93 100644 --- a/addons/sourcemod/scripting/vip/Storage.sp +++ b/addons/sourcemod/scripting/vip/Storage.sp @@ -15,11 +15,20 @@ bool Storage_IsClientLoaded(int iClient) void Storage_SetClientValue(int iClient, const char[] szKey, const char[] szValue) { - if (Storage_IsClientLoaded(iClient)) + if (!Storage_IsClientLoaded(iClient)) { - g_hStorage[iClient].SetString(szKey, szValue); - g_bStorageChanged[iClient] = true; + return; } + + char szOldValue[STORAGE_VALUE_LENGTH]; + if (g_hStorage[iClient].GetString(szKey, SZF(szOldValue)) && strcmp(szOldValue, szValue) == 0) + { + return; + } + + g_hStorage[iClient].SetString(szKey, szValue); + g_bStorageChanged[iClient] = true; + CreateForward_OnClientStorageUpdate(iClient, szKey, szValue); } bool Storage_GetClientValue(int iClient, const char[] szKey, char[] szValue, int iMaxLength) From 0c2b824ab2eec5fa45727328cbc68232d6f63834 Mon Sep 17 00:00:00 2001 From: R1KO Date: Sun, 11 Oct 2026 10:15:46 +0300 Subject: [PATCH 02/11] Add VIP_GroupAddFeature and VIP_GroupRemoveFeature to change a group's features at runtime. In-game VIPs of the group get the change at once via Clients_ReloadGroupFeature; groups.ini is not modified. --- .../sourcemod/scripting/include/vip_core.inc | 32 ++++++++++++++ addons/sourcemod/scripting/vip/API.sp | 44 +++++++++++++++++++ addons/sourcemod/scripting/vip/Clients.sp | 40 +++++++++++++++++ 3 files changed, 116 insertions(+) diff --git a/addons/sourcemod/scripting/include/vip_core.inc b/addons/sourcemod/scripting/include/vip_core.inc index 301e276..7e41d1e 100644 --- a/addons/sourcemod/scripting/include/vip_core.inc +++ b/addons/sourcemod/scripting/include/vip_core.inc @@ -364,6 +364,36 @@ native bool VIP_AddGroup(const char[] szGroup, KeyValues hFeatures); */ native bool VIP_RemoveGroup(const char[] szGroup); +/** + * Adds a feature to a group or changes its value (in memory only, groups.ini is not modified). + * In-game VIPs of the group get the new value at once; a toggle callback and VIP_OnFeatureToggle + * fire only if the player's access to the feature changes. + * sm_reload_vip_cfg rereads groups.ini and drops the change: apply it again in VIP_OnConfigsLoaded(). + * Requires VIP_CORE_INTERFACE 320 (see VIP_GetCurrentVersionInterface). + * + * @param szGroup Group name. + * @param szFeature Feature name (it may be not registered yet). + * @param szValue Feature value. + * + * @return true - on success. + * false - the group does not exist or szFeature is empty. +*/ +native bool VIP_GroupAddFeature(const char[] szGroup, const char[] szFeature, const char[] szValue); + +/** + * Removes a feature from a group (in memory only, groups.ini is not modified). + * In-game VIPs of the group lose the feature at once (toggle callback and VIP_OnFeatureToggle with NO_ACCESS). + * sm_reload_vip_cfg rereads groups.ini and drops the change: apply it again in VIP_OnConfigsLoaded(). + * Requires VIP_CORE_INTERFACE 320 (see VIP_GetCurrentVersionInterface). + * + * @param szGroup Group name. + * @param szFeature Feature name. + * + * @return true - on success. + * false - the group does not exist or has no such feature. +*/ +native bool VIP_GroupRemoveFeature(const char[] szGroup, const char[] szFeature); + /** * Saves a value to the player's storage (vip_storage table). * The storage is available for all players (not only VIP) after VIP_OnClientStorageLoaded(); @@ -766,6 +796,8 @@ public void __pl_vip_core_SetNTVOptional() MarkNativeAsOptional("VIP_IsGroupExists"); MarkNativeAsOptional("VIP_AddGroup"); MarkNativeAsOptional("VIP_RemoveGroup"); + MarkNativeAsOptional("VIP_GroupAddFeature"); + MarkNativeAsOptional("VIP_GroupRemoveFeature"); MarkNativeAsOptional("VIP_SetClientStorageValue"); MarkNativeAsOptional("VIP_GetClientStorageValue"); MarkNativeAsOptional("VIP_GetClientFeatureStatus"); diff --git a/addons/sourcemod/scripting/vip/API.sp b/addons/sourcemod/scripting/vip/API.sp index 97e1e64..68f4db6 100644 --- a/addons/sourcemod/scripting/vip/API.sp +++ b/addons/sourcemod/scripting/vip/API.sp @@ -271,6 +271,8 @@ public APLRes AskPluginLoad2(Handle myself, bool bLate, char[] szError, int err_ RegNative(IsGroupExists); RegNative(AddGroup); RegNative(RemoveGroup); + RegNative(GroupAddFeature); + RegNative(GroupRemoveFeature); RegNative(SetClientStorageValue); RegNative(GetClientStorageValue); @@ -795,6 +797,48 @@ public int Native_RemoveGroup(Handle hPlugin, int iNumParams) return true; } +public int Native_GroupAddFeature(Handle hPlugin, int iNumParams) +{ + char szGroup[64], szFeature[FEATURE_NAME_LENGTH], szValue[256]; + GetNativeString(1, SZF(szGroup)); + GetNativeString(2, SZF(szFeature)); + GetNativeString(3, SZF(szValue)); + + if (!szFeature[0] || !UTIL_CheckValidVIPGroup(szGroup)) + { + return false; + } + + g_hGroups.SetString(szFeature, szValue); + g_hGroups.Rewind(); + + Clients_ReloadGroupFeature(szGroup, szFeature); + + return true; +} + +public int Native_GroupRemoveFeature(Handle hPlugin, int iNumParams) +{ + char szGroup[64], szFeature[FEATURE_NAME_LENGTH]; + GetNativeString(1, SZF(szGroup)); + GetNativeString(2, SZF(szFeature)); + + if (!szFeature[0] || !UTIL_CheckValidVIPGroup(szGroup)) + { + return false; + } + + bool bRemoved = g_hGroups.DeleteKey(szFeature); + g_hGroups.Rewind(); + + if (bRemoved) + { + Clients_ReloadGroupFeature(szGroup, szFeature); + } + + return bRemoved; +} + public int Native_SetClientStorageValue(Handle hPlugin, int iNumParams) { int iClient = GetNativeCell(1); diff --git a/addons/sourcemod/scripting/vip/Clients.sp b/addons/sourcemod/scripting/vip/Clients.sp index 288f6aa..436b7aa 100644 --- a/addons/sourcemod/scripting/vip/Clients.sp +++ b/addons/sourcemod/scripting/vip/Clients.sp @@ -368,6 +368,46 @@ void Clients_LoadVIPFeature(int iClient, const char[] szFeature) */ } +// A group's feature changed at runtime: reload it for the in-game VIPs of that group +void Clients_ReloadGroupFeature(const char[] szGroup, const char[] szFeature) +{ + Feature eFeature; + // Not registered yet: the new value is picked up when the module registers it + if (!Features_Get(szFeature, eFeature)) + { + return; + } + + char szClientGroup[64]; + VIP_ToggleState eOldStatus, eNewStatus; + for (int iClient = 1; iClient <= MaxClients; ++iClient) + { + if (!IsClientInGame(iClient) || !g_ePlayer[iClient].bVIP + || !g_ePlayer[iClient].hFeatures.GetString(KEY_GROUP, SZF(szClientGroup)) + || strcmp(szClientGroup, szGroup) != 0) + { + continue; + } + + eOldStatus = Features_GetStatus(iClient, szFeature); + + // GetValue only sets non-empty values, so the old one must go first or a removed/zeroed feature would stay + g_ePlayer[iClient].hFeatures.Remove(szFeature); + g_ePlayer[iClient].hFeatureStatus.Remove(szFeature); + Clients_LoadVIPFeature(iClient, szFeature); + + eNewStatus = Features_GetStatus(iClient, szFeature); + if (eFeature.eType == TOGGLABLE && eNewStatus != eOldStatus) + { + if (eFeature.fnSelect != INVALID_FUNCTION) + { + Function_OnItemToggle(eFeature.hPlugin, eFeature.fnSelect, iClient, szFeature, eOldStatus, eNewStatus); + } + CreateForward_OnFeatureToggle(iClient, szFeature, eOldStatus, eNewStatus); + } + } +} + void Clients_LoadFeature(int iClient, const char[] szFeature) { Feature eFeature; From d1accab128ad43920d670010db1f8e4c58fb0dea Mon Sep 17 00:00:00 2001 From: R1KO Date: Sun, 11 Oct 2026 10:35:22 +0300 Subject: [PATCH 03/11] Free the previous info.ini KeyValues on sm_reload_vip_cfg; ReadConfigs deleted only the old groups config and leaked g_hInfo on every reload. --- addons/sourcemod/scripting/vip/Configs.sp | 1 + 1 file changed, 1 insertion(+) diff --git a/addons/sourcemod/scripting/vip/Configs.sp b/addons/sourcemod/scripting/vip/Configs.sp index 5357b68..fc00f42 100644 --- a/addons/sourcemod/scripting/vip/Configs.sp +++ b/addons/sourcemod/scripting/vip/Configs.sp @@ -36,6 +36,7 @@ void ReadConfigs() ReadSortingMenu(); delete g_hGroups; + delete g_hInfo; g_hGroups = CreateConfig("data/vip/cfg/groups.ini", "VIP_GROUPS"); g_hInfo = CreateConfig("data/vip/cfg/info.ini", "VIP_INFO"); From bfbc11bd094192e62059e84c05a47b9efea1b774 Mon Sep 17 00:00:00 2001 From: R1KO Date: Sun, 11 Oct 2026 10:37:29 +0300 Subject: [PATCH 04/11] Add group inheritance to groups.ini: "extend" "a;b" copies the plain values of the listed groups, later groups and own values win, and an own "" removes an inherited value. Resolved once when configs load, so the rest of the core sees flat groups; unknown groups and loops are logged and skipped. --- addons/sourcemod/data/vip/cfg/groups.ini | 13 + .../sourcemod/scripting/include/vip_core.inc | 1 + addons/sourcemod/scripting/vip/Configs.sp | 223 +++++++++++++++++- 3 files changed, 236 insertions(+), 1 deletion(-) diff --git a/addons/sourcemod/data/vip/cfg/groups.ini b/addons/sourcemod/data/vip/cfg/groups.ini index 5778098..9aa27c3 100644 --- a/addons/sourcemod/data/vip/cfg/groups.ini +++ b/addons/sourcemod/data/vip/cfg/groups.ini @@ -1,3 +1,16 @@ +// Inheritance: "extend" "group1;group2" copies the plain values (not nested sections) of the listed groups. +// - the group's own values beat inherited ones; +// - on a conflict the group listed later wins; +// - an own empty value ("") removes an inherited one (for this group and the groups extending it); +// - unknown groups and loops are logged to the SourceMod error log and skipped; +// - groups added by modules (VIP_AddGroup) can't be extended and "extend" is not a feature name. +// Example: +// "vip_gold" +// { +// "extend" "vip1;vip2" +// "hp" "200" +// "speed" "" // don't inherit speed +// } "VIP_GROUPS" { "vip1" // Group name diff --git a/addons/sourcemod/scripting/include/vip_core.inc b/addons/sourcemod/scripting/include/vip_core.inc index 301e276..0dd3c2d 100644 --- a/addons/sourcemod/scripting/include/vip_core.inc +++ b/addons/sourcemod/scripting/include/vip_core.inc @@ -345,6 +345,7 @@ native bool VIP_IsValidVIPGroup(const char[] szGroup); /** * Adds a group (in memory only, groups.ini is not modified). * Groups are reset when configs are reloaded; add them again in VIP_OnConfigsLoaded(). + * An "extend" key is not resolved here: pass the final values (inheritance works in groups.ini only). * * @param szGroup Group name. * @param hFeatures Group parameters (keys from the current position are copied into the group). diff --git a/addons/sourcemod/scripting/vip/Configs.sp b/addons/sourcemod/scripting/vip/Configs.sp index 5357b68..865bc5a 100644 --- a/addons/sourcemod/scripting/vip/Configs.sp +++ b/addons/sourcemod/scripting/vip/Configs.sp @@ -38,6 +38,7 @@ void ReadConfigs() delete g_hGroups; g_hGroups = CreateConfig("data/vip/cfg/groups.ini", "VIP_GROUPS"); + Configs_ResolveGroupExtends(g_hGroups); g_hInfo = CreateConfig("data/vip/cfg/info.ini", "VIP_INFO"); CreateForward_OnConfigsLoaded(); @@ -97,4 +98,224 @@ KeyValues CreateConfig(const char[] szFile, const char[] szKvName) hKeyValues.Rewind(); return hKeyValues; -} \ No newline at end of file +} + +#define GROUP_EXTEND_KEY "extend" + +// "extend" "base;silver" in groups.ini: the group inherits the plain values of the listed groups. +// A later group in the list beats an earlier one, the group's own values beat all of them, and an own "" removes an inherited value. +// Resolved once here into flat groups, so the rest of the core never sees "extend". +// Groups added later by VIP_AddGroup are not resolved. +static void Configs_ResolveGroupExtends(KeyValues hGroups) +{ + ArrayList hNames = new ArrayList(ByteCountToCells(MAX_NAME_LENGTH)); + char szGroup[MAX_NAME_LENGTH]; + hGroups.Rewind(); + if (hGroups.GotoFirstSubKey()) + { + do + { + hGroups.GetSectionName(SZF(szGroup)); + hNames.PushString(szGroup); + } + while (hGroups.GotoNextKey()); + } + + StringMap hResolved = new StringMap(); // group -> StringMap of its effective values + ArrayList hExtended = new ArrayList(ByteCountToCells(MAX_NAME_LENGTH)); + ArrayList hStack = new ArrayList(ByteCountToCells(MAX_NAME_LENGTH)); + int iGroups = hNames.Length; + for (int i = 0; i < iGroups; ++i) + { + hNames.GetString(i, SZF(szGroup)); + Configs_ResolveGroup(hGroups, szGroup, hResolved, hExtended, hStack); + } + + StringMap hValues; + int iExtended = hExtended.Length; + for (int i = 0; i < iExtended; ++i) + { + hExtended.GetString(i, SZF(szGroup)); + hResolved.GetValue(szGroup, hValues); + Configs_WriteGroupValues(hGroups, szGroup, hValues); + } + + StringMapSnapshot hSnapshot = hResolved.Snapshot(); + for (int i = 0; i < hSnapshot.Length; ++i) + { + hSnapshot.GetKey(i, SZF(szGroup)); + hResolved.GetValue(szGroup, hValues); + delete hValues; + } + delete hSnapshot; + delete hResolved; + delete hExtended; + delete hStack; + delete hNames; + hGroups.Rewind(); +} + +static StringMap Configs_ResolveGroup(KeyValues hGroups, const char[] szGroup, StringMap hResolved, ArrayList hExtended, ArrayList hStack) +{ + StringMap hValues; + if (hResolved.GetValue(szGroup, hValues)) + { + return hValues; + } + + StringMap hOwn = new StringMap(); + char szExtend[256]; + Configs_ReadGroupValues(hGroups, szGroup, hOwn, SZF(szExtend)); + + hValues = new StringMap(); + if (szExtend[0]) + { + hExtended.PushString(szGroup); + } + hStack.PushString(szGroup); + + // One name buffer instead of an ExplodeString array: this recurses once per inheritance level + char szParent[MAX_NAME_LENGTH]; + int iPos = 0, iNext; + do + { + iNext = SplitString(szExtend[iPos], ";", SZF(szParent)); + if (iNext == -1) + { + strcopy(SZF(szParent), szExtend[iPos]); + } + else + { + iPos += iNext; + } + + TrimString(szParent); + if (!szParent[0]) + { + continue; + } + + // KeyValues names are case-insensitive: use the section's own spelling as the key + hGroups.Rewind(); + if (!hGroups.JumpToKey(szParent)) + { + LogError("groups.ini: group \"%s\" extends unknown group \"%s\"", szGroup, szParent); + continue; + } + hGroups.GetSectionName(SZF(szParent)); + + if (hStack.FindString(szParent) != -1) + { + LogError("groups.ini: group \"%s\" extends \"%s\", which leads back to itself", szGroup, szParent); + continue; + } + + Configs_CopyValues(Configs_ResolveGroup(hGroups, szParent, hResolved, hExtended, hStack), hValues); + } + while (iNext != -1); + + hStack.Erase(hStack.Length - 1); + + Configs_CopyValues(hOwn, hValues); + delete hOwn; + + hResolved.SetValue(szGroup, hValues); + return hValues; +} + +// Plain values only: nested sections are not inherited. +// Keys are lowercased because KeyValues keys are case-insensitive and StringMap keys are not ("HP" must override "hp"). +static void Configs_ReadGroupValues(KeyValues hGroups, const char[] szGroup, StringMap hValues, char[] szExtend, int iMaxLength) +{ + szExtend[0] = 0; + hGroups.Rewind(); + if (!hGroups.JumpToKey(szGroup) || !hGroups.GotoFirstSubKey(false)) + { + return; + } + + char szKey[FEATURE_NAME_LENGTH], szValue[256]; + do + { + if (hGroups.GetDataType(NULL_STRING) == KvData_None) + { + continue; + } + + hGroups.GetSectionName(SZF(szKey)); + // Floats come back as "%f" (6 decimals), which is enough for feature values + hGroups.GetString(NULL_STRING, SZF(szValue)); + if (strcmp(szKey, GROUP_EXTEND_KEY, false) == 0) + { + strcopy(szExtend, iMaxLength, szValue); + continue; + } + + for (int i = 0; szKey[i]; ++i) + { + szKey[i] = CharToLower(szKey[i]); + } + hValues.SetString(szKey, szValue); + } + while (hGroups.GotoNextKey(false)); +} + +// An own "" removes the inherited value +static void Configs_CopyValues(StringMap hFrom, StringMap hTo) +{ + StringMapSnapshot hSnapshot = hFrom.Snapshot(); + char szKey[FEATURE_NAME_LENGTH], szValue[256]; + for (int i = 0; i < hSnapshot.Length; ++i) + { + hSnapshot.GetKey(i, SZF(szKey)); + hFrom.GetString(szKey, SZF(szValue)); + if (szValue[0]) + { + hTo.SetString(szKey, szValue); + } + else + { + hTo.Remove(szKey); + } + } + delete hSnapshot; +} + +// Own keys are rewritten too (KeyValues matches them case-insensitively); own "" keys are dropped +static void Configs_WriteGroupValues(KeyValues hGroups, const char[] szGroup, StringMap hValues) +{ + StringMap hOwn = new StringMap(); + char szExtend[256]; + Configs_ReadGroupValues(hGroups, szGroup, hOwn, SZF(szExtend)); + + hGroups.Rewind(); + if (!hGroups.JumpToKey(szGroup)) + { + delete hOwn; + return; + } + + hGroups.DeleteKey(GROUP_EXTEND_KEY); + + StringMapSnapshot hSnapshot = hOwn.Snapshot(); + char szKey[FEATURE_NAME_LENGTH], szValue[256]; + for (int i = 0; i < hSnapshot.Length; ++i) + { + hSnapshot.GetKey(i, SZF(szKey)); + if (!hValues.GetString(szKey, SZF(szValue))) + { + hGroups.DeleteKey(szKey); + } + } + delete hSnapshot; + delete hOwn; + + hSnapshot = hValues.Snapshot(); + for (int i = 0; i < hSnapshot.Length; ++i) + { + hSnapshot.GetKey(i, SZF(szKey)); + hValues.GetString(szKey, SZF(szValue)); + hGroups.SetString(szKey, szValue); + } + delete hSnapshot; +} From bf0e1795a1736c6b5a89ff6d1233fc37128aec54 Mon Sep 17 00:00:00 2001 From: R1KO Date: Sun, 11 Oct 2026 23:26:05 +0300 Subject: [PATCH 05/11] Put each operand of the in-game VIP check in Clients_ReloadGroupFeature on its own line, operators at the end. AGENTS.md now describes this layout for multi-line conditions. --- AGENTS.md | 2 +- addons/sourcemod/scripting/vip/Clients.sp | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6ec33d7..f8ca28a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ ## Code style (new/changed code; legacy deviations are not a reason to copy them) - `#pragma semicolon 1`, `#pragma newdecls required`. Tabs, no trailing whitespace. Allman braces, always braced, also single-statement `if`/`for` and `case` bodies (`case MenuAction_End: delete hMenu;` is the only one-liner). -- Space after keywords: `if (`, `for (`, `while (`, `switch (`. `++i` in loops. `!bFlag`, not `bFlag == false`. Buffers: `SZF(szBuf)`, not `szBuf, sizeof(szBuf)`; `FormatEx` unless formatting a buffer into itself. +- Space after keywords: `if (`, `for (`, `while (`, `switch (`. A condition that doesn't fit one line: `if (` on its own line, one operand per line with the operator (`||`, `&&`) at the end, then `)` on its own line. `++i` in loops. `!bFlag`, not `bFlag == false`. Buffers: `SZF(szBuf)`, not `szBuf, sizeof(szBuf)`; `FormatEx` unless formatting a buffer into itself. - Names: locals/params Hungarian `iClient`, `szName`, `hMenu`, `bFlag`, `fTime`, `eStatus`; globals `g_` + prefix (`g_hDatabase`, `g_szSID`), cvars `g_CVAR_*`; macros/consts `UPPER_SNAKE`, StringMap keys `KEY_*` / `DATA_KEY_*`. - Functions: `PascalCase` with file prefix: `Clients_*`, `Features_*`, `Storage_*`, `DB_*`, `UTIL_*`, `API_*`. Callbacks: `SQL_Callback_*`, `SQL_TxnCallback_*`, `Timer_*`, `MenuHandler_*`, `Event_*`; commands `*_CMD`; natives `Native_*`; forward wrappers `CreateForward_*`. - SQL: backticks around identifiers, single quotes for values, trailing `;`, user strings via `g_hDatabase.Format`/`Escape`. Callbacks: `if (hResult == null || szError[0]) { LogError(": %s", szError); return; }`, free the DataPack on every path. diff --git a/addons/sourcemod/scripting/vip/Clients.sp b/addons/sourcemod/scripting/vip/Clients.sp index 436b7aa..41671c8 100644 --- a/addons/sourcemod/scripting/vip/Clients.sp +++ b/addons/sourcemod/scripting/vip/Clients.sp @@ -382,9 +382,12 @@ void Clients_ReloadGroupFeature(const char[] szGroup, const char[] szFeature) VIP_ToggleState eOldStatus, eNewStatus; for (int iClient = 1; iClient <= MaxClients; ++iClient) { - if (!IsClientInGame(iClient) || !g_ePlayer[iClient].bVIP - || !g_ePlayer[iClient].hFeatures.GetString(KEY_GROUP, SZF(szClientGroup)) - || strcmp(szClientGroup, szGroup) != 0) + if ( + !IsClientInGame(iClient) || + !g_ePlayer[iClient].bVIP || + !g_ePlayer[iClient].hFeatures.GetString(KEY_GROUP, SZF(szClientGroup)) || + strcmp(szClientGroup, szGroup) != 0 + ) { continue; } From dc4ba64aef8d983e149192b7cc3f8051ad1c4ff7 Mon Sep 17 00:00:00 2001 From: R1KO Date: Sun, 11 Oct 2026 23:28:18 +0300 Subject: [PATCH 06/11] Restore groups.ini without the inheritance comments. --- addons/sourcemod/data/vip/cfg/groups.ini | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/addons/sourcemod/data/vip/cfg/groups.ini b/addons/sourcemod/data/vip/cfg/groups.ini index 9aa27c3..5778098 100644 --- a/addons/sourcemod/data/vip/cfg/groups.ini +++ b/addons/sourcemod/data/vip/cfg/groups.ini @@ -1,16 +1,3 @@ -// Inheritance: "extend" "group1;group2" copies the plain values (not nested sections) of the listed groups. -// - the group's own values beat inherited ones; -// - on a conflict the group listed later wins; -// - an own empty value ("") removes an inherited one (for this group and the groups extending it); -// - unknown groups and loops are logged to the SourceMod error log and skipped; -// - groups added by modules (VIP_AddGroup) can't be extended and "extend" is not a feature name. -// Example: -// "vip_gold" -// { -// "extend" "vip1;vip2" -// "hp" "200" -// "speed" "" // don't inherit speed -// } "VIP_GROUPS" { "vip1" // Group name From 0e5e4631f7d43349747aa6b90bf01070e6e3f938 Mon Sep 17 00:00:00 2001 From: R1KO Date: Sun, 11 Oct 2026 23:32:29 +0300 Subject: [PATCH 07/11] Match group names in extend exactly: a name that differs only in case is logged as an unknown group instead of being resolved through KeyValues. --- addons/sourcemod/scripting/vip/Configs.sp | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/addons/sourcemod/scripting/vip/Configs.sp b/addons/sourcemod/scripting/vip/Configs.sp index 865bc5a..2796839 100644 --- a/addons/sourcemod/scripting/vip/Configs.sp +++ b/addons/sourcemod/scripting/vip/Configs.sp @@ -128,7 +128,7 @@ static void Configs_ResolveGroupExtends(KeyValues hGroups) for (int i = 0; i < iGroups; ++i) { hNames.GetString(i, SZF(szGroup)); - Configs_ResolveGroup(hGroups, szGroup, hResolved, hExtended, hStack); + Configs_ResolveGroup(hGroups, hNames, szGroup, hResolved, hExtended, hStack); } StringMap hValues; @@ -155,7 +155,7 @@ static void Configs_ResolveGroupExtends(KeyValues hGroups) hGroups.Rewind(); } -static StringMap Configs_ResolveGroup(KeyValues hGroups, const char[] szGroup, StringMap hResolved, ArrayList hExtended, ArrayList hStack) +static StringMap Configs_ResolveGroup(KeyValues hGroups, ArrayList hNames, const char[] szGroup, StringMap hResolved, ArrayList hExtended, ArrayList hStack) { StringMap hValues; if (hResolved.GetValue(szGroup, hValues)) @@ -195,14 +195,12 @@ static StringMap Configs_ResolveGroup(KeyValues hGroups, const char[] szGroup, S continue; } - // KeyValues names are case-insensitive: use the section's own spelling as the key - hGroups.Rewind(); - if (!hGroups.JumpToKey(szParent)) + // Exact spelling only, although KeyValues itself would match "VIP1" to "vip1" + if (hNames.FindString(szParent) == -1) { LogError("groups.ini: group \"%s\" extends unknown group \"%s\"", szGroup, szParent); continue; } - hGroups.GetSectionName(SZF(szParent)); if (hStack.FindString(szParent) != -1) { @@ -210,7 +208,7 @@ static StringMap Configs_ResolveGroup(KeyValues hGroups, const char[] szGroup, S continue; } - Configs_CopyValues(Configs_ResolveGroup(hGroups, szParent, hResolved, hExtended, hStack), hValues); + Configs_CopyValues(Configs_ResolveGroup(hGroups, hNames, szParent, hResolved, hExtended, hStack), hValues); } while (iNext != -1); From c0816e0db8178dd7a2095cc4244a2d30dde3f364 Mon Sep 17 00:00:00 2001 From: R1KO Date: Sun, 11 Oct 2026 23:35:35 +0300 Subject: [PATCH 08/11] Keep feature keys as written when resolving extend; keys that differ only in case are the admin's mistake. --- addons/sourcemod/scripting/vip/Configs.sp | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/addons/sourcemod/scripting/vip/Configs.sp b/addons/sourcemod/scripting/vip/Configs.sp index 2796839..ed23102 100644 --- a/addons/sourcemod/scripting/vip/Configs.sp +++ b/addons/sourcemod/scripting/vip/Configs.sp @@ -221,8 +221,7 @@ static StringMap Configs_ResolveGroup(KeyValues hGroups, ArrayList hNames, const return hValues; } -// Plain values only: nested sections are not inherited. -// Keys are lowercased because KeyValues keys are case-insensitive and StringMap keys are not ("HP" must override "hp"). +// Plain values only: nested sections are not inherited static void Configs_ReadGroupValues(KeyValues hGroups, const char[] szGroup, StringMap hValues, char[] szExtend, int iMaxLength) { szExtend[0] = 0; @@ -249,10 +248,6 @@ static void Configs_ReadGroupValues(KeyValues hGroups, const char[] szGroup, Str continue; } - for (int i = 0; szKey[i]; ++i) - { - szKey[i] = CharToLower(szKey[i]); - } hValues.SetString(szKey, szValue); } while (hGroups.GotoNextKey(false)); @@ -279,7 +274,7 @@ static void Configs_CopyValues(StringMap hFrom, StringMap hTo) delete hSnapshot; } -// Own keys are rewritten too (KeyValues matches them case-insensitively); own "" keys are dropped +// Own "" keys are dropped static void Configs_WriteGroupValues(KeyValues hGroups, const char[] szGroup, StringMap hValues) { StringMap hOwn = new StringMap(); From a705a42ef9ed87c18b0034ed73981b6ec323f741 Mon Sep 17 00:00:00 2001 From: R1KO Date: Sun, 11 Oct 2026 23:41:37 +0300 Subject: [PATCH 09/11] Resolve extend through a temporary KeyValues per group instead of StringMaps: parents are merged in list order, own values on top, and the result is written back. Same rules, about half the code. --- addons/sourcemod/scripting/vip/Configs.sp | 157 +++++++--------------- 1 file changed, 47 insertions(+), 110 deletions(-) diff --git a/addons/sourcemod/scripting/vip/Configs.sp b/addons/sourcemod/scripting/vip/Configs.sp index ed23102..717ce81 100644 --- a/addons/sourcemod/scripting/vip/Configs.sp +++ b/addons/sourcemod/scripting/vip/Configs.sp @@ -121,60 +121,41 @@ static void Configs_ResolveGroupExtends(KeyValues hGroups) while (hGroups.GotoNextKey()); } - StringMap hResolved = new StringMap(); // group -> StringMap of its effective values - ArrayList hExtended = new ArrayList(ByteCountToCells(MAX_NAME_LENGTH)); + ArrayList hResolved = new ArrayList(ByteCountToCells(MAX_NAME_LENGTH)); ArrayList hStack = new ArrayList(ByteCountToCells(MAX_NAME_LENGTH)); - int iGroups = hNames.Length; - for (int i = 0; i < iGroups; ++i) + for (int i = 0; i < hNames.Length; ++i) { hNames.GetString(i, SZF(szGroup)); - Configs_ResolveGroup(hGroups, hNames, szGroup, hResolved, hExtended, hStack); + Configs_ResolveGroup(hGroups, hNames, szGroup, hResolved, hStack); } - StringMap hValues; - int iExtended = hExtended.Length; - for (int i = 0; i < iExtended; ++i) - { - hExtended.GetString(i, SZF(szGroup)); - hResolved.GetValue(szGroup, hValues); - Configs_WriteGroupValues(hGroups, szGroup, hValues); - } - - StringMapSnapshot hSnapshot = hResolved.Snapshot(); - for (int i = 0; i < hSnapshot.Length; ++i) - { - hSnapshot.GetKey(i, SZF(szGroup)); - hResolved.GetValue(szGroup, hValues); - delete hValues; - } - delete hSnapshot; delete hResolved; - delete hExtended; delete hStack; delete hNames; hGroups.Rewind(); } -static StringMap Configs_ResolveGroup(KeyValues hGroups, ArrayList hNames, const char[] szGroup, StringMap hResolved, ArrayList hExtended, ArrayList hStack) +static void Configs_ResolveGroup(KeyValues hGroups, ArrayList hNames, const char[] szGroup, ArrayList hResolved, ArrayList hStack) { - StringMap hValues; - if (hResolved.GetValue(szGroup, hValues)) + if (hResolved.FindString(szGroup) != -1) { - return hValues; + return; } - StringMap hOwn = new StringMap(); char szExtend[256]; - Configs_ReadGroupValues(hGroups, szGroup, hOwn, SZF(szExtend)); - - hValues = new StringMap(); - if (szExtend[0]) + hGroups.Rewind(); + hGroups.JumpToKey(szGroup); + hGroups.GetString(GROUP_EXTEND_KEY, SZF(szExtend)); + if (!szExtend[0]) { - hExtended.PushString(szGroup); + hResolved.PushString(szGroup); + return; } + hStack.PushString(szGroup); - // One name buffer instead of an ExplodeString array: this recurses once per inheritance level + // Parents in list order, then the group's own values on top + KeyValues hMerged = new KeyValues(szGroup); char szParent[MAX_NAME_LENGTH]; int iPos = 0, iNext; do @@ -195,7 +176,6 @@ static StringMap Configs_ResolveGroup(KeyValues hGroups, ArrayList hNames, const continue; } - // Exact spelling only, although KeyValues itself would match "VIP1" to "vip1" if (hNames.FindString(szParent) == -1) { LogError("groups.ini: group \"%s\" extends unknown group \"%s\"", szGroup, szParent); @@ -208,107 +188,64 @@ static StringMap Configs_ResolveGroup(KeyValues hGroups, ArrayList hNames, const continue; } - Configs_CopyValues(Configs_ResolveGroup(hGroups, hNames, szParent, hResolved, hExtended, hStack), hValues); + Configs_ResolveGroup(hGroups, hNames, szParent, hResolved, hStack); + Configs_CopyGroupValues(hGroups, szParent, hMerged); } while (iNext != -1); hStack.Erase(hStack.Length - 1); - Configs_CopyValues(hOwn, hValues); - delete hOwn; - - hResolved.SetValue(szGroup, hValues); - return hValues; -} + Configs_CopyGroupValues(hGroups, szGroup, hMerged); -// Plain values only: nested sections are not inherited -static void Configs_ReadGroupValues(KeyValues hGroups, const char[] szGroup, StringMap hValues, char[] szExtend, int iMaxLength) -{ - szExtend[0] = 0; hGroups.Rewind(); - if (!hGroups.JumpToKey(szGroup) || !hGroups.GotoFirstSubKey(false)) - { - return; - } + hGroups.JumpToKey(szGroup); + hGroups.DeleteKey(GROUP_EXTEND_KEY); + // Only own "" values are left in hMerged (parents are already resolved): they remove the inherited value char szKey[FEATURE_NAME_LENGTH], szValue[256]; - do + if (hMerged.GotoFirstSubKey(false)) { - if (hGroups.GetDataType(NULL_STRING) == KvData_None) - { - continue; - } - - hGroups.GetSectionName(SZF(szKey)); - // Floats come back as "%f" (6 decimals), which is enough for feature values - hGroups.GetString(NULL_STRING, SZF(szValue)); - if (strcmp(szKey, GROUP_EXTEND_KEY, false) == 0) + do { - strcopy(szExtend, iMaxLength, szValue); - continue; + hMerged.GetSectionName(SZF(szKey)); + hMerged.GetString(NULL_STRING, SZF(szValue)); + if (szValue[0]) + { + hGroups.SetString(szKey, szValue); + } + else + { + hGroups.DeleteKey(szKey); + } } - - hValues.SetString(szKey, szValue); + while (hMerged.GotoNextKey(false)); } - while (hGroups.GotoNextKey(false)); -} -// An own "" removes the inherited value -static void Configs_CopyValues(StringMap hFrom, StringMap hTo) -{ - StringMapSnapshot hSnapshot = hFrom.Snapshot(); - char szKey[FEATURE_NAME_LENGTH], szValue[256]; - for (int i = 0; i < hSnapshot.Length; ++i) - { - hSnapshot.GetKey(i, SZF(szKey)); - hFrom.GetString(szKey, SZF(szValue)); - if (szValue[0]) - { - hTo.SetString(szKey, szValue); - } - else - { - hTo.Remove(szKey); - } - } - delete hSnapshot; + delete hMerged; + hResolved.PushString(szGroup); } -// Own "" keys are dropped -static void Configs_WriteGroupValues(KeyValues hGroups, const char[] szGroup, StringMap hValues) +// Plain values only: nested sections are not inherited +static void Configs_CopyGroupValues(KeyValues hGroups, const char[] szGroup, KeyValues hTo) { - StringMap hOwn = new StringMap(); - char szExtend[256]; - Configs_ReadGroupValues(hGroups, szGroup, hOwn, SZF(szExtend)); - hGroups.Rewind(); - if (!hGroups.JumpToKey(szGroup)) + if (!hGroups.JumpToKey(szGroup) || !hGroups.GotoFirstSubKey(false)) { - delete hOwn; return; } - hGroups.DeleteKey(GROUP_EXTEND_KEY); - - StringMapSnapshot hSnapshot = hOwn.Snapshot(); char szKey[FEATURE_NAME_LENGTH], szValue[256]; - for (int i = 0; i < hSnapshot.Length; ++i) + do { - hSnapshot.GetKey(i, SZF(szKey)); - if (!hValues.GetString(szKey, SZF(szValue))) + hGroups.GetSectionName(SZF(szKey)); + if (hGroups.GetDataType(NULL_STRING) == KvData_None || strcmp(szKey, GROUP_EXTEND_KEY, false) == 0) { - hGroups.DeleteKey(szKey); + continue; } - } - delete hSnapshot; - delete hOwn; - hSnapshot = hValues.Snapshot(); - for (int i = 0; i < hSnapshot.Length; ++i) - { - hSnapshot.GetKey(i, SZF(szKey)); - hValues.GetString(szKey, SZF(szValue)); - hGroups.SetString(szKey, szValue); + // Floats come back as "%f" (6 decimals), which is enough for feature values + hGroups.GetString(NULL_STRING, SZF(szValue)); + hTo.SetString(szKey, szValue); } - delete hSnapshot; + while (hGroups.GotoNextKey(false)); } From 0c5daf24319bd8c5ed329ba6c28460c8a37d0b14 Mon Sep 17 00:00:00 2001 From: R1KO Date: Mon, 12 Oct 2026 11:45:06 +0300 Subject: [PATCH 10/11] Point HlMod links to hlmod.net; hlmod.ru no longer resolves. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 78935e0..185e4d4 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This plugin allows you to give VIP status to players for a specific period or fo Given the plugin’s modular system, the functionality can always be expanded. The plugin is very flexible and easy to configure, it can work with both SQLite and MySQL database. -[[VIP] Core official thread on HlMod.ru](http://hlmod.ru/resources/vip-core.245/) +[[VIP] Core official thread on HlMod.net](https://hlmod.net/resources/vip-core.245/) [Documentation](https://r1ko.github.io/VIP-Core/) From 04d1500b6944a9d72ecc7ab081b0a04c6fe3927e Mon Sep 17 00:00:00 2001 From: R1KO Date: Tue, 13 Oct 2026 13:20:54 +0300 Subject: [PATCH 11/11] Bump version to 3.2.0 R. Co-Authored-By: Claude Opus 5.5 --- addons/sourcemod/scripting/VIP_Core.sp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/sourcemod/scripting/VIP_Core.sp b/addons/sourcemod/scripting/VIP_Core.sp index a1d5769..31a9e1b 100644 --- a/addons/sourcemod/scripting/VIP_Core.sp +++ b/addons/sourcemod/scripting/VIP_Core.sp @@ -7,7 +7,7 @@ #include #if !defined VIP_VERSION -#define VIP_VERSION "3.1.1 R" +#define VIP_VERSION "3.2.0 R" #endif