Server-Authored UI Payloads
Contract
uiFrame_t.buffer is a wire payload, not a place to serialize an entire
renderer or parser runtime struct for convenience. The game module authors the
smallest frame-type-specific schema the client drawer needs. Shared frame data
already carried on the wire by uiFrame_t—rectangle, color, texture, text, and
commands—must not be repeated in the payload.
The payload length is one unsigned byte, so the hard wire limit is 255 bytes:
svc_layout, layer
MSG_WriteDeltaUIFrame(...)
byte payload_size
payload[payload_size]
...
long 0, short 0 termination marker
The flow is:
game UI authoring
-> UI_WriteProxyFrame
-> gi.Write(PF_UIFRAME)
-> server/sv_game.c:PF_Write
-> client/cl_parse.c:CL_ParseLayout
-> client/cl_layout.c:SCR_Clear
-> client/cl_scrn.c frame-type drawer
MSG_ReadByte intentionally retains signed-char behavior for legacy callers,
including -1 sentinels. Code reading an unsigned wire field must cast its
result to BYTE; do not globally change MSG_ReadByte semantics.
What Went Wrong
In the first textured WoW quest scrollbar implementation, I made the mistake of
extending uiScrollBar_t, the full legacy FDF runtime shape, with three
complete uiSimpleButtonState_t values and transmitted button/thumb
dimensions. That made every scrollbar payload 192 bytes.
This was the wrong payload design for two reasons:
- It serialized data the quest drawer did not use: four FDF backdrops, three fonts, three font colors, three independent UV rectangles, and explicit dimensions already implied by the frame geometry.
- It crossed 127 bytes and exposed an existing decode bug. The one-byte value
192was read through signedcharas-64, then assigned toDWORDas4294967232.CL_ParseLayoutconsequently reportedmalformed layer 8because the apparent payload exceeded the message.
The diagnostic regression serialized one frame with a 192-byte payload. Before the fix it produced this decisive boundary evidence:
The mistake was not that 192 exceeds the protocol limit—it does not. The mistake was treating a broad runtime struct as an appropriate wire schema. The signed-byte bug was a separate defect revealed by that waste.
Fix
Both layout decoders now read uiFrame_t.buffer.size as
(BYTE)MSG_ReadByte(...), so all legal sizes from 0 through 255 retain their
wire value. The server-side layout diagnostic uses the same interpretation.
The WoW scrollbar now sends uiScrollBarImage_t:
| Field | Bytes | Meaning |
|---|---|---|
RESOURCE image[3] |
6 | Down arrow, up arrow, and thumb texture IDs |
BYTE texcoord[4] |
4 | One UV crop shared by all three textures |
| Total | 10 | Asserted by the WoW quest serialization test |
The client selects compact scrollbar art only when the payload size exactly
matches sizeof(uiScrollBarImage_t). Otherwise it accepts the full legacy
uiScrollBar_t FDF backdrop payload. Thus the optimization does not remove the
generic scrollbar path.
Compact mode deliberately supports only what the authoritative WoW template requires:
- one visual state;
- one UV rectangle shared by all parts;
- no track backdrop;
- square arrow and thumb parts whose height is the frame width multiplied by
the per-game
UI_PIXEL_ASPECT; - white texture tint.
Those limits reduced the type payload from 192 to 10 bytes (94.8%) without changing the visible 16x16 quest controls. If a future control genuinely needs more states or independent geometry, define another explicit compact wire shape or use the existing full schema; do not inflate the common compact shape.
What We Strive For
For every new uiFrame_t.buffer schema:
- Start from the draw call's irreducible inputs, not an existing runtime struct. Write down which values the client cannot infer.
- Reuse
uiFrame_tfields before adding payload fields. Geometry belongs inframe.size/anchors and common art inframe.texwhen one texture is sufficient. Confirm the field is present inuiFrameFields; a member merely existing in the runtime struct does not make it part of the wire contract. - Transmit resource IDs, compact enums, flags, and quantized bytes rather than paths, pointers, duplicated colors, or unused state objects.
- Share values that are identical across parts. A single UV rectangle is preferable to three copies.
- Prefer a documented limitation over speculative generality. Add capability only when authoritative data or a real caller requires it.
- Keep the schema fixed, bounded, and recognizable. Exact payload size may be used as a variant discriminator when the drawer supports legacy and compact forms.
- Test the exact serialized size, every field the drawer consumes, the compact path, and the legacy/inverse path.
- Test wire boundaries independently of the current payload. Values above 127 remain legal and must decode unsigned; values above 255 require a different protocol rather than truncation.
- Document ownership, limitations, and the authoritative source that justifies the fields.
The goal is not merely “under 255 bytes.” The goal is a wire schema whose every byte has a current consumer and whose constraints are obvious to the next author.
Diagnostic Workflow
When a layout reports malformed layer N:
- Inspect
PF_Write(PF_UIFRAME)andCL_ParseLayouttogether; verify frame number, raw payload byte, decoded payload size, read offset, and message size. - Reproduce with a serialization test that writes delta-frame metadata, the size byte, the raw payload, and the six-byte zero terminator.
- Check every one-byte field for signed extension before investigating renderer geometry or assets.
- Use
git blameandgit log -p -S <symbol>before changing a shared reader; signed behavior may be an established sentinel contract elsewhere. - Remove temporary diagnostic logs after the regression captures the root cause.
Verification commands:
Relevant regressions:
net.layout_parser_accepts_scrollbar_payload_above_127_bytesnet.layout_scrollbar_draws_cropped_texture_parts_top_to_bottomnet.layout_scrollbar_without_art_draws_nothingwow_game.deputy_willem_opens_classic_first_human_quest_frame
Entity-Context Bindings
uiFrame_t.stat remains an unsigned byte on the wire. Values below MAX_STATS bind player numeric stats, the existing
MAX_STATS range binds player text slots, and the reserved high values UI_STAT_CONTEXT_NAME, UI_STAT_CONTEXT_HEALTH, and
UI_STAT_CONTEXT_MANA bind a server-declared frame to the current layout context.
LAYER_WORLD_HOVER uses cl.hover_entity as that context only when the recipient's snapshot carries EF_HOVER_HEALTH, a live
model, and nonzero health. The generic client resolves the pooled name from entityState_t.name/CS_GENERAL, reads compressed health
and mana from entityState_t.stats, and projects re.GetEntityOverheadPosition through the current view matrix. The layer is skipped
when the point is behind the camera or outside the world scissor.
This is not a per-hover network protocol. The server sends the static frame tree, art/font indexes, geometry, and binding declarations
once; mouse picking, projection, and evaluation of already-replicated values happen locally each render frame. Do not revive
clc_request_unit_ui/svc_unit_ui, add an entity-name query, or resend svc_layout on mouse motion.
Game modules own the layer contents. WC3 and WoW send their native frame trees during ClientBegin; WoW authors compressed creature
vitals through CustomizeEntity and keeps entityState_t.name hidden until that recipient selects the creature. SC2 sends a header
and terminator with no frames until its gameplay state can author a real widget. The renderer has no parallel health-bar pass, and
there is no ALT-driven show-all mode because one LAYER_WORLD_HOVER instance has one cl.hover_entity context.
Key Files
| File | Responsibility |
|---|---|
common/shared.h |
Shared payload schemas such as uiScrollBarImage_t |
server/sv_game.c |
Writes delta frame, unsigned size byte, and raw payload |
client/cl_parse.c |
Validates and retains complete svc_layout blobs |
client/cl_layout.c |
Decodes retained frames and attaches payload views |
client/cl_scrn.c |
Dispatches frame drawers and interprets typed payloads |
tests/test_net.c |
Wire-boundary and client-drawer regressions |