Tagged Strings¶
Tagged strings are a Torque Engine optimisation for sending strings between
client and server. Instead of sending the same literal text over the wire
every time, Torque keeps a shared netStringTable of strings and sends
a small numeric index plus a single control byte. Both endpoints look the
index up in their copy of the table to recover the original text.
This page documents how tagged strings are encoded, how the related
TorqueScript functions behave, and why they interact with $MSGCB /
addMessageCallback the way they do.
Source
The bulk of this writeup is adapted from notes by Jetz, shared in the Age of Time Discord on 2024-03-31. Phrasing has been reorganised into sections; the technical claims are his.
Wire format¶
A tagged string is not a normal string. It carries an out-of-band identifier so the engine can tell tagged strings apart from regular ones:
| Stage | Byte layout |
|---|---|
| Before sending | SOH + index |
| After receiving | SOH + index + (space) + resolved string |
SOH is ASCII control character 0x01 — "Start of Heading". It is the
reason a tagged string isn't a valid identifier in TorqueScript and why the
console handles it specially (more on that below).
Argument substitution: %1, %2, …¶
When a tagged string is sent via commandToClient or commandToServer,
any %1, %2, … placeholders in the table entry are automatically
substituted with the arguments that appear that many places after the
tagged string itself in the call. Substitution is resolved
last-to-first, so placeholders can be nested — a %2 argument can
itself be a tagged string referring to %1, %2, … of the surrounding
call, and they will all resolve correctly.
A typical handler signature looks like:
function clientCmdServerMessage(%msgString, %a1, %a2, %a3, %a4, ...)
{
// %a1, %a2, ... are the substitution arguments. The script body
// usually doesn't need to touch them — by the time this function
// runs, %msgString already has them resolved into its tail.
}
You will not normally use %a1/%a2/… inside the function — they're
already baked into the resolved portion of %msgString — but their values
are still relevant internally, because the engine needed them to perform
the substitution.
Looking up handlers via $MSGCB¶
addMessageCallback('SomeName', callback) registers a handler in the
global array $MSGCB. The lookup key is the first word of the received
tagged string, which (because of the wire format above) is
SOH + index, not the human-readable name.
That has a few practical consequences:
getWord(%msgString, 0)andgetTag(%msgString)-style operations both pull out that prefix, which is what$MSGCBis keyed on. So the callback name passed toaddMessageCallbackmatches automatically.- You cannot reference
$MSGCB[10, 0]directly with a numeric index — the actual key has a leadingSOHbyte attached, so the engine sees a different key entirely. -
You can reference it by name:
This works because the array key is built from the same
SOH+ index prefix that received tagged strings carry. -
An equivalent (ugly) form using a borrowed
SOHbyte from any other tagged string demonstrates how the key is composed:
The phantom space in the console
If you tab through $MSGCB entries in the in-game console, you have
to press ← or → twice to step the cursor past
the space that follows the B in $MSGCB. That extra invisible
character is the leading SOH byte of the key.
Function reference¶
getTag(taggedString)¶
Returns the numeric netStringTable index of a tagged string, with the
leading SOH stripped — effectively getWord(taggedString, 0) minus the
control byte, parsed as a number.
If you pass something that isn't a tagged string, getTag returns its
input unchanged.
detag(taggedString)¶
Returns everything after the SOH + index + space prefix — i.e. the
resolved (substituted) string body that came down the wire.
detag only works on tagged strings that were received from a
commandToClient / commandToServer (because that is when the resolved
body is appended). Calling it on a tagged string you have only built
locally does nothing useful. As with getTag, passing a non-tagged string
returns it unchanged.
getTaggedString(index)¶
Takes either a netStringTable index or a tagged string (in which case it
extracts the index itself) and returns the raw entry from the
netStringTable — unformatted, with the %1, %2, … placeholders still
present.
buildTaggedString(formatString, args...)¶
Same idea as getTaggedString, but performs the %1, %2, … substitution
locally using the supplied arguments. Unlike network delivery, this
does not automatically resolve tagged strings passed in as arguments —
nesting only works through the network path.
addTaggedString(str)¶
Adds a normal string to the netStringTable (if it isn't already present) and returns its index. You can use this to mint tagged strings programmatically, although doing so routinely defeats the optimisation — the whole point of tagged strings is reusing entries that are already in the table.
removeTaggedString(...)¶
Decrements the script-side reference count incremented by
addTaggedString. From observation it never actually evicts the entry,
because the engine also keeps an internal reference count that is
incremented on add but not decremented on remove.