Skip to content

feat(string): add UTF-8 string conversion and validation functions#2528

Open
bobtista wants to merge 11 commits into
TheSuperHackers:mainfrom
bobtista:bobtista/feat/utf8-string-functions
Open

feat(string): add UTF-8 string conversion and validation functions#2528
bobtista wants to merge 11 commits into
TheSuperHackers:mainfrom
bobtista:bobtista/feat/utf8-string-functions

Conversation

@bobtista

@bobtista bobtista commented Apr 3, 2026

Copy link
Copy Markdown

Adds UTF-8 string handling to WWLib and plumbs it through the codebase, replacing the GameSpy-specific Win32 wrappers with a shared implementation.

Picks up the work proposed in #2045 by @slurmlord, with API adjustments per the review from @xezon.

New: WWLib/utf8.h / utf8.cpp

  • Utf8_Num_Bytes(char lead) — byte count of a UTF-8 character from its lead byte
  • Utf8_Trailing_Invalid_Bytes(const char* str, size_t length) — count of invalid trailing bytes due to an incomplete multi-byte sequence
  • Utf8_Validate(const char* str) / Utf8_Validate(const char* str, size_t length) — returns true if the string is valid UTF-8 per RFC 3629 (rejects overlong encodings and codepoints above U+10FFFF)
  • Utf16Le_To_Utf8_Len(const wchar_t* src, size_t srcLen) / Utf8_To_Utf16Le_Len(const char* src, size_t srcLen) — required output size, not counting null terminator
  • Utf16Le_To_Utf8(char* dest, size_t destLen, const wchar_t* src, size_t srcLen)
  • Utf8_To_Utf16Le(wchar_t* dest, size_t destLen, const char* src, size_t srcLen)

Naming follows the Snake_Case convention used in WWVegas. The conversion functions return the number of units required: if the return is <= destLen the conversion was written (with a null terminator if room remains); if > destLen the buffer was too small and the return value tells the caller how much to allocate; 0 indicates a conversion failure. Implementation is Windows-only and treats wchar_t as UTF-16LE, wrapping Win32 WideCharToMultiByte / MultiByteToWideChar.

AsciiString::translate / UnicodeString::translate

Replaces the broken implementations that only worked for 7-bit ASCII (marked @todo since the original code) with proper UTF-8 conversion using the new WWLib functions.

ThreadUtils.cpp

Replaces raw Win32 API calls in MultiByteToWideCharSingleLine and WideCharStringToMultiByte with the new WWLib functions, using std::string::resize / std::wstring::resize to avoid duplicate allocation.

@greptile-apps

greptile-apps Bot commented Apr 3, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces WWLib/utf8.h and utf8.cpp with Win32-backed UTF-16LE ↔ UTF-8 conversion utilities, and replaces the long-standing ASCII-only translate() stubs in AsciiString / UnicodeString with proper UTF-8 conversions using the new functions. ThreadUtils.cpp's raw Win32 calls are also refactored to use the shared helpers, eliminating manual heap allocations.

  • utf8.cpp wraps WideCharToMultiByte / MultiByteToWideChar with a clear size-query/convert two-step API; the #ifdef _WIN32 … #else #error #endif pattern intentionally gates non-Windows builds.
  • AsciiString::translate and UnicodeString::translate now correctly handle multi-byte Unicode content, and an unrelated null-terminator placement fix in ensureUniqueBufferOfSize (moved outside the strToCopy guard) is included.
  • MultiByteToWideCharSingleLine and WideCharStringToMultiByte in ThreadUtils.cpp now check the conversion return value and avoid the previous separate heap allocation, addressing the concern raised in a prior review thread.

Confidence Score: 5/5

The conversion functions are straightforward Win32 wrappers with no new memory management risk, and the translate() rewrites correctly handle empty strings and conversion failures by clearing the target.

All changed paths — translate(), ensureUniqueBufferOfSize(), MultiByteToWideCharSingleLine(), WideCharStringToMultiByte() — have been reviewed against their callers. The null-terminator placement fix is correct and the conversion return-value checks resolve the issue flagged in a prior thread. No functional regressions or new bugs were found beyond a minor readability nit on the >= 0 guard.

No files require special attention; the only outstanding remark is a cosmetic guard condition in utf8.cpp.

