diff --git a/codxe.vcxproj b/codxe.vcxproj
index 902aba34..fba6c513 100644
--- a/codxe.vcxproj
+++ b/codxe.vcxproj
@@ -174,6 +174,7 @@
+
diff --git a/src/game/iw5/mp/console.cpp b/src/game/iw5/mp/console.cpp
new file mode 100644
index 00000000..f2bfb56e
--- /dev/null
+++ b/src/game/iw5/mp/console.cpp
@@ -0,0 +1,253 @@
+#include "pch.h"
+#include "console.h"
+
+namespace iw5
+{
+namespace mp
+{
+namespace
+{
+const DWORD keyboard_user_index = 0;
+const DWORD keyboard_flags = XINPUT_FLAG_KEYBOARD;
+const int max_keystrokes_per_frame = 16;
+
+const int max_lines = 128;
+const int max_line_length = 160;
+const int visible_lines = 16;
+const int max_input_length = 200;
+
+// Layout in 640x480 virtual space (HORIZONTAL_ALIGN_LEFT / VERTICAL_ALIGN_TOP)
+const int horz_align_left = 1;
+const int vert_align_top = 1;
+const float text_x = 12.0f;
+const float text_y = 24.0f;
+const float line_height = 11.0f;
+const float text_scale = 0.22f;
+
+const float color_output[4] = {1.0f, 1.0f, 1.0f, 0.9f};
+const float color_input[4] = {1.0f, 1.0f, 0.4f, 1.0f};
+const float color_hint[4] = {0.7f, 0.7f, 0.7f, 0.8f};
+
+CRITICAL_SECTION lines_lock;
+char lines[max_lines][max_line_length];
+int line_next = 0;
+int line_count = 0;
+int scroll_offset = 0;
+
+bool console_open = false;
+char input_line[max_input_length + 1];
+int input_length = 0;
+
+void AddLine(const char *text, int length)
+{
+ if (length >= max_line_length)
+ length = max_line_length - 1;
+
+ EnterCriticalSection(&lines_lock);
+ memcpy(lines[line_next], text, length);
+ lines[line_next][length] = '\0';
+ line_next = (line_next + 1) % max_lines;
+ if (line_count < max_lines)
+ line_count++;
+ LeaveCriticalSection(&lines_lock);
+}
+
+// CL_ConsolePrint text can hold several lines and usually ends with '\n'
+void AddText(const char *txt)
+{
+ if (!txt)
+ return;
+
+ const char *start = txt;
+ for (const char *p = txt;; ++p)
+ {
+ if (*p == '\n' || *p == '\0')
+ {
+ if (p > start)
+ AddLine(start, static_cast(p - start));
+
+ if (*p == '\0')
+ break;
+
+ start = p + 1;
+ }
+ }
+}
+
+void ExecuteInput()
+{
+ if (input_length == 0)
+ return;
+
+ char echo[max_input_length + 3];
+ _snprintf(echo, sizeof(echo), "]%s", input_line);
+ echo[sizeof(echo) - 1] = '\0';
+ AddText(echo);
+
+ char command[max_input_length + 2];
+ _snprintf(command, sizeof(command), "%s\n", input_line);
+ command[sizeof(command) - 1] = '\0';
+ Cbuf_AddText(0, command);
+
+ input_length = 0;
+ input_line[0] = '\0';
+ scroll_offset = 0;
+}
+
+bool IsConsoleKey(const XINPUT_KEYSTROKE &keystroke)
+{
+ return keystroke.VirtualKey == VK_OEM_3 || keystroke.Unicode == '`' || keystroke.Unicode == '~';
+}
+
+bool IsRepeat(const XINPUT_KEYSTROKE &keystroke)
+{
+#ifdef XINPUT_KEYSTROKE_REPEAT
+ return (keystroke.Flags & XINPUT_KEYSTROKE_REPEAT) != 0;
+#else
+ return (keystroke.Flags & 4) != 0;
+#endif
+}
+
+void HandleKeystroke(const XINPUT_KEYSTROKE &keystroke)
+{
+ if ((keystroke.Flags & XINPUT_KEYSTROKE_KEYDOWN) == 0 && !IsRepeat(keystroke))
+ return;
+
+ if (IsConsoleKey(keystroke))
+ {
+ if (keystroke.Flags & XINPUT_KEYSTROKE_KEYDOWN)
+ console_open = !console_open;
+ return;
+ }
+
+ if (!console_open)
+ return;
+
+ switch (keystroke.VirtualKey)
+ {
+ case VK_RETURN:
+ ExecuteInput();
+ return;
+ case VK_BACK:
+ if (input_length > 0)
+ input_line[--input_length] = '\0';
+ return;
+ case VK_ESCAPE:
+ console_open = false;
+ return;
+ case VK_PRIOR:
+ scroll_offset += visible_lines / 2;
+ if (scroll_offset > line_count - visible_lines)
+ scroll_offset = line_count > visible_lines ? line_count - visible_lines : 0;
+ return;
+ case VK_NEXT:
+ scroll_offset -= visible_lines / 2;
+ if (scroll_offset < 0)
+ scroll_offset = 0;
+ return;
+ default:
+ break;
+ }
+
+ const WCHAR ch = keystroke.Unicode;
+ if (ch >= 32 && ch <= 126 && input_length < max_input_length)
+ {
+ input_line[input_length++] = static_cast(ch);
+ input_line[input_length] = '\0';
+ }
+}
+
+void PollKeyboard()
+{
+ for (int i = 0; i < max_keystrokes_per_frame; ++i)
+ {
+ XINPUT_KEYSTROKE keystroke = {};
+ const DWORD result = XInputGetKeystroke(keyboard_user_index, keyboard_flags, &keystroke);
+
+ if (result != ERROR_SUCCESS || keystroke.Flags == 0)
+ return;
+
+ HandleKeystroke(keystroke);
+ }
+}
+
+void Draw(LocalClientNum_t localClientNum)
+{
+ const ScreenPlacement *placement = ScrPlace_GetActivePlacement(localClientNum);
+ Font_s *font = sharedUiInfo->assets.consoleFont;
+ if (!placement || !font)
+ return;
+
+ EnterCriticalSection(&lines_lock);
+ const int shown = line_count < visible_lines ? line_count : visible_lines;
+ for (int i = 0; i < shown; ++i)
+ {
+ // oldest visible line first
+ const int age = scroll_offset + shown - 1 - i;
+ const int index = (line_next - 1 - age + max_lines * 2) % max_lines;
+ UI_DrawText(placement, lines[index], max_line_length, font, text_x, text_y + line_height * i, horz_align_left,
+ vert_align_top, text_scale, color_output, 0);
+ }
+ LeaveCriticalSection(&lines_lock);
+
+ char prompt[max_input_length + 4];
+ const bool cursor_on = (GetTickCount() / 500) % 2 == 0;
+ _snprintf(prompt, sizeof(prompt), "]%s%s", input_line, cursor_on ? "_" : "");
+ prompt[sizeof(prompt) - 1] = '\0';
+ UI_DrawText(placement, prompt, max_input_length + 2, font, text_x, text_y + line_height * (visible_lines + 1),
+ horz_align_left, vert_align_top, text_scale, color_input, 0);
+
+ if (scroll_offset > 0)
+ UI_DrawText(placement, "^3-- scrolled (PgDn) --", 32, font, text_x,
+ text_y + line_height * (visible_lines + 2), horz_align_left, vert_align_top, text_scale,
+ color_hint, 0);
+}
+} // namespace
+
+Detour Console::UI_Refresh_Detour;
+Detour Console::CL_ConsolePrint_Detour;
+
+void Console::UI_Refresh_Hook(LocalClientNum_t localClientNum)
+{
+ UI_Refresh_Detour.GetOriginal()(localClientNum);
+
+ // UI refresh runs once per local client; the keyboard belongs to the first one
+ if (localClientNum != 0)
+ return;
+
+ PollKeyboard();
+
+ if (console_open)
+ Draw(localClientNum);
+}
+
+void Console::CL_ConsolePrint_Hook(LocalClientNum_t localClientNum, int channel, const char *txt,
+ unsigned int duration, unsigned int pixelWidth, int flags)
+{
+#ifndef NDEBUG
+ DbgPrint("[codxe][IW5][CL_ConsolePrint] %s\n", txt);
+#endif
+ AddText(txt);
+ CL_ConsolePrint_Detour.GetOriginal()(localClientNum, channel, txt, duration, pixelWidth, flags);
+}
+
+Console::Console()
+{
+ InitializeCriticalSection(&lines_lock);
+ input_line[0] = '\0';
+
+ CL_ConsolePrint_Detour = Detour(CL_ConsolePrint, CL_ConsolePrint_Hook);
+ CL_ConsolePrint_Detour.Install();
+
+ UI_Refresh_Detour = Detour(UI_Refresh, UI_Refresh_Hook);
+ UI_Refresh_Detour.Install();
+}
+
+Console::~Console()
+{
+ UI_Refresh_Detour.Remove();
+ CL_ConsolePrint_Detour.Remove();
+ DeleteCriticalSection(&lines_lock);
+}
+} // namespace mp
+} // namespace iw5
diff --git a/src/game/iw5/mp/console.h b/src/game/iw5/mp/console.h
new file mode 100644
index 00000000..3bb8c58b
--- /dev/null
+++ b/src/game/iw5/mp/console.h
@@ -0,0 +1,27 @@
+#pragma once
+
+#include "pch.h"
+
+namespace iw5
+{
+namespace mp
+{
+// Retail MW3 has the interactive console compiled out (no toggleconsole command, no console drawing),
+// so this is a small overlay: keyboard input via XInputGetKeystroke, commands via Cbuf_AddText,
+// output captured from CL_ConsolePrint and drawn with UI_DrawText.
+class Console : public Module
+{
+ public:
+ Console();
+ ~Console();
+
+ private:
+ static Detour UI_Refresh_Detour;
+ static void UI_Refresh_Hook(LocalClientNum_t localClientNum);
+
+ static Detour CL_ConsolePrint_Detour;
+ static void CL_ConsolePrint_Hook(LocalClientNum_t localClientNum, int channel, const char *txt,
+ unsigned int duration, unsigned int pixelWidth, int flags);
+};
+} // namespace mp
+} // namespace iw5
diff --git a/src/game/iw5/mp/main.cpp b/src/game/iw5/mp/main.cpp
index ccf809da..98f45e82 100644
--- a/src/game/iw5/mp/main.cpp
+++ b/src/game/iw5/mp/main.cpp
@@ -6,6 +6,7 @@
#include "pm.h"
#include "gsc.h"
#include "bots.h"
+#include "console.h"
#include "events.h"
namespace iw5
@@ -226,6 +227,7 @@ IW5_MP_Plugin::IW5_MP_Plugin()
RegisterModule(new PlayerMovement());
RegisterModule(new GSC());
RegisterModule(new Bots());
+ RegisterModule(new Console());
DB_FindXAssetHeader_Detour = Detour(DB_FindXAssetHeader, DB_FindXAssetHeader_Hook);
DB_FindXAssetHeader_Detour.Install();
diff --git a/src/game/iw5/mp/patches.cpp b/src/game/iw5/mp/patches.cpp
index a4f1368b..121c686f 100644
--- a/src/game/iw5/mp/patches.cpp
+++ b/src/game/iw5/mp/patches.cpp
@@ -47,17 +47,6 @@ void DisableDvarWriteChecks()
Detour Jump_Start_Detour;
-#ifndef NDEBUG
-Detour CL_ConsolePrint_Detour;
-
-void CL_ConsolePrint_Hook(LocalClientNum_t localClientNum, int channel, const char *txt, unsigned int duration,
- unsigned int pixelWidth, int flags)
-{
- DbgPrint("[codxe][IW5][CL_ConsolePrint] %s\n", txt);
- CL_ConsolePrint_Detour.GetOriginal()(localClientNum, channel, txt, duration, pixelWidth, flags);
-}
-#endif
-
void Jump_Start_Hook(pmove_t *pm, pml_t *pml, double height)
{
static const dvar_t *jump_height = Dvar_FindMalleableVar("jump_height");
@@ -73,17 +62,11 @@ patches::patches()
Jump_Start_Detour = Detour(Jump_Start, Jump_Start_Hook);
// Jump_Start_Detour.Install();
-#ifndef NDEBUG
- CL_ConsolePrint_Detour = Detour(CL_ConsolePrint, CL_ConsolePrint_Hook);
- CL_ConsolePrint_Detour.Install();
-#endif
+ // CL_ConsolePrint is detoured by the Console module (it also DbgPrints in debug builds)
}
patches::~patches()
{
-#ifndef NDEBUG
- CL_ConsolePrint_Detour.Remove();
-#endif
// Jump_Start_Detour.Remove();
}
} // namespace mp
diff --git a/src/game/iw5/mp/structs.h b/src/game/iw5/mp/structs.h
index 7d1c5e8c..4d8014eb 100644
--- a/src/game/iw5/mp/structs.h
+++ b/src/game/iw5/mp/structs.h
@@ -1988,6 +1988,7 @@ typedef void (*PlayerCmd_GetViewmodel_t)(scr_entref_t entref);
typedef void (*PlayerCmd_UFO_t)(scr_entref_t *entref);
typedef void (*UI_DrawBuildNumber_t)(LocalClientNum_t localClientNum);
+typedef void (*UI_Refresh_t)(LocalClientNum_t localClientNum);
typedef void (*UI_DrawText_t)(const ScreenPlacement *scrPlace, const char *text, int maxChars, Font_s *font, double x,
double y, int horzAlign, int vertAlign, double scale, const float *color, int style);
diff --git a/src/game/iw5/mp/symbols.h b/src/game/iw5/mp/symbols.h
index a533cd31..a8bfb2e5 100644
--- a/src/game/iw5/mp/symbols.h
+++ b/src/game/iw5/mp/symbols.h
@@ -73,6 +73,11 @@ static auto PlayerCmd_UFO = reinterpret_cast(0x82236588);
static auto va = reinterpret_cast(0x823365F0);
static auto UI_DrawBuildNumber = reinterpret_cast(0x822ECA88);
+// Per-frame UI refresh, called from both the menu and the in-game frame draw (0x8217CF90 / 0x8217CB60).
+// Calls UI_DrawBuildNumber when disconnected with a menu open.
+static auto UI_Refresh = reinterpret_cast(0x822F82E8);
+
+static auto Cbuf_AddText = reinterpret_cast(0x82287F68);
static auto UI_DrawText = reinterpret_cast(0x822EC490);
static auto Weapon_RocketLauncher_Fire = reinterpret_cast(0x82271710);