Skip to content

Hold an interpreted string as bytes, the way the game does - #1238

Merged
Frotty merged 2 commits into
masterfrom
strings/byte-semantics
Aug 16, 2026
Merged

Hold an interpreted string as bytes, the way the game does#1238
Frotty merged 2 commits into
masterfrom
strings/byte-semantics

Conversation

@Frotty

Copy link
Copy Markdown
Member

Warcraft III counts and indexes a string in bytes: StringLength returns a byte count and SubString takes byte offsets, so a slice may stop between the bytes of one character. Lua agrees, its strings being byte arrays. The interpreter held a Java string and counted UTF-16 code units, which gives the same answer only for ascii — StringProvider was string.getVal().length() and str.substring(s, e).

Why it matters now

The standard library depends on the difference rather than avoiding it. String.wurst sets ENABLE_MULTIBYTE_SUPPORT = true and:

  • takes "ä".substring(0, 1) to find out how the engine represents half a character,
  • slices a 64 character Cyrillic literal byte by byte to enumerate every continuation byte 0x800xBF, keeping them apart by hash,
  • slices an emoji for the 4-byte lead case.

Under UTF-16 the first of those returns the whole character, so PARTIAL_CHAR_DETECTABLE goes false and the detection concludes the engine has no multibyte characters. That is the graceful degradation its author intended for a future engine change, not a compiletime semantics we should ship: ChunkedString and object editor text are built from lengths the game will not agree with, and nothing reports it.

The representation

The value is held one char per byte, so every char is below 256 and Java's own length and substring already give the game's answers. Text is encoded coming in (fromText) and decoded going back out to a file, a program literal, or a screen (text()). A half character has no text to decode to, which is the point — it keeps its byte until the other half is added back.

The constructor is private, so every one of the 21 construction sites had to be classified as text or as bytes rather than left to inspection.

Two consequences of the representation:

  • StringCase folds only ascii letters. The bytes of a multibyte character are not letters; folding one the way a latin-1 char folds rewrites the character into a different one.
  • StringHash is computed over the bytes. The library's StringHash.hash takes text and does getBytes("UTF-8") itself, so it cannot hash half a character, and its byte[] overload is private. Decoding first is not a way out either: every partial slice would decode to the same replacement character and collapse onto one hash, taking the 64 continuation bytes with it — exactly the thing the standard library tells apart. Wc3StringHash implements the same function (Bob Jenkins' lookup2) over bytes.

Test runtime

The Lua runtime never implemented StringLength or SubString. The generated fallback for an undefined native raises an error, so the Lua half of any test using them could not have been passing for the reason it appeared to. Both are in wc3shim.lua now, as the plain Lua byte operations the game performs.

Tests

  • StringByteSemanticsTests — length of a two byte character, a slice cutting a character in half, and the halves rejoining into the original. Each runs on the interpreter and on Lua, so the two are pinned against each other rather than against an assumption.
  • Wc3StringHashTest — the byte hash against the library's across ascii of every length up to 40, strings needing case and slash normalisation, and whole multibyte text, where both are defined. Then the two properties the library cannot express: the halves of a character hash apart, and all 64 continuation bytes stay distinct.

Full suite green.

Not covered

The engine collapses every half-character slice to one marker string with a constant hash. This does not emulate that quirk: byte-accurate, a 0xD0 lead byte hashes as itself rather than matching PARTIAL_CHAR_HASH. The standard library still gets right answers, by a different route — isCharBoundary falls through to the continuation byte table, does not find a lead byte there, and reports a boundary — but PARTIAL_CHAR_DETECTABLE is true at compiletime while matching a narrower set of slices than in game. Reproducing the collapse is a deliberate further step and wants its own test.

