Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Phobos.vcxproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
<ClCompile Include="src\Phobos.Ext.cpp" />
<ClCompile Include="src\Phobos.INI.cpp" />
<ClCompile Include="src\Phobos.Save.cpp" />
<ClCompile Include="src\TextRenderer\TextRenderer.cpp" />
<ClCompile Include="src\TextRenderer\Hooks.cpp" />
<!-- src\Blowfish -->
<ClCompile Include="src\Blowfish\blowfish.cpp" />
<ClCompile Include="src\Blowfish\Hooks.Blowfish.cpp" />
Expand Down Expand Up @@ -286,6 +288,7 @@
<ClInclude Include="src\Phobos.CRT.h" />
<ClInclude Include="src\Phobos.h" />
<ClInclude Include="src\Phobos.version.h" />
<ClInclude Include="src\TextRenderer\TextRenderer.h" />
<!-- src\Blowfish\ -->
<ClInclude Include="src\Blowfish\blowfish.h" />
<!-- src\Commands\ -->
Expand Down
74 changes: 74 additions & 0 deletions src/TextRenderer/Hooks.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#include <Helpers/Macro.h>
#include "TextRenderer.h"
#include <BitFont.h>
#include <BitText.h>
#include <Surface.h>

// BitText::Print is designed for single-line text but the game passes a narrow
// width that causes GDI to word-wrap. We override with a large fixed width to
// force single-line rendering for CSF messages and other Print callers.
static constexpr int PRINT_SINGLELINE_WIDTH = 2000;

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;
}

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;
}

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, W, 0x18);

Check warning on line 51 in src/TextRenderer/Hooks.cpp

View workflow job for this annotation

GitHub Actions / build

'W': local variable is initialized but not referenced [D:\a\Phobos\Phobos\Phobos.vcxproj]
GET_STACK(int, H, 0x1C);

if (TextRenderer::DrawText(pFont, pSurface, pWideString, X, Y, PRINT_SINGLELINE_WIDTH, H, 0))
return 0x434BDE;
return 0;
}

DEFINE_HOOK(0x434120, BitFont_Blit, 6)
{
GET(BitFont*, pFont, ECX);
GET_STACK(wchar_t, wch, 0x4);
GET_STACK(int, X, 0x8);
GET_STACK(int, Y, 0xC);

Check warning on line 64 in src/TextRenderer/Hooks.cpp

View workflow job for this annotation

GitHub Actions / build

'Y': local variable is initialized but not referenced [D:\a\Phobos\Phobos\Phobos.vcxproj]
GET_STACK(int, nColor, 0x10);

Check warning on line 65 in src/TextRenderer/Hooks.cpp

View workflow job for this annotation

GitHub Actions / build

'nColor': local variable is initialized but not referenced [D:\a\Phobos\Phobos\Phobos.vcxproj]

// Measure-only: return character width for cursor positioning.
// Actual drawing is handled by BitText_Print for the full string.
wchar_t pText[2] = { wch, L'\0' };
int charWidth = 0;
TextRenderer::GetTextDimension(pFont, pText, &charWidth, nullptr, 0);
R->EAX(X + charWidth + 1);
return 0x434155;
}
202 changes: 202 additions & 0 deletions src/TextRenderer/TextRenderer.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
#include "TextRenderer.h"
#include <BitFont.h>
#include <Surface.h>
#include <CCINIClass.h>
#include <GameStrings.h>
#include <algorithm>

