From 324ebec00666aeaa45828712fcc27b70ef86350a Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Mon, 20 Nov 2023 20:22:55 +0000 Subject: Add cutscene skipping to L4D quick reset Also done with quite a lot of RE help from bill - thanks again! --- compile.bat | 3 +- gamedata/gamelib.kv | 10 +++ src/dbg.c | 40 ++++++---- src/engineapi.c | 6 +- src/engineapi.h | 1 + src/ent.c | 32 ++++++-- src/l4dmm.c | 50 ++++++++---- src/l4dmm.h | 6 ++ src/l4dreset.c | 215 ++++++++++++++++++++++++++++++++++++++++++++++++---- 9 files changed, 309 insertions(+), 54 deletions(-) diff --git a/compile.bat b/compile.bat index fb635ba..ec420ed 100644 --- a/compile.bat +++ b/compile.bat @@ -19,8 +19,7 @@ set warnings=-Wall -pedantic -Wno-parentheses -Wno-missing-braces ^ set dbg=0 :: XXX: -Og would be nice but apparently a bunch of stuff still gets inlined -:: which can be somewhat annoying so -O0 it is. Still using -Og in the linux -:: script; will need to investigate when linux is actually a thing later. +:: which can be somewhat annoying so -O0 it is. if "%dbg%"=="1" ( set cflags=-O0 -g3 set ldflags=-O0 -g3 diff --git a/gamedata/gamelib.kv b/gamedata/gamelib.kv index 653be12..8cf86d2 100644 --- a/gamedata/gamelib.kv +++ b/gamedata/gamelib.kv @@ -40,4 +40,14 @@ vtidx_Teleport { off_curtime 12 off_edicts { L4D 88 } +// IServerGameDLL +vtidx_GameFrame 4 +vtidx_GameShutdown 7 + +// CDirector +vtidx_OnGameplayStart { + L4D2 11 // note: just happens the same on linux! + L4D1 11 // TODO(linux): unknown whether or not this is the same +} + // vi: sw=4 ts=4 noet tw=80 cc=80 ft=text 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 // 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, ; 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 -- cgit v1.2.3