Important Files Changed

Filename Overview
Core/Libraries/Source/WWVegas/WWLib/utf8.h New header declaring UTF-8/UTF-16LE conversion functions; correctly uses #pragma once, clear documentation, and Windows-only note.
Core/Libraries/Source/WWVegas/WWLib/utf8.cpp New Win32-backed conversion implementation; prior review threads raised overlong encoding and surrogate rejection gaps in a Utf8_Validate stub that was removed from this iteration. Remaining conversion functions are straightforward wrappers.
Core/GameEngine/Source/Common/System/AsciiString.cpp translate() replaced with proper UTF-8 conversion; also includes an unrelated fix moving null-terminator write outside the strToCopy guard in ensureUniqueBufferOfSize, which is a real bugfix but changes pre-existing behavior.
Core/GameEngine/Source/Common/System/UnicodeString.cpp Mirror of AsciiString.cpp changes; translate() and ensureUniqueBufferOfSize null-terminator fix applied consistently.
Core/GameEngine/Source/GameNetwork/GameSpy/Thread/ThreadUtils.cpp Replaces raw Win32 calls with WWLib wrappers; eliminates separate heap allocations and correctly checks conversion return values. Empty-string early return is a behaviour-identical replacement.
Core/Libraries/Source/WWVegas/WWLib/CMakeLists.txt Adds utf8.cpp/h to unconditional WWLIB_SRC list; prior thread noted this should be inside the if(WIN32) block, but dev replied the #error placeholder is intentional for now.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant translate
    participant Utf_Len as Utf16Le_To_Utf8_Len / Utf8_To_Utf16Le_Len
    participant Win32 as WideCharToMultiByte / MultiByteToWideChar
    participant Utf_Conv as Utf16Le_To_Utf8 / Utf8_To_Utf16Le
    participant Buffer as AsciiString / UnicodeString Buffer

    Caller->>translate: translate(src)
    translate->>Utf_Len: query required output size (srcLen chars)
    Utf_Len->>Win32: "Win32 query call (destLen=0)"
    Win32-->>Utf_Len: byte/wchar count (or 0 on fail/empty)
    Utf_Len-->>translate: len
    alt "len == 0 (empty or failure)"
        translate->>Buffer: clear()
        translate-->>Caller: return
    else "len > 0"
        translate->>Buffer: ensureUniqueBufferOfSize(len+1)
        translate->>Utf_Conv: "convert to buffer (destLen=len+1)"
        Utf_Conv->>Win32: Win32 convert call
        Win32-->>Utf_Conv: written chars (or 0 on fail)
        Utf_Conv-->>translate: "written (0 = failure)"
        alt "written == 0 (failure)"
            translate->>Buffer: clear()
        end
        translate->>Buffer: validate()
        translate-->>Caller: return
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant translate
    participant Utf_Len as Utf16Le_To_Utf8_Len / Utf8_To_Utf16Le_Len
    participant Win32 as WideCharToMultiByte / MultiByteToWideChar
    participant Utf_Conv as Utf16Le_To_Utf8 / Utf8_To_Utf16Le
    participant Buffer as AsciiString / UnicodeString Buffer

    Caller->>translate: translate(src)
    translate->>Utf_Len: query required output size (srcLen chars)
    Utf_Len->>Win32: "Win32 query call (destLen=0)"
    Win32-->>Utf_Len: byte/wchar count (or 0 on fail/empty)
    Utf_Len-->>translate: len
    alt "len == 0 (empty or failure)"
        translate->>Buffer: clear()
        translate-->>Caller: return
    else "len > 0"
        translate->>Buffer: ensureUniqueBufferOfSize(len+1)
        translate->>Utf_Conv: "convert to buffer (destLen=len+1)"
        Utf_Conv->>Win32: Win32 convert call
        Win32-->>Utf_Conv: written chars (or 0 on fail)
        Utf_Conv-->>translate: "written (0 = failure)"
        alt "written == 0 (failure)"
            translate->>Buffer: clear()
        end
        translate->>Buffer: validate()
        translate-->>Caller: return
    end
Loading

Reviews (14): Last reviewed commit: "fix(string): return empty string when Th..." | Re-trigger Greptile

Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.cpp Outdated
@bobtista

