This document is the reference for the on-disk .tmpl files under templates/cs/ and templates/ac/, and for the small Template Toolkit–style renderer that turns them into LW_show payloads sent to the legacy Cossacks / American Conquest client.
It covers:
- What templates are and how they fit into the server
- File layout, lookup, and resolution
- The TT-subset language understood by the Go renderer
- Variables: how they flow from Go into a template
- Show-body output: directives, regions, and styles
- Client-side tokens:
GV_*,CG_*,LW_*,GW|, layout ids - Catalogues of every template and where it is rendered
- Authoring workflow, testing, and maintenance
- Cheat sheet and further reading
Source of truth. Whenever this guide and the code disagree, the code wins. The renderer is implemented in internal/render/templates.go; template-driven dispatch lives in internal/app/gsc/controller.go and its siblings.
A template in this repository is a plain UTF-8 text file with a .tmpl extension that contains:
- Show-body primitives the game client understands at runtime (
#font,#txt,#ebox,#edit,#btn,GW|…,LW_*, …). These are emitted as-is. - TT-style fragments (
<? … ?>, with<? IF ?> / <? ELSE ?> / <? END ?>) that the Go server evaluates while loading the file. After evaluation only the show-body primitives remain.
The renderer is intentionally minimal: it is not Go's text/template, not html/template, and not a full Perl Template Toolkit. It is a hand-written subset tailored to the original Cossacks server templates so existing CML/LW assets keep working.
.tmpl file ──► LoadShowBodyFromRoots ──► RenderShowTemplate ──► LW_show body
│ │
│ ├─ renderInlineIfBlocks (inline <? IF ?>…<? END ?>)
│ ├─ line-based <? IF/ELSE/END ?>
│ └─ <? expr ?> interpolation (evalExpr / evalCondition)
│
└─ search roots × cs|ac × name.tmpl ── falls back to FallbackShowBody
Implemented in internal/render/templates.go (LoadShowBodyFromRoots, RenderShowTemplate, evalExpr, evalCondition, lookupVar, renderInlineIfBlocks, FallbackShowBody).
port.TemplateRenderer(internal/port/renderer.go) — the abstraction the controller depends on.render.TemplateRenderer(internal/render/templates.go) — the production implementation.render.Service(internal/render/service.go) — nil-safe wrapper used inside controllers.Controller.render(internal/app/gsc/controller_render.go) — the dispatcher used by every GSC handler.
The client family is selected by the protocol version ver:
| Directory | When used | Versions |
|---|---|---|
| templates/cs/ | Cossacks family (non-AC) | 2, 5, 6, 7 (IsAC(ver) is false) |
| templates/ac/ | American Conquest family | 3, 8, 10 (IsAC(ver) is true) |
The mapping is IsAC in internal/render/templates.go:
func IsAC(ver uint8) bool { return ver == 3 || ver == 8 || ver == 10 }If a template only makes sense for one family, leave the other absent — LoadShowBodyFromRoots will fall back to FallbackShowBody().
NormalizeShowTemplateName accepts:
- A plain name:
enter→enter.tmpl. - A
.tmplsuffix: passed through. - A
.cmlsuffix: rewritten to.tmpl(so legacy.dcml/.cmlreferences work). - Forward slashes for sub-paths:
started_room_info/statcols.
Empty or whitespace-only names yield "" and the fallback body is returned.
DefaultTemplateRoots (internal/render/templates.go) is, in order:
/app/templates/cossacks/templatestemplates../templates../../templates/cossacks/SimpleCossacksServer/share
NewTemplateRenderer(customRoot) puts customRoot (e.g. from configuration) before the defaults via BuildTemplateRoots, deduplicating empty entries. The resolver tries {root}/{cs|ac}/{name}.tmpl against each root, returning the first hit. If every root misses, the result is the minimal fallback body:
#font(WF,WF,WF)
#txt(%BOX[x:10,y:10,w:100%,h:24],{},"server response")
The fallback is intentionally ugly so missing templates are obvious in tests and on screen.
Sub-directories are allowed (and used). The only example today is templates/cs/started_room_info/statcols.tmpl, reached as started_room_info/statcols.tmpl.
RenderShowTemplate(src, vars) runs three passes over the source string:
- Inline
<? IF ?>…<? END ?>blocks are replaced first viarenderInlineIfBlocks. This handles control flow embedded inside a single physical line (e.g. inside a#apanargument). - Whole-line directives (
<? IF ?>,<? ELSE ?>,<? END ?>) are interpreted line-by-line and their lines are removed from the output. - Remaining
<? expr ?>fragments are replaced by the string result ofevalExpr.
After all three passes the result is strings.TrimSpaced and returned.
| Delimiter | Evaluated by | Purpose |
|---|---|---|
<? … ?> |
Server (Go renderer) | TT-subset: control flow and expressions. |
<% … %>, {% … }, <%NAME>, {%NAME} |
Client (game binary) | Live bindings to client globals (GV_*, CG_*, ASTATE, RL_ID, …). |
The Go renderer never touches <%…%> or {%…}. It just passes them through into the show body for the client to interpret.
A line is treated as a whole-line directive when, after trimming, it starts with <?, ends with ?>, and contains no <%. Recognized forms (after stripping leading/trailing ~, TT-style):
<? IF condition ?>— opens a nested block.<? ELSE ?>— flips the current block.<? END ?>— closes the current block.
Anything else on a whole-line <? … ?> (e.g. <? USE Date ?>, <? FOREACH … ?>) is silently dropped. These constructs are not implemented; legacy TT directives in old templates serve as documentation only.
Inline blocks are the exact same syntax but on a single span:
#txt(%BOX[…], {}, "<? IF nick ?>Hello, <? nick ?>!<? ELSE ?>Hello.<? END ?>")
Inline blocks may be nested by repetition (the regex iterates until no match remains), but they cannot reference a label across lines once the inline pass has completed.
evalCondition walks the expression with the following precedence (top is lowest):
||(left-to-right, short-circuit).&&(left-to-right, short-circuit).- Unary
!(binds the whole right-hand side after trimming). !=,>=,<=,>,<,==(string==/!=, numeric for the others viacompareNum).- Bare expression — the value from
evalExpris truthy when it is non-empty, not"0", and not"false"(case-insensitive).
Notes and gotchas:
>=,<=,>,<parse both sides withstrconv.ParseFloat. Non-numeric operands silently make the comparison false.- The split-on-
||/ split-on-&&is textual: parentheses do not protect the operator. Avoid nested logical operators on a single line — split them across<? IF ?>blocks instead. ==and!=compare trimmed strings. Quote literals:<? type == 'LCN' ?>.
evalExpr handles, in order:
- Strip a
| filtersuffix — only the left side is evaluated. Filters like| cmd,| arg,| date,| CMLStringArgFilterexist in legacy assets but are not executed. - Try
tryEvalAddMul: top-level+and*arithmetic, left-associative, integer-truncated result. - Otherwise call
evalExprLeaf:- Quoted literal (
'…'or"…") — return inner text. - Ternary
cond ? a : b— split at the first top-level?followed by a:further right. ==short-circuit — returns"1"or""..lengthsuffix — UTF-8 rune count of the inner expression's value (empty →"0").POSIX.floor(x)— floor of the numeric value; non-numeric →"0".h.req.ver→vars["ver"].server.config.X→lookupVar("X", vars).P.X→lookupVar("X", vars)(param-style alias).- Numeric literal — returned verbatim.
_(space-underscore-space) — string concatenation of evaluated parts.- Otherwise —
lookupVar(name, vars).
- Quoted literal (
| Construct | Status |
|---|---|
FOREACH, WHILE, SWITCH, CASE, BLOCK, MACRO, INCLUDE, WRAPPER, PROCESS |
Not implemented. Whole-line forms are dropped silently; inline forms are passed through. |
USE Date, USE … |
Not implemented. Filter pipes are stripped without execution. |
${expr} interpolation in keys |
Not implemented. Build the key in Go first. |
expr.method(args) calls (date.format(…), list joins, …) |
Not implemented. Pre-format in Go, set a flat key. |
Arithmetic with -, /, %, ** |
Not implemented. Only + and * work. |
| Parenthesized sub-expressions | Tokenization respects parentheses for +/* only; ternaries inside arithmetic are unreliable. Keep complex math in Go. |
When in doubt: pre-compute the value in the call site and pass it as a flat string in vars.
normalizeTT strips a single leading and trailing ~ from a directive body, mirroring TT's whitespace-control idiom (<?~ … ~?>). The renderer does not otherwise alter whitespace.
All template data is a single map[string]string. Every value is a string; the template never sees structured data.
Templates are loaded in two equivalent ways:
// Through the controller, the canonical path:
body := c.render(req.Ver, "enter.tmpl", vars)
// Or directly:
body := renderer.Render(ver, "enter.tmpl", vars)The controller wrapper lives in internal/app/gsc/controller_render.go; search the codebase for c.render( to find every dispatched template.
Inside a <? … ?> fragment, lookupVar(name, vars) resolves a bare identifier:
- A small switch of well-known short keys returns the matching map entry. Most are pass-through (
id,nick,error_text,chat_server,logged_in,type,window_size,table_timeout,ver,header,text,ok_text,height,command,ip,port,max_pl,name,active_players,exited_players,has_exited_players,room_players_start). - Legacy alias:
NICKis normalized tonick(uppercase variant of the same value). - The default branch is
vars[name]after trimming.
Outside the switch, the dotted key is the literal string: room.title reads vars["room.title"]. Builders in internal/render/ (e.g. MergeRoomDottedVars, BuildRoomInfoVars) populate dotted keys explicitly so the templates can reference them naturally.
| Source form | Resolved as |
|---|---|
h.req.ver |
vars["ver"] |
server.config.X |
vars["X"] (legacy) |
P.X |
vars["X"] (param-style alias) |
Use these forms where the original templates already do; otherwise pass the value flat.
A bare value is truthy unless it is empty, "0", or "false" (case-insensitive). This applies to both <? IF flag ?> and <? IF flag && other ?>.
internal/render/ ships builders that produce map[string]string payloads ready to pass to Render. Use them rather than constructing maps inline, so dotted keys stay consistent across CS and AC:
| File | Purpose |
|---|---|
| room_info.go | BuildRoomInfoVars, BuildStartedRoomInfoVars, RoomInfoBackto. |
| room_lifecycle_vars.go | MergeRoomDottedVars, StartedPlayerNames, RoomTimeInterval. |
| room_vars.go | BuildRegNewRoomVars, BuildJoinRoomVars, SetRoomPlayersColumn. |
| user_details.go | UserDetailsBody, plus CML-safe helpers. |
| ggcup.go | GGCupThanksBody, GGCupThanksBoxHeight. |
| time_interval.go | TimeIntervalFromElapsedSec. |
| cml.go | CMLSafe — quote/newline scrubbing for free-form data. |
| builders.go | Show, Echo, Time, Alert response wrappers. |
After the Go renderer finishes, the result is the body of an LW_show command sent to the client. Everything below is interpreted by the client, not the server.
Each directive is #name(args) on its own line (or chained inside a widget action). Common ones:
| Directive | Purpose |
|---|---|
#font(A,B,C) |
Set the active font/colour triple for the next text directives. See §5.4. |
#txt(box, style, "text") |
Static text (left-aligned). |
#ctxt(box, style, "text") |
Centred text. |
#rtxt(box, style, "text") |
Right-aligned text. |
#edit(box, {bind}) |
Editable input bound to a GV_* global. |
#cbb(box, {bind}, "opt1", "opt2", …) |
Combo box / dropdown. |
#btn[%STYLE](box, {action}, "label") |
Button with style id and action pipeline. |
#sbtn[%STYLE](box, {action}, "label") |
Submit-style button (default footer button). |
#pan[%ID](box) |
Plain panel container. |
#apan[%ID](box, {action}) |
Action panel — a clickable region. |
#ebox[%ID](box) / #box[%ID](box) |
Empty box / generic box. |
#exec(LW_…&args) |
Execute an LW_* opcode at show time. |
| `#resize(LW_cfile&… | LW_show&…)` |
#DBTBL(…) |
Database-driven table (rooms list). |
The Go renderer treats each directive as text. If you need to compute a coordinate or label, do it in vars and reference it via <? … ?>.
Rectangular regions are written %BOX[x:…, y:…, w:…, h:…]. Coordinates may be:
- Numeric literals (
x:10). - Percentages (
w:100%). - Anchor references with offsets:
y:%L_NAME+6(six pixels below the line registered underL_NAME). The client maintains the registry; the Go server does not. - Conditionally-chosen anchors via
<? … ?>evaluated server-side, e.g.y:%<? has_exited_players ? "T_EXPLAYERS" : "T_PLAYERS" ?>+6.
Common region/identifier prefixes:
| Prefix | Typical role |
|---|---|
%BOX, %LBX, %MPN |
Outer dialog regions: dialog box, lockbox, main panel. |
%TIT |
Title bar / region. |
%L_… |
Static label rows (L_NAME, L_HOST, L_PASSWD, …). |
%T_… |
Value rows paired with L_… (T_NAME, T_PLAYERS, …). |
%E_…, %P_… |
Edit / panel rows on form dialogs (E_NAME, E_PASS, P_NICK, …). |
%B_… |
Button chrome ids (B_RGST standard footer, B_C AC create, B_J AC join). |
| Id | Used in | Role |
|---|---|---|
L_NAME |
most dialog templates | First/title label. |
L_PASS, L_MAXPL, L_LEVEL |
new room dialogs | Form labels. |
L_TYPE |
AC new room dialog | Battle type label (AC only). |
L_CTIME |
user_details, room_info_dgl | "Connected at" / "Created at". |
L_ACCOUNT, L_ROOM |
user_details | Account and room labels. |
L_HOST, L_PLAYERS, L_EXPLAYERS, L_PASSWD |
room_info_dgl | Room info labels. |
| Id | Role |
|---|---|
T_CTIME |
Time / date string. |
T_ACCOUNT |
Account type string. |
T_ROOM |
Room title; Join/Info buttons align to it. |
T_NAME, T_HOST, T_PLAYERS, T_EXPLAYERS, T_LEVEL, T_PASSWD |
Room info values. |
| Id | Role |
|---|---|
B_RGST |
Standard footer button (Enter / Cancel / OK across most dialogs). |
B_C |
AC startup Create room button. |
B_J |
AC startup Join room button. |
Reuse ids from the nearest existing dialog rather than inventing new ones; the client only knows the chrome resources for ids it ships with.
#font(A,B,C) takes three comma-separated font/colour slot names that the client maps to skin resources. The Go server does not interpret them. Common patterns:
| Triple | Typical use |
|---|---|
WF,WF,WF |
Default body / dialog text. |
YF,YF,YF / YF,YF,WF |
Emphasis, table cells, nick fields. |
YF,WF,BF / WF,WF,BF |
Logon / link rows. |
SYF,SWF,SWF |
Strong styling, often error rows. |
RF,RF,RF |
Red — exited players, warnings. |
B1F40, YF16 |
Sized / themed variants on AC startup.tmpl. |
Letters are mnemonic in original assets (Y=yellow, W=white, B=blue, F=face/font). Match the nearest existing dialog when you add new lines so colours stay consistent.
These tokens flow through the Go renderer untouched and reach the client embedded in the LW_show body. The server does not know what they mean — knowing the catalogue below is enough to keep CS and AC templates in sync with the original client.
GV_* names live in the client's gvar namespace. Templates use them in two shapes:
{%GV_NAME}— the binding target of#edit,#cbb, etc.<%GV_NAME>— embedded inside aGW|…action so the current value is substituted on activation.
| Name | Templates | Role |
|---|---|---|
GV_LCN_NICK |
cs/enter, ac/enter, ac/new_room_dgl | Nickname; on AC create room also pushed as VE_NICK in `GW |
GV_LCN_INFR |
cs/started_room_info/statcols | Resource tab selector for started-room stats. |
GV_LCN_PROF |
cs/ok_enter, ac/ok_enter (commented LW_cfile) |
Intended for profile / cookie persistence. |
GV_VE_NICK |
ac/enter (commented) | AC cookie / nick file hook. |
GV_VE_TITLE |
cs/new_room_dgl, ac/new_room_dgl | New room title field. |
GV_VE_PASSWD |
same | New room password. |
GV_VE_MAX_PL |
same | Max players (#cbb, choices 2–7). |
GV_VE_LEVEL |
same | Difficulty (Easy / Normal / Hard). |
GV_VE_TYPE |
ac/new_room_dgl only | Battle type combobox (Ordinal / Battle). CS form omits this. |
When you add a GV_* field:
- Pick a name consistent with neighbours (
GV_VE_*for room form fields,GV_LCN_*for lobby). The client must already understand the name — invent only if you control the client too. - Bind with
{%GV_NEW}on the widget; reference<%GV_NEW>in the correspondingGW|…action. - Wire the server route that handles the form to read the matching parameter (
parseOpenParams,handleGo,dispatchOpenin internal/app/gsc/controller.go).
CG_* are pushed in a single #exec(LW_gvar&%CG_…&value&…\00) line in reg_new_room.tmpl / join_room.tmpl. They tell the native game executable how to host or join a session.
| Name | Meaning | reg_new_room | join_room |
|---|---|---|---|
CG_GAMEID |
Game / room id (sometimes prefixed HB… for AC battle type). |
CS + AC | CS + AC |
CG_MAXPL |
Maximum players. | CS + AC | CS + AC |
CG_GAMENAME |
Room title. | CS + AC | CS + AC |
CG_IP |
Host IP for joins. | — | CS + AC |
CG_PORT |
Host port for joins. | — | CS only |
CG_HOLEHOST |
STUN / hole-punch host. | CS only | — |
CG_HOLEPORT |
Hole-punch port. | CS only | — |
CG_HOLEINT |
Hole-punch interval (seconds). | CS only | — |
The same LW_gvar line also sets %COMMAND:
CGAMEafterreg_new_roomsucceeds (host).JGAMEafterjoin_roomsucceeds (joiner).
The TCP transport (internal/transport/tcp/server.go) sniffs CG_HOLEHOST, CG_HOLEPORT, CG_HOLEINT from outgoing show bodies for STUN / hole-punch debugging via extractGVar.
LW_* appears in two distinct places:
Built in Go, sent to the client. Templates do not produce these directly; they produce the body that goes into LW_show.
| Command | Role |
|---|---|
LW_show |
The dominant command — carries the rendered template body. |
LW_echo |
Debug echo of arguments (response to echo). |
LW_time |
Delayed action; used by the url handler to open a browser after a timer. |
LW_dtbl / LW_tbl |
Table definition + row data; used for the rooms list and similar. |
Interpreted by the client while running the show. Templates emit them via #exec(LW_…), #resize(LW_…|…), or chained inside {…} widget actions.
| Token | Pattern | Role |
|---|---|---|
LW_gvar |
#exec(LW_gvar&%K1&v1&%K2&v2…\00) |
Push name/value pairs into client globals (%PROF, %NICK, %CG_*, %COMMAND, …). |
LW_lockbox |
#exec(LW_lockbox&%LBX) |
Lock the dialog container as a modal. |
LW_lockall |
…|LW_lockall after a `GW |
` |
LW_enb |
#exec(LW_enb&0&%RMLST) |
Enable/configure a list/table control. |
LW_key |
{LW_key&#CANCEL|LW_lockall} |
Inject a logical key press (Cancel, etc.). |
LW_file |
{LW_file&Internet/Cash/cancel.cml} |
Load a bundled .cml from game assets. |
LW_cfile |
#resize(LW_cfile&<#WinH#>&height.dat|LW_show&…) |
Read a client-side config file (e.g. window height). |
LW_show (nested) |
inside the same #resize pipeline |
Run another show fragment after LW_cfile. |
LW_visbox |
(commented in legacy templates) | Toggle a box's visibility. |
GW| is the legacy command-string prefix used inside widget actions ({GW|…}). The client parses the string and later issues GSC commands against the server.
Wire shape: GW|verb&arg1&arg2|verb&arg1 — segments separated by |, arguments separated by &. Reserved characters are escaped (see §6.6).
| Verb (first token) | Server route | Notes |
|---|---|---|
open&<resource> |
dispatchOpen in controller.go |
Open a dialog. The server strips a .dcml suffix. A second &-segment may carry KEY=value pairs separated by ^ (parsed by parseOpenParams). |
go&<method>&… |
handleGo |
Form / action submission. Args after the method are key=value; the alternate key:= form takes the next argument as the value. |
url&<http…> |
case "url" in HandleWithMeta |
Open an external URL in a browser, returned as LW_time with open: payload. |
login&… |
login route | Triggers the login flow. |
Chaining: {GW|open&dialog.dcml|LW_lockall} — the LW_* tail is a client-side helper executed after the server call.
Embedding server values: use <? … ?> to inject a flat vars value at render time. Use <%…%> (<%ASTATE>, <%RL_ID>, <%PASSWORD>) to defer to the client at submit time.
Leading colon: payloads sometimes begin with :GW|… inside a show body. The colon is a client convention for embedding a pipeline in show text; it is not part of the GSC GW| prefix.
Beyond GV_* and CG_*:
<%ASTATE>— current application state, often passed throughopen&page.dcml&ASTATE=<%ASTATE>.<%RL_ID>,<%RL_HOST>,<%RL_TITLE>— room-list row columns picked from theRMLSTtable.<%PASSWORD>,<%VE_PASSWD>— password fields.<@%HEIGHT>— current window height (in#resizepipelines).<#WinH#>— built-in client expression for window height.
The Go server does not enumerate these symbols; copy from an adjacent template that already does what you need.
When &, |, \, or NUL appear inside a string that is already an arg of GW|… or LW_…, they are escaped as backslash-hex byte sequences:
| Sequence | Byte |
|---|---|
\26 |
& |
\7C |
| |
\5C |
\ |
\00 |
NUL |
The same escaping is implemented in Go for the wire protocol — see CommandFromString / CommandSetFromString in internal/transport/gsc/. A token like \52ESIZE you may see in templates is uppercase 'R' + literal ESIZE, i.e. an obfuscated RESIZE.
For free-form user data inserted into a CML literal (room titles, nicks, …), use render.CMLSafe (internal/render/cml.go) before embedding: it converts double quotes to apostrophes and collapses newlines/CR to spaces.
Templates by family; each row also lists the primary call site that loads it. Search internal/app/gsc/ for the exact handler when you need the full vars map.
7.1 Cossacks (templates/cs/)
| Template | Loaded by | What it shows |
|---|---|---|
| enter.tmpl | renderEnter |
Login screen; branches on type (anonymous vs LCN/WCL) and logged_in. Sets error, height. |
| ok_enter.tmpl | enter / login post-auth | Confirmation: pushes %PROF, %NICK, etc. into client globals. |
| error_enter.tmpl | login error path | Auth failure dialog. |
| startup.tmpl | go&startup route |
Lobby: rooms table, GG Cup marketing block, server start time. |
| new_room_dgl.tmpl | open&new_room_dgl |
Create-room form (title, password, max players, level). |
| reg_new_room.tmpl | regNewRoom |
Host-side confirmation; pushes %CG_* + hole-punch settings + %COMMAND=CGAME. |
| join_room.tmpl | joinGame |
Joiner-side confirmation; pushes CG_IP, CG_PORT, %COMMAND=JGAME. |
| confirm_password_dgl.tmpl | password-protected join | Password entry; submits to go&join_game. |
| confirm_dgl.tmpl | reusable | Generic OK/Cancel using header, text, command, ok_text, height. |
| alert_dgl.tmpl | reusable | Single-button alert using header, text. |
| room_info_dgl.tmpl | open&room_info_dgl |
Room details (title, host, players, level, password status). |
| started_room_info.tmpl | go&room_info_dgl for started rooms |
In-game stats with pagination. |
| started_room_info/statcols.tmpl | partial (part=statcols) |
Resource columns for the stats tab. |
| user_details.tmpl | open&user_details |
Player card: nick, account, connect time, current room, Join/Info buttons. |
| gg_cup_thanks_dgl.tmpl | open&gg_cup_thanks |
Sponsors / supporters dialog with dynamic height. |
7.2 American Conquest (templates/ac/)
The AC family currently mirrors the CS subset minus the started-room and user-details views, plus an AC-only battle-type field on new_room_dgl:
| Template | Notes vs CS |
|---|---|
| enter.tmpl | Smaller layout; commented LW_cfile cookie path retained for reference. |
| ok_enter.tmpl | AC variant of ok_enter. |
| error_enter.tmpl | AC variant of error_enter. |
| startup.tmpl | AC lobby; uses B_C / B_J startup buttons and AC font sizes (B1F40, YF16). |
| new_room_dgl.tmpl | Adds GV_VE_TYPE / L_TYPE; passes VE_NICK=<%GV_LCN_NICK> on submit. |
| reg_new_room.tmpl | Omits CG_HOLE* — AC does not use hole punching. |
| join_room.tmpl | Omits CG_PORT. |
| confirm_password_dgl.tmpl | AC variant. |
| confirm_dgl.tmpl | AC variant. |
| alert_dgl.tmpl | AC variant. |
When you change a screen that exists in both families, edit both files unless you have a deliberate reason to diverge.
- Create
templates/cs/<name>.tmpl(andtemplates/ac/<name>.tmplif the screen exists in AC). - Render it from a controller handler:
Use a builder in
body := c.render(req.Ver, "<name>.tmpl", map[string]string{ "header": "…", "text": "…", // every key the template references })
internal/render/if a similar dialog already has one; otherwise keep the map flat. - Pass every key the template reads. Missing keys resolve to
""and silently drop UI elements — easy to miss until a test fails. - Register the template in the golden suite (see below).
- Run the tests and refresh goldens once the output looks right.
- Verify in the real client when layout matters; goldens only lock text.
The wire test internal/app/gsc/share_template_fullbody_test.go does two things:
- Asserts that every
templates/{cs,ac}/**/*.tmplis listed inshareGoldenTemplateRels. - Renders each entry with
varsForShareTemplateGoldenand compares againstinternal/app/gsc/testdata/template_fullbody/*.golden.
To update goldens after an intentional change:
go test ./internal/app/gsc -golden -run TestShareTemplatesFullbodyGoldenIf the default vars make your template pick the wrong branch, override per-template inside varsForShareTemplateGolden (e.g. force logged_in="" for the anonymous enter branch, room_started="true" for in-game stats, gg_cup.started="0" for marketing copy).
Add or extend tests in internal/render/ when you touch the language semantics:
- templates_renderer_test.go — end-to-end rendering and search-path behaviour.
- templates_path_resolution_test.go — root precedence, name normalization.
- templates_startup_expr_test.go — expression / arithmetic semantics.
- time_interval_test.go — duration formatting helper.
Some screens build the show body in Go but keep a .tmpl on disk for drift detection (e.g. buildUserDetailsBody in controller.go vs user_details.tmpl). When you change either side, change the other so the golden render via loadShowBody stays representative.
- Match neighbours. Reuse
#fonttriples,%B_*button ids,L_*/T_*row ids, andGW|shapes from the closest existing dialog. - Keep ASCII unless you have a documented reason to do otherwise. Templates are protocol data.
- Use
//for line comments — the renderer leaves them in the output, but the client ignores them. - Server logic stays in Go. If a template needs branching beyond a couple of
IFblocks, compute the value in Go and pass it flat. - CS and AC drift — keep them in sync unless a difference is intentional and documented (e.g. AC battle-type field, hole-punch absence).
Server-side TT (Go renderer)
<? IF cond ?> … <? ELSE ?> … <? END ?> # whole-line OR inline
<? expr ?> # interpolation
expr → ternary | + | * | _ | .length | POSIX.floor() | var
cond → || | && | ! | == | != | <,>,<=,>= | bare-truthy
Truthy: non-empty AND not "0" AND not "false"
Special: h.req.ver, server.config.X, P.X
NOT supported: FOREACH, USE, filters, '-', '/', methods, ${dynamic}
Client-side (passed through)
<%NAME> inside command strings
{%NAME} widget binding target
%BOX[…], %L_…, %T_…, %B_…, %E_…, %P_… layout ids
GV_* client globals (editable fields)
CG_* game-launch globals (CGAME / JGAME)
GW|verb&arg|verb&arg command pipelines
open&page.dcml&K=V^K=V open a dialog
go&method&K=V submit / dispatch
url&http://… external URL
LW_* inside show body lockbox, lockall, gvar, key, file, cfile, enb
LW_* on the wire show, echo, time, dtbl, tbl
Escapes: \26 = &, \7C = |, \5C = \, \00 = NUL
Files & code
templates/cs/, templates/ac/ on-disk bodies
internal/render/templates.go renderer + lookup
internal/render/{room_info,room_vars,…}.go vars builders
internal/app/gsc/controller*.go dispatch and call sites
internal/app/gsc/share_template_fullbody_test.go golden tests
internal/app/gsc/testdata/template_fullbody/ golden bodies
Update goldens
go test ./internal/app/gsc -golden -run TestShareTemplatesFullbodyGolden
- Renderer behaviour:
RenderShowTemplate,evalExpr,evalExprLeaf,evalCondition,lookupVar,renderInlineIfBlocksin internal/render/templates.go. - Search path & resolution:
LoadShowBodyFromRoots,BuildTemplateRoots,NormalizeShowTemplateName,IsAC,FallbackShowBodyin the same file. - Vars builders: internal/render/ —
BuildRoomInfoVars,BuildStartedRoomInfoVars,BuildRegNewRoomVars,BuildJoinRoomVars,UserDetailsBody,GGCupThanksBody,MergeRoomDottedVars,TimeIntervalFromElapsedSec,CMLSafe. - GSC dispatch:
HandleWithMeta,dispatchOpen,handleGo,parseOpenParams,renderEnterin internal/app/gsc/controller.go. - Wire encoding:
CommandFromString,CommandSetFromStringin internal/transport/gsc/. - STUN / hole-punch debugging via show bodies:
extractGVarin internal/transport/tcp/server.go.