From bef2e6bda3eedcf0abfca836b7c847861361eb54 Mon Sep 17 00:00:00 2001 From: JeanxPereira Date: Mon, 13 Jul 2026 21:10:23 -0300 Subject: [PATCH] fix(dev-console): register telnet connections + echo command output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes make the reactivated dev console actually usable end-to-end: - Poll TelnetTransport +0x1c/+0x20 each frame before draining commands. GetNextCommand (+0x14) only accumulates keystrokes for a client whose per-connection line-editor state is registered, and only PollNewConnection (+0x1c) allocates+registers it (and sends IAC WILL-ECHO). Without it a TCP client connected but every byte was discarded — no command ever executed. - Mirror AppConsole output back to the client. Detours FUN_00866230 (the registry's line broadcast) and forwards each line to the active connection via the transport's own Send (vtable +0xc), then calls through. The hook is installed on the main thread in StartOnce, NOT in DevConsoleInit — a Detours commit under the DllMain loader lock prevents the game from launching. Proven in-game: connect over telnet, `help` lists the registered cheats, `toggleCaptureUI`/`prop`/etc. run and their output returns over the socket. --- Source/Recap/ReCapDevConsole.cpp | 82 ++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/Source/Recap/ReCapDevConsole.cpp b/Source/Recap/ReCapDevConsole.cpp index 1839bf9..6c466cd 100644 --- a/Source/Recap/ReCapDevConsole.cpp +++ b/Source/Recap/ReCapDevConsole.cpp @@ -4,10 +4,12 @@ // own ArgScript command executor. Flow, all on the game's main thread: // 1. one-shot: construct a TelnetTransport, then ConsoleServer::SetTransport(&console, t, port) // — this opens a real listen socket (socket/bind/listen + accept thread), synchronously. -// 2. each frame: drain the transport's completed command lines and run each through the game's -// command executor (registry->ExecuteLine), the exact call the retail localCheats.txt path -// makes. A raw TCP client connects, the transport echoes typed chars locally; on Enter the -// line executes. Command effects are visible in-game (server->client text echo is a follow-up). +// 2. each frame: poll the transport for new/dropped connections (registers each client's +// line-editor state — a prerequisite for the transport to accumulate its keystrokes at all), +// then drain the completed command lines and run each through the game's command executor +// (registry->ExecuteLine), the exact call the retail localCheats.txt path makes. A raw TCP +// client connects, the transport echoes typed chars locally; on Enter the line executes. +// Command effects are visible in-game (server->client text echo is a follow-up). // // All client addresses are offsets from the image base — Darkspore.exe has no ASLR (base 0x400000), // and we resolve GetModuleHandle(NULL)+offset the same way ReCapHooks.cpp resolves its cert thunks. @@ -15,6 +17,7 @@ // docs/architecture/research/CONSOLE_REACTIVATION_FEASIBILITY.md. #include +#include #include #include @@ -29,10 +32,22 @@ constexpr unsigned kOff_TelnetTransportCtor = 0x006b6250u; // FUN_00ab6250(this) constexpr unsigned kOff_SetTransport = 0x006ad990u; // ConsoleServer::SetTransport(this, transport, u16 port) constexpr unsigned kOff_GetCmdRegistry = 0x003b3760u; // AppCommandRegistry::GetInstance() -> singleton constexpr unsigned kOff_ExecuteLine = 0x004675e0u; // registry vtbl+0x20: ExecuteLine(this, const char* line) +constexpr unsigned kOff_ConsolePrint = 0x00466230u; // AppConsole broadcast: FUN_00866230(registry this, const char* line) // TelnetTransport (vtable 0x0103c8dc) virtual slots the retail ProcessCommand uses to pull lines. constexpr unsigned kVt_GetNextCommand = 0x14u; // () -> cmd object, or null when the queue is empty constexpr unsigned kVt_PopCommand = 0x18u; // (cmd) release a handled command +// Connection lifecycle slots. GetNextCommand ONLY accumulates typed bytes for a client whose +// per-connection line-editor state (a 0x100c block) has been registered — and ONLY PollNewConnection +// allocates+registers it (FUN_00ab6960) and sends the IAC WILL-ECHO negotiation. Without polling it +// each frame, a TCP client connects but GetNextCommand discards its bytes and never yields a command. +// PollDisconnect (FUN_00ab6ac0) reaps the state when the client drops. Both: (this, SystemAddress* out). +constexpr unsigned kVt_PollNewConnection = 0x1cu; +constexpr unsigned kVt_PollDisconnect = 0x20u; +// Send text back to a connected client — the retail ProcessCommand routes parser output through this +// (transport, connLo, connHi, printf-fmt, ...). Variadic, so MSVC lowers it to __cdecl with `this` +// pushed as the first stack arg, NOT ECX. +constexpr unsigned kVt_Send = 0x0cu; // Command object fields, as read by the retail ProcessCommand (local_8[6]/local_8[8]). constexpr unsigned kCmd_Len = 0x18u; // int length of the typed line constexpr unsigned kCmd_Buffer = 0x20u; // char* the typed line bytes (not necessarily NUL-terminated) @@ -43,6 +58,11 @@ typedef void* (__cdecl* GetRegistry_t)(void); typedef unsigned (__thiscall* ExecuteLine_t)(void* self, const char* line); typedef void* (__thiscall* TGetCmd_t)(void* transport); typedef void (__thiscall* TPop_t)(void* transport, void* cmd); +typedef void* (__thiscall* TPoll_t)(void* transport, void* outSystemAddress); +typedef void (__cdecl* TSend_t)(void* transport, unsigned connLo, unsigned connHi, const char* fmt, ...); +// FUN_00866230 is __thiscall(registry, line); model it as __fastcall with a dummy EDX so the trampoline +// receives `registry` in ECX and `line` from the stack. +typedef void (__fastcall* ConsolePrint_t)(void* registry, void* edx, const char* line); unsigned char* g_base = nullptr; bool g_enabled = false; @@ -50,6 +70,14 @@ unsigned g_port = 0; bool g_started = false; DWORD g_thread = 0; +// Echo state: the SystemAddress of the client whose command we are currently running, cached from the +// command object each drain so the output hook (which only receives the text) knows where to send it. +unsigned g_connLo = 0; +unsigned g_connHi = 0; +bool g_haveConn = false; +ConsolePrint_t Real_ConsolePrint = nullptr; +void __fastcall Hook_ConsolePrint(void* registry, void* edx, const char* line); // defined below + // A ConsoleServer only needs {transport@0, parserArray@4, parserCount@8}; a zeroed static block is // a valid empty ConsoleServer (proven live). The TelnetTransport ctor zero-inits its own fields; // Open() lazily allocates its internal TCPInterface via the game's allocator. @@ -75,6 +103,19 @@ unsigned SafeExecute(const char* line) } } +// Mirror AppConsole output to the telnet client. MUST run on the game's main thread post-boot (Detours +// suspends threads + rewrites a prologue — illegal under the DllMain loader lock), so it lives here, not +// in DevConsoleInit. +void InstallEchoHook() +{ + Real_ConsolePrint = At(kOff_ConsolePrint); + DetourTransactionBegin(); + DetourUpdateThread(GetCurrentThread()); + DetourAttach(&(PVOID&)Real_ConsolePrint, reinterpret_cast(Hook_ConsolePrint)); + LONG err = DetourTransactionCommit(); + recap::MbLogf("[DevConsole] output-echo hook %s (err=%ld)", err == NO_ERROR ? "installed" : "FAILED", err); +} + // Bind the console: construct the transport, then SetTransport opens the listen socket + accept // thread synchronously. Returns true if SetTransport stored the transport into the console slot. bool StartOnce() @@ -82,9 +123,32 @@ bool StartOnce() void* t = At(kOff_TelnetTransportCtor)(g_transportObj); if (!t) t = g_transportObj; At(kOff_SetTransport)(g_console, t, g_port); + InstallEchoHook(); return *reinterpret_cast(g_console) == t; } +// Forward one console line to the current telnet client via the transport's own send path. Isolated +// with NO C++ objects so __try/__except is legal — a stale/closed connection must not fault the game. +void SendToTelnet(const char* line) +{ + if (!g_haveConn || !line) return; + __try { + void* t = *reinterpret_cast(g_console); + if (!t) return; + void** vt = *reinterpret_cast(t); + reinterpret_cast(vt[kVt_Send / 4])(t, g_connLo, g_connHi, "%s", line); + } + __except (EXCEPTION_EXECUTE_HANDLER) {} +} + +// AppConsole broadcast hook: mirror every line the game prints to the connected telnet client, then let +// the original run so in-game sinks still receive it. Probes whether command output flows through here. +void __fastcall Hook_ConsolePrint(void* registry, void* edx, const char* line) +{ + SendToTelnet(line); + Real_ConsolePrint(registry, edx, line); +} + void Drain() { void* t = *reinterpret_cast(g_console); // the bound TelnetTransport @@ -93,10 +157,20 @@ void Drain() TGetCmd_t getCmd = reinterpret_cast(vt[kVt_GetNextCommand / 4]); TPop_t popCmd = reinterpret_cast(vt[kVt_PopCommand / 4]); + // Register any newly-connected client (allocates its line-editor state + sends IAC WILL-ECHO) and + // reap any that dropped. GetNextCommand yields nothing for an unregistered client, so this MUST run + // before the drain loop. Both are no-ops on a frame with no connect/disconnect pending — safe/cheap. + alignas(8) char addr[16]; + reinterpret_cast(vt[kVt_PollNewConnection / 4])(t, addr); + reinterpret_cast(vt[kVt_PollDisconnect / 4])(t, addr); + for (int guard = 0; guard < 64; ++guard) // bounded: the queue drains to null each frame { void* cmd = getCmd(t); if (!cmd) break; + g_connLo = *reinterpret_cast(static_cast(cmd)); // command's SystemAddress, + g_connHi = *reinterpret_cast(static_cast(cmd) + 4); // valid while we run it + g_haveConn = true; int len = *reinterpret_cast(static_cast(cmd) + kCmd_Len); char* buf = *reinterpret_cast(static_cast(cmd) + kCmd_Buffer); if (len > 0 && buf)