bobtista commented Apr 3, 2026

Copy link
Copy Markdown
Author
  • Fixed the if formatting
  • added RFC 3629 overlong and out-of-range checks
  • RE the theoretical memory leak, can that even happen here? set() allocates via the engine's custom memory allocator which crashes on failure rather than throwing, so the leak path can't really be reached right?

Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.h Outdated
Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.cpp Outdated
Comment thread Core/GameEngine/Source/GameNetwork/GameInfo.cpp Outdated
Comment thread Core/GameEngine/Source/GameNetwork/GameSpy/Thread/ThreadUtils.cpp Outdated
Comment thread Core/GameEngine/Source/GameNetwork/GameSpy/Thread/ThreadUtils.cpp Outdated
Comment thread Core/GameEngine/Source/Common/System/UnicodeString.cpp Outdated
Comment thread Core/GameEngine/Source/Common/System/AsciiString.cpp Outdated
Comment thread Core/GameEngine/Source/GameNetwork/GameInfo.cpp Outdated
Comment thread Core/GameEngine/Source/Common/System/AsciiString.cpp Outdated
@xezon xezon added Enhancement Is new feature or request Minor Severity: Minor < Major < Critical < Blocker labels Apr 3, 2026
Comment thread Core/GameEngine/Source/GameNetwork/GameInfo.cpp Outdated

@xezon xezon left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Get_Utf8_Size should not include the null terminator in its size.

Comment thread Core/GameEngine/Source/Common/System/AsciiString.cpp Outdated
Comment thread Core/GameEngine/Source/Common/System/AsciiString.cpp Outdated
Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.h Outdated
Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.h Outdated
Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.cpp Outdated
Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.cpp Outdated
Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.cpp Outdated
Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.cpp Outdated
Comment thread Core/GameEngine/Source/GameNetwork/GameSpy/Thread/ThreadUtils.cpp Outdated
Comment thread Core/GameEngine/Source/GameNetwork/GameSpy/Thread/ThreadUtils.cpp Outdated
Comment thread Core/GameEngine/Source/GameNetwork/GameSpy/Thread/ThreadUtils.cpp Outdated
if (dest_size == 0)
return;
return false;
int result = MultiByteToWideChar(CP_UTF8, 0, src, -1, dest, (int)dest_size);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if dest_size does not have enough room for a null terminator?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc says "Does not write a null terminator" - should we add more comments? Change the functions to always null-terminate? What do you want here?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe make it behave like strncpy? Writes null if there is room, otherwise not.

The only issue with the current interface then is that we will not know if it wrote the null terminator. Maybe it should return size_t instead, returning the number of characters it writes or would like to write? MultiByteToWideChar also does that.

I suggest to think this through and design the function interface in a way that it can be conveniently be used for fixed size strings (std::string, AsciiString) and large throwaway buffers (char arr[512]).

The behavior definitely needs to be documented.

Comment thread Core/GameEngine/Source/Common/System/UnicodeString.cpp Outdated
Comment thread Core/GameEngine/Source/Common/System/AsciiString.cpp Outdated
Comment thread Core/GameEngine/Source/Common/System/AsciiString.cpp Outdated
Comment thread Core/GameEngine/Source/Common/System/AsciiString.cpp Outdated
Comment thread Core/GameEngine/Source/GameNetwork/GameSpy/Thread/ThreadUtils.cpp Outdated
Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.cpp Outdated
Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.cpp Outdated
@xezon

xezon commented Apr 6, 2026

Copy link
Copy Markdown

The diff now shows unrelated changes.

@bobtista bobtista force-pushed the bobtista/feat/utf8-string-functions branch from 39d7229 to 40393b8 Compare April 6, 2026 20:02
@bobtista

bobtista commented Apr 6, 2026

Copy link
Copy Markdown
Author

The diff now shows unrelated changes.

Try again cleaned up the commits and force pushed

}
ensureUniqueBufferOfSize((Int)size + 1, false, nullptr, nullptr);
char* buf = peek();
if (!Unicode_To_Utf8(buf, src, srcLen, size))

@Mauller Mauller Apr 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So is this translating UTF16LE from windows into UTF8 that is then stored within AsciiString?

If so this may help with the paths issue with usernames and paths not using Latin characters, but file handling functions will need updating to use unicode variants instead of Ascii.