Warcraft III counts and indexes a string in bytes, so a slice may stop
between the bytes of one character, and Lua agrees because its strings are
byte arrays. The interpreter held a Java string and counted UTF-16 code
units, which is the same answer only for ascii.
The standard library depends on the difference rather than avoiding it: it
cuts a character in half on purpose to find out how the engine represents a
partial slice, and slices a 64 character literal byte by byte to enumerate
every continuation byte. Under UTF-16 that detection concludes the engine
has no multibyte characters, so anything computed at compiletime is built
from lengths the game will not agree with, and nothing reports it.
The value is now held one char per byte, so Java's own length and substring
give the game's answers. Text is encoded coming in and decoded going back
out to a file or a screen; a half character has no text to decode to, which
is the point.
Two things follow from the representation. StringCase folds only ascii
letters, because the bytes of a multibyte character are not letters and
folding them rewrites the character. StringHash is computed over the bytes:
the library's takes text and encodes it as UTF-8 itself, so it cannot hash
half a character, and decoding first would collapse every partial slice onto
one value and take the continuation bytes with it.
The Lua test runtime never implemented StringLength or SubString, so the
generated fallback raised an error and the Lua half of such a test could not
have been passing for the reason it appeared to. Both are in the shim now.

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:caebb0ecd7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return JassIm.ImRealVal("" + ((ILconstReal) value).getVal());
} else if (value instanceof ILconstString) {
return JassIm.ImStringVal(((ILconstString) value).getVal());
return JassIm.ImStringVal(((ILconstString) value).text());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve partial bytes when materializing compiletime strings

When a compiletime expression returns a deliberately partial UTF-8 slice, text() decodes the invalid byte sequence to U+FFFD before embedding it in the transformed IM. For example, compiletime(SubString("ä", 0, 1)) becomes a three-byte replacement character, so a subsequent StringLength returns 3 instead of 1; the new tests consume slices entirely inside the interpreter and do not exercise this compiletime-to-runtime boundary. Materialize the byte view without lossy decoding and add a regression that uses the returned value after transforms.

AGENTS.md reference: AGENTS.md:L62-L64

Useful? React with 👍 / 👎.

// from an empty string / zero value.
String val = rs.getString(index.getVal() + 1);
return new ILconstString(val == null ? "" : val);
return ILconstString.fromText(val == null ? "" : val);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Decode interpreter strings before passing them to SQLite

When SQLite paths, queries, or bound values contain non-ASCII text, this return conversion is not paired with conversion at the outbound JDBC boundary: sqlite_open, sqlite_prepare, sqlite_bind_string, and sqlite_exec still pass getVal(), which now exposes the ISO-8859-1 byte view rather than host text. Consequently, binding "ä" stores "ä", and reading it here applies another UTF-8 encoding so the value no longer equals the original; non-ASCII database paths and SQL literals are similarly mangled. Use text() for the outbound SQLite calls and cover a non-ASCII bind/read round trip.

AGENTS.md reference: AGENTS.md:L62-L64

Useful? React with 👍 / 👎.

… literal
Every value handed to the driver is text, so the four outbound calls decode
the way the inbound read already did. Binding "ä" without it stored the bytes
as though each were a character, and it came back a different string than the
one that went in.
A compiletime expression's result becomes a literal in the generated script,
which is written as UTF-8, and neither Jass nor the escaping here can write a
byte down numerically. Half a character therefore went in as the replacement
character and came back three bytes long where the interpreter counted one.
Refused with a message pointing at the fix instead of carried across at a
different length. Whole characters cross unchanged, which is what the standard
library does at compiletime.
@Frotty

Copy link
Copy Markdown
MemberAuthor

Both addressed.

Decode interpreter strings before passing them to SQLite — my mistake, and exactly as described: I converted the inbound read and left the outbound side on getVal(). sqlite_open, sqlite_prepare, sqlite_bind_string and sqlite_exec all decode now.

CompiletimeNativesTest.sqliteRoundTripsNonAsciiText binds "Grüße 日本", reads it back, and checks both the text and the bytes match, so a length taken either side of the round trip agrees. It also runs a non-ASCII literal in the SQL itself, which takes the sqlite_exec/sqlite_prepare path. It fails without the fix.

Preserve partial bytes when materializing compiletime strings — the diagnosis is right and the test gap was real, but lossless materialisation is not reachable from here. The value has to become an ImStringVal that both backends print through Utils.escapeString, and the script is written as UTF-8 (WurstCompilerJassImpl:658). That escaper emits only \n \r \t \" \, and Jass has no numeric escape either — so a lone 0xC3 has no representation in a generated literal. Writing it as a char yields 0xC3 0x83, which is the same corruption wearing different clothes. Making it representable means byte strings through the IM and both writers, which is a much larger change than this one.

So it refuses rather than mangles: literalText checks the bytes round-trip through text and raises a CompileError naming the situation when they do not.

Two tests at that boundary, since it was untested either way:

  • aCompiletimeStringKeepsItsLengthAfterTransformscompiletime("hällo") is still six bytes after transforms and still equals the literal, so whole characters cross unchanged. This is the case the standard library actually relies on.
  • aCompiletimeStringHoldingHalfACharacterIsRefused — the reviewed example, pinned as a refusal rather than as a length of 3.

Flagging the judgement call rather than burying it: refusing is not what was asked for, and if the larger byte-through-the-backend change is wanted, this is one line to swap out and the test documents precisely what would change.

Targeted tests green; full suite still running and I will report if it turns.

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit:8fa85ac3dd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Frotty
Frotty merged commit e6ca641 into masterAug 16, 2026
3 checks passed
@Frotty
Frotty deleted the strings/byte-semantics branch August 16, 2026 12:06
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Frotty