aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Hayden K <imaciidz@gmail.com> 2025-04-04 01:02:07 +0100
committerGravatar Michael Smith <mikesmiffy128@gmail.com> 2025-04-06 20:59:36 +0100
commitb36e90b5500e3d3baa3d02d1859d39c09a728689 (patch)
tree6133f0cf55b3103c2d6ea045b99c151c87011f34
parentcb474d0e3bacd0de13c06c2e4ab107302351abdc (diff)
downloadsst-b36e90b5500e3d3baa3d02d1859d39c09a728689.tar.gz
sst-b36e90b5500e3d3baa3d02d1859d39c09a728689.zip
Fix broken behaviour in the L4D2 addon system
This should greatly improve the experience of running newest/TLS as well as custom campaigns. The bugginess in question is quite a lot to explain so there's some rather substantial exposition via code comments. The actual fixes are comparatively simple, although still a little subtle to get exactly right and took a few iterations to nail down the edge cases. Thanks to bill for helping me with the RE & assistance on writing the hooks/code and so on. I tracked down a lot of this myself, but the end result wouldn't have been possible without his help. Committers' note: I ended up wrangling this change a fair bit, as I am apparently just always wont to do, and also fixed a bug in the process, hence adding my copyright notice as well. Nonetheless, big thanks to aciidz (and bill) for doing the bulk of the *actual* hard work of figuring out how to do any of this! The actual code changes I made to the original submitted patch were relatively minor; a lot of my effort honestly went into attempting to shorten the massive wall of comment text. At the end of the day, there's still a really long comment, but it's just a lot to explain really so it is what it is. I hope it's at least somewhat understandable to a reader, anyway.
-rw-r--r--LICENCE4
-rwxr-xr-xcompile1
-rw-r--r--compile.bat1
-rw-r--r--dist/LICENCE.linux4
-rw-r--r--dist/LICENCE.windows4
-rw-r--r--gamedata/engine.txt2
-rw-r--r--src/l4daddon.c264
7 files changed, 274 insertions, 6 deletions
diff --git a/LICENCE b/LICENCE
index 8c8afa8..b3fc948 100644
--- a/LICENCE
+++ b/LICENCE
@@ -1,8 +1,8 @@
Except where otherwise noted, the following terms apply:
════════════════════════════════════════════════════════════════════════════════
Copyright © 2025 Michael Smith <mikesmiffy128@gmail.com>
-Copyright © 2024 Willian Henrique <wsimanbrazil@yahoo.com.br>
-Copyright © 2024 Hayden K <imaciidz@gmail.com>
+Copyright © 2025 Willian Henrique <wsimanbrazil@yahoo.com.br>
+Copyright © 2025 Hayden K <imaciidz@gmail.com>
Copyright © 2023 Matthew Wozniak <sirtomato999@gmail.com>
Permission to use, copy, modify, and/or distribute this software for any purpose
diff --git a/compile b/compile
index 5b55f55..a51186a 100755
--- a/compile
+++ b/compile
@@ -76,6 +76,7 @@ src="\
hud.c
inputhud.c
kvsys.c
+ l4daddon.c
l4dmm.c
l4dreset.c
l4dwarp.c
diff --git a/compile.bat b/compile.bat
index 8b0e877..20df825 100644
--- a/compile.bat
+++ b/compile.bat
@@ -88,6 +88,7 @@ setlocal DisableDelayedExpansion
:+ hud.c
:+ inputhud.c
:+ kvsys.c
+:+ l4daddon.c
:+ l4dmm.c
:+ l4dreset.c
:+ l4dwarp.c
diff --git a/dist/LICENCE.linux b/dist/LICENCE.linux
index 8991329..a9448aa 100644
--- a/dist/LICENCE.linux
+++ b/dist/LICENCE.linux
@@ -1,8 +1,8 @@
Source Speedrun Tools is released under the following copyright licence:
════════════════════════════════════════════════════════════════════════════════
Copyright © 2025 Michael Smith <mikesmiffy128@gmail.com>
-Copyright © 2024 Willian Henrique <wsimanbrazil@yahoo.com.br>
-Copyright © 2024 Hayden K <imaciidz@gmail.com>
+Copyright © 2025 Willian Henrique <wsimanbrazil@yahoo.com.br>
+Copyright © 2025 Hayden K <imaciidz@gmail.com>
Copyright © 2023 Matthew Wozniak <sirtomato999@gmail.com>
Permission to use, copy, modify, and/or distribute this software for any purpose
diff --git a/dist/LICENCE.windows b/dist/LICENCE.windows
index 1691946..59b688b 100644
--- a/dist/LICENCE.windows
+++ b/dist/LICENCE.windows
@@ -1,8 +1,8 @@
Source Speedrun Tools is released under the following copyright licence:
════════════════════════════════════════════════════════════════════════════════
Copyright © 2025 Michael Smith <mikesmiffy128@gmail.com>
-Copyright © 2024 Willian Henrique <wsimanbrazil@yahoo.com.br>
-Copyright © 2024 Hayden K <imaciidz@gmail.com>
+Copyright © 2025 Willian Henrique <wsimanbrazil@yahoo.com.br>
+Copyright © 2025 Hayden K <imaciidz@gmail.com>
Copyright © 2023 Matthew Wozniak <sirtomato999@gmail.com>
Permission to use, copy, modify, and/or distribute this software for any purpose
diff --git a/gamedata/engine.txt b/gamedata/engine.txt
index 4be0c96..9c37f6d 100644
--- a/gamedata/engine.txt
+++ b/gamedata/engine.txt
@@ -58,6 +58,8 @@ vtidx_GetEngineBuildNumber
#Portal1_5135 102
#L4D1_1005 99
#L4D1_Steam 97
+vtidx_ManageAddonsForActiveSession
+ L4D2_2147plus 179
# IGameUIFuncs
vtidx_GetDesktopResolution 5
diff --git a/src/l4daddon.c b/src/l4daddon.c
new file mode 100644
index 0000000..19c7a2f
--- /dev/null
+++ b/src/l4daddon.c
@@ -0,0 +1,264 @@
+/*
+ * Copyright © 2025 Hayden K <imaciidz@gmail.com>
+ * Copyright © 2025 Willian Henrique <wsimanbrazil@yahoo.com.br>
+ * Copyright © 2025 Michael Smith <mikesmiffy128@gmail.com>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+ * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+ * AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+ * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+ * LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+ * OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+ * PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <string.h>
+
+#include "con_.h"
+#include "engineapi.h"
+#include "errmsg.h"
+#include "feature.h"
+#include "gamedata.h"
+#include "hook.h"
+#include "intdefs.h"
+#include "langext.h"
+#include "mem.h"
+#include "os.h"
+#include "ppmagic.h"
+#include "sst.h"
+#include "vcall.h"
+#include "x86.h"
+#include "x86util.h"
+
+FEATURE("Left 4 Dead 2 addon bugfixes")
+GAMESPECIFIC(L4D2_2147plus)
+REQUIRE_GAMEDATA(vtidx_ManageAddonsForActiveSession)
+REQUIRE_GLOBAL(engclient)
+
+// Keeping this here since it will be useful for Future Addon Feature Plans™
+/*struct SAddOnMetaData { // literal psychopath capitalisation
+ char absolutePath;
+ char name;
+ int type; // (0 = content, 1 = mission, 2 = mode)
+ int unknown;
+};*/
+
+// count of how many addon metadata entries there are - this is the m_Size
+// member of s_vecAddonMetaData (which is a CUtlVector<SAddOnMetaData>)
+static int *addonvecsz;
+static char last_mission[128] = {0}, last_gamemode[128] = {0};
+static int last_addonvecsz = 0;
+static bool last_disallowaddons = false;
+
+DECL_VFUNC_DYN(void, ManageAddonsForActiveSession)
+
+// Crazy full name: FileSystem_ManageAddonsForActiveSession. Hence the acronym.
+// Note: the 4th parameter was first added in 2.2.0.4 (21 Oct 2020), but we
+// don't have to worry about that since it's cdecl (and we don't use it
+// ourselves, just pass it straight through).
+typedef void (*FS_MAFAS_func)(bool, char *, char *, bool);
+static FS_MAFAS_func orig_FS_MAFAS;
+static void hook_FS_MAFAS(bool disallowaddons, char *mission, char *gamemode,
+ bool ismutation) {
+ // At the start of a map, particularly in 2204+ in campaigns with L4D1
+ // commons, there can be a ton of hitches due to the game trying to
+ // load uncached materials as models are drawn. This FS_MAFAS function is
+ // the main cause of materials becoming uncached. When hosting a server,
+ // there are 2 calls to this function per map load: first by the server
+ // module calling CVEngineServer::ManageAddonsForActiveSession() and then by
+ // the client module calling CEngineClient::ManageAddonsForActiveSession()
+ // (non-host players only call call the latter). Omitting the second call
+ // (for partially unknown reasons) fixes most hitches, but the work done by
+ // FS_MAFAS can be omitted in a few more cases too.
+ //
+ // The function tries to evaluate which addon VPKs should be loaded in the
+ // FS based on the current gamemode, campaign and addon restrictions, adding
+ // and removing VPKs from the FS interface as necessary, and invalidating
+ // many caches (material, model and audio) to ensure proper reloading of
+ // assets when necessary. Given that enabled addons and addon restrictions
+ // should not change mid-campaign and as such the parameters given to this
+ // function should change very rarely, we can avoid unnecessary cache
+ // invalidation by checking the parameter values along with whether addons
+ // are enabled. Both disconnecting from a server and using the addon menu
+ // call FS_MAFAS to allow every enabled VPK to be loaded, so we let those
+ // calls go through. This fixes the vast majority of laggy cases without
+ // breaking anything practice.
+ //
+ // As a bonus, doing all this also seems to speed up map loads by about 1s.
+ //
+ // TODO(opt): There's one unhandled edge case reconnecting the to the same
+ // server we were just on, if the server hasn't changed maps. It's unclear
+ // why hitches still occur in this case; further research is required.
+
+ int curaddonvecsz = *addonvecsz;
+ if (curaddonvecsz != last_addonvecsz) {
+ // addons list has changed, meaning we're in the main menu. we will have
+ // already been called with null mission and/or gamemode and reset the
+ // last_ things, so update the count then call the original function.
+ last_addonvecsz = curaddonvecsz;
+ goto e;
+ }
+
+ // if we have zero addons loaded, we can skip doing anything else.
+ if (!curaddonvecsz) return;
+
+ // we have some addons, which may or may not have changed. based on the
+ // above assumption that nothing will change *during* a campaign, cache
+ // campaign and mode names try to early-exit if neither has changed. the
+ // mission string can be empty if playing a gamemode not supported by the
+ // current map (such as survival on c8m1), so always call the original in
+ // that case since we can't know whether we changed campaigns or not.
+ if (mission && gamemode && *mission) {
+ int missionlen = strlen(mission + 1) + 1;
+ int gamemodelen = strlen(gamemode);
+ if (missionlen < sizeof(last_mission) &&
+ gamemodelen < sizeof(last_gamemode)) {
+ bool canskip = disallowaddons == last_disallowaddons &&
+ !strncmp(mission, last_mission, missionlen + 1) &&
+ !strncmp(gamemode, last_gamemode, gamemodelen + 1);
+ if_hot (canskip) {
+ disallowaddons = last_disallowaddons;
+ memcpy(last_mission, mission, missionlen + 1);
+ memcpy(last_gamemode, gamemode, gamemodelen + 1);
+ return;
+ }
+ }
+ }
+
+ // If we get here, we don't know for sure whether something might have
+ // changed, so we have to assume it did; we reset our cached values to avoid
+ // any false negatives in future, and then call the original function.
+ last_disallowaddons = false;
+ last_mission[0] = '\0';
+ last_gamemode[0] = '\0';
+
+e: orig_FS_MAFAS(disallowaddons, mission, gamemode, ismutation);
+}
+
+static inline bool find_FS_MAFAS() {
+#ifdef _WIN32
+ const uchar *insns = (const uchar *)VFUNC(engclient,
+ ManageAddonsForActiveSession);
+ // CEngineClient::ManageAddonsForActiveSession just calls FS_MAFAS
+ for (const uchar *p = insns; p - insns < 32;) {
+ if (p[0] == X86_CALL) {
+ orig_FS_MAFAS = (FS_MAFAS_func)(p + 5 + mem_loads32(p + 1));
+ return true;
+ }
+ NEXT_INSN(p, "FileSystem_ManageAddonsForActiveSession function");
+ }
+#else
+#warning: TODO(linux): asm search stuff
+#endif
+ return false;
+}
+
+static inline bool find_addonvecsz(con_cmdcb show_addon_metadata_cb) {
+#ifdef _WIN32
+ const uchar *insns = (const uchar*)show_addon_metadata_cb;
+ // show_addon_metadata immediately checks if s_vecAddonMetadata.m_Size is 0,
+ // so we can just grab it from the CMP instruction
+ for (const uchar *p = insns; p - insns < 32;) {
+ if (p[0] == X86_ALUMI8S && p[1] == X86_MODRM(0, 7, 5) && p[6] == 0) {
+ addonvecsz = mem_loadptr(p + 2);
+ return true;
+ }
+ NEXT_INSN(p, "addonvecsz variable");
+ }
+#else
+#warning: TODO(linux): asm search stuff
+#endif
+ return false;
+}
+
+static void *broken_addon_check = 0;
+static uchar orig_broken_addon_check_bytes[13];
+
+enum { SMALLNOP = 9, BIGNOP = 13 };
+static inline bool nop_addon_check(int noplen) {
+ // In versions prior to 2204 (21 Oct 2020), FS_MAFAS checks if any
+ // addons are enabled before doing anything else. If no addons are enabled,
+ // then the function just returns immediately. FS_MAFAS gets called by
+ // update_addon_paths, which is run when you click 'Done' in the addons
+ // menu. This means that turning off all addons breaks everything until the
+ // game is restarted or another addon is loaded. To fix this, we just
+ // replace the CMP and JZ instructions with NOPs. Depending on the version
+ // of the code, we either have to replace 9 bytes (e.g. 2203) or 13 bytes
+ // (e.g. 2147). So, we have a 9-byte NOP followed by a 4-byte NOP and can
+ // just use the given length value.
+ static const uchar nops[] =
+ HEXBYTES(66, 0F, 1F, 84, 00, 00, 00, 00, 00, 0F, 1F, 40, 00);
+ // NOTE: always using 13 for orig even though noplen can be 9 or 13; not
+ // worth tracking that vs. just always putting 13 bytes back later.
+ // Also passing 13 to mprot just in case the instructions straddle a page
+ // boundary (unlikely, of course).
+ if_hot (os_mprot(broken_addon_check, 13, PAGE_EXECUTE_READWRITE)) {
+ memcpy(orig_broken_addon_check_bytes, broken_addon_check, 13);
+ memcpy(broken_addon_check, nops, noplen);
+ return true;
+ }
+ else {
+ errmsg_warnsys("couldn't fix broken addon check: "
+ "couldn't make make memory writable");
+ return false;
+ }
+}
+
+static inline void try_fix_broken_addon_check() {
+ uchar *insns = (uchar *)orig_FS_MAFAS;
+ for (uchar *p = insns; p - insns < 32;) {
+ if (p[0] == X86_ALUMI8S && p[1] == X86_MODRM(0, 7, 5) &&
+ mem_loadptr(p + 2) == addonvecsz) {
+ if (nop_addon_check(p[7] == X86_2BYTE ? SMALLNOP : BIGNOP)) {
+ broken_addon_check = p; // conditional so END doesn't crash!
+ }
+ return;
+ }
+ int len = x86_len(p);
+ if_cold (len == -1) {
+ errmsg_warnx("couldn't find broken addon check code: "
+ "unknown or invalid instruction");
+ return;
+ }
+ p += len;
+ }
+ return;
+}
+
+INIT {
+ struct con_cmd *show_addon_metadata = con_findcmd("show_addon_metadata");
+ if_cold (!show_addon_metadata) return FEAT_INCOMPAT; // shouldn't happen!
+ if_cold (!find_addonvecsz(show_addon_metadata->cb)) {
+ errmsg_errorx("couldn't find pointer to addon list");
+ return FEAT_INCOMPAT;
+ }
+ if_cold (!find_FS_MAFAS()) {
+ errmsg_errorx("couldn't find FileSystem_ManageAddonsForActiveSession");
+ return FEAT_INCOMPAT;
+ }
+ try_fix_broken_addon_check();
+ orig_FS_MAFAS = (FS_MAFAS_func)hook_inline((void *)orig_FS_MAFAS,
+ (void *)&hook_FS_MAFAS);
+ if_cold (!orig_FS_MAFAS) {
+ errmsg_errorsys("couldn't hook FileSystem_ManageAddonsForActiveSession");
+ return FEAT_FAIL;
+ }
+ return FEAT_OK;
+}
+
+END {
+ // TODO(opt): can this unhook be made conditional too? bill suggested it
+ // before but I don't know. maybe Hayden knows - mike
+ unhook_inline((void *)orig_FS_MAFAS);
+ if_cold (sst_userunloaded) {
+ if (broken_addon_check) {
+ memcpy(broken_addon_check, orig_broken_addon_check_bytes, 13);
+ }
+ }
+}
+
+// vi: sw=4 ts=4 noet tw=80 cc=80