namespace TextRenderer
{
// GDI objects created once and reused for all text rendering
static HFONT g_FontHandle = nullptr;
static HDC g_DeviceContext = nullptr;
static bool g_FontLoaded = false;

// DIB (Device Independent Bitmap) cache - recreated only when draw dimensions change
static void* g_BitmapData = nullptr;
static HBITMAP g_BitmapHandle = nullptr;
static int g_BitmapWidth = 0;
static int g_BitmapHeight = 0;

// Maximum width for a single text draw - prevents excessive memory allocation
static constexpr int MAX_DRAWING_WIDTH = 1200;

// Returns true if the font handle and device context are valid and ready for drawing
bool IsInitialized()
{
return g_FontHandle != nullptr && g_DeviceContext != nullptr;
}

// Loads font configuration from UIMD.INI and creates the GDI font handle once.
// Called automatically on first DrawText or GetTextDimension call.
static void LoadFontOnce()
{
if (g_FontLoaded) return;
g_FontLoaded = true;

CCINIClass config;
config.LoadFromFile(GameStrings::UIMD_INI);

// TTF rendering must be explicitly enabled in the INI file
if (!config.ReadBool("EnableTTF", "Enabled", false)) return;

// Read font name and size from the [EnableTTF] section
char fileName[MAX_PATH];
config.ReadString("EnableTTF", "FontName", "arial.ttf", fileName);
int fontSize = config.ReadInteger("EnableTTF", "FontSize", 14);

// Anti-aliasing: true = ClearType (smoother, faster), false = no smoothing
bool antiAlias = config.ReadBool("EnableTTF", "AntiAlias", true);
DWORD quality = antiAlias ? CLEARTYPE_QUALITY : NONANTIALIASED_QUALITY;

// Create the logical font for GDI text rendering
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);
}

// Renders text onto a game surface using Windows GDI.
// Handles surface locking, background preservation, color conversion, alignment,
// single-line vs multi-line formatting, and word wrapping.
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* directDrawSurface = static_cast<DSurface*>(gameSurface);
int surfaceWidth = directDrawSurface->GetWidth();
int surfaceHeight = directDrawSurface->GetHeight();

// Small vertical adjustment for button-sized text to visually center within capsules
if (boxHeight > 0 && boxHeight < 30)
posY = std::max(0, posY - 2);

// Clamp drawing position to visible surface area and calculate offset for GDI rect
int clampedX = std::max(0, posX);
int clampedY = std::max(0, posY);
int offsetX = clampedX - posX;
int offsetY = clampedY - posY;

// Calculate actual draw dimensions, accounting for off-screen clamping
int drawingWidth = boxWidth > 0 ? boxWidth : surfaceWidth - posX;
int drawingHeight = boxHeight > 0 ? boxHeight : surfaceHeight - posY;
drawingWidth -= offsetX;
drawingHeight -= offsetY;

// Apply safety caps to prevent memory exhaustion on large surfaces
if (drawingWidth > MAX_DRAWING_WIDTH) drawingWidth = MAX_DRAWING_WIDTH;
if (drawingWidth > surfaceWidth - clampedX) drawingWidth = surfaceWidth - clampedX;
drawingWidth = (drawingWidth + 1) & ~1; // Align to 16-bit word boundary
if (drawingWidth < 16) drawingWidth = 16;

if (drawingWidth <= 0 || drawingHeight <= 0 || clampedX >= surfaceWidth || clampedY >= surfaceHeight)
return false;

// Lock only the region we need to draw on (not the entire surface)
void* surfaceBuffer = directDrawSurface->Lock(clampedX, clampedY);
if (!surfaceBuffer) return false;

// Recreate the DIB only when draw dimensions change (cached for performance)
if (drawingWidth != g_BitmapWidth || drawingHeight != g_BitmapHeight)
{
if (g_BitmapHandle) DeleteObject(g_BitmapHandle);

// Set up 16-bit RGB565 bitmap matching the game's surface format
BITMAPINFO bitmapInfo = { { sizeof(BITMAPINFOHEADER), drawingWidth, -drawingHeight, 1, 16, BI_BITFIELDS } };
((DWORD*)&bitmapInfo.bmiColors)[0] = 0xF800; // Red mask (5 bits)
((DWORD*)&bitmapInfo.bmiColors)[1] = 0x07E0; // Green mask (6 bits)
((DWORD*)&bitmapInfo.bmiColors)[2] = 0x001F; // Blue mask (5 bits)

g_BitmapHandle = CreateDIBSection(g_DeviceContext, &bitmapInfo, DIB_RGB_COLORS, &g_BitmapData, nullptr, 0);
g_BitmapWidth = drawingWidth;
g_BitmapHeight = drawingHeight;
}
if (!g_BitmapHandle) { directDrawSurface->Unlock(); return false; }

// Select font and bitmap into the device context for drawing
HBITMAP oldBitmap = (HBITMAP)SelectObject(g_DeviceContext, g_BitmapHandle);
HFONT oldFont = (HFONT)SelectObject(g_DeviceContext, g_FontHandle);
int surfacePitch = directDrawSurface->GetPitch();
int copyWidth = std::min(drawingWidth, surfaceWidth - clampedX);

