diff --git a/Core/Libraries/Source/WWVegas/WW3D2/CMakeLists.txt b/Core/Libraries/Source/WWVegas/WW3D2/CMakeLists.txt
index 68f59638745..6e8d52b0fb5 100644
--- a/Core/Libraries/Source/WWVegas/WW3D2/CMakeLists.txt
+++ b/Core/Libraries/Source/WWVegas/WW3D2/CMakeLists.txt
@@ -163,6 +163,9 @@ set(WW3D2_SRC
#render2d.h
render2dsentence.cpp
render2dsentence.h
+ complextext.h
+ unicodebidi.h
+ supplementarybidi.inl
renderobjectrecycler.cpp
renderobjectrecycler.h
rendobj.cpp
diff --git a/Core/Libraries/Source/WWVegas/WW3D2/complextext.h b/Core/Libraries/Source/WWVegas/WW3D2/complextext.h
new file mode 100644
index 00000000000..0cb3583eef5
--- /dev/null
+++ b/Core/Libraries/Source/WWVegas/WW3D2/complextext.h
@@ -0,0 +1,228 @@
+/*
+** Command & Conquer Generals Zero Hour(tm)
+** Copyright 2026 TheSuperHackers
+**
+** This program is free software: you can redistribute it and/or modify
+** it under the terms of the GNU General Public License as published by
+** the Free Software Foundation, either version 3 of the License, or
+** (at your option) any later version.
+**
+** This program is distributed in the hope that it will be useful,
+** but WITHOUT ANY WARRANTY; without even the implied warranty of
+** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+** GNU General Public License for more details.
+**
+** You should have received a copy of the GNU General Public License
+** along with this program. If not, see .
+*/
+
+#pragma once
+
+#include "WWLib/Usp10Loader.h"
+#include "unicodebidi.h"
+#include
+#include
+#include
+#include
+
+struct ComplexTextRun
+{
+ ComplexTextRun () :
+ Analysis(nullptr),
+ Font(nullptr),
+ CharacterPosition(0),
+ CharacterCount(0),
+ Width(0),
+ Ascent(0)
+ {
+ ::memset(&State, 0, sizeof(State));
+ }
+
+ Usp10Loader::ScriptStringAnalysis Analysis;
+ HFONT Font;
+ int CharacterPosition;
+ int CharacterCount;
+ int Width;
+ int Ascent;
+ Usp10Loader::ScriptState State;
+};
+
+
+struct ComplexTextLayout
+{
+ ComplexTextLayout (HDC source_dc) :
+ DC(::CreateCompatibleDC(source_dc)),
+ Width(0), Height(0), Ascent(0), Descent(0)
+ {
+ }
+
+ ~ComplexTextLayout ()
+ {
+ for (size_t index = 0; index < Runs.size(); ++index) {
+ if (Runs[index].Analysis != nullptr) {
+ Usp10Loader::ScriptStringFree(&Runs[index].Analysis);
+ }
+ }
+
+ if (DC != nullptr) {
+ ::DeleteDC(DC);
+ }
+ }
+
+ HDC DC;
+ std::vector Runs;
+ std::vector VisualToLogical;
+ int Width;
+ int Height;
+ int Ascent;
+ int Descent;
+
+private:
+ ComplexTextLayout (const ComplexTextLayout &);
+ ComplexTextLayout &operator= (const ComplexTextLayout &);
+};
+
+
+static bool Uses_Alternate_Unicode_Font (const WCHAR *text, int character_position, HFONT alternate_font)
+{
+ return alternate_font != nullptr && text[character_position] >= 256;
+}
+
+
+static bool Build_Complex_Text_Layout (HDC dc, const WCHAR *text, int text_length,
+ HFONT primary_font, HFONT alternate_font, ComplexTextLayout *layout)
+{
+ if (dc == nullptr || text == nullptr || text_length <= 0 ||
+ text_length > (INT_MAX - 16) / 3 || primary_font == nullptr)
+ {
+ return false;
+ }
+
+ if (alternate_font == primary_font) {
+ alternate_font = nullptr;
+ }
+
+ // TheSuperHackers @bugfix Omar Aglan 13/09/2026 Preserve paragraph order for mixed RTL text.
+ // ScriptItemize needs both control and state for full bidirectional analysis.
+ Usp10Loader::ScriptControl control = { 0 };
+ Usp10Loader::ScriptState initial_state = { 0 };
+ initial_state.bidi_level = UnicodeBidi::Get_Paragraph_Level(text, text_length);
+
+ std::vector items(text_length + 2);
+ int item_count = 0;
+ if (Usp10Loader::ScriptItemize(text, text_length, text_length + 1, &control, &initial_state, &items[0], &item_count) != S_OK ||
+ item_count <= 0)
+ {
+ return false;
+ }
+
+ std::vector attributes(text_length);
+ for (int break_item_index = 0; break_item_index < item_count; ++break_item_index) {
+ const int item_start = items[break_item_index].character_position;
+ const int item_length = items[break_item_index + 1].character_position - item_start;
+ if (Usp10Loader::ScriptBreak(text + item_start, item_length, &items[break_item_index].analysis,
+ &attributes[item_start]) != S_OK)
+ {
+ return false;
+ }
+ }
+
+ layout->Runs.reserve(text_length);
+ for (int run_item_index = 0; run_item_index < item_count; ++run_item_index) {
+ const int item_end = items[run_item_index + 1].character_position;
+ int run_start = items[run_item_index].character_position;
+ bool uses_alternate_font = Uses_Alternate_Unicode_Font(text, run_start, alternate_font);
+ for (int run_end = run_start + 1; run_end <= item_end; ++run_end) {
+ const bool run_ends = run_end == item_end ||
+ (attributes[run_end].char_stop &&
+ uses_alternate_font != Uses_Alternate_Unicode_Font(text, run_end, alternate_font));
+ if (run_ends) {
+ layout->Runs.push_back(ComplexTextRun());
+ ComplexTextRun &run = layout->Runs.back();
+ run.Font = uses_alternate_font ? alternate_font : primary_font;
+ run.CharacterPosition = run_start;
+ run.CharacterCount = run_end - run_start;
+ run.State = items[run_item_index].analysis.state;
+ run_start = run_end;
+ if (run_end < item_end) {
+ uses_alternate_font = Uses_Alternate_Unicode_Font(text, run_end, alternate_font);
+ }
+ }
+ }
+ }
+ const int run_count = (int)layout->Runs.size();
+ layout->VisualToLogical.resize(run_count);
+
+ std::vector bidi_levels(run_count);
+ HFONT old_font = (HFONT)::GetCurrentObject(dc, OBJ_FONT);
+ bool success = true;
+ for (int index = 0; index < run_count; ++index) {
+ ComplexTextRun &run = layout->Runs[index];
+ if (::SelectObject(dc, run.Font) == nullptr) {
+ success = false;
+ break;
+ }
+
+ bidi_levels[index] = (BYTE)run.State.bidi_level;
+ const int glyph_count = run.CharacterCount + run.CharacterCount / 2 + 16;
+ DWORD flags = Usp10Loader::SSA_GLYPHS | Usp10Loader::SSA_FALLBACK;
+ if ((run.State.bidi_level & 1) != 0) {
+ flags |= Usp10Loader::SSA_RTL;
+ }
+
+ // TheSuperHackers @bugfix Omar Aglan 13/09/2026 Keep directional overrides inside font runs.
+ const HRESULT analysis_result = Usp10Loader::ScriptStringAnalyse(dc,
+ text + run.CharacterPosition, run.CharacterCount, glyph_count, -1, flags, 0,
+ &control, &run.State, nullptr, nullptr, nullptr, &run.Analysis);
+ const SIZE *run_size = analysis_result == S_OK ? Usp10Loader::ScriptString_pSize(run.Analysis) : nullptr;
+ TEXTMETRIC text_metrics = { 0 };
+ if (run_size == nullptr || run_size->cx < 0 || run_size->cx > INT_MAX - layout->Width ||
+ run_size->cy <= 0 || !::GetTextMetrics(dc, &text_metrics))
+ {
+ success = false;
+ break;
+ }
+
+ run.Width = run_size->cx;
+ run.Ascent = (std::min)((int)text_metrics.tmAscent, (int)run_size->cy);
+ layout->Width += run.Width;
+ layout->Ascent = (std::max)(layout->Ascent, run.Ascent);
+ layout->Descent = (std::max)(layout->Descent, (int)run_size->cy - run.Ascent);
+ }
+ if (layout->Ascent > INT_MAX - layout->Descent) {
+ success = false;
+ } else {
+ layout->Height = layout->Ascent + layout->Descent;
+ }
+
+ if (success) {
+ success = Usp10Loader::ScriptLayout(run_count, &bidi_levels[0], &layout->VisualToLogical[0], nullptr) == S_OK &&
+ layout->Width > 0 && layout->Height > 0;
+ }
+
+ ::SelectObject(dc, old_font);
+ return success;
+}
+
+
+static bool Draw_Complex_Text_Layout (ComplexTextLayout &layout)
+{
+ HFONT old_font = (HFONT)::GetCurrentObject(layout.DC, OBJ_FONT);
+ int x = 0;
+ bool success = true;
+ for (size_t visual_index = 0; visual_index < layout.Runs.size(); ++visual_index) {
+ ComplexTextRun &run = layout.Runs[layout.VisualToLogical[visual_index]];
+ if (::SelectObject(layout.DC, run.Font) == nullptr ||
+ Usp10Loader::ScriptStringOut(run.Analysis, x, layout.Ascent - run.Ascent,
+ 0, nullptr, 0, 0, FALSE) != S_OK)
+ {
+ success = false;
+ break;
+ }
+ x += run.Width;
+ }
+ ::SelectObject(layout.DC, old_font);
+
+ // TheSuperHackers @bugfix Omar Aglan 13/09/2026 Complete GDI drawing before the bitmap is read or freed.
+ return ::GdiFlush() != FALSE && success;
+}
diff --git a/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.cpp b/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.cpp
index 29d1a22a82d..4a563325665 100644
--- a/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.cpp
+++ b/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.cpp
@@ -40,6 +40,9 @@
#include "WWDebug/wwprofile.h"
#include "WWDebug/wwmemlog.h"
#include "dx8wrapper.h"
+#if defined(_WIN32)
+#include "complextext.h"
+#endif
////////////////////////////////////////////////////////////////////////////////////
@@ -48,6 +51,13 @@
#define no_TEST_PLACEMENT 1 // Shows alignment markers for text.
#define TEXTURE_OFFSET 2
+
+static inline uint16 Convert_Font_Pixel (uint8 intensity)
+{
+ const uint16 color = intensity == 0 ? 0 : 0x0FFF;
+ return color | ((intensity >> 4) << 12);
+}
+
////////////////////////////////////////////////////////////////////////////////////
//
// Render2DSentenceClass
@@ -1269,6 +1279,26 @@ FontCharsClass::Get_Char_Spacing (WCHAR ch)
}
+////////////////////////////////////////////////////////////////////////////////////
+//
+// Is_Complex_Text
+//
+////////////////////////////////////////////////////////////////////////////////////
+bool
+FontCharsClass::Is_Complex_Text (const WCHAR *text)
+{
+#if defined(_WIN32)
+ if (text == nullptr || text[0] == 0) {
+ return false;
+ }
+
+ return Usp10Loader::ScriptIsComplex(text, (int)wcslen(text), Usp10Loader::SIC_COMPLEX) == S_OK;
+#else
+ return false;
+#endif
+}
+
+
////////////////////////////////////////////////////////////////////////////////////
//
// Blit_Char
@@ -1305,6 +1335,122 @@ FontCharsClass::Blit_Char (WCHAR ch, uint16 *dest_ptr, int dest_stride, int x, i
}
+////////////////////////////////////////////////////////////////////////////////////
+//
+// Build_Complex_Text
+//
+////////////////////////////////////////////////////////////////////////////////////
+bool
+FontCharsClass::Build_Complex_Text (const WCHAR *text, int *width, int *height,
+ float maximum_width, int maximum_height, uint16 **raster)
+{
+ if (width == nullptr || height == nullptr) {
+ return false;
+ }
+
+ if (raster != nullptr) {
+ *raster = nullptr;
+ }
+ *width = 0;
+ *height = 0;
+
+#if defined(_WIN32)
+ const size_t text_length = text == nullptr ? 0 : wcslen(text);
+ if (text_length == 0 || text_length > (INT_MAX - 16) / 3 || MemDC == nullptr) {
+ return false;
+ }
+
+ ComplexTextLayout layout(MemDC);
+ HDC text_dc = layout.DC;
+ if (text_dc == nullptr) {
+ return false;
+ }
+
+ ::SetBkColor(text_dc, RGB(0, 0, 0));
+ ::SetTextColor(text_dc, RGB(255, 255, 255));
+ ::SetBkMode(text_dc, TRANSPARENT);
+
+ // TheSuperHackers @bugfix Omar Aglan 02/09/2026 Preserve the primary font for
+ // Latin runs and use the configured Unicode font for the remaining runs.
+ HFONT alternate_font = AlternateUnicodeFont != nullptr && AlternateUnicodeFont != this ?
+ AlternateUnicodeFont->GDIFont : nullptr;
+ if (!Build_Complex_Text_Layout(text_dc, text, (int)text_length, GDIFont, alternate_font, &layout)) {
+ return false;
+ }
+
+ const int text_width = layout.Width;
+ const int text_height = layout.Height;
+ if ((maximum_width > 0 && text_width >= maximum_width) ||
+ (maximum_height > 0 && text_height >= maximum_height))
+ {
+ return false;
+ }
+
+ if (raster == nullptr) {
+ *width = text_width;
+ *height = text_height;
+ return true;
+ }
+
+ if (text_width > (INT_MAX - 3) / 3) {
+ return false;
+ }
+ const int bitmap_stride = ((text_width * 3) + 3) & ~3;
+ if (text_height > INT_MAX / bitmap_stride) {
+ return false;
+ }
+
+ BITMAPINFO bitmap_info = { 0 };
+ bitmap_info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
+ bitmap_info.bmiHeader.biWidth = text_width;
+ bitmap_info.bmiHeader.biHeight = -text_height;
+ bitmap_info.bmiHeader.biPlanes = 1;
+ bitmap_info.bmiHeader.biBitCount = 24;
+ bitmap_info.bmiHeader.biCompression = BI_RGB;
+
+ uint8 *bitmap_bits = nullptr;
+ HBITMAP bitmap = ::CreateDIBSection(MemDC, &bitmap_info, DIB_RGB_COLORS,
+ (void **)&bitmap_bits, nullptr, 0L);
+ if (bitmap == nullptr || bitmap_bits == nullptr) {
+ if (bitmap != nullptr) {
+ ::DeleteObject(bitmap);
+ }
+ return false;
+ }
+
+ HBITMAP old_bitmap = (HBITMAP)::SelectObject(text_dc, bitmap);
+ if (old_bitmap == nullptr) {
+ ::DeleteObject(bitmap);
+ return false;
+ }
+
+ ::memset(bitmap_bits, 0, bitmap_stride * text_height);
+
+ const bool success = Draw_Complex_Text_Layout(layout);
+
+ if (success) {
+ uint16 *pixels = W3DNEWARRAY uint16[text_width * text_height];
+ for (int row = 0; row < text_height; ++row) {
+ const uint8 *source = bitmap_bits + row * bitmap_stride;
+ uint16 *destination = pixels + row * text_width;
+ for (int column = 0; column < text_width; ++column) {
+ destination[column] = Convert_Font_Pixel(source[column * 3]);
+ }
+ }
+ *raster = pixels;
+ *width = text_width;
+ *height = text_height;
+ }
+
+ ::SelectObject(text_dc, old_bitmap);
+ ::DeleteObject(bitmap);
+ return success;
+#else
+ return false;
+#endif
+}
+
+
////////////////////////////////////////////////////////////////////////////////////
//
// Store_GDI_Char
@@ -1390,17 +1536,11 @@ FontCharsClass::Store_GDI_Char (WCHAR ch)
}
#endif
- uint16 pixel_color = 0;
- if (pixel_value != 0) {
- pixel_color = 0x0FFF;
- }
-
//
// Convert the pixel intensity from 8bit to 4bit and
// store it in our buffer
//
- uint8 alpha_value = ((pixel_value >> 4) & 0xF);
- *curr_buffer_p++ = pixel_color | (alpha_value << 12);
+ *curr_buffer_p++ = Convert_Font_Pixel(pixel_value);
}
}
@@ -1564,7 +1704,6 @@ FontCharsClass::Create_GDI_Font (const char *font_name)
TEXTMETRIC text_metric = { 0 };
::GetTextMetrics (MemDC, &text_metric);
CharHeight = text_metric.tmHeight;
- CharAscent = text_metric.tmAscent;
CharOverhang = text_metric.tmOverhang;
if (doingGenerals) {
CharOverhang = 0;
diff --git a/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.h b/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.h
index a6c19625cfb..d55f7b195cd 100644
--- a/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.h
+++ b/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.h
@@ -95,6 +95,10 @@ class FontCharsClass : public RefCountClass
int Get_Char_Height() { return CharHeight; }
int Get_Char_Width( WCHAR ch );
int Get_Char_Spacing( WCHAR ch );
+ bool Is_Complex_Text( const WCHAR *text );
+ // A null raster requests measurement only; both paths apply the same size limits.
+ bool Build_Complex_Text( const WCHAR *text, int *width, int *height,
+ float maximum_width, int maximum_height, uint16 **raster = nullptr );
int Get_Extra_Overlap() {return PixelOverlap;}
@@ -121,7 +125,6 @@ class FontCharsClass : public RefCountClass
DynamicVectorClass BufferList;
int CurrPixelOffset;
int CharHeight;
- int CharAscent;
int CharOverhang;
int PixelOverlap;
int PointSize;
diff --git a/Core/Libraries/Source/WWVegas/WW3D2/supplementarybidi.inl b/Core/Libraries/Source/WWVegas/WW3D2/supplementarybidi.inl
new file mode 100644
index 00000000000..317207d8226
--- /dev/null
+++ b/Core/Libraries/Source/WWVegas/WW3D2/supplementarybidi.inl
@@ -0,0 +1,265 @@
+// Generated by scripts/generate_supplementary_bidi.py; do not edit.
+// Unicode 17.0.0 DerivedBidiClass.txt, including its @missing defaults.
+// Copyright 2025 Unicode, Inc. See unicode-license.txt.
+// Omitted ranges have direction LeftToRight. Neutral means neither L, R nor AL.
+ { 0x10101, 0x10101, Neutral },
+ { 0x10140, 0x1018C, Neutral },
+ { 0x10190, 0x1019C, Neutral },
+ { 0x101A0, 0x101A0, Neutral },
+ { 0x101FD, 0x101FD, Neutral },
+ { 0x102E0, 0x102FB, Neutral },
+ { 0x10376, 0x1037A, Neutral },
+ { 0x10800, 0x1091E, RightToLeft },
+ { 0x1091F, 0x1091F, Neutral },
+ { 0x10920, 0x10A00, RightToLeft },
+ { 0x10A01, 0x10A03, Neutral },
+ { 0x10A04, 0x10A04, RightToLeft },
+ { 0x10A05, 0x10A06, Neutral },
+ { 0x10A07, 0x10A0B, RightToLeft },
+ { 0x10A0C, 0x10A0F, Neutral },
+ { 0x10A10, 0x10A37, RightToLeft },
+ { 0x10A38, 0x10A3A, Neutral },
+ { 0x10A3B, 0x10A3E, RightToLeft },
+ { 0x10A3F, 0x10A3F, Neutral },
+ { 0x10A40, 0x10AE4, RightToLeft },
+ { 0x10AE5, 0x10AE6, Neutral },
+ { 0x10AE7, 0x10B38, RightToLeft },
+ { 0x10B39, 0x10B3F, Neutral },
+ { 0x10B40, 0x10D23, RightToLeft },
+ { 0x10D24, 0x10D27, Neutral },
+ { 0x10D28, 0x10D2F, RightToLeft },
+ { 0x10D30, 0x10D39, Neutral },
+ { 0x10D3A, 0x10D3F, RightToLeft },
+ { 0x10D40, 0x10D49, Neutral },
+ { 0x10D4A, 0x10D68, RightToLeft },
+ { 0x10D69, 0x10D6E, Neutral },
+ { 0x10D6F, 0x10E5F, RightToLeft },
+ { 0x10E60, 0x10E7E, Neutral },
+ { 0x10E7F, 0x10EAA, RightToLeft },
+ { 0x10EAB, 0x10EAC, Neutral },
+ { 0x10EAD, 0x10ECF, RightToLeft },
+ { 0x10ED0, 0x10ED8, Neutral },
+ { 0x10ED9, 0x10EF9, RightToLeft },
+ { 0x10EFA, 0x10EFF, Neutral },
+ { 0x10F00, 0x10F45, RightToLeft },
+ { 0x10F46, 0x10F50, Neutral },
+ { 0x10F51, 0x10F81, RightToLeft },
+ { 0x10F82, 0x10F85, Neutral },
+ { 0x10F86, 0x10FFF, RightToLeft },
+ { 0x11001, 0x11001, Neutral },
+ { 0x11038, 0x11046, Neutral },
+ { 0x11052, 0x11065, Neutral },
+ { 0x11070, 0x11070, Neutral },
+ { 0x11073, 0x11074, Neutral },
+ { 0x1107F, 0x11081, Neutral },
+ { 0x110B3, 0x110B6, Neutral },
+ { 0x110B9, 0x110BA, Neutral },
+ { 0x110C2, 0x110C2, Neutral },
+ { 0x11100, 0x11102, Neutral },
+ { 0x11127, 0x1112B, Neutral },
+ { 0x1112D, 0x11134, Neutral },
+ { 0x11173, 0x11173, Neutral },
+ { 0x11180, 0x11181, Neutral },
+ { 0x111B6, 0x111BE, Neutral },
+ { 0x111C9, 0x111CC, Neutral },
+ { 0x111CF, 0x111CF, Neutral },
+ { 0x1122F, 0x11231, Neutral },
+ { 0x11234, 0x11234, Neutral },
+ { 0x11236, 0x11237, Neutral },
+ { 0x1123E, 0x1123E, Neutral },
+ { 0x11241, 0x11241, Neutral },
+ { 0x112DF, 0x112DF, Neutral },
+ { 0x112E3, 0x112EA, Neutral },
+ { 0x11300, 0x11301, Neutral },
+ { 0x1133B, 0x1133C, Neutral },
+ { 0x11340, 0x11340, Neutral },
+ { 0x11366, 0x1136C, Neutral },
+ { 0x11370, 0x11374, Neutral },
+ { 0x113BB, 0x113C0, Neutral },
+ { 0x113CE, 0x113CE, Neutral },
+ { 0x113D0, 0x113D0, Neutral },
+ { 0x113D2, 0x113D2, Neutral },
+ { 0x113E1, 0x113E2, Neutral },
+ { 0x11438, 0x1143F, Neutral },
+ { 0x11442, 0x11444, Neutral },
+ { 0x11446, 0x11446, Neutral },
+ { 0x1145E, 0x1145E, Neutral },
+ { 0x114B3, 0x114B8, Neutral },
+ { 0x114BA, 0x114BA, Neutral },
+ { 0x114BF, 0x114C0, Neutral },
+ { 0x114C2, 0x114C3, Neutral },
+ { 0x115B2, 0x115B5, Neutral },
+ { 0x115BC, 0x115BD, Neutral },
+ { 0x115BF, 0x115C0, Neutral },
+ { 0x115DC, 0x115DD, Neutral },
+ { 0x11633, 0x1163A, Neutral },
+ { 0x1163D, 0x1163D, Neutral },
+ { 0x1163F, 0x11640, Neutral },
+ { 0x11660, 0x1166C, Neutral },
+ { 0x116AB, 0x116AB, Neutral },
+ { 0x116AD, 0x116AD, Neutral },
+ { 0x116B0, 0x116B5, Neutral },
+ { 0x116B7, 0x116B7, Neutral },
+ { 0x1171D, 0x1171D, Neutral },
+ { 0x1171F, 0x1171F, Neutral },
+ { 0x11722, 0x11725, Neutral },
+ { 0x11727, 0x1172B, Neutral },
+ { 0x1182F, 0x11837, Neutral },
+ { 0x11839, 0x1183A, Neutral },
+ { 0x1193B, 0x1193C, Neutral },
+ { 0x1193E, 0x1193E, Neutral },
+ { 0x11943, 0x11943, Neutral },
+ { 0x119D4, 0x119D7, Neutral },
+ { 0x119DA, 0x119DB, Neutral },
+ { 0x119E0, 0x119E0, Neutral },
+ { 0x11A01, 0x11A06, Neutral },
+ { 0x11A09, 0x11A0A, Neutral },
+ { 0x11A33, 0x11A38, Neutral },
+ { 0x11A3B, 0x11A3E, Neutral },
+ { 0x11A47, 0x11A47, Neutral },
+ { 0x11A51, 0x11A56, Neutral },
+ { 0x11A59, 0x11A5B, Neutral },
+ { 0x11A8A, 0x11A96, Neutral },
+ { 0x11A98, 0x11A99, Neutral },
+ { 0x11B60, 0x11B60, Neutral },
+ { 0x11B62, 0x11B64, Neutral },
+ { 0x11B66, 0x11B66, Neutral },
+ { 0x11C30, 0x11C36, Neutral },
+ { 0x11C38, 0x11C3D, Neutral },
+ { 0x11C92, 0x11CA7, Neutral },
+ { 0x11CAA, 0x11CB0, Neutral },
+ { 0x11CB2, 0x11CB3, Neutral },
+ { 0x11CB5, 0x11CB6, Neutral },
+ { 0x11D31, 0x11D36, Neutral },
+ { 0x11D3A, 0x11D3A, Neutral },
+ { 0x11D3C, 0x11D3D, Neutral },
+ { 0x11D3F, 0x11D45, Neutral },
+ { 0x11D47, 0x11D47, Neutral },
+ { 0x11D90, 0x11D91, Neutral },
+ { 0x11D95, 0x11D95, Neutral },
+ { 0x11D97, 0x11D97, Neutral },
+ { 0x11EF3, 0x11EF4, Neutral },
+ { 0x11F00, 0x11F01, Neutral },
+ { 0x11F36, 0x11F3A, Neutral },
+ { 0x11F40, 0x11F40, Neutral },
+ { 0x11F42, 0x11F42, Neutral },
+ { 0x11F5A, 0x11F5A, Neutral },
+ { 0x11FD5, 0x11FF1, Neutral },
+ { 0x13440, 0x13440, Neutral },
+ { 0x13447, 0x13455, Neutral },
+ { 0x1611E, 0x16129, Neutral },
+ { 0x1612D, 0x1612F, Neutral },
+ { 0x16AF0, 0x16AF4, Neutral },
+ { 0x16B30, 0x16B36, Neutral },
+ { 0x16F4F, 0x16F4F, Neutral },
+ { 0x16F8F, 0x16F92, Neutral },
+ { 0x16FE2, 0x16FE2, Neutral },
+ { 0x16FE4, 0x16FE4, Neutral },
+ { 0x1BC9D, 0x1BC9E, Neutral },
+ { 0x1BCA0, 0x1BCA3, Neutral },
+ { 0x1CC00, 0x1CCD5, Neutral },
+ { 0x1CCF0, 0x1CCFC, Neutral },
+ { 0x1CD00, 0x1CEB3, Neutral },
+ { 0x1CEBA, 0x1CED0, Neutral },
+ { 0x1CEE0, 0x1CEF0, Neutral },
+ { 0x1CF00, 0x1CF2D, Neutral },
+ { 0x1CF30, 0x1CF46, Neutral },
+ { 0x1D167, 0x1D169, Neutral },
+ { 0x1D173, 0x1D182, Neutral },
+ { 0x1D185, 0x1D18B, Neutral },
+ { 0x1D1AA, 0x1D1AD, Neutral },
+ { 0x1D1E9, 0x1D1EA, Neutral },
+ { 0x1D200, 0x1D245, Neutral },
+ { 0x1D300, 0x1D356, Neutral },
+ { 0x1D6C1, 0x1D6C1, Neutral },
+ { 0x1D6DB, 0x1D6DB, Neutral },
+ { 0x1D6FB, 0x1D6FB, Neutral },
+ { 0x1D715, 0x1D715, Neutral },
+ { 0x1D735, 0x1D735, Neutral },
+ { 0x1D74F, 0x1D74F, Neutral },
+ { 0x1D76F, 0x1D76F, Neutral },
+ { 0x1D789, 0x1D789, Neutral },
+ { 0x1D7A9, 0x1D7A9, Neutral },
+ { 0x1D7C3, 0x1D7C3, Neutral },
+ { 0x1D7CE, 0x1D7FF, Neutral },
+ { 0x1DA00, 0x1DA36, Neutral },
+ { 0x1DA3B, 0x1DA6C, Neutral },
+ { 0x1DA75, 0x1DA75, Neutral },
+ { 0x1DA84, 0x1DA84, Neutral },
+ { 0x1DA9B, 0x1DA9F, Neutral },
+ { 0x1DAA1, 0x1DAAF, Neutral },
+ { 0x1E000, 0x1E006, Neutral },
+ { 0x1E008, 0x1E018, Neutral },
+ { 0x1E01B, 0x1E021, Neutral },
+ { 0x1E023, 0x1E024, Neutral },
+ { 0x1E026, 0x1E02A, Neutral },
+ { 0x1E08F, 0x1E08F, Neutral },
+ { 0x1E130, 0x1E136, Neutral },
+ { 0x1E2AE, 0x1E2AE, Neutral },
+ { 0x1E2EC, 0x1E2EF, Neutral },
+ { 0x1E2FF, 0x1E2FF, Neutral },
+ { 0x1E4EC, 0x1E4EF, Neutral },
+ { 0x1E5EE, 0x1E5EF, Neutral },
+ { 0x1E6E3, 0x1E6E3, Neutral },
+ { 0x1E6E6, 0x1E6E6, Neutral },
+ { 0x1E6EE, 0x1E6EF, Neutral },
+ { 0x1E6F5, 0x1E6F5, Neutral },
+ { 0x1E800, 0x1E8CF, RightToLeft },
+ { 0x1E8D0, 0x1E8D6, Neutral },
+ { 0x1E8D7, 0x1E943, RightToLeft },
+ { 0x1E944, 0x1E94A, Neutral },
+ { 0x1E94B, 0x1EEEF, RightToLeft },
+ { 0x1EEF0, 0x1EEF1, Neutral },
+ { 0x1EEF2, 0x1EFFF, RightToLeft },
+ { 0x1F000, 0x1F02B, Neutral },
+ { 0x1F030, 0x1F093, Neutral },
+ { 0x1F0A0, 0x1F0AE, Neutral },
+ { 0x1F0B1, 0x1F0BF, Neutral },
+ { 0x1F0C1, 0x1F0CF, Neutral },
+ { 0x1F0D1, 0x1F0F5, Neutral },
+ { 0x1F100, 0x1F10F, Neutral },
+ { 0x1F12F, 0x1F12F, Neutral },
+ { 0x1F16A, 0x1F16F, Neutral },
+ { 0x1F1AD, 0x1F1AD, Neutral },
+ { 0x1F260, 0x1F265, Neutral },
+ { 0x1F300, 0x1F6D8, Neutral },
+ { 0x1F6DC, 0x1F6EC, Neutral },
+ { 0x1F6F0, 0x1F6FC, Neutral },
+ { 0x1F700, 0x1F7D9, Neutral },
+ { 0x1F7E0, 0x1F7EB, Neutral },
+ { 0x1F7F0, 0x1F7F0, Neutral },
+ { 0x1F800, 0x1F80B, Neutral },
+ { 0x1F810, 0x1F847, Neutral },
+ { 0x1F850, 0x1F859, Neutral },
+ { 0x1F860, 0x1F887, Neutral },
+ { 0x1F890, 0x1F8AD, Neutral },
+ { 0x1F8B0, 0x1F8BB, Neutral },
+ { 0x1F8C0, 0x1F8C1, Neutral },
+ { 0x1F8D0, 0x1F8D8, Neutral },
+ { 0x1F900, 0x1FA57, Neutral },
+ { 0x1FA60, 0x1FA6D, Neutral },
+ { 0x1FA70, 0x1FA7C, Neutral },
+ { 0x1FA80, 0x1FA8A, Neutral },
+ { 0x1FA8E, 0x1FAC6, Neutral },
+ { 0x1FAC8, 0x1FAC8, Neutral },
+ { 0x1FACD, 0x1FADC, Neutral },
+ { 0x1FADF, 0x1FAEA, Neutral },
+ { 0x1FAEF, 0x1FAF8, Neutral },
+ { 0x1FB00, 0x1FB92, Neutral },
+ { 0x1FB94, 0x1FBFA, Neutral },
+ { 0x1FFFE, 0x1FFFF, Neutral },
+ { 0x2FFFE, 0x2FFFF, Neutral },
+ { 0x3FFFE, 0x3FFFF, Neutral },
+ { 0x4FFFE, 0x4FFFF, Neutral },
+ { 0x5FFFE, 0x5FFFF, Neutral },
+ { 0x6FFFE, 0x6FFFF, Neutral },
+ { 0x7FFFE, 0x7FFFF, Neutral },
+ { 0x8FFFE, 0x8FFFF, Neutral },
+ { 0x9FFFE, 0x9FFFF, Neutral },
+ { 0xAFFFE, 0xAFFFF, Neutral },
+ { 0xBFFFE, 0xBFFFF, Neutral },
+ { 0xCFFFE, 0xCFFFF, Neutral },
+ { 0xDFFFE, 0xE0FFF, Neutral },
+ { 0xEFFFE, 0xEFFFF, Neutral },
+ { 0xFFFFE, 0xFFFFF, Neutral },
+ { 0x10FFFE, 0x10FFFF, Neutral },
diff --git a/Core/Libraries/Source/WWVegas/WW3D2/unicode-license.txt b/Core/Libraries/Source/WWVegas/WW3D2/unicode-license.txt
new file mode 100644
index 00000000000..56da5891280
--- /dev/null
+++ b/Core/Libraries/Source/WWVegas/WW3D2/unicode-license.txt
@@ -0,0 +1,39 @@
+UNICODE LICENSE V3
+
+COPYRIGHT AND PERMISSION NOTICE
+
+Copyright © 1991-2026 Unicode, Inc.
+
+NOTICE TO USER: Carefully read the following legal agreement. BY
+DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
+SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
+TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
+DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of data files and any associated documentation (the "Data Files") or
+software and any associated documentation (the "Software") to deal in the
+Data Files or Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, and/or sell
+copies of the Data Files or Software, and to permit persons to whom the
+Data Files or Software are furnished to do so, provided that either (a)
+this copyright and permission notice appear with all copies of the Data
+Files or Software, or (b) this copyright and permission notice appear in
+associated Documentation.
+
+THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
+KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
+THIRD PARTY RIGHTS.
+
+IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
+BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
+OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
+WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
+ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
+FILES OR SOFTWARE.
+
+Except as contained in this notice, the name of a copyright holder shall
+not be used in advertising or otherwise to promote the sale, use or other
+dealings in these Data Files or Software without prior written
+authorization of the copyright holder.
diff --git a/Core/Libraries/Source/WWVegas/WW3D2/unicodebidi.h b/Core/Libraries/Source/WWVegas/WW3D2/unicodebidi.h
new file mode 100644
index 00000000000..b9d1dfcd306
--- /dev/null
+++ b/Core/Libraries/Source/WWVegas/WW3D2/unicodebidi.h
@@ -0,0 +1,84 @@
+/*
+** Command & Conquer Generals Zero Hour(tm)
+** Copyright 2026 TheSuperHackers
+**
+** This program is free software: you can redistribute it and/or modify
+** it under the terms of the GNU General Public License as published by
+** the Free Software Foundation, either version 3 of the License, or
+** (at your option) any later version.
+**
+** This program is distributed in the hope that it will be useful,
+** but WITHOUT ANY WARRANTY; without even the implied warranty of
+** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+** GNU General Public License for more details.
+**
+** You should have received a copy of the GNU General Public License
+** along with this program. If not, see .
+*/
+
+#pragma once
+
+#include "WWLib/win.h"
+
+namespace UnicodeBidi
+{
+
+enum Direction { Neutral, LeftToRight, RightToLeft };
+
+inline Direction Get_Supplementary_Direction(unsigned codepoint)
+{
+ struct Range { unsigned First, Last; Direction Value; };
+ static const Range ranges[] = {
+#include "supplementarybidi.inl"
+ };
+ unsigned first = 0, last = sizeof(ranges) / sizeof(ranges[0]);
+ while (first < last) {
+ const unsigned middle = first + (last - first) / 2;
+ if (codepoint < ranges[middle].First) {
+ last = middle;
+ } else if (codepoint > ranges[middle].Last) {
+ first = middle + 1;
+ } else {
+ return ranges[middle].Value;
+ }
+ }
+ return LeftToRight;
+}
+
+// TheSuperHackers @bugfix Omar Aglan 13/09/2026 Determine paragraph direction from complete UTF-16
+// characters. GetStringTypeW does not classify supplementary characters reliably.
+inline WORD Get_Paragraph_Level(const WCHAR *text, int length)
+{
+ int isolate_depth = 0;
+ for (int index = 0; index < length; ++index) {
+ const WCHAR ch = text[index];
+ if (ch >= 0x2066 && ch <= 0x2068) {
+ ++isolate_depth;
+ continue;
+ }
+ if (ch == 0x2069) {
+ if (isolate_depth > 0) --isolate_depth;
+ continue;
+ }
+ // UAX #9 P2 excludes the contents of directional isolates.
+ if (isolate_depth > 0) continue;
+
+ Direction direction = Neutral;
+ if (ch >= 0xD800 && ch <= 0xDBFF) {
+ if (index + 1 < length && text[index + 1] >= 0xDC00 && text[index + 1] <= 0xDFFF) {
+ const unsigned codepoint = 0x10000 + ((ch - 0xD800) << 10) + (text[++index] - 0xDC00);
+ direction = Get_Supplementary_Direction(codepoint);
+ }
+ } else if (ch < 0xDC00 || ch > 0xDFFF) {
+ WORD type = C2_NOTAPPLICABLE;
+ if (::GetStringTypeW(CT_CTYPE2, &ch, 1, &type)) {
+ if (type == C2_LEFTTORIGHT) direction = LeftToRight;
+ if (type == C2_RIGHTTOLEFT) direction = RightToLeft;
+ }
+ }
+ if (direction != Neutral) return direction == RightToLeft ? 1 : 0;
+ }
+ return 0;
+}
+
+}
diff --git a/Core/Libraries/Source/WWVegas/WW3D2/ww3d.cpp b/Core/Libraries/Source/WWVegas/WW3D2/ww3d.cpp
index c43588d279d..0acf0831f86 100644
--- a/Core/Libraries/Source/WWVegas/WW3D2/ww3d.cpp
+++ b/Core/Libraries/Source/WWVegas/WW3D2/ww3d.cpp
@@ -115,6 +115,9 @@
#include "sortingrenderer.h"
#include "WWLib/thread.h"
#include "WWLib/cpudetect.h"
+#if defined(_WIN32)
+#include "WWLib/Usp10Loader.h"
+#endif
#include "dx8texman.h"
#include "animatedsoundmgr.h"
#include "static_sort_list.h"
@@ -384,6 +387,9 @@ WW3DErrorType WW3D::Shutdown()
** Release the animation-triggered sound data
*/
AnimatedSoundMgrClass::Shutdown ();
+#if defined(_WIN32)
+ Usp10Loader::unload();
+#endif
IsInitted = false;
return WW3D_ERROR_OK;
diff --git a/Core/Libraries/Source/WWVegas/WWLib/Usp10Loader.h b/Core/Libraries/Source/WWVegas/WWLib/Usp10Loader.h
index 3b35b03b82b..2ac7646b75d 100644
--- a/Core/Libraries/Source/WWVegas/WWLib/Usp10Loader.h
+++ b/Core/Libraries/Source/WWVegas/WWLib/Usp10Loader.h
@@ -29,9 +29,16 @@ class Usp10Loader
public:
typedef void *ScriptStringAnalysis;
- struct ScriptControl;
struct ScriptTabDefinition;
+ // TheSuperHackers @info Omar Aglan 13/09/2026 ScriptItemize needs a non-null control
+ // structure for bidirectional analysis. Keep the unused control flags zero.
+ struct ScriptControl
+ {
+ DWORD default_language : 16;
+ DWORD reserved : 16;
+ };
+
struct ScriptState
{
WORD bidi_level : 5;
diff --git a/scripts/generate_supplementary_bidi.py b/scripts/generate_supplementary_bidi.py
new file mode 100644
index 00000000000..e5e779c64b6
--- /dev/null
+++ b/scripts/generate_supplementary_bidi.py
@@ -0,0 +1,54 @@
+"""Generate the supplementary first-strong-direction table from Unicode 17.0.0.
+
+Input: https://www.unicode.org/Public/17.0.0/ucd/extracted/DerivedBidiClass.txt
+Usage: python scripts/generate_supplementary_bidi.py DerivedBidiClass.txt [--check]
+"""
+
+import argparse
+import hashlib
+from pathlib import Path
+import re
+
+
+def generate(source):
+ if not source.startswith("# DerivedBidiClass-17.0.0.txt"):
+ raise ValueError("Expected Unicode 17.0.0 DerivedBidiClass.txt")
+ if hashlib.sha256(source.encode("utf-8")).hexdigest() != "4867b4b7f0731ed1bfcd34cc6251211ff1542541fce0734b6fbda139ee80b3a4":
+ raise ValueError("Unexpected Unicode data checksum")
+ classes = ["L"] * 0x110000
+ aliases = {"Left_To_Right": "L", "Right_To_Left": "R", "Arabic_Letter": "AL"}
+ missing = re.findall(r"@missing: ([0-9A-F]+)\.\.([0-9A-F]+); (\w+)", source)
+ explicit = re.findall(r"^([0-9A-F]+)(?:\.\.([0-9A-F]+))?\s*;\s*(\w+)", source, re.M)
+ for lo, hi, value in missing + explicit:
+ lo, hi = int(lo, 16), int(hi or lo, 16)
+ classes[lo:hi + 1] = [aliases.get(value, value)] * (hi - lo + 1)
+ directions = ["LeftToRight" if c == "L" else "RightToLeft" if c in ("R", "AL")
+ else "Neutral" for c in classes]
+ lines = [
+ "// Generated by scripts/generate_supplementary_bidi.py; do not edit.",
+ "// Unicode 17.0.0 DerivedBidiClass.txt, including its @missing defaults.",
+ "// Copyright 2025 Unicode, Inc. See unicode-license.txt.",
+ "// Omitted ranges have direction LeftToRight. Neutral means neither L, R nor AL.",
+ ]
+ start = 0x10000
+ for end in range(start + 1, len(directions) + 1):
+ if end == len(directions) or directions[end] != directions[start]:
+ if directions[start] != "LeftToRight":
+ lines.append("\t{ 0x%05X, 0x%05X, %s }," % (start, end - 1, directions[start]))
+ start = end
+ return "\n".join(lines) + "\n"
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("source", type=Path)
+ parser.add_argument("--check", action="store_true")
+ args = parser.parse_args()
+ output = Path(__file__).resolve().parents[1] / "Core/Libraries/Source/WWVegas/WW3D2/supplementarybidi.inl"
+ generated = generate(args.source.read_text(encoding="utf-8"))
+ if args.check:
+ if output.read_text(encoding="utf-8") != generated:
+ raise SystemExit("Supplementary bidi table is out of date")
+ print("Unicode 17.0.0 supplementary bidi table matches")
+ else:
+ output.write_text(generated, encoding="utf-8")