From e7a113885a89d380de835bc97f5658251e648cb1 Mon Sep 17 00:00:00 2001 From: rtw1x1 Date: Tue, 30 Dec 2025 22:20:04 +0000 Subject: [PATCH 1/2] fix: Better BiDi logic for formatting --- extern/include/utf8.h | 351 ++++++++++++++++++++++++------- src/EterLib/GrpTextInstance.cpp | 314 +++++++++++++++++++++------ src/EterLib/GrpTextInstance.h | 4 + src/UserInterface/PythonChat.cpp | 44 +++- 4 files changed, 563 insertions(+), 150 deletions(-) diff --git a/extern/include/utf8.h b/extern/include/utf8.h index 1ed5eef..e6101b3 100644 --- a/extern/include/utf8.h +++ b/extern/include/utf8.h @@ -22,9 +22,11 @@ constexpr size_t ARABIC_SHAPING_EXPANSION_FACTOR_RETRY = 4; constexpr size_t ARABIC_SHAPING_SAFETY_MARGIN_RETRY = 64; // ============================================================================ -// DEBUG LOGGING (Uncomment to enable BiDi debugging) +// DEBUG LOGGING (Only enabled in Debug builds) // ============================================================================ -// #define DEBUG_BIDI +#ifdef _DEBUG + #define DEBUG_BIDI // Enabled in debug builds for diagnostics +#endif #ifdef DEBUG_BIDI #include @@ -242,8 +244,9 @@ static inline bool IsNameTokenPunct(wchar_t ch) case L'\\': case L'(': case L')': - case L'[': - case L']': + // Brackets are handled specially - see GetCharDirSmart + // case L'[': + // case L']': case L'{': case L'}': case L'<': @@ -264,16 +267,44 @@ static inline bool IsStrongLTR(wchar_t ch) static inline bool HasStrongLTRNeighbor(const wchar_t* s, int n, int i) { - // Remove null/size check (caller guarantees validity) - // Early exit after first strong neighbor found + // Skip neutral characters (spaces, punctuation) to find nearest strong character + // This fixes mixed-direction text like "english + arabic" - // Check previous character - if (i > 0 && IsStrongLTR(s[i - 1])) - return true; + // Search backwards for strong character (skip neutrals/whitespace) + for (int j = i - 1; j >= 0; --j) + { + wchar_t ch = s[j]; - // Check next character - if (i + 1 < n && IsStrongLTR(s[i + 1])) - return true; + // Skip spaces and common neutral punctuation + if (ch == L' ' || ch == L'\t' || ch == L'\n') + continue; + + // Found strong LTR + if (IsStrongLTR(ch)) + return true; + + // Found strong RTL or other strong character + if (IsRTLCodepoint(ch) || IsStrongAlpha(ch)) + break; + } + + // Search forwards for strong character (skip neutrals/whitespace) + for (int j = i + 1; j < n; ++j) + { + wchar_t ch = s[j]; + + // Skip spaces and common neutral punctuation + if (ch == L' ' || ch == L'\t' || ch == L'\n') + continue; + + // Found strong LTR + if (IsStrongLTR(ch)) + return true; + + // Found strong RTL or other strong character + if (IsRTLCodepoint(ch) || IsStrongAlpha(ch)) + break; + } return false; } @@ -302,6 +333,132 @@ static inline ECharDir GetCharDirSmart(const wchar_t* s, int n, int i) if (IsStrongLTR(ch)) return ECharDir::LTR; + // Parentheses: always LTR to keep them with their content + if (ch == L'(' || ch == L')') + return ECharDir::LTR; + + // Percentage sign: attach to numbers (scan nearby for digits/minus/plus) + // Handles: "%20", "20%", "-6%", "%d%%", etc. + if (ch == L'%') + { + // Look backward for digit, %, -, or + + for (int j = i - 1; j >= 0 && (i - j) < 5; --j) + { + wchar_t prev = s[j]; + if (IsDigit(prev) || prev == L'%' || prev == L'-' || prev == L'+') + return ECharDir::LTR; + if (prev != L' ' && prev != L'\t') + break; // Stop if we hit non-numeric character + } + // Look forward for digit, %, -, or + + for (int j = i + 1; j < n && (j - i) < 5; ++j) + { + wchar_t next = s[j]; + if (IsDigit(next) || next == L'%' || next == L'-' || next == L'+') + return ECharDir::LTR; + if (next != L' ' && next != L'\t') + break; // Stop if we hit non-numeric character + } + return ECharDir::Neutral; + } + + // Minus/dash: attach to numbers (scan nearby for digits/%) + // Handles: "-6", "5-10", "-6%%", etc. + if (ch == L'-') + { + // Look backward for digit or % + for (int j = i - 1; j >= 0 && (i - j) < 3; --j) + { + wchar_t prev = s[j]; + if (IsDigit(prev) || prev == L'%') + return ECharDir::LTR; + if (prev != L' ' && prev != L'\t') + break; + } + // Look forward for digit or % + for (int j = i + 1; j < n && (j - i) < 3; ++j) + { + wchar_t next = s[j]; + if (IsDigit(next) || next == L'%') + return ECharDir::LTR; + if (next != L' ' && next != L'\t') + break; + } + return ECharDir::Neutral; + } + + // Colon: attach to preceding text direction + // Look backward to find strong character + if (ch == L':') + { + for (int j = i - 1; j >= 0; --j) + { + if (s[j] == L' ' || s[j] == L'\t') + continue; // Skip spaces + if (IsRTLCodepoint(s[j])) + return ECharDir::RTL; // Attach to RTL text + if (IsStrongLTR(s[j])) + return ECharDir::LTR; // Attach to LTR text + } + return ECharDir::Neutral; + } + + // Enhancement marker: '+' followed by digit(s) should attach to preceding text + // If preceded by RTL, treat as RTL to keep "+9" with the item name + // Otherwise treat as LTR + if (ch == L'+' && i + 1 < n && IsDigit(s[i + 1])) + { + // Look backward for the last strong character + for (int j = i - 1; j >= 0; --j) + { + if (IsRTLCodepoint(s[j])) + return ECharDir::RTL; // Attach to preceding RTL text + if (IsStrongLTR(s[j])) + return ECharDir::LTR; // Attach to preceding LTR text + // Skip neutral characters + } + return ECharDir::LTR; // Default to LTR if no strong character found + } + + // Brackets: always attach to the content inside them + // This fixes hyperlinks like "[درع فولاذي أسود+9]" + if (ch == L'[' || ch == L']') + { + // Opening bracket '[': look forward for strong character + if (ch == L'[') + { + for (int j = i + 1; j < n; ++j) + { + wchar_t next = s[j]; + if (next == L']') break; // End of bracket content + if (IsRTLCodepoint(next)) return ECharDir::RTL; + if (IsStrongLTR(next)) return ECharDir::LTR; + } + } + // Closing bracket ']': look backward for strong character + else if (ch == L']') + { + for (int j = i - 1; j >= 0; --j) + { + wchar_t prev = s[j]; + if (prev == L'[') break; // Start of bracket content + if (IsRTLCodepoint(prev)) return ECharDir::RTL; + if (IsStrongLTR(prev)) return ECharDir::LTR; + } + } + // If we can't determine, treat as neutral + return ECharDir::Neutral; + } + + // Spaces should attach to adjacent strong characters to avoid fragmentation + // This fixes "english + arabic" by keeping " + " with "english" + if (ch == L' ' || ch == L'\t') + { + if (HasStrongLTRNeighbor(s, n, i)) + return ECharDir::LTR; + // Note: We don't check for RTL neighbor because ResolveNeutralDir handles that + } + // Name-token punctuation: if adjacent to LTR, treat as LTR to keep token intact if (IsNameTokenPunct(ch) && HasStrongLTRNeighbor(s, n, i)) return ECharDir::LTR; @@ -318,10 +475,11 @@ struct TStrongDirCache TStrongDirCache(const wchar_t* s, int n, EBidiDir base) : nextStrong(n), baseDir(base) { // Build reverse lookup: scan from end to beginning + // Use GetCharDirSmart for context-aware character classification EBidiDir lastSeen = baseDir; for (int i = n - 1; i >= 0; --i) { - ECharDir cd = GetCharDir(s[i]); + ECharDir cd = GetCharDirSmart(s, n, i); if (cd == ECharDir::LTR) lastSeen = EBidiDir::LTR; else if (cd == ECharDir::RTL) @@ -397,66 +555,6 @@ static std::vector BuildVisualBidiText_Tagless(const wchar_t* s, int n, if (!s || n <= 0) return {}; - // Detect chat format "name : msg" and extract components - int chatSepPos = -1; - for (int i = 0; i < n - 2; ++i) - { - if (s[i] == L' ' && s[i + 1] == L':' && s[i + 2] == L' ') - { - chatSepPos = i; - break; - } - } - - // If chat format detected, process name and message separately - if (chatSepPos > 0 && forceRTL) - { - // Use pointers instead of copying (zero-copy optimization) - const wchar_t* name = s; - const int nameLen = chatSepPos; - - const int msgStart = chatSepPos + 3; - const wchar_t* msg = s + msgStart; - const int msgLen = n - msgStart; - - // Check if message contains RTL - bool msgHasRTL = false; - for (int i = 0; i < msgLen; ++i) - { - if (IsRTLCodepoint(msg[i])) - { - msgHasRTL = true; - break; - } - } - - // Build result based on message direction (pre-reserve exact size) - std::vector visual; - visual.reserve((size_t)n); - - if (msgHasRTL) - { - // Arabic message: apply BiDi to message, then add " : name" - std::vector msgVisual = BuildVisualBidiText_Tagless(msg, msgLen, false); - visual.insert(visual.end(), msgVisual.begin(), msgVisual.end()); - visual.push_back(L' '); - visual.push_back(L':'); - visual.push_back(L' '); - visual.insert(visual.end(), name, name + nameLen); // Direct pointer insert - } - else - { - // English message: "msg : name" - visual.insert(visual.end(), msg, msg + msgLen); // Direct pointer insert - visual.push_back(L' '); - visual.push_back(L':'); - visual.push_back(L' '); - visual.insert(visual.end(), name, name + nameLen); // Direct pointer insert - } - - return visual; - } - // 1) base direction EBidiDir base = forceRTL ? EBidiDir::RTL : DetectBaseDir_FirstStrong(s, n); @@ -503,6 +601,16 @@ static std::vector BuildVisualBidiText_Tagless(const wchar_t* s, int n, d = ResolveNeutralDir(s, n, i, base, lastStrong, &strongCache); } +#ifdef DEBUG_BIDI + if (i < 50) // Only log first 50 chars to avoid spam + { + BIDI_LOG("Char[%d] U+%04X '%lc' → CharDir=%s, RunDir=%s", + i, (unsigned int)ch, (ch >= 32 && ch < 127) ? ch : L'?', + cd == ECharDir::RTL ? "RTL" : (cd == ECharDir::LTR ? "LTR" : "Neutral"), + d == EBidiDir::RTL ? "RTL" : "LTR"); + } +#endif + push_run(d); runs.back().text.push_back(ch); } @@ -528,7 +636,13 @@ static std::vector BuildVisualBidiText_Tagless(const wchar_t* s, int n, int outLen = Arabic_MakeShape(r.text.data(), (int)r.text.size(), shaped.data(), (int)shaped.size()); if (outLen <= 0) { - BIDI_LOG("Arabic_MakeShape failed for run of %zu chars", r.text.size()); + BIDI_LOG("Arabic_MakeShape FAILED for RTL run of %zu characters", r.text.size()); + BIDI_LOG(" WARNING: This RTL text segment will NOT be displayed!"); + BIDI_LOG(" First few characters: U+%04X U+%04X U+%04X U+%04X", + r.text.size() > 0 ? (unsigned int)r.text[0] : 0, + r.text.size() > 1 ? (unsigned int)r.text[1] : 0, + r.text.size() > 2 ? (unsigned int)r.text[2] : 0, + r.text.size() > 3 ? (unsigned int)r.text[3] : 0); continue; } @@ -584,6 +698,95 @@ static std::vector BuildVisualBidiText_Tagless(const wchar_t* s, int n, return visual; } +// ============================================================================ +// Chat Message BiDi Processing (Separate name/message handling) +// ============================================================================ + +// Build visual BiDi text for chat messages with separate name and message +// This avoids fragile " : " detection and handles cases where username contains " : " +// +// RECOMMENDED USAGE: +// Instead of: SetValue("PlayerName : Message") +// Use this function with separated components: +// - name: "PlayerName" (without " : ") +// - msg: "Message" (without " : ") +// +// INTEGRATION NOTES: +// To use this properly, you need to: +// 1. Modify the server/network code to send chat name and message separately +// 2. Or parse the chat string in PythonNetworkStreamPhaseGame.cpp BEFORE passing to GrpTextInstance +// 3. Then call this function instead of BuildVisualBidiText_Tagless +// +static inline std::vector BuildVisualChatMessage( + const wchar_t* name, int nameLen, + const wchar_t* msg, int msgLen, + bool forceRTL) +{ + if (!name || !msg || nameLen <= 0 || msgLen <= 0) + return {}; + + // Check if message contains RTL or hyperlink tags + bool msgHasRTL = false; + bool msgHasTags = false; + for (int i = 0; i < msgLen; ++i) + { + if (IsRTLCodepoint(msg[i])) + msgHasRTL = true; + if (msg[i] == L'|') + msgHasTags = true; + if (msgHasRTL && msgHasTags) + break; + } + + // Build result based on UI direction (pre-reserve exact size) + std::vector visual; + visual.reserve((size_t)(nameLen + msgLen + 3)); // +3 for " : " + + // Decision: UI direction determines order (for visual consistency) + // RTL UI: "Message : Name" (message on right, consistent with RTL reading flow) + // LTR UI: "Name : Message" (name on left, consistent with LTR reading flow) + if (forceRTL) + { + // RTL UI: "Message : Name" + // Don't apply BiDi if message has tags (hyperlinks are pre-formatted) + if (msgHasTags) + { + visual.insert(visual.end(), msg, msg + msgLen); + } + else + { + // Apply BiDi to message based on its content + std::vector msgVisual = BuildVisualBidiText_Tagless(msg, msgLen, msgHasRTL); + visual.insert(visual.end(), msgVisual.begin(), msgVisual.end()); + } + visual.push_back(L' '); + visual.push_back(L':'); + visual.push_back(L' '); + visual.insert(visual.end(), name, name + nameLen); // Name on left side + } + else + { + // LTR UI: "Name : Message" + visual.insert(visual.end(), name, name + nameLen); // Name on left side + visual.push_back(L' '); + visual.push_back(L':'); + visual.push_back(L' '); + // Don't apply BiDi if message has tags (hyperlinks are pre-formatted) + if (msgHasTags) + { + visual.insert(visual.end(), msg, msg + msgLen); + } + else + { + // Apply BiDi to message based on its content + std::vector msgVisual = BuildVisualBidiText_Tagless(msg, msgLen, msgHasRTL); + visual.insert(visual.end(), msgVisual.begin(), msgVisual.end()); + } + } + + return visual; +} + // ============================================================================ // TextTail formatting for RTL UI // ============================================================================ diff --git a/src/EterLib/GrpTextInstance.cpp b/src/EterLib/GrpTextInstance.cpp index 22ada7b..a3d9539 100644 --- a/src/EterLib/GrpTextInstance.cpp +++ b/src/EterLib/GrpTextInstance.cpp @@ -135,11 +135,37 @@ void CGraphicTextInstance::Update() // UTF-8 -> UTF-16 conversion - reserve enough space to avoid reallocation std::vector wTextBuf((size_t)utf8Len + 1u, 0); - const int wTextLen = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, utf8, utf8Len, wTextBuf.data(), (int)wTextBuf.size()); + int wTextLen = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, utf8, utf8Len, wTextBuf.data(), (int)wTextBuf.size()); + + // If strict UTF-8 conversion fails, try lenient mode (replaces invalid sequences) if (wTextLen <= 0) { - ResetState(); - return; + DWORD dwError = GetLastError(); + BIDI_LOG("GrpTextInstance::Update() - STRICT UTF-8 conversion failed (error %u), trying LENIENT mode", dwError); + BIDI_LOG(" Text length: %d bytes", utf8Len); + BIDI_LOG(" First 32 bytes (hex): %02X %02X %02X %02X %02X %02X %02X %02X...", + (utf8Len > 0 ? (unsigned char)utf8[0] : 0), + (utf8Len > 1 ? (unsigned char)utf8[1] : 0), + (utf8Len > 2 ? (unsigned char)utf8[2] : 0), + (utf8Len > 3 ? (unsigned char)utf8[3] : 0), + (utf8Len > 4 ? (unsigned char)utf8[4] : 0), + (utf8Len > 5 ? (unsigned char)utf8[5] : 0), + (utf8Len > 6 ? (unsigned char)utf8[6] : 0), + (utf8Len > 7 ? (unsigned char)utf8[7] : 0)); + BIDI_LOG(" Text preview: %.64s", utf8); + + // Try lenient conversion (no MB_ERR_INVALID_CHARS flag) + // This will replace invalid UTF-8 sequences with default character + wTextLen = MultiByteToWideChar(CP_UTF8, 0, utf8, utf8Len, wTextBuf.data(), (int)wTextBuf.size()); + + if (wTextLen <= 0) + { + BIDI_LOG(" LENIENT conversion also failed! Text cannot be displayed."); + ResetState(); + return; + } + + BIDI_LOG(" LENIENT conversion succeeded - text will display with replacement characters"); } @@ -244,38 +270,57 @@ void CGraphicTextInstance::Update() m_visualToLogicalPos[i] = i; } - // Check for RTL characters and chat message format in single pass - bool hasRTL = false; - bool isChatMessage = false; - const wchar_t* wTextPtr = wTextBuf.data(); - - for (int i = 0; i < wTextLen; ++i) + // Use optimized chat message processing if SetChatValue was used + if (m_isChatMessage && !m_chatName.empty() && !m_chatMessage.empty()) { - if (!hasRTL && IsRTLCodepoint(wTextPtr[i])) - hasRTL = true; + // Convert chat name and message to wide chars + std::wstring wName = Utf8ToWide(m_chatName); + std::wstring wMsg = Utf8ToWide(m_chatMessage); - if (!isChatMessage && i < wTextLen - 2 && - wTextPtr[i] == L' ' && wTextPtr[i + 1] == L':' && wTextPtr[i + 2] == L' ') - isChatMessage = true; + // Use BuildVisualChatMessage for proper BiDi handling + std::vector visual = BuildVisualChatMessage( + wName.data(), (int)wName.size(), + wMsg.data(), (int)wMsg.size(), + baseRTL); - // Early exit if both found - if (hasRTL && isChatMessage) - break; - } - - // Apply BiDi if text contains RTL OR is a chat message in RTL UI - // Skip BiDi for regular input text like :)) in RTL UI - if (hasRTL || (baseRTL && isChatMessage)) - { - std::vector visual = BuildVisualBidiText_Tagless(wTextBuf.data(), wTextLen, baseRTL); for (size_t i = 0; i < visual.size(); ++i) __DrawCharacter(pFontTexture, visual[i], defaultColor); } else { - // Pure LTR text or non-chat input - no BiDi processing + // Legacy path: Check for RTL characters and chat message format in single pass + bool hasRTL = false; + bool isChatMessage = false; + const wchar_t* wTextPtr = wTextBuf.data(); + for (int i = 0; i < wTextLen; ++i) - __DrawCharacter(pFontTexture, wTextBuf[i], defaultColor); + { + if (!hasRTL && IsRTLCodepoint(wTextPtr[i])) + hasRTL = true; + + if (!isChatMessage && i < wTextLen - 2 && + wTextPtr[i] == L' ' && wTextPtr[i + 1] == L':' && wTextPtr[i + 2] == L' ') + isChatMessage = true; + + // Early exit if both found + if (hasRTL && isChatMessage) + break; + } + + // Apply BiDi if text contains RTL OR is a chat message in RTL UI + // Skip BiDi for regular input text like :)) in RTL UI + if (hasRTL || (baseRTL && isChatMessage)) + { + std::vector visual = BuildVisualBidiText_Tagless(wTextBuf.data(), wTextLen, baseRTL); + for (size_t i = 0; i < visual.size(); ++i) + __DrawCharacter(pFontTexture, visual[i], defaultColor); + } + else + { + // Pure LTR text or non-chat input - no BiDi processing + for (int i = 0; i < wTextLen; ++i) + __DrawCharacter(pFontTexture, wTextBuf[i], defaultColor); + } } } // ======================================================================== @@ -283,6 +328,37 @@ void CGraphicTextInstance::Update() // ======================================================================== else { + // Special handling for chat messages with tags (e.g., hyperlinks) + // We need to process the message separately and then combine with name + std::wstring chatNameWide; + bool isChatWithTags = false; + + if (m_isChatMessage && !m_chatName.empty() && !m_chatMessage.empty()) + { + isChatWithTags = true; + chatNameWide = Utf8ToWide(m_chatName); + + // Process only the message part (not the full text) + const char* msgUtf8 = m_chatMessage.c_str(); + const int msgUtf8Len = (int)m_chatMessage.size(); + + // Re-convert to wide chars for message only + wTextBuf.clear(); + wTextBuf.resize((size_t)msgUtf8Len + 1u, 0); + wTextLen = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, msgUtf8, msgUtf8Len, wTextBuf.data(), (int)wTextBuf.size()); + + if (wTextLen <= 0) + { + // Try lenient conversion + wTextLen = MultiByteToWideChar(CP_UTF8, 0, msgUtf8, msgUtf8Len, wTextBuf.data(), (int)wTextBuf.size()); + if (wTextLen <= 0) + { + ResetState(); + return; + } + } + } + // Check if text contains RTL characters (cache pointer for performance) bool hasRTL = false; const wchar_t* wTextPtr = wTextBuf.data(); @@ -302,7 +378,7 @@ void CGraphicTextInstance::Update() int logicalPos; // logical index in original wTextBuf (includes tags) }; - auto ReorderTaggedWithBidi = [&](std::vector& vis, bool forceRTL) + auto ReorderTaggedWithBidi = [&](std::vector& vis, bool forceRTL, bool isHyperlink, bool shapeOnly = false) { if (vis.empty()) return; @@ -313,7 +389,71 @@ void CGraphicTextInstance::Update() for (const auto& vc : vis) buf.push_back(vc.ch); - // Use the exact same BiDi engine as tagless text + // Special handling for hyperlinks: extract content between [ and ], apply BiDi, then re-add brackets + if (isHyperlink && buf.size() > 2) + { + // Find opening and closing brackets + int openIdx = -1, closeIdx = -1; + for (int i = 0; i < (int)buf.size(); ++i) + { + if (buf[i] == L'[' && openIdx < 0) openIdx = i; + if (buf[i] == L']' && closeIdx < 0) closeIdx = i; + } + + // If we have valid brackets, process content between them + if (openIdx >= 0 && closeIdx > openIdx) + { + // Extract content (without brackets) + std::vector content(buf.begin() + openIdx + 1, buf.begin() + closeIdx); + + // Apply BiDi to content with LTR base (keeps +9 at end) + std::vector contentVisual = BuildVisualBidiText_Tagless(content.data(), (int)content.size(), false); + + // Rebuild: everything before '[', then '[', then processed content, then ']', then everything after + std::vector visual; + visual.reserve(buf.size() + contentVisual.size()); + + // Copy prefix (before '[') + visual.insert(visual.end(), buf.begin(), buf.begin() + openIdx); + + // Add opening bracket + visual.push_back(L'['); + + // Add processed content + visual.insert(visual.end(), contentVisual.begin(), contentVisual.end()); + + // Add closing bracket + visual.push_back(L']'); + + // Copy suffix (after ']') + visual.insert(visual.end(), buf.begin() + closeIdx + 1, buf.end()); + + // Handle size change due to Arabic shaping + if ((int)visual.size() != (int)vis.size()) + { + std::vector resized; + resized.reserve(visual.size()); + + for (size_t i = 0; i < visual.size(); ++i) + { + size_t src = (i < vis.size()) ? i : (vis.size() - 1); + TVisChar tmp = vis[src]; + tmp.ch = visual[i]; + resized.push_back(tmp); + } + vis.swap(resized); + } + else + { + // Same size: write back characters + for (size_t i = 0; i < vis.size(); ++i) + vis[i].ch = visual[i]; + } + return; + } + } + + // Non-hyperlink or no brackets found: use normal BiDi processing std::vector visual = BuildVisualBidiText_Tagless(buf.data(), (int)buf.size(), forceRTL); // If size differs (rare, but can happen with Arabic shaping expansion), @@ -434,7 +574,10 @@ void CGraphicTextInstance::Update() // ==================================================================== // PHASE 2: Apply BiDi to hyperlinks (if RTL text or RTL UI) // ==================================================================== - if (hasRTL || baseRTL) + // DISABLED: Hyperlinks are pre-formatted and stored in visual order + // Applying BiDi to them reverses text that's already correct + // This section is kept for reference but should not execute + if (false) { // Collect all hyperlink ranges (reserve typical count) struct LinkRange { int start; int end; int linkIdx; }; @@ -578,8 +721,13 @@ void CGraphicTextInstance::Update() } } - // Apply BiDi to non-hyperlink segments and reorder segments for RTL UI - if (hasRTL || baseRTL) + // Apply BiDi to segments - always process hyperlinks, optionally process other text + // For input fields: only process hyperlinks (preserve cursor for regular text) + // For display text: process everything and reorder segments + const bool hasHyperlinks = !linkTargets.empty(); + const bool shouldProcessBidi = (hasRTL || baseRTL) && (!m_isCursor || hasHyperlinks); + + if (shouldProcessBidi) { // Split text into hyperlink and non-hyperlink segments (reserve typical count) const size_t estimatedSegments = linkTargets.size() * 2 + 1; @@ -609,21 +757,32 @@ void CGraphicTextInstance::Update() } } - // Apply BiDi to non-hyperlink segments only (cache segment count) + // Apply BiDi to segments + // For input fields: skip non-hyperlink segments to preserve cursor + // For display text: process all segments const size_t numSegments = segments.size(); for (size_t s = 0; s < numSegments; ++s) { - if (!isHyperlink[s]) - ReorderTaggedWithBidi(segments[s], baseRTL); + // Skip non-hyperlink segments in input fields to preserve cursor + if (m_isCursor && !isHyperlink[s]) + continue; + + ReorderTaggedWithBidi(segments[s], baseRTL, isHyperlink[s], false); } - // Rebuild text from segments (reverse order for RTL UI) + // Rebuild text from segments logicalVis.clear(); logicalVis.reserve(logicalVisSize2); // Reserve original size - if (baseRTL) + // IMPORTANT: Only reverse segments for display text, NOT for input fields + // Input fields need to preserve logical order for proper cursor positioning + // Display text (received chat messages) can reverse for better RTL reading flow + const bool shouldReverseSegments = baseRTL && !m_isCursor; + + if (shouldReverseSegments) { - // RTL UI - reverse segments for right-to-left reading + // RTL display text - reverse segments for right-to-left reading + // Example: "Hello [link]" becomes "[link] Hello" visually for (int s = (int)numSegments - 1; s >= 0; --s) { logicalVis.insert(logicalVis.end(), segments[s].begin(), segments[s].end()); @@ -631,7 +790,8 @@ void CGraphicTextInstance::Update() } else { - // LTR UI - keep original segment order + // LTR UI or input field - keep original segment order + // Input fields must preserve logical order for cursor to work correctly for (size_t s = 0; s < numSegments; ++s) { logicalVis.insert(logicalVis.end(), segments[s].begin(), segments[s].end()); @@ -690,6 +850,18 @@ void CGraphicTextInstance::Update() curLinkRange.sx = 0; curLinkRange.ex = 0; + // For LTR chat messages with tags, render name and separator first + if (isChatWithTags && !chatNameWide.empty() && !baseRTL) + { + // LTR UI: "Name : Message" + for (size_t i = 0; i < chatNameWide.size(); ++i) + x += __DrawCharacter(pFontTexture, chatNameWide[i], defaultColor); + x += __DrawCharacter(pFontTexture, L' ', defaultColor); + x += __DrawCharacter(pFontTexture, L':', defaultColor); + x += __DrawCharacter(pFontTexture, L' ', defaultColor); + } + // For RTL, we'll append name AFTER the message (see below) + // Cache size for loop (avoid repeated size() calls) const size_t logicalVisRenderSize = logicalVis.size(); for (size_t idx = 0; idx < logicalVisRenderSize; ++idx) @@ -733,6 +905,19 @@ void CGraphicTextInstance::Update() curLinkRange.text = linkTargets[(size_t)currentLink]; m_hyperlinkVector.push_back(curLinkRange); } + + // Add chat name and separator for RTL messages with tags + if (isChatWithTags && !chatNameWide.empty() && baseRTL) + { + // RTL UI: "Message : Name" (render order matches BuildVisualChatMessage) + // Message was already rendered above, now append separator and name + // When RTL-aligned, this produces visual: [item] on right, name on left + __DrawCharacter(pFontTexture, L' ', defaultColor); + __DrawCharacter(pFontTexture, L':', defaultColor); + __DrawCharacter(pFontTexture, L' ', defaultColor); + for (size_t i = 0; i < chatNameWide.size(); ++i) + __DrawCharacter(pFontTexture, chatNameWide[i], defaultColor); + } } pFontTexture->UpdateTexture(); @@ -1090,38 +1275,7 @@ void CGraphicTextInstance::Render(RECT * pClipRect) continue; STATEMANAGER.SetTexture(0, pTexture); - - // Each character is 4 vertices forming a quad (0=TL, 1=BL, 2=TR, 3=BR) - // We need to convert quads to triangle list format - // Triangle list needs 6 vertices per quad: v0,v1,v2, v2,v1,v3 - - size_t numQuads = vtxBatch.size() / 4; - std::vector triangleVerts; - triangleVerts.reserve(numQuads * 6); - - for (size_t i = 0; i < numQuads; ++i) - { - size_t baseIdx = i * 4; - const SVertex& v0 = vtxBatch[baseIdx + 0]; // TL - const SVertex& v1 = vtxBatch[baseIdx + 1]; // BL - const SVertex& v2 = vtxBatch[baseIdx + 2]; // TR - const SVertex& v3 = vtxBatch[baseIdx + 3]; // BR - - // First triangle: TL, BL, TR - triangleVerts.push_back(v0); - triangleVerts.push_back(v1); - triangleVerts.push_back(v2); - - // Second triangle: TR, BL, BR - triangleVerts.push_back(v2); - triangleVerts.push_back(v1); - triangleVerts.push_back(v3); - } - - if (!triangleVerts.empty()) - { - STATEMANAGER.DrawPrimitiveUP(D3DPT_TRIANGLELIST, triangleVerts.size() / 3, triangleVerts.data(), sizeof(SVertex)); - } + STATEMANAGER.DrawPrimitiveUP(D3DPT_TRIANGLESTRIP, vtxBatch.size() - 2, vtxBatch.data(), sizeof(SVertex)); } if (m_isCursor) @@ -1392,6 +1546,23 @@ void CGraphicTextInstance::SetValue(const char* c_szText, size_t len) return; m_stText = c_szText; + m_isChatMessage = false; // Reset chat mode + m_isUpdate = false; +} + +void CGraphicTextInstance::SetChatValue(const char* c_szName, const char* c_szMessage) +{ + if (!c_szName || !c_szMessage) + return; + + // Store separated components + m_chatName = c_szName; + m_chatMessage = c_szMessage; + m_isChatMessage = true; + + // Build combined text for rendering (will be processed by Update()) + // Use BuildVisualChatMessage in Update() instead of BuildVisualBidiText_Tagless + m_stText = std::string(c_szName) + " : " + std::string(c_szMessage); m_isUpdate = false; } @@ -1525,6 +1696,9 @@ void CGraphicTextInstance::__Initialize() // Only chat messages should be explicitly set to RTL m_direction = ETextDirection::Auto; m_computedRTL = false; + m_isChatMessage = false; + m_chatName = ""; + m_chatMessage = ""; m_textWidth = 0; m_textHeight = 0; diff --git a/src/EterLib/GrpTextInstance.h b/src/EterLib/GrpTextInstance.h index 72462c1..ec7d1aa 100644 --- a/src/EterLib/GrpTextInstance.h +++ b/src/EterLib/GrpTextInstance.h @@ -62,6 +62,7 @@ class CGraphicTextInstance void SetTextPointer(CGraphicText* pText); void SetValueString(const std::string& c_stValue); void SetValue(const char* c_szValue, size_t len = -1); + void SetChatValue(const char* c_szName, const char* c_szMessage); // Chat-specific setter with name/message separation void SetPosition(float fx, float fy, float fz = 0.0f); void SetSecret(bool Value); void SetOutline(bool Value); @@ -131,6 +132,9 @@ class CGraphicTextInstance bool m_isUpdate; bool m_isUpdateFontTexture; bool m_computedRTL; // Result of BiDi analysis (used when m_direction == Auto) + bool m_isChatMessage; // True if this text was set via SetChatValue (has separated name/message) + std::string m_chatName; // Chat sender name (only used when m_isChatMessage is true) + std::string m_chatMessage; // Chat message text (only used when m_isChatMessage is true) CGraphicText::TRef m_roText; CGraphicFontTexture::TPCharacterInfomationVector m_pCharInfoVector; diff --git a/src/UserInterface/PythonChat.cpp b/src/UserInterface/PythonChat.cpp index 1fb2048..ad4fe0e 100644 --- a/src/UserInterface/PythonChat.cpp +++ b/src/UserInterface/PythonChat.cpp @@ -499,9 +499,27 @@ void CPythonChat::AppendChat(int iType, const char * c_szChat) SChatLine * pChatLine = SChatLine::New(); pChatLine->iType = iType; - // Pass chat text as-is to BiDi algorithm - // BuildVisualBidiText_Tagless will detect chat format and handle reordering - pChatLine->Instance.SetValue(c_szChat); + // Parse chat format "name : message" for proper BiDi handling + // This avoids issues with usernames containing " : " + const char* colonPos = strstr(c_szChat, " : "); + if (colonPos && colonPos != c_szChat) // Make sure " : " is not at the start + { + // Extract name and message + size_t nameLen = colonPos - c_szChat; + const char* msgStart = colonPos + 3; // Skip " : " + + // Create temporary buffers + std::string name(c_szChat, nameLen); + std::string message(msgStart); + + // Use new SetChatValue API for proper BiDi handling + pChatLine->Instance.SetChatValue(name.c_str(), message.c_str()); + } + else + { + // Fallback: Not in chat format (INFO, NOTICE, etc.) + pChatLine->Instance.SetValue(c_szChat); + } if (IsRTL()) { @@ -768,9 +786,23 @@ void CWhisper::AppendChat(int iType, const char * c_szChat) SChatLine * pChatLine = SChatLine::New(); - // Pass chat text as-is to BiDi algorithm - // BuildVisualBidiText_Tagless will detect chat format and handle reordering - pChatLine->Instance.SetValue(c_szChat); + // Parse chat format "name : message" for proper BiDi handling + const char* colonPos = strstr(c_szChat, " : "); + if (colonPos && colonPos != c_szChat) + { + // Extract name and message + size_t nameLen = colonPos - c_szChat; + const char* msgStart = colonPos + 3; + + std::string name(c_szChat, nameLen); + std::string message(msgStart); + + pChatLine->Instance.SetChatValue(name.c_str(), message.c_str()); + } + else + { + pChatLine->Instance.SetValue(c_szChat); + } if (IsRTL()) { From b201fd6dd6eae2ce29c91dcba692c734b4d44abb Mon Sep 17 00:00:00 2001 From: rtw1x1 Date: Wed, 31 Dec 2025 09:05:58 +0000 Subject: [PATCH 2/2] Stop crashing on bad meshes like it's the end of the world Pushing this on behalf of savis --- src/EterGrnLib/Model.cpp | 23 ++++++++++++++++--- .../ModelInstanceCollisionDetection.cpp | 4 ++++ src/EterGrnLib/ModelInstanceUpdate.cpp | 3 +++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/EterGrnLib/Model.cpp b/src/EterGrnLib/Model.cpp index 968f838..28f6c09 100644 --- a/src/EterGrnLib/Model.cpp +++ b/src/EterGrnLib/Model.cpp @@ -14,16 +14,33 @@ const CGrannyModel::TMeshNode* CGrannyModel::GetMeshNodeList(CGrannyMesh::EType CGrannyMesh * CGrannyModel::GetMeshPointer(int iMesh) { - assert(CheckMeshIndex(iMesh)); + if (!CheckMeshIndex(iMesh)) assert(m_meshs != NULL); + { + TraceError("CGrannyModel::GetMeshPointer - Invalid mesh index: %d (max: %d)", iMesh, m_meshNodeSize); + return nullptr; + } + if (m_meshs == NULL) + { + TraceError("CGrannyModel::GetMeshPointer - m_meshs is NULL"); + return nullptr; + } return m_meshs + iMesh; } const CGrannyMesh* CGrannyModel::GetMeshPointer(int iMesh) const { - assert(CheckMeshIndex(iMesh)); - assert(m_meshs != NULL); + if (!CheckMeshIndex(iMesh)) + { + TraceError("CGrannyModel::GetMeshPointer(const) - Invalid mesh index: %d (max: %d)", iMesh, m_meshNodeSize); + return nullptr; + } + if (m_meshs == NULL) + { + TraceError("CGrannyModel::GetMeshPointer(const) - m_meshs is NULL"); + return nullptr; + } return m_meshs + iMesh; } diff --git a/src/EterGrnLib/ModelInstanceCollisionDetection.cpp b/src/EterGrnLib/ModelInstanceCollisionDetection.cpp index 44ff77e..7f6e8f0 100644 --- a/src/EterGrnLib/ModelInstanceCollisionDetection.cpp +++ b/src/EterGrnLib/ModelInstanceCollisionDetection.cpp @@ -101,6 +101,10 @@ bool CGrannyModelInstance::Intersect(const D3DXMATRIX * c_pMatrix, granny_matrix_4x4* pgrnMatCompositeBuffer = GrannyGetWorldPoseComposite4x4Array(m_pgrnWorldPose); const CGrannyMesh* c_pMesh = m_pModel->GetMeshPointer(rcurBoundBox.meshIndex); + + if (!c_pMesh) + continue; + const granny_mesh* c_pgrnMesh = c_pMesh->GetGrannyMeshPointer(); if (!GrannyMeshIsRigid(c_pgrnMesh)) diff --git a/src/EterGrnLib/ModelInstanceUpdate.cpp b/src/EterGrnLib/ModelInstanceUpdate.cpp index 3e634c6..a3ea1b9 100644 --- a/src/EterGrnLib/ModelInstanceUpdate.cpp +++ b/src/EterGrnLib/ModelInstanceUpdate.cpp @@ -177,6 +177,9 @@ void CGrannyModelInstance::UpdateWorldMatrices(const D3DXMATRIX* c_pWorldMatrix) const CGrannyMesh * pMesh = m_pModel->GetMeshPointer(i); + if (!pMesh) + continue; + // WORK int * boneIndices = __GetMeshBoneIndices(i); // END_OF_WORK