From fa0dfb5272edd0ffaea19c2cd192785880e178c5 Mon Sep 17 00:00:00 2001 From: Michael Oliver Date: Tue, 16 Jun 2026 10:09:09 +0100 Subject: [PATCH] feat(IW3 MP): add stats module for unlock stats functionality --- codxe.vcxproj | 2 + src/game/iw3/mp/components/stats.cpp | 372 +++++++++++++++++++++++++++ src/game/iw3/mp/components/stats.h | 21 ++ src/game/iw3/mp/main.cpp | 2 + src/game/iw3/mp/symbols.h | 4 +- 5 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 src/game/iw3/mp/components/stats.cpp create mode 100644 src/game/iw3/mp/components/stats.h diff --git a/codxe.vcxproj b/codxe.vcxproj index e9c6a4f..574163c 100644 --- a/codxe.vcxproj +++ b/codxe.vcxproj @@ -103,6 +103,7 @@ + @@ -222,6 +223,7 @@ + diff --git a/src/game/iw3/mp/components/stats.cpp b/src/game/iw3/mp/components/stats.cpp new file mode 100644 index 0000000..19759ff --- /dev/null +++ b/src/game/iw3/mp/components/stats.cpp @@ -0,0 +1,372 @@ +#include "pch.h" +#include +#include +#include "command.h" +#include "stats.h" + +namespace iw3 +{ +namespace mp +{ +namespace +{ +const char *TableLookup(const StringTable *table, int row, int column) +{ + if (!table || row < 0 || column < 0 || row >= table->rowCount || column >= table->columnCount || !table->values) + { + return ""; + } + + const char *value = table->values[row * table->columnCount + column]; + return value ? value : ""; +} + +bool TryParseInt(const char *text, int *out) +{ + if (!text || !*text) + { + return false; + } + + const char *p = text; + if (*p == '-') + { + ++p; + } + + if (*p < '0' || *p > '9') + { + return false; + } + + *out = atoi(text); + return true; +} + +bool HasText(const char *text) +{ + return text && *text; +} + +bool Equals(const char *lhs, const char *rhs) +{ + return I_stricmp(lhs, rhs) == 0; +} + +bool StartsWith(const char *text, const char *prefix) +{ + if (!text || !prefix) + { + return false; + } + + while (*prefix) + { + if (*text != *prefix) + { + return false; + } + + ++text; + ++prefix; + } + + return true; +} + +std::string ToLower(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), + [](char c) { return static_cast(std::tolower(static_cast(c))); }); + return value; +} + +const StringTable *FindStringTable(const char *name) +{ + XAssetHeader header = DB_FindXAssetHeader(ASSET_TYPE_STRINGTABLE, name); + if (header.stringTable) + { + return header.stringTable; + } + + const std::string lowerName = ToLower(name); + header = DB_FindXAssetHeader(ASSET_TYPE_STRINGTABLE, lowerName.c_str()); + return header.stringTable; +} + +void ExecuteCommand(const char *command) +{ + char commandLine[1024] = {}; + const size_t commandLength = std::strlen(command); + + if (commandLength >= sizeof(commandLine) - 1) + { + Com_Printf(CON_CHANNEL_DONT_FILTER, "unlockstats: command too long: %s\n", command); + return; + } + + std::memcpy(commandLine, command, commandLength); + commandLine[commandLength] = '\n'; + + Cbuf_ExecuteBuffer(0, 0, commandLine); +} + +void StatSet(int stat, int value) +{ + ExecuteCommand(va(const_cast("statset %i %i"), stat, value)); +} + +void BuildUnlockBitMasks(std::map &attachmentBits, int &allCamoBits) +{ + const StringTable *attachmentTable = FindStringTable("mp/attachmenttable.csv"); + if (!attachmentTable) + { + Com_Printf(CON_CHANNEL_DONT_FILTER, "unlockstats: mp/attachmenttable.csv not found\n"); + return; + } + + allCamoBits = 0; + + for (int row = 1; row < attachmentTable->rowCount; ++row) + { + const char *name = TableLookup(attachmentTable, row, 4); + int bit = 0; + if (!HasText(name) || !TryParseInt(TableLookup(attachmentTable, row, 10), &bit) || !bit) + { + continue; + } + + attachmentBits[name] = bit; + + if (Equals(TableLookup(attachmentTable, row, 2), "camo")) + { + allCamoBits |= bit; + } + } +} + +int GetAttachmentMask(const std::map &attachmentBits, const char *attachmentList) +{ + int mask = 0; + const std::map::const_iterator none = attachmentBits.find("none"); + if (none != attachmentBits.end()) + { + mask |= none->second; + } + + std::stringstream stream(attachmentList ? attachmentList : ""); + std::string token; + while (stream >> token) + { + const std::map::const_iterator bit = attachmentBits.find(token); + if (bit != attachmentBits.end()) + { + mask |= bit->second; + } + } + + return mask; +} + +bool HasCamos(const char *category) +{ + return Equals(category, "weapon_smg") || Equals(category, "weapon_assault") || Equals(category, "weapon_sniper") || + Equals(category, "weapon_shotgun") || Equals(category, "weapon_lmg"); +} + +void UnlockItems(int &weaponCount, int &itemCount) +{ + const StringTable *statsTable = FindStringTable("mp/statstable.csv"); + if (!statsTable) + { + Com_Printf(CON_CHANNEL_DONT_FILTER, "unlockstats: mp/statstable.csv not found\n"); + return; + } + + std::map attachmentBits; + int allCamoBits = 0; + BuildUnlockBitMasks(attachmentBits, allCamoBits); + + for (int row = 1; row < statsTable->rowCount; ++row) + { + int stat = 0; + if (!TryParseInt(TableLookup(statsTable, row, 1), &stat)) + { + continue; + } + + const char *category = TableLookup(statsTable, row, 2); + const char *name = TableLookup(statsTable, row, 4); + if (!HasText(category) || !HasText(name)) + { + continue; + } + + if (StartsWith(category, "weapon_")) + { + int mask = GetAttachmentMask(attachmentBits, TableLookup(statsTable, row, 8)); + if (HasCamos(category)) + { + mask |= allCamoBits; + } + + StatSet(stat, mask); + ++weaponCount; + } + else if (Equals(category, "specialty") || Equals(category, "grenade") || Equals(category, "specialgrenade") || + Equals(category, "inventory") || Equals(category, "null_specialty") || Equals(category, "feature")) + { + StatSet(stat, 1); + ++itemCount; + } + } + + for (int stat = 256; stat <= 269; ++stat) + { + StatSet(stat, 1); + } + + for (int stat = 270; stat <= 289; ++stat) + { + StatSet(stat, 1); + } +} + +void UnlockRank() +{ + const StringTable *rankTable = FindStringTable("mp/ranktable.csv"); + int maxRank = 54; + int minXp = 120280; + int maxXp = 125490; + + if (rankTable) + { + for (int row = 1; row < rankTable->rowCount; ++row) + { + if (Equals(TableLookup(rankTable, row, 0), "maxrank")) + { + TryParseInt(TableLookup(rankTable, row, 1), &maxRank); + continue; + } + + int rank = 0; + int xp = 0; + if (TryParseInt(TableLookup(rankTable, row, 0), &rank) && + TryParseInt(TableLookup(rankTable, row, 7), &xp) && xp >= maxXp) + { + maxRank = rank; + TryParseInt(TableLookup(rankTable, row, 2), &minXp); + maxXp = xp; + } + } + } + + StatSet(2301, maxXp); + StatSet(2326, 10); + StatSet(2350, maxRank); + StatSet(2351, minXp); + StatSet(2352, maxXp); + StatSet(2353, maxXp); + StatSet(251, maxRank); + StatSet(252, maxRank); +} + +void FlushChallengeGroup(int stateStat, int progressStat, int maxProgress, int &challengeCount) +{ + if (stateStat <= 0 || progressStat <= 0) + { + return; + } + + // GSC treats 255 as a completed challenge state; 1..n are active tiers. + StatSet(stateStat, 255); + StatSet(progressStat, maxProgress); + ++challengeCount; +} + +void UnlockChallengeTable(const StringTable *challengeTable, int &challengeCount) +{ + int stateStat = 0; + int progressStat = 0; + int maxProgress = 0; + + for (int row = 1; row < challengeTable->rowCount; ++row) + { + int newStateStat = 0; + if (TryParseInt(TableLookup(challengeTable, row, 2), &newStateStat)) + { + FlushChallengeGroup(stateStat, progressStat, maxProgress, challengeCount); + + stateStat = newStateStat; + TryParseInt(TableLookup(challengeTable, row, 3), &progressStat); + maxProgress = 0; + } + + if (stateStat <= 0) + { + continue; + } + + int target = 0; + if (TryParseInt(TableLookup(challengeTable, row, 4), &target) && target > maxProgress) + { + maxProgress = target; + } + } + + FlushChallengeGroup(stateStat, progressStat, maxProgress, challengeCount); +} + +void UnlockChallenges(int &challengeCount) +{ + const StringTable *challengeList = FindStringTable("mp/challengetable.csv"); + if (!challengeList) + { + Com_Printf(CON_CHANNEL_DONT_FILTER, "unlockstats: mp/challengetable.csv not found\n"); + return; + } + + for (int row = 1; row < challengeList->rowCount; ++row) + { + const char *challengeTableName = TableLookup(challengeList, row, 4); + if (!HasText(challengeTableName)) + { + continue; + } + + const StringTable *challengeTable = FindStringTable(challengeTableName); + if (!challengeTable) + { + Com_Printf(CON_CHANNEL_DONT_FILTER, "unlockstats: %s not found\n", challengeTableName); + continue; + } + + UnlockChallengeTable(challengeTable, challengeCount); + } +} + +void Cmd_UnlockStats_f() +{ + int weaponCount = 0; + int itemCount = 0; + int challengeCount = 0; + + UnlockRank(); + UnlockItems(weaponCount, itemCount); + UnlockChallenges(challengeCount); + + ExecuteCommand("updategamerprofile"); +} +} // namespace + +stats::stats() +{ + command::add("unlockstats", Cmd_UnlockStats_f); +} + +stats::~stats() +{ +} + +} // namespace mp +} // namespace iw3 diff --git a/src/game/iw3/mp/components/stats.h b/src/game/iw3/mp/components/stats.h new file mode 100644 index 0000000..b09cc43 --- /dev/null +++ b/src/game/iw3/mp/components/stats.h @@ -0,0 +1,21 @@ +#pragma once + +#include "pch.h" + +namespace iw3 +{ +namespace mp +{ +class stats : public Module +{ + public: + stats(); + ~stats(); + + const char *get_name() override + { + return "stats"; + } +}; +} // namespace mp +} // namespace iw3 diff --git a/src/game/iw3/mp/main.cpp b/src/game/iw3/mp/main.cpp index 91fd5d7..f882484 100644 --- a/src/game/iw3/mp/main.cpp +++ b/src/game/iw3/mp/main.cpp @@ -15,6 +15,7 @@ #include "components/mpsp.h" #include "components/pm.h" #include "components/scr_parser.h" +#include "components/stats.h" #include "components/sv_bots.h" #include "common/config.h" #include "main.h" @@ -134,6 +135,7 @@ IW3_MP_Plugin::IW3_MP_Plugin() RegisterModule(new pm()); RegisterModule(new mpsp()); RegisterModule(new scr_parser()); + RegisterModule(new stats()); RegisterModule(new sv_bots()); Load_MapEntsPtr_Detour = Detour(Load_MapEntsPtr, Load_MapEntsPtr_Hook); diff --git a/src/game/iw3/mp/symbols.h b/src/game/iw3/mp/symbols.h index d00e607..c5d62ca 100644 --- a/src/game/iw3/mp/symbols.h +++ b/src/game/iw3/mp/symbols.h @@ -109,8 +109,8 @@ static auto DB_EnumXAssets_FastFile = reinterpret_cast(0x8229ED48); static auto DB_FindXAssetEntry = reinterpret_cast(0x8229EB98); -static auto DB_FindXAssetHeader = - reinterpret_cast(0x822A0298); +typedef XAssetHeader (*DB_FindXAssetHeader_t)(const XAssetType type, const char *name); +static DB_FindXAssetHeader_t DB_FindXAssetHeader = reinterpret_cast(0x822A0298); static auto DB_GetAllXAssetOfType_FastFile = reinterpret_cast(0x8229E8E0);