@Mauller

Mauller commented Apr 7, 2026

Copy link
Copy Markdown

I wonder if we should also add a flag to state that the Ascii string is holding a UTF8 string?

I guess all normal ascii characters will display properly, it's just extended character sets that will look garbled.

Comment thread Core/Libraries/Source/WWVegas/WWLib/CMakeLists.txt
@OmniBlade

Copy link
Copy Markdown

Slight nitpick on function naming, shouldn't it be Utf16 rather than Wchar as Wchar is only Utf16 on windows yet we will likely want the conversion functions on other platforms as well for at least csf parsing if nothing else.

@bobtista

bobtista commented Apr 8, 2026

Copy link
Copy Markdown
Author

Slight nitpick on function naming, shouldn't it be Utf16 rather than Wchar as Wchar is only Utf16 on windows yet we will likely want the conversion functions on other platforms as well for at least csf parsing if nothing else.

Yeah, but it's consistent with the other naming as it is for now. How about we keep the naming and using a uint16_t/char16_t type internally rather than wchar_t when we make non windows paths? Or would you rather we rename to something like Utf16_To_Utf8?

@Mauller

Mauller commented Apr 13, 2026

Copy link
Copy Markdown

Slight nitpick on function naming, shouldn't it be Utf16 rather than Wchar as Wchar is only Utf16 on windows yet we will likely want the conversion functions on other platforms as well for at least csf parsing if nothing else.

Yeah, but it's consistent with the other naming as it is for now. How about we keep the naming and using a uint16_t/char16_t type internally rather than wchar_t when we make non windows paths? Or would you rather we rename to something like Utf16_To_Utf8?

If anything it would be Utf16Le_To_Utf8, windows uses the little endian utf16 format. Not sure if Utf16Be is used much anywhere but worth being concise with it.

Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.cpp Outdated
Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.cpp Outdated
Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.cpp Outdated
Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.cpp Outdated
Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.cpp Outdated
Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.cpp
Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.cpp Outdated
Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.cpp Outdated
Comment thread Core/GameEngine/Source/GameNetwork/GameSpy/Thread/ThreadUtils.cpp Outdated
return required;
}
}
WWASSERT(destLen >= Utf16Le_To_Utf8_Len(src, srcLen));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we perhaps use written to assert with, instead of another call to WideCharToMultiByte ?

Comment thread Core/Libraries/Source/WWVegas/WWLib/utf8.cpp
@@ -109,24 +112,9 @@ size_t Utf8_To_Utf16Le_Len(const char* src, size_t srcLen)
return (wchars > 0) ? (size_t)wchars : 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe do >=, so the branch predictor is 100% correct.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or max(0, wchars)

@@ -109,24 +112,9 @@ size_t Utf8_To_Utf16Le_Len(const char* src, size_t srcLen)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: const

}
WWASSERT(destLen >= Utf16Le_To_Utf8_Len(src, srcLen));
const int written = WideCharToMultiByte(CP_UTF8, 0, src, (int)srcLen, dest, (int)destLen, nullptr, nullptr);
if (written <= 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this now a contradiction to the assert? Would this only be true if the assert was failing?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this branch is dead code when the assert holds, but WWASSERT compiles out in release, so do we keep it?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can replace this entire branch and substitute it with written = max(0, written)

@githubawn

Copy link
Copy Markdown

Could the unconditional replacement of translate() silently break callers passing legacy CP1252 data, causing strings that are valid CP1252 but invalid UTF-8 to corrupt or clear?

Maybe something like:
Check if the source is valid UTF-8 via Utf8_Validate.
If valid: Proceed with UTF-8 conversion.
If invalid: Fall back to the legacy 1:1 byte-to-wide cast (treating it as CP1252).

@xezon

xezon commented Apr 23, 2026

Copy link
Copy Markdown

Maybe put a breakpoint and check usage patterns.

@xezon

xezon commented May 30, 2026

Copy link
Copy Markdown

What is the current state of this? Can it be finalized?

@bobtista

Copy link
Copy Markdown
Author

What is the current state of this? Can it be finalized?

Yeah, I just pushed cleanups addressing the open threads, it should be ready for another pass

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Enhancement Is new feature or request Minor Severity: Minor < Major < Critical < Blocker

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants