diff --git a/Phobos.vcxproj b/Phobos.vcxproj
index 5d2d15eca0..6ab78c497d 100644
--- a/Phobos.vcxproj
+++ b/Phobos.vcxproj
@@ -29,6 +29,8 @@
+
+
@@ -286,6 +288,7 @@
+
diff --git a/src/TextRenderer/Hooks.cpp b/src/TextRenderer/Hooks.cpp
new file mode 100644
index 0000000000..72d235a173
--- /dev/null
+++ b/src/TextRenderer/Hooks.cpp
@@ -0,0 +1,95 @@
+// Hooks.cpp
+// Engine hooks that intercept the game's text rendering functions and route
+// them through the GDI-based TTF renderer. Four hooks cover all text paths:
+// measurement, main drawing, single-line printing, and character blitting.
+#include
+#include "TextRenderer.h"
+#include
+#include
+
+// ============================================================================
+// HOOK: BitFont::GetTextDimension (0x433CF0)
+// Intercepts text measurement calls. The game uses this to determine how large
+// a text box needs to be before allocating UI space.
+// Returns TTF-measured dimensions instead of .FNT bitmap font dimensions.
+// ============================================================================
+DEFINE_HOOK(0x433CF0, BitFont_GetTextDimension, 8)
+{
+ GET(BitFont*, pFont, ECX);
+ GET_STACK(const wchar_t*, pText, 0x4);
+ GET_STACK(int*, pWidth, 0x8);
+ GET_STACK(int*, pHeight, 0xC);
+ GET_STACK(int, nMaxWidth, 0x10);
+
+ if (TextRenderer::GetTextDimension(pFont, pText, pWidth, pHeight, nMaxWidth))
+ {
+ R->EAX(1);
+ return 0x433EA2;
+ }
+ return 0;
+}
+
+// ============================================================================
+// HOOK: BitText::DrawText (0x434CD0)
+// Main text rendering hook. Catches all UI text: menu buttons, tooltips,
+// sidebar text, loading screen, power bar, and general game messages.
+// Routes directly to the TTF renderer with the game's original parameters.
+// ============================================================================
+DEFINE_HOOK(0x434CD0, BitText_DrawText, 10)
+{
+ GET_STACK(BitFont*, pFont, 0x4);
+ GET_STACK(Surface*, pSurface, 0x8);
+ GET_STACK(const wchar_t*, pWideString, 0xC);
+ GET_STACK(int, X, 0x10);
+ GET_STACK(int, Y, 0x14);
+ GET_STACK(int, W, 0x18);
+ GET_STACK(int, H, 0x1C);
+ GET_STACK(int, alignment, 0x20);
+
+ if (TextRenderer::DrawText(pFont, pSurface, pWideString, X, Y, W, H, alignment))
+ return 0x435310;
+ return 0;
+}
+
+// ============================================================================
+// HOOK: BitText::Print (0x434B90)
+// Handles single-line text: CSF messages ("Unit Lost", "Tech Building Captured"),
+// player names on the loading screen, and other game notifications.
+// The game passes a narrow width that would cause GDI to word-wrap mid-sentence.
+// We override with MAX_TEXT_WIDTH (800px) to force single-line output.
+// ============================================================================
+DEFINE_HOOK(0x434B90, BitText_Print, 6)
+{
+ GET_STACK(BitFont*, pFont, 0x4);
+ GET_STACK(Surface*, pSurface, 0x8);
+ GET_STACK(const wchar_t*, pWideString, 0xC);
+ GET_STACK(int, X, 0x10);
+ GET_STACK(int, Y, 0x14);
+ GET_STACK(int, H, 0x1C);
+
+ if (TextRenderer::DrawText(pFont, pSurface, pWideString, X, Y, TextRenderer::MAX_TEXT_WIDTH, H, 0))
+ return 0x434BDE;
+ return 0;
+}
+
+// ============================================================================
+// HOOK: BitFont::Blit (0x434120)
+// Handles per-character rendering for chat input and typewriter text effects.
+// Uses measure-only mode: returns the character width for cursor positioning
+// without performing an expensive DIB draw operation.
+// The full string is rendered at once by BitText::Print on the next frame,
+// avoiding per-keystroke surface lock/unlock cycles.
+// ============================================================================
+DEFINE_HOOK(0x434120, BitFont_Blit, 6)
+{
+ GET(BitFont*, pFont, ECX);
+ GET_STACK(wchar_t, wch, 0x4);
+ GET_STACK(int, X, 0x8);
+
+ // Measure character width for cursor advancement only - do not draw
+ wchar_t pText[2] = { wch, L'\0' };
+ int charWidth = 0;
+ TextRenderer::GetTextDimension(pFont, pText, &charWidth, nullptr, 0);
+ R->EAX(X + charWidth + 1);
+ return 0x434155;
+}
diff --git a/src/TextRenderer/TextRenderer.cpp b/src/TextRenderer/TextRenderer.cpp
new file mode 100644
index 0000000000..12f801ff8c
--- /dev/null
+++ b/src/TextRenderer/TextRenderer.cpp
@@ -0,0 +1,206 @@
+// TextRenderer.cpp
+// GDI-based TrueType font rendering engine for Command & Conquer: Yuri's Revenge.
+// Replaces the game's legacy .FNT bitmap fonts with modern .TTF/.OTF support
+// using Windows DrawTextW. All text rendering is routed through a temporary
+// 16-bit RGB565 DIB section that matches the game's surface format.
+#include "TextRenderer.h"
+#include
+#include
+#include
+#include
+
+namespace TextRenderer
+{
+ // GDI objects created once and reused across all text rendering calls
+ static HFONT g_FontHandle = nullptr; // Handle to the selected TrueType font
+ static HDC g_DeviceContext = nullptr; // Memory device context for off-screen drawing
+ static bool g_FontLoaded = false; // Guards one-time font initialization
+
+ // Cached DIB (Device Independent Bitmap) for performance.
+ // Recreated only when draw dimensions change between frames.
+ static void* g_BitmapData = nullptr; // Raw pixel buffer of the DIB section
+ static HBITMAP g_BitmapHandle = nullptr; // GDI handle to the DIB section
+ static int g_BitmapWidth = 0; // Current cached bitmap width
+ static int g_BitmapHeight = 0; // Current cached bitmap height
+
+ // ========================================================================
+ // LoadFontOnce - One-time initialization of the GDI font and device context.
+ // Reads [EnableTTF] section from UIMD.INI. Called automatically on the
+ // first DrawText or GetTextDimension call. Fails silently if TTF is
+ // disabled or the font file cannot be loaded.
+ // ========================================================================
+ static void LoadFontOnce()
+ {
+ if (g_FontLoaded) return;
+ g_FontLoaded = true;
+
+ CCINIClass config;
+ config.LoadFromFile(GameStrings::UIMD_INI);
+
+ // TTF rendering is opt-in; defaults to disabled
+ if (!config.ReadBool("EnableTTF", "Enabled", false)) return;
+
+ // Read font configuration from INI
+ char fileName[MAX_PATH];
+ config.ReadString("EnableTTF", "FontName", "arial.ttf", fileName);
+ int fontSize = config.ReadInteger("EnableTTF", "FontSize", 14);
+
+ // AntiAlias: true = ClearType (smoother and faster), false = no smoothing
+ bool antiAlias = config.ReadBool("EnableTTF", "AntiAlias", true);
+ DWORD quality = antiAlias ? CLEARTYPE_QUALITY : NONANTIALIASED_QUALITY;
+
+ // CreateFontA accepts ANSI strings from the INI reader
+ g_FontHandle = CreateFontA(fontSize, 0, 0, 0, FW_NORMAL, 0, 0, 0,
+ DEFAULT_CHARSET, OUT_TT_PRECIS, CLIP_DEFAULT_PRECIS,
+ quality, FF_DONTCARE, fileName);
+
+ if (g_FontHandle)
+ g_DeviceContext = CreateCompatibleDC(nullptr);
+ }
+
+ // ========================================================================
+ // DrawText - Renders text onto a game DirectDraw surface using Windows GDI.
+ //
+ // Process:
+ // 1. Lock the surface region where text will be drawn
+ // 2. Create or reuse a 16-bit RGB565 DIB matching the surface format
+ // 3. Copy surface background into DIB (preserves existing graphics)
+ // 4. Convert game's BGR565 color to RGB for GDI
+ // 5. Draw text onto DIB using DrawTextW with appropriate flags
+ // 6. Copy finished DIB back to the surface
+ // 7. Unlock surface, restore GDI state
+ //
+ // Parameters:
+ // gameFont - Source for text color (pFont->Color in BGR565 format)
+ // gameSurface - Target DirectDraw surface
+ // text - Wide-character string to render
+ // posX, posY - Top-left position on the surface
+ // boxWidth, boxHeight - Bounding box (0 = use surface dimensions)
+ // alignment - Bit flags: bit0=center, bit1=right, bit2=vertical center
+ // ========================================================================
+ bool DrawText(BitFont* gameFont, Surface* gameSurface, const wchar_t* text,
+ int posX, int posY, int boxWidth, int boxHeight, int alignment)
+ {
+ if (!text || !*text || !gameSurface) return false;
+ LoadFontOnce();
+ if (!g_FontHandle || !g_DeviceContext) return false;
+
+ DSurface* surface = static_cast(gameSurface);
+ int surfaceW = surface->GetWidth(), surfaceH = surface->GetHeight();
+
+ // Micro-adjustment for button-sized text to visually center within capsules
+ if (boxHeight > 0 && boxHeight < 30)
+ posY = std::max(0, posY - 2);
+
+ // Clamp drawing origin to visible surface area.
+ // offsetX/Y represents how far into the off-screen area the origin was.
+ int clampedX = std::max(0, posX), clampedY = std::max(0, posY);
+ int offsetX = clampedX - posX, offsetY = clampedY - posY;
+
+ // Calculate draw area, reduced by any off-screen portion
+ int drawW = boxWidth > 0 ? boxWidth : surfaceW - posX;
+ int drawH = boxHeight > 0 ? boxHeight : surfaceH - posY;
+ drawW -= offsetX;
+ drawH -= offsetY;
+
+ // Safety caps to prevent excessive memory allocation
+ if (drawW > MAX_TEXT_WIDTH) drawW = MAX_TEXT_WIDTH;
+ if (drawW > surfaceW - clampedX) drawW = surfaceW - clampedX;
+ drawW = (drawW + 1) & ~1; // Align to 16-bit word boundary
+ if (drawW < 16) drawW = 16;
+
+ if (drawW <= 0 || drawH <= 0 || clampedX >= surfaceW || clampedY >= surfaceH)
+ return false;
+
+ // Lock only the region being drawn, not the entire surface
+ void* surfaceBuf = surface->Lock(clampedX, clampedY);
+ if (!surfaceBuf) return false;
+
+ // Recreate cached DIB only when draw dimensions change
+ if (drawW != g_BitmapWidth || drawH != g_BitmapHeight)
+ {
+ if (g_BitmapHandle) DeleteObject(g_BitmapHandle);
+
+ // 16-bit RGB565 format matching the game's surface
+ BITMAPINFO bi = { { sizeof(BITMAPINFOHEADER), drawW, -drawH, 1, 16, BI_BITFIELDS } };
+ ((DWORD*)&bi.bmiColors)[0] = 0xF800; // Red mask (5 bits)
+ ((DWORD*)&bi.bmiColors)[1] = 0x07E0; // Green mask (6 bits)
+ ((DWORD*)&bi.bmiColors)[2] = 0x001F; // Blue mask (5 bits)
+
+ g_BitmapHandle = CreateDIBSection(g_DeviceContext, &bi, DIB_RGB_COLORS, &g_BitmapData, nullptr, 0);
+ g_BitmapWidth = drawW; g_BitmapHeight = drawH;
+ }
+ if (!g_BitmapHandle) { surface->Unlock(); return false; }
+
+ // Select our font and DIB into the memory device context
+ HBITMAP oldBmp = (HBITMAP)SelectObject(g_DeviceContext, g_BitmapHandle);
+ HFONT oldFont = (HFONT)SelectObject(g_DeviceContext, g_FontHandle);
+ int pitch = surface->GetPitch(), copyW = std::min(drawW, surfaceW - clampedX);
+
+ // Preserve surface background by copying it into the DIB before drawing
+ for (int y = 0; y < drawH && (clampedY + y) < surfaceH; y++)
+ memcpy((uint8_t*)g_BitmapData + y * drawW * 2, (uint8_t*)surfaceBuf + y * pitch, copyW * 2);
+
+ // Convert game's BGR565 color to RGB for GDI.
+ // BGR565 format: bits 0-4=Red, 5-10=Green, 11-15=Blue
+ uint16_t color = gameFont ? gameFont->Color : 0x7FFF;
+ SetTextColor(g_DeviceContext, RGB(((color >> 11) & 0x1F) << 3,
+ ((color >> 5) & 0x3F) << 2,
+ (color & 0x1F) << 3));
+ SetBkMode(g_DeviceContext, TRANSPARENT);
+
+ // Build formatting flags from the game's alignment parameter
+ UINT flags = DT_NOPREFIX;
+ if (alignment & 1) flags |= DT_CENTER;
+ else if (alignment & 2) flags |= DT_RIGHT;
+ else flags |= DT_LEFT;
+
+ // Define the text rectangle, offset to account for clamped origin
+ RECT rect = { offsetX, offsetY, offsetX + drawW, offsetY + drawH };
+
+ // Select rendering mode based on text type
+ if (wcslen(text) == 1 && boxWidth <= 0 && boxHeight <= 0)
+ flags |= DT_SINGLELINE; // Single character
+ else if (boxHeight > 0 && boxHeight < 30)
+ flags |= DT_SINGLELINE | DT_VCENTER; // Button text
+ else
+ flags |= DT_WORDBREAK | DT_NOCLIP; // Multi-line with word wrap
+
+ // Render text using Windows GDI
+ DrawTextW(g_DeviceContext, text, -1, &rect, flags);
+ GdiFlush(); // Ensure all GDI commands complete before reading back
+
+ // Copy rendered pixels back to the game surface
+ for (int y = 0; y < drawH && (clampedY + y) < surfaceH; y++)
+ memcpy((uint8_t*)surfaceBuf + y * pitch, (uint8_t*)g_BitmapData + y * drawW * 2, copyW * 2);
+
+ // Restore previous GDI state and unlock surface
+ SelectObject(g_DeviceContext, oldFont); SelectObject(g_DeviceContext, oldBmp);
+ surface->Unlock();
+ return true;
+ }
+
+ // ========================================================================
+ // GetTextDimension - Measures text without drawing.
+ // Uses DT_CALCRECT to calculate the pixel dimensions the text would occupy.
+ // Returns width and height through output parameters.
+ // ========================================================================
+ bool GetTextDimension(BitFont*, const wchar_t* text, int* outW, int* outH, int maxW)
+ {
+ if (!text || !*text) return false;
+ LoadFontOnce();
+ if (!g_FontHandle || !g_DeviceContext) return false;
+
+ HFONT oldFont = (HFONT)SelectObject(g_DeviceContext, g_FontHandle);
+
+ // DT_CALCRECT tells GDI to measure only, not draw
+ RECT rect = { 0, 0, maxW > 0 ? maxW : 2000, 0 };
+ DrawTextW(g_DeviceContext, text, -1, &rect, DT_CALCRECT | DT_NOCLIP | DT_WORDBREAK);
+
+ SelectObject(g_DeviceContext, oldFont);
+
+ if (outW) *outW = rect.right - rect.left;
+ if (outH) *outH = rect.bottom - rect.top;
+ return true;
+ }
+}
diff --git a/src/TextRenderer/TextRenderer.h b/src/TextRenderer/TextRenderer.h
new file mode 100644
index 0000000000..383ff51619
--- /dev/null
+++ b/src/TextRenderer/TextRenderer.h
@@ -0,0 +1,25 @@
+// TextRenderer.h
+#pragma once
+#include
+
+class BitFont;
+class Surface;
+
+namespace TextRenderer
+{
+ // Maximum pixel width for text drawing and single-line rendering.
+ // Caps DIB buffer allocation to prevent memory exhaustion on large surfaces
+ // and forces single-line rendering for CSF messages and
+ // Print callers by overriding their narrow width parameter.
+ static constexpr int MAX_TEXT_WIDTH = 800;
+
+ // Renders text onto a game surface using GDI. Handles surface locking,
+ // background preservation, color conversion, alignment, and word wrapping.
+ bool DrawText(BitFont* pFont, Surface* pSurface, const wchar_t* pText,
+ int X, int Y, int W, int H, int alignment);
+
+ // Measures text dimensions without drawing. Used by the game to allocate
+ // appropriately sized text boxes for UI elements.
+ bool GetTextDimension(BitFont* pFont, const wchar_t* pText,
+ int* pWidth, int* pHeight, int nMaxWidth);
+}