summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorMichael Smith <mikesmiffy128@gmail.com>2023-11-20 20:22:55 +0000
committerMichael Smith <mikesmiffy128@gmail.com>2023-12-04 19:30:09 +0000
commit324ebec00666aeaa45828712fcc27b70ef86350a (patch)
treeb4353c23d9237c3acd2bac82b9b3f923e52244d5 /src
parent44d805746e27c58f906ba73f541abcb363323a75 (diff)
Add cutscene skipping to L4D quick reset
Also done with quite a lot of RE help from bill - thanks again!
Diffstat (limited to 'src')
-rw-r--r--src/dbg.c40
-rw-r--r--src/engineapi.c6
-rw-r--r--src/engineapi.h1
-rw-r--r--src/ent.c32
-rw-r--r--src/l4dmm.c50
-rw-r--r--src/l4dmm.h6
-rw-r--r--src/l4dreset.c215
7 files changed, 298 insertions, 52 deletions
diff --git a/src/dbg.c b/src/dbg.c
index 0db23ec..1c3355b 100644
--- a/src/dbg.c
+++ b/src/dbg.c
@@ -24,9 +24,27 @@
#include "ppmagic.h"
#include "udis86.h"
+#ifdef _WIN32
+usize dbg_toghidra(const void *addr) {
+ const void *mod;
+ if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
+ GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, (ushort *)addr,
+ (HMODULE *)&mod /* please leave me alone */)) {
+ con_warn("dbg_toghidra: couldn't get base address\n");
+ return 0;
+ }
+ return (const char *)addr - (const char *)mod + 0x10000000;
+}
+#endif
+
void dbg_hexdump(const char *name, const void *p, int len) {
struct rgba nice_colour = {160, 64, 200, 255}; // a nice purple colour
- con_colourmsg(&nice_colour, "Hex dump \"%s\" (%p):", name, p);
+#ifdef _WIN32
+ con_colourmsg(&nice_colour, "Hex dump \"%s\" (%p | %p):\n", name, p,
+ (void *)dbg_toghidra(p));
+#else
+ con_colourmsg(&nice_colour, "Hex dump \"%s\" (%p):\n", name, p);
+#endif
for (const uchar *cp = p; cp - (uchar *)p < len; ++cp) {
// group into words and wrap every 8 words
switch ((cp - (uchar *)p) & 31) {
@@ -45,23 +63,15 @@ void dbg_asmdump(const char *name, const void *p, int len) {
ud_set_mode(&udis, 32);
ud_set_input_buffer(&udis, p, len);
ud_set_syntax(&udis, UD_SYN_INTEL);
- con_colourmsg(&nice_colour, "Disassembly \"%s\" (%p)\n", name, p);
+#ifdef _WIN32
+ con_colourmsg(&nice_colour, "Disassembly \"%s\" (%p | %p):\n", name, p,
+ (void *)dbg_toghidra(p));
+#else
+ con_colourmsg(&nice_colour, "Disassembly \"%s\" (%p):\n", name, p);
+#endif
while (ud_disassemble(&udis)) {
con_colourmsg(&nice_colour, " %s\n", ud_insn_asm(&udis));
}
}
-#ifdef _WIN32
-usize dbg_toghidra(const void *addr) {
- const void *mod;
- if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
- GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, (ushort *)addr,
- (HMODULE *)&mod /* please leave me alone */)) {
- con_warn("dbg_toghidra: couldn't get base address\n");
- return 0;
- }
- return (const char *)addr - (const char *)mod + 0x10000000;
-}
-#endif
-
// vi: sw=4 ts=4 noet tw=80 cc=80
diff --git a/src/engineapi.c b/src/engineapi.c
index fe34f64..768510d 100644
--- a/src/engineapi.c
+++ b/src/engineapi.c
@@ -37,6 +37,8 @@ ifacefactory factory_client = 0, factory_server = 0, factory_engine = 0,
struct VEngineClient *engclient;
struct VEngineServer *engserver;
+void *srvdll;
+
DECL_VFUNC(void *, GetGlobalVars, 1) // seems to be very stable, thank goodness
void *globalvars;
@@ -47,8 +49,6 @@ DECL_VFUNC_DYN(void *, GetAllServerClasses)
#include <entpropsinit.gen.h> // generated by build/mkentprops.c
-static void *srvdll;
-
bool engineapi_init(int pluginver) {
if (!con_detect(pluginver)) return false;
pluginhandler = factory_engine("ISERVERPLUGINHELPERS001", 0);
@@ -78,7 +78,7 @@ bool engineapi_init(int pluginver) {
vgui = factory_engine("VEngineVGui001", 0);
// TODO(compat): add this back when there's gamedata for 009 (no point atm)
- /*if (srvdll = factory_engine("ServerGameDLL009", 0)) {
+ /*if (srvdll = factory_server("ServerGameDLL009", 0)) {
_gametype_tag |= _gametype_tag_SrvDLL009;
}*/
if (srvdll = factory_server("ServerGameDLL005", 0)) {
diff --git a/src/engineapi.h b/src/engineapi.h
index 44366f5..4489e21 100644
--- a/src/engineapi.h
+++ b/src/engineapi.h
@@ -133,6 +133,7 @@ struct ServerClass {
extern struct VEngineClient *engclient;
extern struct VEngineServer *engserver;
+extern void *srvdll;
extern void *globalvars;
extern void *inputsystem, *vgui;
diff --git a/src/ent.c b/src/ent.c
index 6329152..261db25 100644
--- a/src/ent.c
+++ b/src/ent.c
@@ -97,12 +97,32 @@ static inline ctor_func findctor(const struct CEntityFactory *factory,
const char *classname) {
#ifdef _WIN32
const uchar *insns = (const uchar *)factory->vtable->Create;
- // every Create() method follows the same pattern. after calling what is
- // presumably operator new(), it copies the return value from EAX into ECX
- // and then calls the constructor.
+ // mostly every Create() method follows the same pattern. after calling what
+ // is presumably operator new(), it copies the return value from a register
+ // into ECX and then calls the constructor.
+ //
+ // there have also been some thunky-looking ones, which we attempt to
+ // resolve by following the first call if we bump into a ret before anything
+ // else. this is depth-limited to prevent things getting out of hand.
+ const uchar *seencall = 0;
+ int depth = 3;
for (const uchar *p = insns; p - insns < 32;) {
- if (p[0] == X86_MOVRMW && p[1] == 0xC8 && p[2] == X86_CALL) {
- return (ctor_func)(p + 7 + mem_loadoffset(p + 3));
+ if (!seencall && p[0] == X86_CALL) {
+ seencall = p;
+ }
+ else {
+ if (p[0] == X86_MOVRMW && (p[1] & 0xF8) == 0xC8
+ && p[2] == X86_CALL) {
+ return (ctor_func)(p + 7 + mem_loadoffset(p + 3));
+ }
+ if (p[0] == X86_RET || p[0] == X86_RETI16) {
+ if (seencall && --depth) {
+ p = seencall + 5 + mem_loadoffset(seencall + 1); insns = p;
+ seencall = 0;
+ continue;
+ }
+ return false;
+ }
}
// duping NEXT_INSN macro here in the name of a nicer message
int len = x86_len(p);
@@ -127,7 +147,7 @@ void **ent_findvtable(const struct CEntityFactory *factory,
const uchar *insns = (const uchar *)ctor;
// the constructor itself should do *(void**)this = &vtable; almost right
// away, so look for the first immediate load into indirect register
- for (const uchar *p = insns; p - insns < 24;) {
+ for (const uchar *p = insns; p - insns < 32;) {
if (p[0] == X86_MOVMIW && (p[1] & 0xF8) == 0) return mem_loadptr(p + 2);
int len = x86_len(p);
if (len == -1) {
diff --git a/src/l4dmm.c b/src/l4dmm.c
index d391584..2439d6b 100644
--- a/src/l4dmm.c
+++ b/src/l4dmm.c
@@ -54,6 +54,7 @@ static union { // space saving
#define oldmmiface U.oldmmiface
#define sym_game U.sym_game
#define sym_campaign U.sym_campaign
+static int sym_chapter;
static char campaignbuf[32];
const char *l4dmm_curcampaign(void) {
@@ -61,16 +62,15 @@ const char *l4dmm_curcampaign(void) {
if (!matchfwk) { // we must have oldmmiface, then
struct contextval *ctxt = unknown_contextlookup(oldmmiface,
"CONTEXT_L4D_CAMPAIGN");
- if (ctxt) {
- // HACK: since this context symbol stuff was the best that was found
- // for this old MM interface, just map things back to their names
- // manually. bit stupid, but it gets the (rather difficult) job done
- if (strncmp(ctxt->val, "CONTEXT_L4D_CAMPAIGN_", 21)) return 0;
- if (!strcmp(ctxt->val + 21, "APARTMENTS")) return "Hospital";
- if (!strcmp(ctxt->val + 21, "CAVES")) return "SmallTown";
- if (!strcmp(ctxt->val + 21, "GREENHOUSE")) return "Airport";
- if (!strcmp(ctxt->val + 21, "HILLTOP")) return "Farm";
- }
+ if (!ctxt) return 0;
+ // HACK: since this context symbol stuff was the best that was found for
+ // this old MM interface, just map things back to their names manually.
+ // bit stupid, but it gets the (rather difficult) job done
+ if (strncmp(ctxt->val, "CONTEXT_L4D_CAMPAIGN_", 21)) return 0;
+ if (!strcmp(ctxt->val + 21, "APARTMENTS")) return "Hospital";
+ if (!strcmp(ctxt->val + 21, "CAVES")) return "SmallTown";
+ if (!strcmp(ctxt->val + 21, "GREENHOUSE")) return "Airport";
+ if (!strcmp(ctxt->val + 21, "HILLTOP")) return "Farm";
return 0;
}
#endif
@@ -94,6 +94,30 @@ const char *l4dmm_curcampaign(void) {
return ret;
}
+bool l4dmm_firstmap(void) {
+#ifdef _WIN32
+ if (!matchfwk) { // we must have oldmmiface, then
+ struct contextval *ctxt = unknown_contextlookup(oldmmiface,
+ "CONTEXT_L4D_LEVEL");
+ if (!ctxt) return 0;
+ if (strncmp(ctxt->val, "CONTEXT_L4D_LEVEL_", 18)) return false;
+ return !strcmp(ctxt->val + 18, "APARTMENTS") ||
+ !strcmp(ctxt->val + 18, "CAVES") ||
+ !strcmp(ctxt->val + 18, "GREENHOUSE") ||
+ !strcmp(ctxt->val + 18, "HILLTOP");
+ }
+#endif
+ void *ctrlr = GetMatchNetworkMsgController(matchfwk);
+ struct KeyValues *kv = GetActiveGameServerDetails(ctrlr, 0);
+ if (!kv) return false;
+ int chapter = 0;
+ struct KeyValues *subkey = kvsys_getsubkey(kv, sym_game);
+ if (subkey) subkey = kvsys_getsubkey(subkey, sym_chapter);
+ if (subkey) chapter = subkey->ival;
+ kvsys_free(kv);
+ return chapter == 1;
+}
+
INIT {
void *mmlib = os_dlhandle(OS_LIT("matchmaking") OS_LIT(OS_DLSUFFIX));
if (mmlib) {
@@ -109,17 +133,15 @@ INIT {
}
sym_game = kvsys_strtosym("game");
sym_campaign = kvsys_strtosym("campaign");
+ sym_chapter = kvsys_strtosym("chapter");
}
+#ifdef _WIN32 // L4D1 has no Linux build, btw!
else {
-#ifdef _WIN32
oldmmiface = factory_engine("VENGINE_MATCHMAKING_VERSION001", 0);
if (!oldmmiface) {
errmsg_errorx("couldn't get IMatchmaking interface");
return false;
}
-#else // Linux L4D1 has always used the separate matchmaking library
- errmsg_errordl("couldn't get matchmaking library");
- return false;
#endif
}
return true;
diff --git a/src/l4dmm.h b/src/l4dmm.h
index 2897e17..7af0982 100644
--- a/src/l4dmm.h
+++ b/src/l4dmm.h
@@ -27,6 +27,12 @@
*/
const char *l4dmm_curcampaign(void);
+/*
+ * Returns true if the current map is known to be the first map of a campaign,
+ * false otherwise.
+ */
+bool l4dmm_firstmap(void);
+
#endif
// vi: sw=4 ts=4 noet tw=80 cc=80
diff --git a/src/l4dreset.c b/src/l4dreset.c
index 2245a16..2477b8b 100644
--- a/src/l4dreset.c
+++ b/src/l4dreset.c
@@ -21,12 +21,16 @@
#include "engineapi.h"
#include "ent.h"
#include "errmsg.h"
+#include "event.h"
+#include "fastfwd.h"
#include "feature.h"
#include "gamedata.h"
#include "gametype.h"
+#include "hook.h"
#include "intdefs.h"
#include "l4dmm.h"
#include "mem.h"
+#include "sst.h"
#include "vcall.h"
#include "x86.h"
#include "x86util.h"
@@ -37,12 +41,19 @@
FEATURE("Left 4 Dead quick resetting")
REQUIRE(ent)
+REQUIRE(fastfwd)
REQUIRE(l4dmm)
+REQUIRE_GLOBAL(srvdll)
+REQUIRE_GAMEDATA(vtidx_GameFrame) // note: for L4D1 only, always defined anyway
+REQUIRE_GAMEDATA(vtidx_GameShutdown)
+REQUIRE_GAMEDATA(vtidx_OnGameplayStart)
static void **votecontroller;
static int off_callerrecords = -1;
static int off_voteissues;
+static void *director; // "TheDirector" server global
+
// Note: the vote callers vector contains these as elements. We don't currently
// do anything with the structure, but we're keeping it here for reference.
/*struct CallerRecord {
@@ -74,7 +85,7 @@ static struct CVoteIssue *getissue(const char *textkey) {
}
}
-static void reset(void) {
+static inline void reset(void) {
// reset the vote cooldowns if possible (will skip L4D1). only necessary on
// versions >2045 and on map 1, but it's easiest to do unconditionally
if (off_callerrecords != -1) {
@@ -84,16 +95,90 @@ static void reset(void) {
off_callerrecords);
recordvector->sz = 0;
}
- struct CVoteIssue *issue = getissue("#L4D_vote_restart_game");
- ExecuteCommand(issue);
+ ExecuteCommand(getissue("#L4D_vote_restart_game"));
}
-static void change(const char *missionid) {
+static inline void change(const char *missionid) {
struct CVoteIssue *issue = getissue("#L4D_vote_mission_change");
SetIssueDetails(issue, missionid); // will just nop if invalid
ExecuteCommand(issue);
}
+static short ffdelay = 0, ffamt;
+static bool mapchanging = false;
+
+HANDLE_EVENT(Tick, bool simulating) {
+ if (!mapchanging && ffdelay && !--ffdelay) fastfwd(ffamt, 30);
+}
+
+typedef void (*VCALLCONV OnGameplayStart_func)(void *this);
+static OnGameplayStart_func orig_OnGameplayStart;
+static void VCALLCONV hook_OnGameplayStart(void *this) {
+ orig_OnGameplayStart(this);
+ if (mapchanging) reset(); // prevent bots walking around. note ffdelay is 45
+ mapchanging = false; // resume countdown!
+}
+// Simply reuse the above for L4D1, since the calling ABI is the exact same!
+#define UnfreezeTeam_func OnGameplayStart_func
+#define UnfreezeTeam OnGameplayStart
+#define orig_UnfreezeTeam orig_OnGameplayStart
+#define hook_UnfreezeTeam hook_OnGameplayStart
+
+static inline int getffamt(const char *campaign) {
+ // TODO(compat): add popular custom campaigns too:
+ // - dark blood 2
+ // - grey scale
+ // - left 4 mario
+ // - ravenholm
+ // - warcelona
+ // - tour of terror
+ // - dam it
+ // - carried off
+ if (GAMETYPE_MATCHES(L4D1)) {
+ if (!strcmp(campaign, "Hospital")) return 9; // No Mercy
+ if (!strcmp(campaign, "SmallTown")) return 12; // Death Toll
+ if (!strcmp(campaign, "Airport")) return 12; // Dead Air
+ if (!strcmp(campaign, "Farm")) return 15; // Blood Harvest
+ if (!strcmp(campaign, "River")) return 15; // The Sacrifice
+ if (!strcmp(campaign, "Garage")) return 8; // Crash Course
+ }
+ else /* L4D2 */ {
+ if (!strncmp(campaign, "L4D2C", 5)) {
+ int ret;
+ switch (campaign[5]) {
+ case '1':
+ switch (campaign[6]) {
+ case '\0': return 13; // Dead Center
+ case '0': ret = 12; break; // Death Toll
+ case '1': ret = 13; break; // Dead Air
+ case '2': ret = 16; break; // Blood Harvest
+ case '3': ret = 18; break; // Cold Stream
+ case '4': ret = 16; break; // The Last Stand
+ default: return 0;
+ }
+ if (campaign[7]) return 0;
+ return ret;
+ case '2': ret = 12; break; // Dark Carnival
+ case '3': ret = 19; break; // Swamp Fever
+ case '4': ret = 8; break; // Hard Rain
+ case '5': ret = 13; break; // The Parish
+ case '6': ret = 10; break; // The Passing
+ case '7': ret = 15; break; // The Sacrifice
+ case '8': ret = 9; break; // No Mercy
+ case '9': ret = 8; break; // Crash Course
+ default: return 0;
+ }
+ if (campaign[6]) return 0;
+ return ret;
+ }
+ }
+ return 0; // if unknown, just don't skip, I guess.
+}
+
+DEF_CVAR_UNREG(sst_l4d_quickreset_fastfwd,
+ "Fast-forward through cutscenes when quick-resetting", 1,
+ CON_ARCHIVE | CON_HIDDEN)
+
DEF_CCMD_HERE_UNREG(sst_l4d_quickreset,
"Reset (or switch) campaign and clear all vote cooldowns", 0) {
if (cmd->argc > 2) {
@@ -104,17 +189,29 @@ DEF_CCMD_HERE_UNREG(sst_l4d_quickreset,
con_warn("not hosting a server\n");
return;
}
- if (cmd->argc == 2) {
- const char *cur = l4dmm_curcampaign();
- if (!cur || strcasecmp(cur, cmd->argv[1])) {
- change(cmd->argv[1]);
- return;
- }
+ const char *campaign = l4dmm_curcampaign();
+ if (cmd->argc == 2 && (!campaign || strcasecmp(campaign, cmd->argv[1]))) {
+ change(cmd->argv[1]);
+ campaign = cmd->argv[1];
+ mapchanging = true;
+ }
+ else {
+ reset();
+ // same-map reset is delayed by about a second - save that time here
+ // also, set mapchanging back to false in case it got stuck somehow
+ if (!(mapchanging = !l4dmm_firstmap())) fastfwd(0.8, 10);
+ }
+ if (campaign && con_getvari(sst_l4d_quickreset_fastfwd) &&
+ (ffamt = getffamt(campaign))) {
+ ffdelay = 45; // 1.5s
}
- reset();
}
-PREINIT { return GAMETYPE_MATCHES(L4D); }
+PREINIT {
+ if (!GAMETYPE_MATCHES(L4D)) return false;
+ con_reg(sst_l4d_quickreset_fastfwd);
+ return true;
+}
// Note: this returns a pointer to subsequent bytes for find_voteissues() below
static inline const uchar *find_votecontroller(con_cmdcbv1 listissues_cb) {
@@ -191,6 +288,52 @@ static inline bool find_votecallers(void *votectrlspawn) {
return false;
}
+#ifdef _WIN32
+static inline bool find_TheDirector(void *GameShutdown) {
+ // in 2045, literally the first instruction of this function is loading
+ // TheDirector into ECX. although, do the usual search in case moves a bit.
+ const uchar *insns = (const uchar *)GameShutdown;
+ for (const uchar *p = insns; p - insns < 24;) {
+ if (p[0] == X86_MOVRMW && p[1] == X86_MODRM(0, 1, 5)) {
+ void **indirect = mem_loadptr(p + 2);
+ director = *indirect;
+ return true;
+ }
+ NEXT_INSN(p, "load of TheDirector");
+ }
+ return false;
+}
+#endif
+
+static inline bool find_UnfreezeTeam(void *GameFrame) { // note: L4D1 only
+ // CServerGameDLL::GameFrame() loads TheDirector into ECX and then calls
+ // Director::Update()
+ const uchar *insns = (const uchar *)GameFrame, *p = insns;
+ while (p - insns < 192) {
+ if (p[0] == X86_MOVRMW && p[1] == X86_MODRM(0, 1, 5) &&
+ mem_loadptr(mem_loadptr(p + 2)) == director &&
+ p[6] == X86_CALL) {
+ p += 11 + mem_loadoffset(p + 7);
+ insns = p;
+ goto ok;
+ }
+ NEXT_INSN(p, "Director::Update call");
+ }
+ return false;
+ok: // Director::Update calls UnfreezeTeam after the first jmp instruction
+ while (p - insns < 96) {
+ // jz XXX; mov ecx, <reg>; call Director::UnfreezeTeam
+ if (p[0] == X86_JZ && p[2] == X86_MOVRMW && (p[3] & 0xF8) == 0xC8 &&
+ p[4] == X86_CALL) {
+ p += 9 + mem_loadoffset(p + 5);
+ orig_UnfreezeTeam = (UnfreezeTeam_func)p;
+ return true;
+ }
+ NEXT_INSN(p, "Director::UnfreezeTeam call");
+ }
+ return false;
+ }
+
INIT {
struct con_cmd *cmd_listissues = con_findcmd("listissues");
if (!cmd_listissues) {
@@ -207,8 +350,41 @@ INIT {
errmsg_errorx("couldn't find vote issues list offset\n");
return false;
}
+ void **vtable;
+#ifdef _WIN32
+ void *GameShutdown = (*(void ***)srvdll)[vtidx_GameShutdown];
+ if (!find_TheDirector(GameShutdown)) {
+ errmsg_errorx("couldn't find TheDirector variable");
+ return false;
+ }
+#else
+#warning TODO(linux): should be able to just dlsym(server, "TheDirector")
+ return false;
+#endif
+#ifdef _WIN32 // L4D1 has no Linux build, no need to check whether L4D2
+ if (GAMETYPE_MATCHES(L4D2)) {
+#endif
+ vtable = mem_loadptr(director);
+ if (!os_mprot(vtable + vtidx_OnGameplayStart, sizeof(*vtable),
+ PAGE_READWRITE)) {
+ errmsg_errorsys("couldn't make virtual table writable");
+ return false;
+ }
+ orig_OnGameplayStart = (OnGameplayStart_func)hook_vtable(vtable,
+ vtidx_OnGameplayStart, (void *)&hook_OnGameplayStart);
+#ifdef _WIN32 // L4D1 has no Linux build!
+ }
+ else /* L4D1 */ {
+ void *GameFrame = (*(void ***)srvdll)[vtidx_GameFrame];
+ if (!find_UnfreezeTeam(GameFrame)) {
+ errmsg_errorx("couldn't find UnfreezeTeam function");
+ return false;
+ }
+ orig_UnfreezeTeam = (UnfreezeTeam_func)hook_inline(
+ (void *)orig_UnfreezeTeam, (void *)&hook_UnfreezeTeam);
+ }
+#endif
// Only try cooldown stuff for L4D2, since L4D1 always had unlimited votes.
- // NOTE: assuming L4D2 always has Spawn in gamedata (why wouldn't it?)
if (GAMETYPE_MATCHES(L4D2)) {
// g_voteController is invalid if not running a server so get the
// vtable by inspecting the ent factory code instead
@@ -217,7 +393,7 @@ INIT {
errmsg_errorx("couldn't find vote controller entity factory");
goto nocd;
}
- void **vtable = ent_findvtable(factory, "CVoteController");
+ vtable = ent_findvtable(factory, "CVoteController");
if (!vtable) {
errmsg_errorx("couldn't find CVoteController vtable");
goto nocd;
@@ -228,7 +404,18 @@ nocd: errmsg_note("resetting a first map will not clear vote cooldowns");
}
}
con_reg(sst_l4d_quickreset);
+ sst_l4d_quickreset_fastfwd->base.flags &= ~CON_HIDDEN;
return true;
}
+END {
+ if (GAMETYPE_MATCHES(L4D2)) {
+ unhook_vtable(mem_loadptr(director), vtidx_OnGameplayStart,
+ (void *)orig_OnGameplayStart);
+ }
+ else {
+ unhook_inline((void *)orig_UnfreezeTeam);
+ }
+}
+
// vi: sw=4 ts=4 noet tw=80 cc=80