// Copy the current surface background into the bitmap to preserve what's behind the text
for (int y = 0; y < drawingHeight && (clampedY + y) < surfaceHeight; y++)
{
memcpy((uint8_t*)g_BitmapData + y * drawingWidth * 2,
(uint8_t*)surfaceBuffer + y * surfacePitch, copyWidth * 2);
}

// Convert the game's BGR565 color format to RGB for GDI
uint16_t gameColor = gameFont ? gameFont->Color : 0x7FFF;
SetTextColor(g_DeviceContext, RGB(((gameColor >> 11) & 0x1F) << 3,
((gameColor >> 5) & 0x3F) << 2,
(gameColor & 0x1F) << 3));
SetBkMode(g_DeviceContext, TRANSPARENT); // Text background is transparent

// Build text formatting flags from the alignment parameter
// Bits 0-1: horizontal alignment (0=left, 1=center, 2=right)
// Bit 2: vertical center
UINT drawingFlags = DT_NOPREFIX;
if (alignment & 1) drawingFlags |= DT_CENTER;
else if (alignment & 2) drawingFlags |= DT_RIGHT;
else drawingFlags |= DT_LEFT;

// Define the text drawing rectangle within the bitmap, offset for clamped position
RECT textBoundaryBox = { offsetX, offsetY, offsetX + drawingWidth, offsetY + drawingHeight };

// Single character from Blit hook - force horizontal layout
if (wcslen(text) == 1 && boxWidth <= 0 && boxHeight <= 0)
drawingFlags |= DT_SINGLELINE;
// Button-sized text - single line with vertical centering
else if (boxHeight > 0 && boxHeight < 30)
drawingFlags |= DT_SINGLELINE | DT_VCENTER;
// Multi-line text (tooltips, messages) - allow word wrapping
else
drawingFlags |= DT_WORDBREAK | DT_NOCLIP;

// Render the text using Windows GDI
DrawTextW(g_DeviceContext, text, -1, &textBoundaryBox, drawingFlags);
GdiFlush(); // Ensure all drawing commands are processed

// Copy the rendered text back to the game surface
for (int y = 0; y < drawingHeight && (clampedY + y) < surfaceHeight; y++)
{
memcpy((uint8_t*)surfaceBuffer + y * surfacePitch,
(uint8_t*)g_BitmapData + y * drawingWidth * 2, copyWidth * 2);
}

// Restore previous GDI objects and release the surface lock
SelectObject(g_DeviceContext, oldFont);
SelectObject(g_DeviceContext, oldBitmap);
directDrawSurface->Unlock();
return true;
}

// Measures the pixel dimensions of a text string without drawing it.
// Used by the game to allocate appropriate box sizes for text.
bool GetTextDimension(BitFont*, const wchar_t* text, int* outWidth, int* outHeight, int maxWidth)
{
if (!text || !*text) return false;
LoadFontOnce();
if (!g_FontHandle || !g_DeviceContext) return false;

HFONT oldFont = (HFONT)SelectObject(g_DeviceContext, g_FontHandle);

// Set up measurement rectangle with word wrapping
RECT boundaryCalculationBox = { 0, 0, maxWidth > 0 ? maxWidth : 2000, 0 };
DrawTextW(g_DeviceContext, text, -1, &boundaryCalculationBox,
DT_CALCRECT | DT_NOCLIP | DT_WORDBREAK);

SelectObject(g_DeviceContext, oldFont);

// Return the calculated text dimensions
if (outWidth) *outWidth = boundaryCalculationBox.right - boundaryCalculationBox.left;
if (outHeight) *outHeight = boundaryCalculationBox.bottom - boundaryCalculationBox.top;
return true;
}
}
15 changes: 15 additions & 0 deletions src/TextRenderer/TextRenderer.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#pragma once
#include <Windows.h>
#include <wingdi.h>
#include <string>

class BitFont;
class Surface;

namespace TextRenderer
{
bool IsInitialized();
bool DrawText(BitFont* pFont, Surface* pSurface, const wchar_t* pText,
int X, int Y, int W, int H, int alignment);
bool GetTextDimension(BitFont* pFont, const wchar_t* pText, int* pWidth, int* pHeight, int nMaxWidth);
}
Loading