@px-lsp/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +334 -0
  3. package/THIRD-PARTY-NOTICES.md +83 -0
  4. package/data/ck3/dataTypes.json +2195 -0
  5. package/data/ck3/data_types/data_types_common.txt +2040 -0
  6. package/data/ck3/data_types/data_types_gui.txt +5264 -0
  7. package/data/ck3/data_types/data_types_internalclausewitzgui.txt +14843 -0
  8. package/data/ck3/data_types/data_types_script.txt +4251 -0
  9. package/data/ck3/data_types/data_types_uncategorized.txt +109984 -0
  10. package/data/ck3/freqs.json +1 -0
  11. package/data/ck3/guiSchema.json +6344 -0
  12. package/data/ck3/script_docs/effects.log +16059 -0
  13. package/data/ck3/script_docs/event_targets.log +2098 -0
  14. package/data/ck3/script_docs/modifiers.log +2228 -0
  15. package/data/ck3/script_docs/on_actions.log +5275 -0
  16. package/data/ck3/script_docs/triggers.log +11991 -0
  17. package/data/ck3/structures.json +9743 -0
  18. package/data/ck3/wikidocs/ATTRIBUTION.md +18 -0
  19. package/data/ck3/wikidocs/Data_types.md +2568 -0
  20. package/data/ck3/wikidocs/Effects_list.md +1176 -0
  21. package/data/ck3/wikidocs/Scopes_list.md +341 -0
  22. package/data/ck3/wikidocs/Triggers_list.md +1097 -0
  23. package/data/eu5/data_types/data_types_common.txt +2087 -0
  24. package/data/eu5/data_types/data_types_gui.txt +6732 -0
  25. package/data/eu5/data_types/data_types_internalclausewitzgui.txt +19276 -0
  26. package/data/eu5/data_types/data_types_script.txt +5688 -0
  27. package/data/eu5/data_types/data_types_uncategorized.txt +135569 -0
  28. package/data/vic3/data_types/data_types_common.txt +2021 -0
  29. package/data/vic3/data_types/data_types_gui.txt +5592 -0
  30. package/data/vic3/data_types/data_types_internalclausewitzgui.txt +17304 -0
  31. package/data/vic3/data_types/data_types_script.txt +2817 -0
  32. package/data/vic3/data_types/data_types_uncategorized.txt +84354 -0
  33. package/data/vic3/freqs.json +1 -0
  34. package/data/vic3/guiSchema.json +5578 -0
  35. package/data/vic3/script_docs/effects.log +38135 -0
  36. package/data/vic3/script_docs/event_targets.log +2028 -0
  37. package/data/vic3/script_docs/modifiers.log +18954 -0
  38. package/data/vic3/script_docs/on_actions.log +1561 -0
  39. package/data/vic3/script_docs/triggers.log +15738 -0
  40. package/data/vic3/structures.json +10189 -0
  41. package/dist/server.js +63668 -0
  42. package/media/px-lsp.svg +12 -0
  43. package/package.json +50 -0
  44. package/src/clientMode.ts +60 -0
  45. package/src/coa/coa.ts +184 -0
  46. package/src/coa/coaParse.ts +267 -0
  47. package/src/context.ts +78 -0
  48. package/src/contextKeywords.ts +224 -0
  49. package/src/data/dataBindingMacros.ts +82 -0
  50. package/src/data/dataFnDocs.ts +152 -0
  51. package/src/data/dataFnUsage.ts +431 -0
  52. package/src/data/dataTypes.ts +279 -0
  53. package/src/data/defines.ts +123 -0
  54. package/src/data/docsParser.ts +453 -0
  55. package/src/data/keywordDocs.ts +98 -0
  56. package/src/data/modifierTemplates.ts +143 -0
  57. package/src/data/textFormatting.ts +165 -0
  58. package/src/data/wikiDocs.ts +187 -0
  59. package/src/dds/decoder.ts +1007 -0
  60. package/src/dds/encode.ts +235 -0
  61. package/src/dds/index.ts +58 -0
  62. package/src/dds/png.ts +96 -0
  63. package/src/dds/tga.ts +62 -0
  64. package/src/documents.ts +35 -0
  65. package/src/features/assetPaths.ts +169 -0
  66. package/src/features/codeActions.ts +148 -0
  67. package/src/features/colors.ts +244 -0
  68. package/src/features/completion.ts +961 -0
  69. package/src/features/datafunction.ts +729 -0
  70. package/src/features/definition.ts +84 -0
  71. package/src/features/diagnostics.ts +244 -0
  72. package/src/features/folding.ts +106 -0
  73. package/src/features/formatting.ts +60 -0
  74. package/src/features/guiLanguage.ts +366 -0
  75. package/src/features/guiNavigation.ts +140 -0
  76. package/src/features/guiTree.ts +97 -0
  77. package/src/features/hover.ts +817 -0
  78. package/src/features/hoverRender.ts +222 -0
  79. package/src/features/inlayHints.ts +147 -0
  80. package/src/features/locFormatting.ts +127 -0
  81. package/src/features/references.ts +70 -0
  82. package/src/features/rename.ts +135 -0
  83. package/src/features/scopeAt.ts +65 -0
  84. package/src/features/semanticTokens.ts +188 -0
  85. package/src/features/signatureHelp.ts +72 -0
  86. package/src/features/symbols.ts +241 -0
  87. package/src/features/textureHover.ts +143 -0
  88. package/src/features/workspaceSymbols.ts +69 -0
  89. package/src/games/active.ts +19 -0
  90. package/src/games/ck3/ambientScopes.ts +273 -0
  91. package/src/games/ck3/index.ts +38 -0
  92. package/src/games/ck3/meta.ts +28 -0
  93. package/src/games/ck3/modifierPlaceholders.ts +61 -0
  94. package/src/games/ck3/saveSchema.ts +134 -0
  95. package/src/games/ck3/scaffolds.ts +197 -0
  96. package/src/games/ck3/schema.ts +422 -0
  97. package/src/games/ck3/structures.ts +887 -0
  98. package/src/games/eu5/index.ts +75 -0
  99. package/src/games/eu5/meta.ts +44 -0
  100. package/src/games/eu5/scaffolds.ts +49 -0
  101. package/src/games/eu5/schema.generated.ts +1043 -0
  102. package/src/games/jomini/variables.ts +134 -0
  103. package/src/games/profile.ts +205 -0
  104. package/src/games/registry.ts +27 -0
  105. package/src/games/vic3/index.ts +52 -0
  106. package/src/games/vic3/meta.ts +55 -0
  107. package/src/games/vic3/saveSchema.ts +77 -0
  108. package/src/games/vic3/scaffolds.ts +135 -0
  109. package/src/games/vic3/schema.ts +650 -0
  110. package/src/games/vic3/structures.ts +33 -0
  111. package/src/gui/anchorSpec.ts +66 -0
  112. package/src/gui/declMarkers.ts +30 -0
  113. package/src/gui/fillGeometry.ts +101 -0
  114. package/src/gui/guiDefs.ts +386 -0
  115. package/src/gui/guiDependencies.ts +352 -0
  116. package/src/gui/guiLinks.ts +64 -0
  117. package/src/gui/layoutEngine.ts +1998 -0
  118. package/src/gui/layoutService.ts +221 -0
  119. package/src/gui/measuredMetrics.ts +21 -0
  120. package/src/gui/previewService.ts +89 -0
  121. package/src/gui/saveSchema.ts +220 -0
  122. package/src/gui/saveValues.ts +399 -0
  123. package/src/gui/saveZip.ts +60 -0
  124. package/src/gui/sourceEdit.ts +535 -0
  125. package/src/gui/sourceEditService.ts +439 -0
  126. package/src/gui/sourceModel.ts +603 -0
  127. package/src/gui/textResolve.ts +145 -0
  128. package/src/gui/textureInfo.ts +106 -0
  129. package/src/gui/vocabulary.ts +149 -0
  130. package/src/gui/widgetEdit.ts +52 -0
  131. package/src/gui/widgetInfo.ts +245 -0
  132. package/src/index/docComments.ts +103 -0
  133. package/src/index/extract.ts +252 -0
  134. package/src/index/indexer.ts +369 -0
  135. package/src/index/intern.ts +101 -0
  136. package/src/index/lazyRefs.ts +145 -0
  137. package/src/index/modOrigin.ts +69 -0
  138. package/src/index/references.ts +534 -0
  139. package/src/overview/dependencies.ts +240 -0
  140. package/src/overview/eventBanner.ts +95 -0
  141. package/src/overview/eventDetail.ts +482 -0
  142. package/src/overview/eventGraph.ts +617 -0
  143. package/src/overview/eventVocabulary.ts +214 -0
  144. package/src/overview/locCoverage.ts +138 -0
  145. package/src/overview/modOverview.ts +29 -0
  146. package/src/overview/overrides.ts +89 -0
  147. package/src/parseCache.ts +81 -0
  148. package/src/parser/cst.ts +257 -0
  149. package/src/parser/encoding.ts +106 -0
  150. package/src/parser/index.ts +7 -0
  151. package/src/parser/lexer.ts +245 -0
  152. package/src/parser/locParser.ts +276 -0
  153. package/src/parser/parser.ts +360 -0
  154. package/src/schema/freqs.ts +70 -0
  155. package/src/schema/loader.ts +113 -0
  156. package/src/schema/types.ts +142 -0
  157. package/src/scopes/inference.ts +478 -0
  158. package/src/scopes/model.ts +148 -0
  159. package/src/scopes/varTypes.ts +290 -0
  160. package/src/server.ts +1894 -0
  161. package/src/serverData.ts +98 -0
  162. package/src/structure.ts +56 -0
  163. package/src/wordAt.ts +49 -0
@@ -0,0 +1,1998 @@
1
+ /**
2
+ * PdxGui layout engine: turn a .gui document into absolute-positioned
3
+ * rectangles, the model behind the GUI designer's canvas.
4
+ *
5
+ * Every layout rule here is MEASURED, not guessed: the authority is
6
+ * docs/gui-designer/calibration/spec.md, and rule comments cite their batch
7
+ * ("B2-I1" = calibration batch 02, case I1). Where the spec is silent the
8
+ * comment says "unmeasured" and names the assumption, those are the first
9
+ * candidates for a future calibration batch when a rendering looks wrong.
10
+ *
11
+ * Scope (phase 1): structural widgets, boxes with layout policies,
12
+ * flowcontainer, container, margin_widget, scrollarea, textboxes with the
13
+ * calibrated font metrics, template/type/blockoverride resolution.
14
+ * Phase 2 (presentation, NOT calibrated pixel rules): datamodel-list ghost
15
+ * placeholders, nine-slice `spriteborder` geometry on fills, and confirmed
16
+ * exclusion of `state = {}` transition blocks from layout.
17
+ * Phase 3 (G2 layout merge): the rules spec.md carries under "Studio-verified
18
+ * engine behaviors", namely grid box flow and cell math, clipping containers,
19
+ * `ignoreinvisible`, `resizeparent`, container/item content sizing, sprite
20
+ * fill MODE and frame sheets. Those comments cite the spec bullet's own
21
+ * source tag plus the parity-checklist row, e.g. "(Studio §K v3, L14a)";
22
+ * docs/gui-designer/parity-checklist.md is the row index. Three rows are
23
+ * DISPUTED between the two engines (L07c state-supplied position, L13e sized
24
+ * flowcontainer, L23 position on a box child) and are deliberately NOT
25
+ * implemented: both sides measured, so the checklist asks for a re-run rather
26
+ * than letting one engine overwrite the other.
27
+ *
28
+ * No `vscode` imports: unit-tested in plain Node (test/guiLayout.test.ts
29
+ * holds the golden fixtures derived from the calibration screenshots).
30
+ */
31
+ import { LineIndex, parseScript, type BlockNode, type ScalarNode, type Statement } from "../parser";
32
+ import {
33
+ collectBlockOverrides,
34
+ collectGuiDefs,
35
+ collectGuiDefsParsed,
36
+ emptyGuiDefs,
37
+ expandWidget,
38
+ type GuiDefs,
39
+ } from "./guiDefs";
40
+ import { DECL_MARKERS, SLOT_KEYS } from "./declMarkers";
41
+ import type { GuiTextSegment } from "@px-lsp/protocol/protocol";
42
+ // The anchor table is a leaf so the webview's anchor picker offers exactly the
43
+ // words this engine parses (B1-B/C).
44
+ import { anchorFractions } from "./anchorSpec";
45
+ import { GITAN_MEASURED_METRICS } from "./measuredMetrics";
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Public model
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export interface LayoutRect {
52
+ x: number;
53
+ y: number;
54
+ w: number;
55
+ h: number;
56
+ }
57
+
58
+ /**
59
+ * How a texture fills its rect. Nine-slicing needs BOTH a `Cornered*`
60
+ * spriteType AND a non-zero `spriteborder`; a border on its own is IGNORED and
61
+ * the whole texture plain-stretches, and a `*tiled*` type without a border
62
+ * tiles the whole texture. Nine-sliced edges then tile or stretch with the
63
+ * type. (Studio §J1-J7, in-game 2026-07-17; L21a-d.)
64
+ */
65
+ export type FillMode = "stretch" | "tile" | "nineslice-stretch" | "nineslice-tile";
66
+
67
+ export interface Fill {
68
+ texture?: string;
69
+ /** rgba 0..1; rendered = round(v*255), straight sRGB multiply (B1-G). */
70
+ color?: [number, number, number, number];
71
+ /**
72
+ * Nine-slice border widths [left, top, right, bottom] in texture pixels,
73
+ * sourced from the `spriteborder`/`spriteborder_<side>` .gui attributes.
74
+ * The values are read straight from the document, not a calibrated layout
75
+ * rule; `mode` is what says whether they APPLY (a border without a
76
+ * `Cornered*` type does not, Studio §J4).
77
+ */
78
+ border?: [number, number, number, number];
79
+ /**
80
+ * Fill mode from `spriteType` + `spriteborder` (Studio §J, L21a-d). Set on
81
+ * every textured fill. `nineslice-*` means computeNineSlice's regions apply,
82
+ * with edges tiled or stretched per the suffix; `tile` repeats the whole
83
+ * texture; `stretch` scales it to the rect.
84
+ */
85
+ mode?: FillMode;
86
+ /** `framesize = { w h }` grid cell size when the texture is a frame sheet (Studio §L, L22). */
87
+ framesize?: [number, number];
88
+ /** 1-based `frame` index into that grid, clamped by computeFrameCell (Studio §L, L22). */
89
+ frame?: number;
90
+ /** `alpha = x` on the widget or the background block: the fill's opacity, 0..1. */
91
+ alpha?: number;
92
+ /**
93
+ * `modify_texture = { texture blend_mode = alphamultiply }`: a mask whose
94
+ * alpha multiplies the fill's, stretched over the rect. Only the
95
+ * alphamultiply blend is carried; the engine's other blends are not drawn.
96
+ */
97
+ mask?: string;
98
+ /** `fittype = centercrop`: the texture covers the rect, cropped to centre, never stretched. */
99
+ fit?: "centercrop";
100
+ }
101
+
102
+ export interface TextInfo {
103
+ text: string;
104
+ raw?: string;
105
+ segments?: GuiTextSegment[];
106
+ fontsize: number;
107
+ /** Ink offset of the text run inside the widget rect (align; B4-T6). */
108
+ offsetX: number;
109
+ offsetY: number;
110
+ lines: string[];
111
+ /** Font color when the textbox sets one (rgba 0..1). */
112
+ color?: [number, number, number, number];
113
+ }
114
+
115
+ export interface LayoutNode {
116
+ key: string;
117
+ name?: string;
118
+ /** Absolute rect in canvas coordinates. */
119
+ rect: LayoutRect;
120
+ /** True for scrollarea viewports, the only measured clipper (B3-R1). */
121
+ clip: boolean;
122
+ bg?: Fill;
123
+ /** The widget's own texture fill (icon, textured widgets/buttons). */
124
+ fill?: Fill;
125
+ text?: TextInfo;
126
+ /**
127
+ * 0-based source line of the instance statement in the CURRENT document.
128
+ * Children spliced in from type definitions inherit their instance
129
+ * ancestor's line (their own statements live in other files).
130
+ */
131
+ line?: number;
132
+ /**
133
+ * True when the widget was placed by anchor+position rules (its `position`
134
+ * is honored, so it is draggable). False for children whose rect is
135
+ * dictated by a box/flow parent.
136
+ */
137
+ positioned: boolean;
138
+ /**
139
+ * True when `line` is the widget's OWN statement in the current document
140
+ * (safe to edit). False for children spliced from type definitions, whose
141
+ * `line` is the instance ancestor's, editing those would modify the
142
+ * wrong widget.
143
+ */
144
+ editable: boolean;
145
+ /** Raw `position = { x y }` source values, when present. */
146
+ srcPosition?: [number, number];
147
+ /** Raw `size = { w h }` source values, when present (may be %, see sizePct). */
148
+ srcSize?: [number, number];
149
+ /**
150
+ * The widget's index among its parent body's REORDER SIBLINGS, which is what
151
+ * a `reorder`, `insert` or `delete` op counts: the body's declarations, the
152
+ * `blockoverride`/`block`/`template` entries included, properties excluded
153
+ * (sourceModel.ts `GuiBody.children`). Absent for anything whose statement is
154
+ * not a direct entry of a body in THIS document: a template- or type-spliced
155
+ * child, a datamodel ghost, the contents of a named slot, and a scrollarea's
156
+ * pass-through children, whose indices count a body their new parent does not
157
+ * own. Absent means "not addressable by index", which is the only honest
158
+ * answer a client can act on.
159
+ */
160
+ srcIndex?: number;
161
+ /**
162
+ * True for placeholder copies of a datamodel item template: the list has no
163
+ * real runtime data in the preview, so GHOST_COUNT reduced-opacity instances
164
+ * stand in. Presentation only, propagated to the whole ghost subtree.
165
+ */
166
+ ghost?: boolean;
167
+ /** The widget's `onclick` source string, verbatim, when it has one (see GuiLayoutNode.onclick). */
168
+ onclick?: string;
169
+ /** The widget's `tooltip` source string, verbatim, when it has one. */
170
+ tooltip?: string;
171
+ /**
172
+ * True for a root that is a `type name = base { }` DECLARATION laid out as
173
+ * one instance of itself, which is what a document that instantiates nothing
174
+ * at top level shows (see {@link declaredRoots}). The declaration header
175
+ * itself is not an editable widget, its children, which are real statements
176
+ * in this document, are.
177
+ */
178
+ declared?: boolean;
179
+ children: LayoutNode[];
180
+ }
181
+
182
+ /**
183
+ * How a widget's rect came to be, recorded only when a caller asks for it.
184
+ * Structurally identical to the wire types (`GuiPlacementTerm`, `GuiPlacedBy`,
185
+ * `GuiPlacement`), like LayoutNode is to GuiLayoutNode.
186
+ */
187
+ export interface PlacementTerm {
188
+ kind: "parentOrigin" | "parentanchor" | "widgetanchor" | "position";
189
+ source?: string;
190
+ dx: number;
191
+ dy: number;
192
+ }
193
+ export interface PlacedBy {
194
+ key: string;
195
+ name?: string;
196
+ layout: "box" | "flow" | "grid";
197
+ droppedPosition?: [number, number];
198
+ }
199
+ export interface Placement {
200
+ rect: LayoutRect;
201
+ parentRect: LayoutRect;
202
+ terms: PlacementTerm[];
203
+ placedBy?: PlacedBy;
204
+ clippedBy?: { key: string; name?: string; rect: LayoutRect };
205
+ }
206
+
207
+ /**
208
+ * Placement-explanation request AND sink: name the widget's own 0-based line,
209
+ * read `result` after the run. Passing one is what turns the trace on; without
210
+ * it `arrange` pays a single truthy test per node and allocates nothing.
211
+ */
212
+ export interface PlacementExplain {
213
+ line: number;
214
+ result?: Placement;
215
+ }
216
+
217
+ /** Ancestor facts the trace needs, built only while explaining. */
218
+ interface ExplainChain {
219
+ sink: PlacementExplain;
220
+ parent?: WNode;
221
+ /** The rect the parent laid its children in (for a container-placed child). */
222
+ parentRect?: LayoutRect;
223
+ /** The innermost clipping ancestor above this node. */
224
+ clip?: { key: string; name?: string; rect: LayoutRect };
225
+ }
226
+
227
+ /**
228
+ * How the layout treats a widget whose `visible` holds an expression a static
229
+ * preview cannot evaluate. Structurally the wire's `GuiVisibilityOptions`.
230
+ * `visible = no` / `visible = yes` are deterministic and unaffected.
231
+ */
232
+ export interface VisibilityOptions {
233
+ mode: "showAll" | "hideAll" | "evaluate";
234
+ /** `evaluate` only: condition source string -> shown/hidden. */
235
+ checks?: Record<string, boolean>;
236
+ }
237
+
238
+ /** One conditional `visible` the run met (the wire's `GuiVisibilityCheck`). */
239
+ export interface VisibilityCheck {
240
+ key: string;
241
+ count: number;
242
+ hidden: boolean;
243
+ }
244
+
245
+ /** Per-stage wall clock, filled in when a caller passes one. */
246
+ export interface LayoutTiming {
247
+ /** Parsing the document and collecting its own template/type declarations. */
248
+ parseMs: number;
249
+ /** Building the widget tree and arranging every rect. */
250
+ layoutMs: number;
251
+ }
252
+
253
+ /**
254
+ * Number of placeholder rows drawn for a datamodel-driven list (unmeasured:
255
+ * a preview affordance, capped per ghostCount so it never overruns a container
256
+ * whose own size is known). GHOST_OPACITY is applied by the client renderer.
257
+ */
258
+ export const GHOST_COUNT = 3;
259
+ export const GHOST_OPACITY = 0.45;
260
+
261
+ /**
262
+ * Sprite fill geometry lives in its own leaf module so the canvas renderer can
263
+ * bundle it without the parser behind it, and re-exports here because it is
264
+ * part of this model's published surface.
265
+ */
266
+ export { computeFrameCell, computeNineSlice, type NineSliceRegion } from "./fillGeometry";
267
+
268
+ /**
269
+ * Fill mode: nine-slice iff a `Cornered*` type AND a non-zero border,
270
+ * otherwise tile for a `*tiled*` type, else stretch (Studio §J, L21a-d).
271
+ */
272
+ function fillMode(spriteType: string | undefined, border?: [number, number, number, number]): FillMode {
273
+ const type = spriteType?.toLowerCase() ?? "";
274
+ const tiled = type.includes("tiled");
275
+ const cornered = type.startsWith("cornered") && (border?.some((v) => v > 0) ?? false);
276
+ if (cornered) return tiled ? "nineslice-tile" : "nineslice-stretch";
277
+ return tiled ? "tile" : "stretch";
278
+ }
279
+
280
+ export interface TextMeasurer {
281
+ /** Advance-model width of one line: (n-1)*advance + ink(last). (B2-L) */
282
+ lineWidth(text: string, fontsize: number): number;
283
+ /** Line box height; 21 at fontsize 15, scales linearly. (B1-G, B3-S3) */
284
+ lineHeight(fontsize: number): number;
285
+ }
286
+
287
+ /**
288
+ * A game's measured default-font text metrics: glyph advance/ink table and
289
+ * line-box height at the fontsize they were measured at. All metrics scale
290
+ * exactly linearly with fontsize (B3-S3), so one base size is enough. A game
291
+ * profile carries one once an in-game probe has measured its font
292
+ * (docs/gui-designer/calibration/probes/); until then the profile leaves it
293
+ * absent and the batch-01..03 table below is the assumption.
294
+ */
295
+ export interface GuiTextMetrics {
296
+ /** fontsize the table was measured at. */
297
+ baseFontsize: number;
298
+ /** Line box height at baseFontsize. */
299
+ lineHeight: number;
300
+ glyphs: Record<string, { adv: number; ink: number }>;
301
+ /** Fallback for glyphs outside the measured set, a rough average. */
302
+ defaultGlyph: { adv: number; ink: number };
303
+ /**
304
+ * The fontsize a textbox renders at when it sets none. Absent = 15 (the
305
+ * default profile's Font_Size_Small).
306
+ */
307
+ defaultFontsize?: number;
308
+ /**
309
+ * When true, each glyph's advance/ink is ROUNDED after scaling to the
310
+ * requested fontsize instead of scaling the sum linearly. The 2026-08-09 probe
311
+ * measured this law: M advance = round(0.9 * fontsize), 14 at
312
+ * 15, 15 at 17, 27 at 30, which no linear table reproduces. Pick
313
+ * baseFontsize so adv/base is the glyph's true em fraction. lineHeight
314
+ * still scales linearly (the same probe's 1.3 * fontsize law is exact).
315
+ */
316
+ roundPerSize?: boolean;
317
+ }
318
+
319
+ /**
320
+ * Measured per-game layout rule divergences. Every flag cites the probe that
321
+ * measured it; absent flags mean the engine's default (in-game-measured for
322
+ * the default profile) applies.
323
+ */
324
+ export interface GuiLayoutQuirks {
325
+ /**
326
+ * An EMPTY `container` with an authored `size` KEEPS it instead of
327
+ * collapsing to 0. Measured in-game (probe 2026-08-09: the empty sized
328
+ * container rendered its full 150x60, engine warning logged yet applied);
329
+ * the default profile measured the opposite, narrow rule (probe
330
+ * 2026-08-02, L25).
331
+ */
332
+ emptySizedContainerKept?: boolean;
333
+ }
334
+
335
+ /**
336
+ * What the engine threads through layout: the text measurer plus the game's
337
+ * measured defaults and quirks. A plain TextMeasurer is a valid LayoutEnv
338
+ * (every extra field optional = the default profile's measured behavior).
339
+ */
340
+ export interface LayoutEnv extends TextMeasurer, GuiLayoutQuirks {
341
+ defaultFontsize?: number;
342
+ /**
343
+ * What a `text =` value shows (textResolve.ts): loc keys and datafunctions
344
+ * resolved as far as the preview can know. Absent = the value verbatim.
345
+ */
346
+ resolveText?: (raw: string) => { text: string; segments?: GuiTextSegment[] };
347
+ }
348
+
349
+ /** Advance-model measurer over a measured metrics table (B2-L, B3-S3). */
350
+ export function measurerFromMetrics(m: GuiTextMetrics): LayoutEnv {
351
+ return {
352
+ lineWidth(text, fontsize) {
353
+ if (text.length === 0) return 0;
354
+ const s = fontsize / m.baseFontsize; // linear scaling (B3-S3)
355
+ const scaled = m.roundPerSize ? (v: number) => Math.round(v * s) : (v: number) => v * s;
356
+ let w = 0;
357
+ for (let n = 0; n < text.length; n++) {
358
+ const g = m.glyphs[text[n]] ?? m.defaultGlyph;
359
+ w += scaled(n === text.length - 1 ? g.ink : g.adv);
360
+ }
361
+ return w;
362
+ },
363
+ lineHeight(fontsize) {
364
+ return m.lineHeight * (fontsize / m.baseFontsize); // B1-G, B3-S3
365
+ },
366
+ defaultFontsize: m.defaultFontsize,
367
+ };
368
+ }
369
+
370
+ /**
371
+ * The default profile's measured font table (gui/measuredMetrics.ts) is also
372
+ * the assumption for games whose probe has not run yet.
373
+ */
374
+ export const calibratedMeasurer: TextMeasurer = measurerFromMetrics(GITAN_MEASURED_METRICS);
375
+
376
+ export interface LayoutOptions {
377
+ /** Rect the top-level widgets are laid out against. */
378
+ viewport?: { w: number; h: number };
379
+ measurer?: TextMeasurer;
380
+ /**
381
+ * Cross-file template/type store (vanilla + mod, FIOS-merged via
382
+ * guiDefs.mergeGuiDefs). The current document's own declarations are always
383
+ * collected on top: store entries win for globals (FIOS), the file's
384
+ * local_templates win locally.
385
+ */
386
+ defs?: GuiDefs;
387
+ /** Conditional-visibility preview mode; absent = `showAll` (today's rule). */
388
+ visibility?: VisibilityOptions;
389
+ /** Filled with every conditional `visible` met, key -> count + outcome. */
390
+ checks?: Map<string, VisibilityCheck>;
391
+ /** Record why ONE widget's rect is where it is; absent = no trace. */
392
+ explain?: PlacementExplain;
393
+ /** Filled with this run's per-stage wall clock; absent = no measurement. */
394
+ timing?: LayoutTiming;
395
+ }
396
+
397
+ export function computeGuiLayout(text: string, options?: LayoutOptions): LayoutNode[] {
398
+ const viewport = options?.viewport ?? { w: 1920, h: 1080 };
399
+ const measurer = options?.measurer ?? calibratedMeasurer;
400
+ const t0 = options?.timing ? performance.now() : 0;
401
+ const result = parseScript(text);
402
+ const consts = collectConstants(result.root.statements);
403
+ const defs = effectiveDefs(text, options?.defs);
404
+ const lineIndex = new LineIndex(text);
405
+ const ctx: BuildCtx = {
406
+ consts,
407
+ defs,
408
+ overrides: new Map(),
409
+ stack: [],
410
+ lineOf: (offset) => lineIndex.positionAt(offset).line,
411
+ visibility: options?.visibility,
412
+ checks: options?.checks ?? new Map(),
413
+ };
414
+ const t1 = options?.timing ? performance.now() : 0;
415
+ let widgets = collectWidgets(result.root.statements, ctx);
416
+ // Nothing is instantiated here: preview what the file DECLARES instead of an
417
+ // empty canvas (see `declaredRoots`).
418
+ let declared = false;
419
+ if (widgets.length === 0) {
420
+ widgets = declaredRoots(result.root.statements, ctx);
421
+ declared = widgets.length > 0;
422
+ }
423
+ const root: LayoutRect = { x: 0, y: 0, w: viewport.w, h: viewport.h };
424
+ const chain: ExplainChain | undefined = options?.explain ? { sink: options.explain } : undefined;
425
+ const nodes = widgets.map((w) => arrange(w, root, "plain", measurer, undefined, chain));
426
+ if (declared) for (const n of nodes) n.declared = true;
427
+ if (options?.timing) {
428
+ options.timing.parseMs = t1 - t0;
429
+ options.timing.layoutMs = performance.now() - t1;
430
+ }
431
+ return nodes;
432
+ }
433
+
434
+ /**
435
+ * The def store a document is laid out against: the cross-file store with the
436
+ * document's OWN declarations layered in (its local_templates always win, other
437
+ * names only when the store has none, FIOS). Exported so a reader about the
438
+ * same document resolves the same names the rendering did.
439
+ */
440
+ export function effectiveDefs(text: string, store?: GuiDefs): GuiDefs {
441
+ const own = collectGuiDefs(text);
442
+ if (!store) return own;
443
+ const merged = emptyGuiDefs();
444
+ for (const [k, v] of store.types) merged.types.set(k, v);
445
+ for (const [k, v] of own.types) if (!merged.types.has(k)) merged.types.set(k, v);
446
+ for (const [k, v] of store.templates) merged.templates.set(k, v);
447
+ for (const [k, v] of own.templates) {
448
+ if (v.local || !merged.templates.has(k)) merged.templates.set(k, v);
449
+ }
450
+ return merged;
451
+ }
452
+
453
+ /** Top-level `@name = 42` gui constants, referenced as `@name` in values. */
454
+ function collectConstants(statements: Statement[]): Map<string, number> {
455
+ const consts = new Map<string, number>();
456
+ for (const stmt of statements) {
457
+ if (stmt.kind !== "assignment" || !stmt.key.text.startsWith("@")) continue;
458
+ if (stmt.value?.kind !== "scalar") continue;
459
+ const v = parseFloat(stmt.value.text);
460
+ if (Number.isFinite(v)) consts.set(stmt.key.text, v);
461
+ }
462
+ return consts;
463
+ }
464
+
465
+ // ---------------------------------------------------------------------------
466
+ // CST -> raw widget nodes
467
+ // ---------------------------------------------------------------------------
468
+
469
+ export type WidgetClass =
470
+ | "plain" // widget, window, button, icon, ... : explicit size or ZERO (B4-T1)
471
+ | "box" // hbox, vbox
472
+ | "flow" // flowcontainer
473
+ | "container" // container: hugs at origin, empty = 0 (B2-I4, L25)
474
+ | "item" // datamodel item template: content-sizes like a container (L10)
475
+ | "grid" // fixedgridbox / dynamicgridbox (L14, L15)
476
+ | "marginwidget" // margin offsets children (B3-Q2, B4-T3)
477
+ | "scrollarea" // scrollarea / scrollbox: clips (B3-R1, L17b)
478
+ | "textbox" // text metrics sizing
479
+ | "expand"; // growing spacer (B4-T8)
480
+
481
+ interface WNode {
482
+ key: string;
483
+ cls: WidgetClass;
484
+ /** grid only: fixedgridbox (addcolumn/addrow ARE the cell size and stride). */
485
+ fixedCells: boolean;
486
+ vertical: boolean; // vbox / flow direction=vertical
487
+ props: Map<string, ScalarNode>;
488
+ pairs: Map<string, number[]>; // size/position/margin/color number lists
489
+ sizePct: [boolean, boolean]; // per-axis: size value is a percentage (B4-T2)
490
+ consts: Map<string, number>;
491
+ line?: number;
492
+ ownLine: boolean; // line points at this widget's own statement
493
+ /** Rank among the parent body's reorder siblings; see LayoutNode.srcIndex. */
494
+ srcIndex?: number;
495
+ /** Resolved once in the build phase, per the request's visibility mode. */
496
+ invisible: boolean;
497
+ bg?: Fill;
498
+ ghost?: boolean; // placeholder copy of a datamodel item template
499
+ /**
500
+ * Resolved `item = {}` wrapper from a datamodel container, captured during
501
+ * process() and stamped out as ghost copies. The wrapper NODE is kept rather
502
+ * than spliced away: a datamodel item content-sizes to the bounding box of
503
+ * its children the way a container does, and a gridbox needs that rect (L10).
504
+ */
505
+ itemTemplate?: WNode;
506
+ children: WNode[];
507
+ }
508
+
509
+ /**
510
+ * Attribute blocks that are data, not child widgets (a superset of
511
+ * guiTree.ts's list: layout also reads `background`/`state`/`block` blocks).
512
+ * `minimumsize = { w h }` belongs here and used to be walked as a phantom
513
+ * child widget, which cost a box child a whole space-around slot (L04c).
514
+ * Exported because `sourceModel.ts` splits widgets from properties by the same
515
+ * set: if the writer and the engine disagreed here, a preview selection could
516
+ * address an attribute block as if it were a widget.
517
+ */
518
+ export const PROPERTY_BLOCKS = new Set([
519
+ "size",
520
+ "minimumsize",
521
+ "position",
522
+ "framesize",
523
+ "spriteborder",
524
+ "color",
525
+ "disabledcolor",
526
+ "uv_scale",
527
+ "margin",
528
+ "padding",
529
+ "mipmaplodbias",
530
+ "modify_texture",
531
+ "resizeparent",
532
+ "soundeffect",
533
+ "cursor_properties",
534
+ "background",
535
+ "state",
536
+ "animation",
537
+ "attachanimation",
538
+ "blockoverride",
539
+ "block",
540
+ ]);
541
+
542
+ const CLASS_BY_KEY: Record<string, WidgetClass> = {
543
+ hbox: "box",
544
+ vbox: "box",
545
+ flowcontainer: "flow",
546
+ container: "container",
547
+ item: "item",
548
+ fixedgridbox: "grid",
549
+ dynamicgridbox: "grid",
550
+ margin_widget: "marginwidget",
551
+ scrollarea: "scrollarea",
552
+ scrollbox: "scrollarea", // same viewport behavior; both clip (L17b)
553
+ textbox: "textbox",
554
+ text_single: "textbox",
555
+ text_multi: "textbox",
556
+ editbox: "textbox",
557
+ expand: "expand",
558
+ };
559
+
560
+ function classify(key: string): WidgetClass {
561
+ return CLASS_BY_KEY[key] ?? "plain";
562
+ }
563
+
564
+ /**
565
+ * The engine's own key -> class mapping, for the WRITER's refusal guards: a
566
+ * resize is refused on a content-sized class, a drag on a child of a layout
567
+ * container. Exported rather than copied so a guard can never claim a rule the
568
+ * engine does not apply. The caller resolves a type instance to its base key
569
+ * first (`typeBaseChain`), the way `buildWNode` does.
570
+ */
571
+ export function widgetClassOf(baseKey: string): WidgetClass {
572
+ return classify(baseKey.toLowerCase());
573
+ }
574
+
575
+ function blockOf(stmt: Statement): BlockNode | null {
576
+ if (stmt.kind !== "assignment" || !stmt.value) return null;
577
+ if (stmt.value.kind === "block") return stmt.value;
578
+ if (stmt.value.kind === "tagged-block") return stmt.value.block;
579
+ return null;
580
+ }
581
+
582
+ /**
583
+ * Numeric value with @constant resolution; anything unresolvable (data
584
+ * bindings, unknown macros) becomes 0 so rects stay finite on real vanilla
585
+ * files (verified over all 373 game .gui files).
586
+ */
587
+ function toNumber(text: string, consts: Map<string, number>): number {
588
+ if (text.startsWith("@")) return consts.get(text) ?? 0;
589
+ const v = parseFloat(text);
590
+ return Number.isFinite(v) ? v : 0;
591
+ }
592
+
593
+ function numbersIn(block: BlockNode, consts: Map<string, number>): number[] {
594
+ const out: number[] = [];
595
+ for (const s of block.statements) {
596
+ if (s.kind === "value" && s.value.kind === "scalar") {
597
+ out.push(toNumber(s.value.text, consts));
598
+ }
599
+ }
600
+ return out;
601
+ }
602
+
603
+ interface BuildCtx {
604
+ consts: Map<string, number>;
605
+ defs: GuiDefs;
606
+ /**
607
+ * blockoverride map inherited from ancestor instances; outer overrides win
608
+ * over inner ones (an instance override reaches into blocks declared deep
609
+ * inside the type's subtree, the PoD resource-bar pattern).
610
+ */
611
+ overrides: Map<string, BlockNode>;
612
+ /** Type keys currently being instantiated, to break recursion cycles. */
613
+ stack: string[];
614
+ /** Offset -> 0-based line in the current document. */
615
+ lineOf: (offset: number) => number;
616
+ /** Conditional-visibility mode; absent = showAll. */
617
+ visibility?: VisibilityOptions;
618
+ /** Every conditional `visible` met, accumulated across the whole document. */
619
+ checks: Map<string, VisibilityCheck>;
620
+ }
621
+
622
+ /**
623
+ * Subtrees that never take part in window layout. Tooltips are created
624
+ * lazily in-engine, which is also how vanilla legally ships type cycles
625
+ * through them (a tooltip containing its own widget type).
626
+ */
627
+ const SKIP_SUBTREES = new Set(["tooltipwidget"]);
628
+
629
+ function buildWNode(
630
+ key: string,
631
+ block: BlockNode,
632
+ ctx: BuildCtx,
633
+ line?: number,
634
+ ownLine = false,
635
+ srcIndex?: number
636
+ ): WNode {
637
+ const lower = key.toLowerCase();
638
+ // Cycle/depth guard: a TYPE instantiated inside its own expansion gets no
639
+ // type expansion (instance statements + templates only), which breaks
640
+ // mutual-recursion chains that the real engine only resolves lazily.
641
+ // Builtin keys (widget in widget) are not cycles and are never pushed.
642
+ const isType = ctx.defs.types.has(lower);
643
+ const cyclic = (isType && ctx.stack.includes(lower)) || ctx.stack.length > 64;
644
+ const { baseKey, statements } = expandWidget(lower, block, ctx.defs, cyclic);
645
+ const node: WNode = {
646
+ key: lower,
647
+ cls: classify(baseKey),
648
+ fixedCells: baseKey === "fixedgridbox",
649
+ vertical: baseKey === "vbox",
650
+ props: new Map(),
651
+ pairs: new Map(),
652
+ sizePct: [false, false],
653
+ consts: ctx.consts,
654
+ line,
655
+ ownLine,
656
+ srcIndex,
657
+ invisible: false,
658
+ children: [],
659
+ };
660
+ // Local block overrides, shadowed by inherited (outer) ones.
661
+ const rootOverrides = new Map(collectBlockOverrides(statements));
662
+ for (const [k, v] of ctx.overrides) rootOverrides.set(k, v);
663
+ const childStack = isType ? [...ctx.stack, lower] : ctx.stack;
664
+ // The reorder ranks of this instance's OWN body. Statements spliced in from a
665
+ // type, a template or a named slot are not in it, so they get no srcIndex:
666
+ // their index would count a body that is not the one the client would address.
667
+ const ranks = siblingRanks(block.statements);
668
+
669
+ // `ov` is threaded explicitly: an override is CONSUMED when applied, so a
670
+ // block re-declaring its own name inside override content (vanilla's
671
+ // cooltip chaining pattern) falls back to the default instead of recursing.
672
+ let usingDepth = 0;
673
+ const process = (stmts: Statement[], ov: Map<string, BlockNode>): void => {
674
+ let marker: "block" | "blockoverride" | null = null;
675
+ for (const stmt of stmts) {
676
+ if (stmt.kind === "value") {
677
+ const t = stmt.value.kind === "scalar" ? stmt.value.text.toLowerCase() : "";
678
+ marker = t === "block" ? "block" : t === "blockoverride" ? "blockoverride" : null;
679
+ continue;
680
+ }
681
+ const m = marker;
682
+ marker = null;
683
+ if (m === "blockoverride") continue; // consumed by collectBlockOverrides
684
+ const k = stmt.key.text.toLowerCase();
685
+ const child = blockOf(stmt);
686
+ if (k === "using" && stmt.value?.kind === "scalar" && !m) {
687
+ // expandWidget spliced the top-level `using`s; one inside a block's
688
+ // content (vanilla: `block "scrollbox_margins" { using =
689
+ // Scrollbox_Margins }`) or an override body reaches here and is
690
+ // spliced the same way, in place.
691
+ const tpl = ctx.defs.templates.get(stmt.value.text);
692
+ if (tpl && usingDepth < 8) {
693
+ usingDepth++;
694
+ process(tpl.block.statements, ov);
695
+ usingDepth--;
696
+ }
697
+ continue;
698
+ }
699
+ if (m === "block") {
700
+ // Named slot: overridden content (or its own default), spliced inline.
701
+ const override = ov.get(stmt.key.text);
702
+ const content = override ?? child;
703
+ let sub = ov;
704
+ if (override) {
705
+ sub = new Map(ov);
706
+ sub.delete(stmt.key.text);
707
+ }
708
+ if (content) process(content.statements, sub);
709
+ continue;
710
+ }
711
+ if (child) {
712
+ if (k === "item") {
713
+ // Datamodel item template: `item = { <widget> }` holds one instance
714
+ // of the per-row widget (the universal vanilla pattern, verified in
715
+ // window_character.gui skills hbox + modifiers fixedgridbox: item is
716
+ // always a plain wrapper whose children are the row widget). Captured
717
+ // here, stamped out as ghost copies after process(). The wrapper node
718
+ // survives because the item has a rect of its own: it content-sizes
719
+ // to the bounding box of its children (L10).
720
+ node.itemTemplate = buildWNode(
721
+ "item",
722
+ child,
723
+ { ...ctx, overrides: ov, stack: childStack },
724
+ line,
725
+ false
726
+ );
727
+ continue;
728
+ }
729
+ if (SKIP_SUBTREES.has(k)) {
730
+ continue;
731
+ } else if (k === "background") {
732
+ node.bg = resolveFill(child, ctx.consts, ctx.defs, ov);
733
+ } else if (k === "modify_texture") {
734
+ const mask = maskOf(child);
735
+ if (mask) node.props.set("#mask", fakeScalar(mask));
736
+ } else if (k === "scrollwidget") {
737
+ // Pass-through: scrollarea content renders at the viewport origin
738
+ // with no rect of its own observed (B3-R1).
739
+ const inner = buildWNode("scrollwidget", child, {
740
+ ...ctx,
741
+ overrides: ov,
742
+ stack: childStack,
743
+ });
744
+ // Adopted by the scrollarea, so their ranks count a body the client
745
+ // could not address: an index into the scrollwidget's children read
746
+ // as an index into the scrollarea's would move the wrong block.
747
+ for (const c of inner.children) c.srcIndex = undefined;
748
+ node.children.push(...inner.children);
749
+ } else if (PROPERTY_BLOCKS.has(k)) {
750
+ node.pairs.set(k, numbersIn(child, ctx.consts));
751
+ if (k === "size") {
752
+ const vals: number[] = [];
753
+ let i = 0;
754
+ for (const s of child.statements) {
755
+ if (s.kind === "value" && s.value.kind === "scalar") {
756
+ const t = s.value.text;
757
+ if (i < 2) node.sizePct[i] = t.endsWith("%");
758
+ vals.push(toNumber(t.endsWith("%") ? t.slice(0, -1) : t, ctx.consts));
759
+ i++;
760
+ }
761
+ }
762
+ node.pairs.set("size", vals);
763
+ }
764
+ } else {
765
+ // Line info only for statements physically inside this instance's
766
+ // block (type-def content lives in other files): children spliced
767
+ // from types inherit the instance's line.
768
+ const inInstance = stmt.range.start >= block.range.start && stmt.range.end <= block.range.end;
769
+ const childLine = inInstance ? ctx.lineOf(stmt.key.range.start) : line;
770
+ node.children.push(
771
+ buildWNode(
772
+ stmt.key.text,
773
+ child,
774
+ { ...ctx, overrides: ov, stack: childStack },
775
+ childLine,
776
+ inInstance,
777
+ ranks.get(stmt)
778
+ )
779
+ );
780
+ }
781
+ } else if (stmt.value?.kind === "scalar") {
782
+ // A widget may carry several `onclick` lines; the game runs them all,
783
+ // so keep every one (newline-joined) rather than the last.
784
+ const prior = k === "onclick" ? node.props.get(k) : undefined;
785
+ node.props.set(
786
+ k,
787
+ prior
788
+ ? {
789
+ ...stmt.value,
790
+ text: `${prior.text}
791
+ ${stmt.value.text}`,
792
+ }
793
+ : stmt.value
794
+ );
795
+ }
796
+ }
797
+ };
798
+ process(statements, rootOverrides);
799
+ if (node.props.get("direction")?.text.toLowerCase() === "vertical") node.vertical = true;
800
+
801
+ // Builtin fallbacks for the vanilla label types (gui/preload/labels.gui)
802
+ // when no defs store provides the real definitions. text_multi's hardcoded
803
+ // 45x45 bit us in B2-L; reproduce it faithfully.
804
+ if (lower === "text_single" && !ctx.defs.types.has("text_single")) {
805
+ if (!node.props.has("autoresize")) node.props.set("autoresize", fakeScalar("yes"));
806
+ }
807
+ if (lower === "text_multi" && !ctx.defs.types.has("text_multi")) {
808
+ if (!node.pairs.has("size")) node.pairs.set("size", [45, 45]);
809
+ if (!node.props.has("multiline")) node.props.set("multiline", fakeScalar("yes"));
810
+ }
811
+ // Resolved once, after expansion has settled `visible` last-in-wins.
812
+ node.invisible = resolveVisibility(node.props.get("visible")?.text, ctx);
813
+
814
+ // unmeasured: placeholder presentation, not a calibrated layout rule.
815
+ // A datamodel list has no runtime rows in a static preview, so it would draw
816
+ // empty. Assumption: each data row is one instance of the `item` template
817
+ // laid out as a normal child. Stamp GHOST_COUNT (capped) ghost copies so the
818
+ // container's real layout policy (box/flow stacking) is visible. Reuses the
819
+ // already-resolved template widgets; no extra expansion machinery.
820
+ if (node.itemTemplate && node.itemTemplate.children.length > 0) {
821
+ markGhost(node.itemTemplate);
822
+ const count = ghostCount(node);
823
+ for (let i = 0; i < count; i++) node.children.push(node.itemTemplate);
824
+ }
825
+ return node;
826
+ }
827
+
828
+ /** Flag a template subtree as a placeholder (non-editable, dimmed by the client). */
829
+ function markGhost(node: WNode): void {
830
+ node.ghost = true;
831
+ for (const c of node.children) markGhost(c);
832
+ }
833
+
834
+ /**
835
+ * How many ghost rows to draw. GHOST_COUNT, but capped to what the container's
836
+ * own explicit size can hold on its main axis when both that size and the
837
+ * item's explicit size are known (so a small fixed list never overruns). Runs
838
+ * in the build phase, so it uses authored sizes only, no text measurement.
839
+ */
840
+ function ghostCount(node: WNode): number {
841
+ const size = explicitSize(node);
842
+ if (!size || !node.itemTemplate) return GHOST_COUNT;
843
+ const avail = node.vertical ? size.h : size.w;
844
+ if (avail <= 0) return GHOST_COUNT;
845
+ const extent = staticExtent(node.itemTemplate);
846
+ if (!extent) return GHOST_COUNT; // item bounds unknown: no cap
847
+ const itemMain = node.vertical ? extent.h : extent.w;
848
+ if (itemMain <= 0) return GHOST_COUNT;
849
+ return Math.max(1, Math.min(GHOST_COUNT, Math.floor(avail / itemMain)));
850
+ }
851
+
852
+ /**
853
+ * The item template's bounding box from AUTHORED sizes alone (the build phase
854
+ * has no measurer, so a text row stays unknown). Null when any child lacks an
855
+ * explicit size.
856
+ */
857
+ function staticExtent(item: WNode): { w: number; h: number } | null {
858
+ let w = 0;
859
+ let h = 0;
860
+ for (const c of item.children) {
861
+ const s = explicitSize(c);
862
+ if (!s) return null;
863
+ const pos = c.pairs.get("position") ?? [0, 0];
864
+ w = Math.max(w, (pos[0] ?? 0) + s.w);
865
+ h = Math.max(h, (pos[1] ?? 0) + s.h);
866
+ }
867
+ return { w, h };
868
+ }
869
+
870
+ function fakeScalar(text: string): ScalarNode {
871
+ return { kind: "scalar", text, quoted: false, range: { start: 0, end: 0 } };
872
+ }
873
+
874
+ /**
875
+ * The Fill a `background = { ... }` block produces, `using =` templates
876
+ * spliced. Exported so the inspector reads a background exactly the way the
877
+ * canvas drew it instead of re-deriving the same attributes.
878
+ */
879
+ export function resolveFill(
880
+ block: BlockNode,
881
+ consts: Map<string, number>,
882
+ defs: GuiDefs,
883
+ overrides: ReadonlyMap<string, BlockNode> = new Map()
884
+ ): Fill {
885
+ const fill: Fill = {};
886
+ // `background = { using = Background_Area_Dark }` carries its texture via
887
+ // the template; expandWidget with an unknown key just splices templates.
888
+ // A `block "illustration_texture" { texture = ... }` inside it takes the
889
+ // instance's blockoverride, the way a widget's own slots do: vanilla's
890
+ // widget_header_with_picture names its picture exactly so.
891
+ const statements = spliceBlocks(expandWidget("#background", block, defs).statements, overrides, defs);
892
+ let sprite: number[] | undefined;
893
+ let spriteType: string | undefined;
894
+ let framesize: number[] | undefined;
895
+ let frame: number | undefined;
896
+ const side: { l?: number; t?: number; r?: number; b?: number } = {};
897
+ for (const stmt of statements) {
898
+ if (stmt.kind !== "assignment") continue;
899
+ const k = stmt.key.text.toLowerCase();
900
+ if (k === "texture" && stmt.value?.kind === "scalar") fill.texture = stmt.value.text;
901
+ if (k === "spritetype" && stmt.value?.kind === "scalar") spriteType = stmt.value.text;
902
+ if (k === "alpha" && stmt.value?.kind === "scalar") fill.alpha = toNumber(stmt.value.text, consts);
903
+ if (k === "fittype" && stmt.value?.kind === "scalar" && stmt.value.text.toLowerCase() === "centercrop")
904
+ fill.fit = "centercrop";
905
+ if (k === "modify_texture") {
906
+ const b = blockOf(stmt);
907
+ const mask = b ? maskOf(b) : undefined;
908
+ if (mask) fill.mask = mask;
909
+ }
910
+ if (k === "frame" && stmt.value?.kind === "scalar") frame = toNumber(stmt.value.text, consts);
911
+ if (k === "framesize") {
912
+ const b = blockOf(stmt);
913
+ if (b) framesize = numbersIn(b, consts);
914
+ }
915
+ if (k === "color") {
916
+ const b = blockOf(stmt);
917
+ if (b) {
918
+ const v = numbersIn(b, consts);
919
+ if (v.length >= 3) fill.color = [v[0], v[1], v[2], v[3] ?? 1];
920
+ }
921
+ }
922
+ // Nine-slice: `spriteborder = { x y }` (x=left/right, y=top/bottom) plus
923
+ // per-side scalar overrides. Reachable straight off the background block.
924
+ if (k === "spriteborder") {
925
+ const b = blockOf(stmt);
926
+ if (b) sprite = numbersIn(b, consts);
927
+ }
928
+ if (k.startsWith("spriteborder_") && stmt.value?.kind === "scalar") {
929
+ const v = toNumber(stmt.value.text, consts);
930
+ if (k === "spriteborder_left") side.l = v;
931
+ else if (k === "spriteborder_top") side.t = v;
932
+ else if (k === "spriteborder_right") side.r = v;
933
+ else if (k === "spriteborder_bottom") side.b = v;
934
+ }
935
+ }
936
+ const border = borderTuple(sprite, side);
937
+ if (border) fill.border = border;
938
+ if (fill.texture !== undefined) {
939
+ fill.mode = fillMode(spriteType, border); // Studio §J, L21a-d
940
+ if (framesize && framesize.length >= 2) {
941
+ // Studio §L, L22: the sheet grid; `frame` defaults to the first cell.
942
+ fill.framesize = [framesize[0], framesize[1]];
943
+ fill.frame = frame ?? 1;
944
+ }
945
+ }
946
+ return fill;
947
+ }
948
+
949
+ /**
950
+ * The statements with every `block "name" { ... }` slot replaced by its
951
+ * override (or its own default content), recursively, an override consumed
952
+ * once it is applied (the same rule buildWNode's process() follows).
953
+ */
954
+ function spliceBlocks(
955
+ statements: Statement[],
956
+ overrides: ReadonlyMap<string, BlockNode>,
957
+ defs?: GuiDefs,
958
+ depth = 0
959
+ ): Statement[] {
960
+ const out: Statement[] = [];
961
+ let marker: "block" | "blockoverride" | null = null;
962
+ for (const stmt of statements) {
963
+ if (stmt.kind === "value") {
964
+ const t = stmt.value.kind === "scalar" ? stmt.value.text.toLowerCase() : "";
965
+ marker = t === "block" ? "block" : t === "blockoverride" ? "blockoverride" : null;
966
+ continue;
967
+ }
968
+ const m = marker;
969
+ marker = null;
970
+ if (m === "blockoverride") continue;
971
+ if (m === "block") {
972
+ const override = overrides.get(stmt.key.text);
973
+ const content = override ?? blockOf(stmt);
974
+ if (!content) continue;
975
+ let sub = overrides;
976
+ if (override) {
977
+ const next = new Map(overrides);
978
+ next.delete(stmt.key.text);
979
+ sub = next;
980
+ }
981
+ out.push(...spliceBlocks(content.statements, sub, defs, depth + 1));
982
+ continue;
983
+ }
984
+ if (stmt.key.text.toLowerCase() === "using" && stmt.value?.kind === "scalar" && defs && depth < 8) {
985
+ const tpl = defs.templates.get(stmt.value.text);
986
+ if (tpl) out.push(...spliceBlocks(tpl.block.statements, overrides, defs, depth + 1));
987
+ continue;
988
+ }
989
+ out.push(stmt);
990
+ }
991
+ return out;
992
+ }
993
+
994
+ /** The mask texture of a `modify_texture` block when its blend is alphamultiply (the only blend drawn). */
995
+ function maskOf(block: BlockNode): string | undefined {
996
+ let texture: string | undefined;
997
+ let blend = "";
998
+ for (const stmt of block.statements) {
999
+ if (stmt.kind !== "assignment" || stmt.value?.kind !== "scalar") continue;
1000
+ const k = stmt.key.text.toLowerCase();
1001
+ if (k === "texture") texture = stmt.value.text;
1002
+ if (k === "blend_mode") blend = stmt.value.text.toLowerCase();
1003
+ }
1004
+ return texture && blend === "alphamultiply" ? texture : undefined;
1005
+ }
1006
+
1007
+ /**
1008
+ * Resolve `spriteborder = { x y }` (x = left & right, y = top & bottom) plus
1009
+ * per-side overrides into [left, top, right, bottom], or undefined when no
1010
+ * border attribute is present.
1011
+ */
1012
+ function borderTuple(
1013
+ pair: number[] | undefined,
1014
+ side: { l?: number; t?: number; r?: number; b?: number }
1015
+ ): [number, number, number, number] | undefined {
1016
+ const any =
1017
+ pair !== undefined ||
1018
+ side.l !== undefined ||
1019
+ side.t !== undefined ||
1020
+ side.r !== undefined ||
1021
+ side.b !== undefined;
1022
+ if (!any) return undefined;
1023
+ const x = pair?.[0] ?? 0;
1024
+ const y = pair?.[1] ?? 0;
1025
+ return [side.l ?? x, side.t ?? y, side.r ?? x, side.b ?? y];
1026
+ }
1027
+
1028
+ function collectWidgets(statements: Statement[], ctx: BuildCtx): WNode[] {
1029
+ const out: WNode[] = [];
1030
+ const ranks = siblingRanks(statements);
1031
+ let isDecl = false;
1032
+ for (const stmt of statements) {
1033
+ if (stmt.kind === "value") {
1034
+ // A bare `template` / `types` / `type` word marks the next assignment
1035
+ // as a declaration (collected by guiDefs), not a live widget.
1036
+ isDecl =
1037
+ stmt.value.kind === "scalar" &&
1038
+ ["template", "local_template", "types", "type"].includes(stmt.value.text.toLowerCase());
1039
+ continue;
1040
+ }
1041
+ const decl = isDecl;
1042
+ isDecl = false;
1043
+ const block = blockOf(stmt);
1044
+ if (!block || decl) continue;
1045
+ const k = stmt.key.text.toLowerCase();
1046
+ if (PROPERTY_BLOCKS.has(k)) continue;
1047
+ if (k.startsWith("@")) continue;
1048
+ out.push(buildWNode(stmt.key.text, block, ctx, ctx.lineOf(stmt.key.range.start), true, ranks.get(stmt)));
1049
+ }
1050
+ return out;
1051
+ }
1052
+
1053
+ /**
1054
+ * The fallback roots for a document that instantiates NOTHING at top level:
1055
+ * one laid-out instance per `type name = base { }` the document declares, in
1056
+ * declaration order.
1057
+ *
1058
+ * Where the engine instantiates a window BY NAME from code, that is how whole
1059
+ * panels are written: one `types Group { type panel = base_window { … } }` and
1060
+ * no instance at all. Two thirds of one shipped game's vanilla gui tree (132 of
1061
+ * 204 files) and three quarters of the largest community framework built on it
1062
+ * are that shape, and every one of them laid out to nothing.
1063
+ *
1064
+ * The declaration is expanded as its BASE plus its own body, which is exactly
1065
+ * one instantiation: passing the declared name would splice the definition into
1066
+ * itself. `ownLine` is false because the `type X = base` header is a
1067
+ * declaration, not an editable widget (`guiSourceEdit` and `guiWidgetInfo` both
1068
+ * refuse one); the body's children are ordinary statements of this document and
1069
+ * stay editable, since `buildWNode` measures them against the block it is given.
1070
+ */
1071
+ function declaredRoots(statements: Statement[], ctx: BuildCtx): WNode[] {
1072
+ const out: WNode[] = [];
1073
+ for (const [name, def] of collectGuiDefsParsed(statements).types) {
1074
+ // The declared name guards the cycle here; `buildWNode` pushes the base.
1075
+ if (ctx.stack.includes(name)) continue;
1076
+ const node = buildWNode(
1077
+ def.base,
1078
+ def.block,
1079
+ { ...ctx, stack: [...ctx.stack, name] },
1080
+ ctx.lineOf(def.keyOffset),
1081
+ false
1082
+ );
1083
+ node.key = name;
1084
+ out.push(node);
1085
+ }
1086
+ return out;
1087
+ }
1088
+
1089
+ /**
1090
+ * Every DECLARATION entry of one body, ranked by the index a `reorder`,
1091
+ * `insert` or `delete` op counts in. That list is `GuiBody.children` in
1092
+ * `sourceModel.ts`: widgets AND decl entries, properties skipped. The marker
1093
+ * set and the attribute-block set are the shared ones, so the writer's list and
1094
+ * this ranking cannot drift; a `blockoverride` between two widget children
1095
+ * takes a slot here exactly as it does there, which is what a client counting
1096
+ * only the widgets it can see gets wrong.
1097
+ *
1098
+ * Only widget entries end up in the map, a decl has no layout node to carry a
1099
+ * rank, but every decl still advances the counter.
1100
+ */
1101
+ function siblingRanks(statements: readonly Statement[]): Map<Statement, number> {
1102
+ const ranks = new Map<Statement, number>();
1103
+ let marker = false;
1104
+ let next = 0;
1105
+ for (const stmt of statements) {
1106
+ if (stmt.kind === "value") {
1107
+ marker = stmt.value.kind === "scalar" && DECL_MARKERS.has(stmt.value.text.toLowerCase());
1108
+ continue;
1109
+ }
1110
+ const declared = marker;
1111
+ marker = false;
1112
+ // `blockoverride = "name" { ... }`: the second vanilla spelling of a slot,
1113
+ // one assignment the writer reads as a declaration.
1114
+ const slotForm = stmt.value?.kind === "tagged-block" && SLOT_KEYS.has(stmt.key.text.toLowerCase());
1115
+ if (declared || slotForm) {
1116
+ next++;
1117
+ continue;
1118
+ }
1119
+ const key = stmt.key.text.toLowerCase();
1120
+ if (!blockOf(stmt) || PROPERTY_BLOCKS.has(key) || key.startsWith("@")) continue;
1121
+ ranks.set(stmt, next++);
1122
+ }
1123
+ return ranks;
1124
+ }
1125
+
1126
+ // ---------------------------------------------------------------------------
1127
+ // Property helpers
1128
+ // ---------------------------------------------------------------------------
1129
+
1130
+ function num(node: WNode, key: string): number | undefined {
1131
+ const s = node.props.get(key);
1132
+ if (!s) return undefined;
1133
+ return toNumber(s.text, node.consts);
1134
+ }
1135
+
1136
+ function str(node: WNode, key: string): string | undefined {
1137
+ return node.props.get(key)?.text;
1138
+ }
1139
+
1140
+ function yes(node: WNode, key: string): boolean {
1141
+ return node.props.get(key)?.text.toLowerCase() === "yes";
1142
+ }
1143
+
1144
+ /** margin pair + directional overrides -> [left, top, right, bottom]. (B1-E3, B4-T7) */
1145
+ function margins(node: WNode): [number, number, number, number] {
1146
+ const pair = node.pairs.get("margin");
1147
+ let l = pair?.[0] ?? 0;
1148
+ let t = pair?.[1] ?? 0;
1149
+ let r = pair?.[0] ?? 0;
1150
+ let b = pair?.[1] ?? 0;
1151
+ const ml = num(node, "margin_left");
1152
+ const mt = num(node, "margin_top");
1153
+ const mr = num(node, "margin_right");
1154
+ const mb = num(node, "margin_bottom");
1155
+ if (ml !== undefined) l = ml;
1156
+ if (mt !== undefined) t = mt;
1157
+ if (mr !== undefined) r = mr;
1158
+ if (mb !== undefined) b = mb;
1159
+ return [l, t, r, b];
1160
+ }
1161
+
1162
+ type Policy = "fixed" | "expanding" | "growing" | "preferred" | "shrinking";
1163
+
1164
+ function policy(node: WNode, horizontal: boolean): Policy {
1165
+ if (node.cls === "expand") return "growing"; // B4-T8, B3-P2
1166
+ const p = str(node, horizontal ? "layoutpolicy_horizontal" : "layoutpolicy_vertical");
1167
+ switch (p?.toLowerCase()) {
1168
+ case "expanding":
1169
+ return "expanding";
1170
+ case "growing":
1171
+ return "growing";
1172
+ case "preferred":
1173
+ return "preferred";
1174
+ case "shrinking":
1175
+ return "shrinking";
1176
+ default:
1177
+ return "fixed";
1178
+ }
1179
+ }
1180
+
1181
+ /**
1182
+ * Is this widget hidden for layout purposes? `visible = no` is deterministic
1183
+ * and collapses; a `visible = "[binding]"` cannot be evaluated in a static
1184
+ * preview, so by DEFAULT the widget is KEPT even though the engine collapses a
1185
+ * binding that evaluates false at runtime (spec.md `ignoreinvisible`, L27):
1186
+ * showing it is the non-destructive default, and the same unknown makes a
1187
+ * container's content unmeasurable (L11b).
1188
+ *
1189
+ * The preview modes only move that default: `hideAll` collapses every
1190
+ * conditional, `evaluate` collapses the ones the caller assigned false. Every
1191
+ * conditional met is recorded either way, so a client can offer the toggles
1192
+ * before the user has switched mode.
1193
+ */
1194
+ function resolveVisibility(value: string | undefined, ctx: BuildCtx): boolean {
1195
+ if (value === undefined) return false;
1196
+ const lower = value.toLowerCase();
1197
+ if (lower === "no") return true;
1198
+ if (lower === "yes") return false;
1199
+ const mode = ctx.visibility?.mode ?? "showAll";
1200
+ const hiddenNow =
1201
+ mode === "hideAll" ? true : mode === "evaluate" ? ctx.visibility?.checks?.[value] === false : false;
1202
+ const seen = ctx.checks.get(value);
1203
+ if (seen) seen.count++;
1204
+ else ctx.checks.set(value, { key: value, count: 1, hidden: hiddenNow });
1205
+ return hiddenNow;
1206
+ }
1207
+
1208
+ /** The resolved flag, decided once per widget in the build phase. */
1209
+ function hidden(node: WNode): boolean {
1210
+ return node.invisible;
1211
+ }
1212
+
1213
+ /** `ignoreinvisible` defaults to yes on hbox/vbox (spec.md, L27). */
1214
+ function collapsesHidden(box: WNode): boolean {
1215
+ return box.props.get("ignoreinvisible")?.text.toLowerCase() !== "no";
1216
+ }
1217
+
1218
+ /**
1219
+ * `minimumsize = { w h }` floor. Applied on the box MAIN axis only: it is the
1220
+ * floor a shrinking child stops at, which is what the deficit redistribution
1221
+ * needs (spec.md "Minimum sizes in the box distribution", L04c). Cross-axis
1222
+ * effect unmeasured. A binding-valued `minimumsize` folds to 0 like every
1223
+ * other unresolvable value (5 vanilla widgets write one).
1224
+ */
1225
+ function minimumSize(node: WNode): { w: number; h: number } {
1226
+ const min = node.pairs.get("minimumsize");
1227
+ return { w: min?.[0] ?? 0, h: min?.[1] ?? 0 };
1228
+ }
1229
+
1230
+ /**
1231
+ * The child whose `resizeparent = yes` dictates this widget's size: the widget
1232
+ * takes that child's content extent instead of its own authored size
1233
+ * (spec.md "Container sizing", L28). The source's "a fixed-size DIRECT child
1234
+ * of one CAN be collapsed" side effect is NOT implemented: "can" is not a rect
1235
+ * rule, and nothing measured says when it fires.
1236
+ */
1237
+ function resizeParentSource(node: WNode): WNode | undefined {
1238
+ return node.children.find((c) => yes(c, "resizeparent"));
1239
+ }
1240
+
1241
+ /**
1242
+ * Classes whose size flows through naturalSize (spec.md "Container sizing",
1243
+ * L25 and L10). The probe DID say so (in-game 2026-08-02): a NON-empty
1244
+ * container KEEPS an authored `size` (the engine warns "you should not set a
1245
+ * size on a container" yet applies it), so naturalSize implements the narrow
1246
+ * reading and this predicate only routes containers/items through it. See
1247
+ * parity-checklist.md L25.
1248
+ */
1249
+ function contentSized(cls: WidgetClass): boolean {
1250
+ return cls === "container" || cls === "item";
1251
+ }
1252
+
1253
+ // ---------------------------------------------------------------------------
1254
+ // Natural (content-hug) sizes, bottom-up
1255
+ // ---------------------------------------------------------------------------
1256
+
1257
+ function naturalSize(node: WNode, measurer: LayoutEnv): { w: number; h: number } {
1258
+ switch (node.cls) {
1259
+ case "expand":
1260
+ return { w: 0, h: 0 };
1261
+ case "textbox":
1262
+ return textSize(node, measurer).size;
1263
+ case "box": {
1264
+ // Hug = children floors + spacing + margins (B2-I2: exact, packed).
1265
+ // A collapsed hidden child contributes nothing, not even its spacing
1266
+ // (L27). An expanding child contributes its FLOOR only, never a share of
1267
+ // free space: it cannot GROW the box's cross size (L31). A floor wider
1268
+ // than a fixed sibling's still sets the hug, and that shape is unmeasured
1269
+ // by either source (parity-checklist.md L31).
1270
+ const [ml, mt, mr, mb] = margins(node);
1271
+ const spacing = num(node, "spacing") ?? 0;
1272
+ let main = 0;
1273
+ let cross = 0;
1274
+ let laid = 0;
1275
+ for (const c of boxChildren(node)) {
1276
+ const s = naturalSize(c, measurer);
1277
+ const min = minimumSize(c);
1278
+ const cm = Math.max(node.vertical ? s.h : s.w, node.vertical ? min.h : min.w);
1279
+ const cc = node.vertical ? s.w : s.h;
1280
+ main += cm + (laid > 0 ? spacing : 0);
1281
+ cross = Math.max(cross, cc);
1282
+ laid++;
1283
+ }
1284
+ return node.vertical
1285
+ ? { w: cross + ml + mr, h: main + mt + mb }
1286
+ : { w: main + ml + mr, h: cross + mt + mb };
1287
+ }
1288
+ case "grid": {
1289
+ // A grid keeps an authored size; otherwise it hugs the slots it filled
1290
+ // (unmeasured: neither source records a gridbox's own rect without a
1291
+ // size, and every fixture authors the cells rather than the box).
1292
+ const explicit = explicitSize(node);
1293
+ if (explicit) return explicit;
1294
+ let w = 0;
1295
+ let h = 0;
1296
+ for (const cell of gridCells(node, measurer)) {
1297
+ w = Math.max(w, cell.x + cell.w);
1298
+ h = Math.max(h, cell.y + cell.h);
1299
+ }
1300
+ return { w, h };
1301
+ }
1302
+ case "flow": {
1303
+ // Single non-wrapping run (B2-K, B3-Q1). Explicit size sets the flow's
1304
+ // own rect but not the content run (B3-Q1).
1305
+ const explicit = explicitSize(node);
1306
+ if (explicit) return explicit;
1307
+ const spacing = num(node, "spacing") ?? 0;
1308
+ let main = 0;
1309
+ let cross = 0;
1310
+ node.children.forEach((c, i) => {
1311
+ const s = naturalSize(c, measurer);
1312
+ main += (node.vertical ? s.h : s.w) + (i > 0 ? spacing : 0);
1313
+ cross = Math.max(cross, node.vertical ? s.w : s.h);
1314
+ });
1315
+ return node.vertical ? { w: cross, h: main } : { w: main, h: cross };
1316
+ }
1317
+ case "container": {
1318
+ // NARROW rule, measured in-game 2026-08-02 (L25): a container WITH
1319
+ // children keeps an authored `size` (the engine warns yet applies it);
1320
+ // an EMPTY one collapses, a fixed size will not hold it open. Without
1321
+ // an authored size it hugs the children's extent at their positions
1322
+ // (B2-I4). The 2026-08-09 probe measured the BROAD rule instead, an empty sized
1323
+ // container keeps its size too (probe 2026-08-09), carried as a
1324
+ // profile quirk.
1325
+ const explicit = explicitSize(node);
1326
+ if (explicit && (node.children.length > 0 || measurer.emptySizedContainerKept)) return explicit;
1327
+ return hugChildren(node, measurer);
1328
+ }
1329
+ case "item":
1330
+ // A datamodel `item` sizes to its content unconditionally, rather than
1331
+ // taking a generic widget default (L10).
1332
+ return hugChildren(node, measurer);
1333
+ case "marginwidget": {
1334
+ const explicit = explicitSize(node);
1335
+ if (explicit) return explicit;
1336
+ return hugChildren(node, measurer);
1337
+ }
1338
+ default: {
1339
+ // Plain widget/icon/window: explicit size or ZERO, no hug (B4-T1),
1340
+ // unless a `resizeparent = yes` child dictates the size instead (L28).
1341
+ const resizer = resizeParentSource(node);
1342
+ if (resizer) return hugChildren(resizer, measurer);
1343
+ const explicit = explicitSize(node);
1344
+ if (!explicit) return { w: 0, h: 0 };
1345
+ const scale = num(node, "scale") ?? 1; // multiplies the rect (B4-T4)
1346
+ return { w: explicit.w * scale, h: explicit.h * scale };
1347
+ }
1348
+ }
1349
+ }
1350
+
1351
+ /**
1352
+ * Bounding box of the children at their positions (B2-I4). Anchored children
1353
+ * inside a hugging container are unmeasured; the extent uses position +
1354
+ * natural size only. A plainly hidden child is skipped and the rest still
1355
+ * content-size (L11c).
1356
+ */
1357
+ function hugChildren(node: WNode, measurer: TextMeasurer): { w: number; h: number } {
1358
+ const [ml, mt] = margins(node);
1359
+ let w = 0;
1360
+ let h = 0;
1361
+ for (const c of node.children) {
1362
+ if (hidden(c)) continue;
1363
+ const s = naturalSize(c, measurer);
1364
+ const pos = c.pairs.get("position") ?? [0, 0];
1365
+ w = Math.max(w, (pos[0] ?? 0) + s.w);
1366
+ h = Math.max(h, (pos[1] ?? 0) + s.h);
1367
+ }
1368
+ return { w: w + ml, h: h + mt };
1369
+ }
1370
+
1371
+ /** A box's laid-out children: hidden ones collapse out unless asked not to (L27). */
1372
+ function boxChildren(box: WNode): WNode[] {
1373
+ if (!collapsesHidden(box)) return box.children;
1374
+ return box.children.filter((c) => !hidden(c));
1375
+ }
1376
+
1377
+ /** Explicit size with percentages unresolved (returns the raw number). */
1378
+ function explicitSize(node: WNode): { w: number; h: number } | null {
1379
+ const size = node.pairs.get("size");
1380
+ if (!size || size.length < 2) return null;
1381
+ return { w: size[0], h: size[1] };
1382
+ }
1383
+
1384
+ // ---------------------------------------------------------------------------
1385
+ // Arrangement, top-down
1386
+ // ---------------------------------------------------------------------------
1387
+
1388
+ type ParentKind = "plain" | "box" | "flow";
1389
+
1390
+ function arrange(
1391
+ node: WNode,
1392
+ content: LayoutRect,
1393
+ parentKind: ParentKind,
1394
+ measurer: TextMeasurer,
1395
+ forced?: LayoutRect,
1396
+ chain?: ExplainChain
1397
+ ): LayoutNode {
1398
+ const rect = forced ?? placeInParent(node, content, measurer);
1399
+ const srcPosition = node.pairs.get("position");
1400
+ const srcSize = node.pairs.get("size");
1401
+ const out: LayoutNode = {
1402
+ key: node.key,
1403
+ name: str(node, "name"),
1404
+ rect,
1405
+ // scrollarea (measured B3-R1), scrollbox, and any widget carrying
1406
+ // `scissor = yes` clip their subtree (spec.md "Clipping containers", L17b).
1407
+ // L17c's "clamp the descendant rects in the flatten" stays a RENDERER job
1408
+ // here: these rects are true geometry and the client clips them (the
1409
+ // B3-R1 golden pins the unclamped corner rect, and guiPreview clips it).
1410
+ clip: node.cls === "scrollarea" || yes(node, "scissor"),
1411
+ bg: node.bg,
1412
+ line: node.line,
1413
+ positioned: forced === undefined,
1414
+ // Ghosts are synthetic placeholders: never draggable/editable even though
1415
+ // the item template statements physically exist in the document.
1416
+ editable: node.ownLine && node.line !== undefined && !node.ghost,
1417
+ srcPosition: srcPosition && srcPosition.length >= 2 ? [srcPosition[0], srcPosition[1]] : undefined,
1418
+ srcSize: srcSize && srcSize.length >= 2 ? [srcSize[0], srcSize[1]] : undefined,
1419
+ // A ghost's statements exist, but the copies are placeholders: no index of
1420
+ // theirs names a slot a client could move.
1421
+ srcIndex: node.ghost ? undefined : node.srcIndex,
1422
+ ghost: node.ghost ? true : undefined,
1423
+ onclick: str(node, "onclick"),
1424
+ tooltip: str(node, "tooltip"),
1425
+ children: [],
1426
+ };
1427
+ const colorPair = node.pairs.get("color");
1428
+ const color: [number, number, number, number] | undefined =
1429
+ colorPair && colorPair.length >= 3
1430
+ ? [colorPair[0], colorPair[1], colorPair[2], colorPair[3] ?? 1]
1431
+ : undefined;
1432
+ if (node.cls === "textbox") {
1433
+ out.text = textInfo(node, rect, measurer);
1434
+ if (color) out.text.color = color;
1435
+ } else if (node.props.has("texture") || color) {
1436
+ // A widget's own textured fill can carry nine-slice borders directly
1437
+ // (spriteborder is collected into pairs; per-side overrides into props).
1438
+ const border = borderTuple(node.pairs.get("spriteborder"), {
1439
+ l: num(node, "spriteborder_left"),
1440
+ t: num(node, "spriteborder_top"),
1441
+ r: num(node, "spriteborder_right"),
1442
+ b: num(node, "spriteborder_bottom"),
1443
+ });
1444
+ const texture = str(node, "texture");
1445
+ out.fill = { texture, color, border };
1446
+ const alpha = num(node, "alpha");
1447
+ if (alpha !== undefined) out.fill.alpha = alpha;
1448
+ const mask = str(node, "#mask");
1449
+ if (mask) out.fill.mask = mask;
1450
+ if (str(node, "fittype")?.toLowerCase() === "centercrop") out.fill.fit = "centercrop";
1451
+ if (texture !== undefined) {
1452
+ out.fill.mode = fillMode(str(node, "spritetype"), border); // Studio §J, L21a-d
1453
+ const framesize = node.pairs.get("framesize");
1454
+ if (framesize && framesize.length >= 2) {
1455
+ out.fill.framesize = [framesize[0], framesize[1]]; // Studio §L, L22
1456
+ out.fill.frame = num(node, "frame") ?? 1;
1457
+ }
1458
+ }
1459
+ }
1460
+
1461
+ if (
1462
+ chain &&
1463
+ chain.sink.result === undefined &&
1464
+ node.ownLine &&
1465
+ !node.ghost &&
1466
+ node.line === chain.sink.line
1467
+ ) {
1468
+ chain.sink.result = explainPlacement(node, out, content, forced !== undefined, chain);
1469
+ }
1470
+ const sub = chain && descend(chain, node, out);
1471
+
1472
+ switch (node.cls) {
1473
+ case "box":
1474
+ out.children = arrangeBoxChildren(node, rect, measurer, sub);
1475
+ break;
1476
+ case "flow":
1477
+ out.children = arrangeFlowChildren(node, rect, measurer, sub);
1478
+ break;
1479
+ case "grid":
1480
+ out.children = arrangeGridChildren(node, rect, measurer, sub);
1481
+ break;
1482
+ case "marginwidget": {
1483
+ // Margins inset the children's coordinate space; the widget's own rect
1484
+ // is untouched (B4-T3). Symmetric inset on the far sides is ASSUMED
1485
+ // from the vanilla HUD pattern (unmeasured; only the origin is pinned).
1486
+ const [ml, mt, mr, mb] = margins(node);
1487
+ const inner: LayoutRect = {
1488
+ x: rect.x + ml,
1489
+ y: rect.y + mt,
1490
+ w: Math.max(0, rect.w - ml - mr),
1491
+ h: Math.max(0, rect.h - mt - mb),
1492
+ };
1493
+ out.children = node.children.map((c) => arrange(c, inner, "plain", measurer, undefined, sub));
1494
+ // A margin_widget with NO explicit size hugs its children AT the margin
1495
+ // offset: both probes (B3-Q2; 2026-08-09) saw the bg exactly
1496
+ // behind the child, never in the margin strips an origin-anchored hug
1497
+ // would paint. rect came in margin-inclusive from naturalSize; shift
1498
+ // the box, children stay put.
1499
+ if (!explicitSize(node)) {
1500
+ out.rect = {
1501
+ x: rect.x + ml,
1502
+ y: rect.y + mt,
1503
+ w: Math.max(0, rect.w - ml),
1504
+ h: Math.max(0, rect.h - mt),
1505
+ };
1506
+ }
1507
+ break;
1508
+ }
1509
+ default:
1510
+ out.children = node.children.map((c) => arrange(c, rect, "plain", measurer, undefined, sub));
1511
+ break;
1512
+ }
1513
+ return out;
1514
+ }
1515
+
1516
+ /** The chain a node's children see: this node as parent, its clip if it clips. */
1517
+ function descend(chain: ExplainChain, node: WNode, out: LayoutNode): ExplainChain {
1518
+ return {
1519
+ sink: chain.sink,
1520
+ parent: node,
1521
+ parentRect: out.rect,
1522
+ clip: out.clip ? { key: node.key, name: out.name, rect: out.rect } : chain.clip,
1523
+ };
1524
+ }
1525
+
1526
+ /**
1527
+ * "Why is it here": the terms of the anchor sum, or the container that placed
1528
+ * the widget instead, plus the clip rect that bounds it.
1529
+ */
1530
+ function explainPlacement(
1531
+ node: WNode,
1532
+ out: LayoutNode,
1533
+ content: LayoutRect,
1534
+ forced: boolean,
1535
+ chain: ExplainChain
1536
+ ): Placement {
1537
+ const placement: Placement = {
1538
+ rect: out.rect,
1539
+ // A container-placed child was handed its slot as `content`; the rect worth
1540
+ // naming is the container's own, which the chain carries.
1541
+ parentRect: forced ? (chain.parentRect ?? content) : content,
1542
+ terms: forced ? [] : placementTerms(node, content, out.rect),
1543
+ };
1544
+ const parent = chain.parent;
1545
+ if (forced && parent) {
1546
+ const pos = node.pairs.get("position");
1547
+ placement.placedBy = {
1548
+ key: parent.key,
1549
+ name: str(parent, "name"),
1550
+ layout: parent.cls === "box" ? "box" : parent.cls === "flow" ? "flow" : "grid",
1551
+ // The engine logs "Widget cannot have a position in a layout" and drops
1552
+ // it (probe 2026-08-02, L23); naming the dropped value is the point.
1553
+ droppedPosition: pos && pos.length >= 2 ? [pos[0], pos[1]] : undefined,
1554
+ };
1555
+ }
1556
+ if (chain.clip) placement.clippedBy = chain.clip;
1557
+ return placement;
1558
+ }
1559
+
1560
+ /**
1561
+ * The anchor sum, term by term, mirroring placeInParent's formula (B1-B/C/D).
1562
+ * The dx/dy add up to the rect origin exactly, which the test pins: that
1563
+ * equality is what keeps this readout from drifting from the placement it
1564
+ * explains.
1565
+ */
1566
+ function placementTerms(node: WNode, content: LayoutRect, rect: LayoutRect): PlacementTerm[] {
1567
+ const pa = str(node, "parentanchor");
1568
+ const waOwn = str(node, "widgetanchor");
1569
+ const wa = waOwn ?? pa;
1570
+ const [pfx, pfy] = anchorFractions(pa);
1571
+ const [wfx, wfy] = anchorFractions(wa);
1572
+ const pos = node.pairs.get("position");
1573
+ const terms: PlacementTerm[] = [{ kind: "parentOrigin", dx: content.x, dy: content.y }];
1574
+ if (pa !== undefined)
1575
+ terms.push({ kind: "parentanchor", source: pa, dx: pfx * content.w, dy: pfy * content.h });
1576
+ if (wa !== undefined) {
1577
+ terms.push({ kind: "widgetanchor", source: wa, dx: -wfx * rect.w, dy: -wfy * rect.h });
1578
+ }
1579
+ if (pos !== undefined) {
1580
+ terms.push({
1581
+ kind: "position",
1582
+ source: `{ ${pos[0] ?? 0} ${pos[1] ?? 0} }`,
1583
+ dx: pos[0] ?? 0,
1584
+ dy: pos[1] ?? 0,
1585
+ });
1586
+ }
1587
+ return terms;
1588
+ }
1589
+
1590
+ /** Size + anchor + position for a child of a NON-box parent. */
1591
+ function placeInParent(node: WNode, content: LayoutRect, measurer: TextMeasurer): LayoutRect {
1592
+ let w: number;
1593
+ let h: number;
1594
+ if (node.cls === "box") {
1595
+ // Boxes FILL a non-box parent, explicit size ignored entirely
1596
+ // (B1-E/F, B2-I1, B3-P1). Inside another box they hug, but that path
1597
+ // goes through arrangeBoxChildren, not here.
1598
+ w = content.w;
1599
+ h = content.h;
1600
+ } else if (node.cls === "textbox") {
1601
+ // Textboxes always size via the text rules (autoresize measurement can
1602
+ // override an inherited size like Font_Size_Small's `size = { 0 23 }`).
1603
+ const s = textSize(node, measurer).size;
1604
+ w = s.w;
1605
+ h = s.h;
1606
+ } else if (contentSized(node.cls)) {
1607
+ // container / datamodel item: naturalSize owns the rule (narrow L25: a
1608
+ // non-empty container keeps an authored size; item always content, L10).
1609
+ const s = naturalSize(node, measurer);
1610
+ w = s.w;
1611
+ h = s.h;
1612
+ } else {
1613
+ // A `resizeparent = yes` child replaces this widget's authored size with
1614
+ // that child's content extent (L28).
1615
+ const explicit = resizeParentSource(node) ? null : explicitSize(node);
1616
+ if (explicit) {
1617
+ // Percent sizes resolve against the parent rect (B4-T2).
1618
+ w = node.sizePct[0] ? (explicit.w / 100) * content.w : explicit.w;
1619
+ h = node.sizePct[1] ? (explicit.h / 100) * content.h : explicit.h;
1620
+ const scale = num(node, "scale") ?? 1; // B4-T4
1621
+ w *= scale;
1622
+ h *= scale;
1623
+ } else {
1624
+ const s = naturalSize(node, measurer);
1625
+ w = s.w;
1626
+ h = s.h;
1627
+ }
1628
+ }
1629
+
1630
+ // widgetanchor implicitly mirrors parentanchor (B1-B, B1-C); position is
1631
+ // always screen-space +right/+down, added after anchoring (B1-D).
1632
+ const pa = str(node, "parentanchor");
1633
+ const wa = str(node, "widgetanchor") ?? pa;
1634
+ const [pfx, pfy] = anchorFractions(pa);
1635
+ const [wfx, wfy] = anchorFractions(wa);
1636
+ const pos = node.pairs.get("position") ?? [0, 0];
1637
+ const x = content.x + pfx * content.w - wfx * w + (pos[0] ?? 0);
1638
+ const y = content.y + pfy * content.h - wfy * h + (pos[1] ?? 0);
1639
+ return { x, y, w, h };
1640
+ }
1641
+
1642
+ /**
1643
+ * Box (hbox/vbox) child arrangement, per the measured model:
1644
+ * 1. floors = natural sizes; 2. policies resize (expanding: +free/k;
1645
+ * growing acts only without expanding siblings; deficit: preferred and
1646
+ * shrinking each lose deficit/k) (B2-J, B3-P);
1647
+ * 3. residual free space distributes as space-around: each child gets
1648
+ * side = residual/(2n) on both sides (B1-E/F);
1649
+ * 4. cross axis: fill if the cross policy stretches, else centered (B1-E/F).
1650
+ */
1651
+ function arrangeBoxChildren(
1652
+ box: WNode,
1653
+ rect: LayoutRect,
1654
+ measurer: TextMeasurer,
1655
+ chain?: ExplainChain
1656
+ ): LayoutNode[] {
1657
+ const vertical = box.vertical;
1658
+ const [ml, mt, mr, mb] = margins(box);
1659
+ const spacing = num(box, "spacing") ?? 0;
1660
+ const contentMain = vertical ? rect.h - mt - mb : rect.w - ml - mr;
1661
+ const contentCross = vertical ? rect.w - ml - mr : rect.h - mt - mb;
1662
+ if (box.children.length === 0) return [];
1663
+
1664
+ // `ignoreinvisible` defaults to yes: a plainly hidden child is collapsed out
1665
+ // of the layout and its siblings shift up to fill the gap (spec.md, L27). It
1666
+ // still reaches the tree, as a ZERO rect at the cursor, so the preview can
1667
+ // list and select it; nothing of it is drawn, which is what the game does.
1668
+ const laid = boxChildren(box);
1669
+ const kept = new Set(laid);
1670
+ const n = laid.length;
1671
+
1672
+ const naturals = laid.map((c) => {
1673
+ if (c.cls === "box") {
1674
+ // box-in-box hugs (B2-I2)
1675
+ return naturalSize(c, measurer);
1676
+ }
1677
+ return resolvedChildSize(c, rect, measurer);
1678
+ });
1679
+ // `minimumsize = { w h }` floors the main-axis size and is where a shrinking
1680
+ // child stops (spec.md "Minimum sizes in the box distribution", L04c).
1681
+ const minMains = laid.map((c) => {
1682
+ const min = minimumSize(c);
1683
+ return vertical ? min.h : min.w;
1684
+ });
1685
+ const mains = naturals.map((s, i) => Math.max(vertical ? s.h : s.w, minMains[i]));
1686
+ const crosses = naturals.map((s) => (vertical ? s.w : s.h));
1687
+
1688
+ let free = n === 0 ? 0 : contentMain - mains.reduce((a, b) => a + b, 0) - spacing * (n - 1);
1689
+ const mainPolicies = laid.map((c) => policy(c, !vertical));
1690
+ if (free > 0) {
1691
+ const expanders = mainPolicies.map((p, i) => ({ p, i })).filter(({ p }) => p === "expanding");
1692
+ // Without expanding siblings, growing AND preferred take the space; the
1693
+ // growing case is measured (B3-P2), preferred sharing with growing is
1694
+ // unmeasured, treated as the same tier.
1695
+ const growers = mainPolicies
1696
+ .map((p, i) => ({ p, i }))
1697
+ .filter(({ p }) => p === "growing" || p === "preferred");
1698
+ const takers = expanders.length > 0 ? expanders : growers;
1699
+ if (takers.length > 0) {
1700
+ const share = free / takers.length; // floor + free/k (B3-P3)
1701
+ for (const { i } of takers) mains[i] += share;
1702
+ free = 0;
1703
+ }
1704
+ } else if (free < 0) {
1705
+ // Deficit: every shrinkable child loses deficit/k, an equal DELTA with no
1706
+ // shrinking-first priority (B2-J3, B3-P4). A child that reaches its floor
1707
+ // stops there and the REST absorb what it could not give, so the total
1708
+ // still fits (spec.md "Minimum sizes in the box distribution", L04c);
1709
+ // a `fixed` child never shrinks at all (L04b).
1710
+ let owed = -free;
1711
+ let pool = mainPolicies
1712
+ .map((p, i) => ({ p, i }))
1713
+ .filter(({ p }) => p === "preferred" || p === "shrinking")
1714
+ .map(({ i }) => i);
1715
+ while (owed > 1e-9 && pool.length > 0) {
1716
+ const delta = owed / pool.length;
1717
+ const next: number[] = [];
1718
+ for (const i of pool) {
1719
+ const room = Math.max(0, mains[i] - minMains[i]);
1720
+ const take = Math.min(delta, room);
1721
+ mains[i] -= take;
1722
+ owed -= take;
1723
+ if (room > delta) next.push(i);
1724
+ }
1725
+ if (next.length === pool.length) break; // nobody floored: converged
1726
+ pool = next;
1727
+ }
1728
+ // Nothing left that can give: keep the floors and overflow (unmeasured).
1729
+ free = 0;
1730
+ }
1731
+
1732
+ const side = n > 0 ? free / (2 * n) : 0; // space-around (B1-E/F)
1733
+ const crossPolicies = laid.map((c) => policy(c, vertical));
1734
+ const out: LayoutNode[] = [];
1735
+ let cursor = (vertical ? rect.y + mt : rect.x + ml) + side;
1736
+ let i = -1;
1737
+ for (const child of box.children) {
1738
+ if (!kept.has(child)) {
1739
+ const zero: LayoutRect = vertical
1740
+ ? { x: rect.x + ml, y: cursor, w: 0, h: 0 }
1741
+ : { x: cursor, y: rect.y + mt, w: 0, h: 0 };
1742
+ out.push(arrange(child, zero, "box", measurer, zero, chain));
1743
+ continue;
1744
+ }
1745
+ i++;
1746
+ const main = mains[i];
1747
+ const stretchCross =
1748
+ crossPolicies[i] === "expanding" || crossPolicies[i] === "growing" || crossPolicies[i] === "preferred";
1749
+ const cross = stretchCross ? contentCross : Math.min(crosses[i], Number.POSITIVE_INFINITY);
1750
+ const crossOffset =
1751
+ (vertical ? rect.x + ml : rect.y + mt) + (stretchCross ? 0 : (contentCross - cross) / 2);
1752
+ // `position` on a box child is DROPPED: the box places its children.
1753
+ // In-game probe 2026-08-02 (px_positioned sat exactly where a plain
1754
+ // sibling does), and the engine logs "Widget cannot have a position in a
1755
+ // layout". Settles L23; the writer's positionIgnoredReason guard matches.
1756
+ const forced: LayoutRect = vertical
1757
+ ? { x: crossOffset, y: cursor, w: cross, h: main }
1758
+ : { x: cursor, y: crossOffset, w: main, h: cross };
1759
+ out.push(arrange(child, forced, "box", measurer, forced, chain));
1760
+ cursor += main + 2 * side + spacing;
1761
+ }
1762
+ return out;
1763
+ }
1764
+
1765
+ /** flowcontainer: pack children from the origin, never wrap (B2-K, B3-Q1). */
1766
+ function arrangeFlowChildren(
1767
+ flow: WNode,
1768
+ rect: LayoutRect,
1769
+ measurer: TextMeasurer,
1770
+ chain?: ExplainChain
1771
+ ): LayoutNode[] {
1772
+ const spacing = num(flow, "spacing") ?? 0;
1773
+ const out: LayoutNode[] = [];
1774
+ let cursor = flow.vertical ? rect.y : rect.x;
1775
+ for (const child of flow.children) {
1776
+ const s = resolvedChildSize(child, rect, measurer);
1777
+ // flowcontainer is the ONE container that honors a child's `parentanchor`
1778
+ // on the cross axis (spec.md "Container sizing", L13d); widgetanchor still
1779
+ // mirrors it (B1-B/C). Unset anchors keep the measured origin alignment
1780
+ // (B2-K1). The MAIN axis stays the flow cursor.
1781
+ const pa = str(child, "parentanchor");
1782
+ const [pfx, pfy] = anchorFractions(pa);
1783
+ const [wfx, wfy] = anchorFractions(str(child, "widgetanchor") ?? pa);
1784
+ const crossOffset = flow.vertical ? rect.x + pfx * rect.w - wfx * s.w : rect.y + pfy * rect.h - wfy * s.h;
1785
+ const forced: LayoutRect = flow.vertical
1786
+ ? { x: crossOffset, y: cursor, w: s.w, h: s.h }
1787
+ : { x: cursor, y: crossOffset, w: s.w, h: s.h };
1788
+ out.push(arrange(child, forced, "flow", measurer, forced, chain));
1789
+ cursor += (flow.vertical ? s.h : s.w) + spacing;
1790
+ }
1791
+ return out;
1792
+ }
1793
+
1794
+ /** One slotted grid child, in the grid's own (0,0-based) coordinates. */
1795
+ interface GridCell {
1796
+ child: WNode;
1797
+ x: number;
1798
+ y: number;
1799
+ w: number;
1800
+ h: number;
1801
+ }
1802
+
1803
+ /**
1804
+ * Grid box slotting, shared by naturalSize and the arrangement so the two
1805
+ * cannot drift. Both kinds fill VERTICALLY by default (down a column, wrapping
1806
+ * into a new column after `datamodel_wrap` items, so datamodel_wrap is
1807
+ * items-per-COLUMN); `flipdirection = yes` transposes the fill to horizontal
1808
+ * and mirrors nothing (the flipped grid still starts top-left);
1809
+ * `maxhorizontalslots` caps the slots per line only while filling horizontally.
1810
+ * (Studio §K v2/v3, in-game 2026-07-17; L14b, L15.)
1811
+ *
1812
+ * fixedgridbox uses `addcolumn`/`addrow` as the CELL SIZE and therefore the
1813
+ * stride (L14a); dynamicgridbox packs items at their OWN size, where
1814
+ * addcolumn/addrow are not the stride (L15).
1815
+ */
1816
+ function gridCells(grid: WNode, measurer: TextMeasurer): GridCell[] {
1817
+ if (grid.children.length === 0) return [];
1818
+ const horizontal = yes(grid, "flipdirection");
1819
+ const wrap = num(grid, "datamodel_wrap") ?? 0;
1820
+ const maxSlots = num(grid, "maxhorizontalslots") ?? 0;
1821
+ let perLine = wrap > 0 ? wrap : Number.POSITIVE_INFINITY;
1822
+ if (horizontal && maxSlots > 0) perLine = Math.min(perLine, maxSlots);
1823
+
1824
+ const contents = grid.children.map((c) => resolvedChildSize(c, { x: 0, y: 0, w: 0, h: 0 }, measurer));
1825
+ let cellW = num(grid, "addcolumn") ?? 0;
1826
+ let cellH = num(grid, "addrow") ?? 0;
1827
+ if (grid.fixedCells && yes(grid, "setitemsizefromcell")) {
1828
+ // Every cell takes the WIDEST item's size, so ragged rows go uniform
1829
+ // (Studio §K v3, L29). Measured on width; applied per axis here, and an
1830
+ // axis no item can size falls back to addcolumn/addrow.
1831
+ const w = Math.max(0, ...contents.map((s) => s.w));
1832
+ const h = Math.max(0, ...contents.map((s) => s.h));
1833
+ if (w > 0) cellW = w;
1834
+ if (h > 0) cellH = h;
1835
+ }
1836
+
1837
+ const out: GridCell[] = [];
1838
+ let slot = 0;
1839
+ let line = 0;
1840
+ let mainCursor = 0;
1841
+ let crossCursor = 0;
1842
+ let lineCross = 0;
1843
+ grid.children.forEach((child, i) => {
1844
+ const content = contents[i];
1845
+ let cell: GridCell;
1846
+ if (grid.fixedCells) {
1847
+ // An item with NO concrete size anywhere in its chain takes the CELL
1848
+ // size; one with a concrete size keeps it at the cell ORIGIN
1849
+ // (Studio §K v3, L14c).
1850
+ const concrete = content.w > 0 || content.h > 0;
1851
+ cell = {
1852
+ child,
1853
+ x: (horizontal ? slot : line) * cellW,
1854
+ y: (horizontal ? line : slot) * cellH,
1855
+ w: concrete ? content.w : cellW,
1856
+ h: concrete ? content.h : cellH,
1857
+ };
1858
+ } else {
1859
+ cell = {
1860
+ child,
1861
+ x: horizontal ? mainCursor : crossCursor,
1862
+ y: horizontal ? crossCursor : mainCursor,
1863
+ w: content.w,
1864
+ h: content.h,
1865
+ };
1866
+ mainCursor += horizontal ? content.w : content.h;
1867
+ // Cross stride = the widest item of the line (unmeasured beyond the
1868
+ // uniform-item case every calibration grid used).
1869
+ lineCross = Math.max(lineCross, horizontal ? content.h : content.w);
1870
+ }
1871
+ out.push(cell);
1872
+ slot++;
1873
+ if (slot >= perLine) {
1874
+ slot = 0;
1875
+ line++;
1876
+ mainCursor = 0;
1877
+ crossCursor += lineCross;
1878
+ lineCross = 0;
1879
+ }
1880
+ });
1881
+ return out;
1882
+ }
1883
+
1884
+ /** fixedgridbox / dynamicgridbox: slot the children, cells relative to the grid. */
1885
+ function arrangeGridChildren(
1886
+ grid: WNode,
1887
+ rect: LayoutRect,
1888
+ measurer: TextMeasurer,
1889
+ chain?: ExplainChain
1890
+ ): LayoutNode[] {
1891
+ return gridCells(grid, measurer).map((cell) => {
1892
+ const forced: LayoutRect = { x: rect.x + cell.x, y: rect.y + cell.y, w: cell.w, h: cell.h };
1893
+ return arrange(cell.child, forced, "plain", measurer, forced, chain);
1894
+ });
1895
+ }
1896
+
1897
+ /** Child size with % and scale resolved (children of boxes/flows/grids). */
1898
+ function resolvedChildSize(
1899
+ node: WNode,
1900
+ parentRect: LayoutRect,
1901
+ measurer: TextMeasurer
1902
+ ): { w: number; h: number } {
1903
+ if (node.cls === "textbox") return textSize(node, measurer).size;
1904
+ const explicit = explicitSize(node);
1905
+ if (node.cls !== "box" && !contentSized(node.cls) && explicit) {
1906
+ const scale = num(node, "scale") ?? 1;
1907
+ return {
1908
+ w: (node.sizePct[0] ? (explicit.w / 100) * parentRect.w : explicit.w) * scale,
1909
+ h: (node.sizePct[1] ? (explicit.h / 100) * parentRect.h : explicit.h) * scale,
1910
+ };
1911
+ }
1912
+ return naturalSize(node, measurer);
1913
+ }
1914
+
1915
+ // ---------------------------------------------------------------------------
1916
+ // Text
1917
+ // ---------------------------------------------------------------------------
1918
+
1919
+ function rawTextContent(node: WNode): string {
1920
+ return str(node, "raw_text") ?? str(node, "text") ?? "";
1921
+ }
1922
+
1923
+ /** The shown text: resolved through the env when it can resolve, else the raw value. */
1924
+ function textContent(node: WNode, env?: LayoutEnv): string {
1925
+ const raw = rawTextContent(node);
1926
+ return env?.resolveText ? env.resolveText(raw).text : raw;
1927
+ }
1928
+
1929
+ function textSize(node: WNode, measurer: LayoutEnv): { size: { w: number; h: number }; lines: string[] } {
1930
+ // The game's measured default size when the textbox sets none; 15 is the
1931
+ // default profile's Font_Size_Small (probe 2026-08-09 measured 17 there).
1932
+ const fontsize = num(node, "fontsize") ?? measurer.defaultFontsize ?? 15;
1933
+ const content = textContent(node, measurer);
1934
+ const maxWidth = num(node, "max_width");
1935
+ const explicit = explicitSize(node);
1936
+ // Vanilla `textbox` does not autoresize; text_single opts in (labels.gui).
1937
+ // A fixed-size textbox ignores max_width entirely, that is exactly the
1938
+ // measured text_multi 45x45 behavior (B2-L).
1939
+ const autoresize = yes(node, "autoresize");
1940
+
1941
+ if (autoresize) {
1942
+ if (yes(node, "multiline") && maxWidth !== undefined) {
1943
+ // Word wrap at max_width; box width = widest line, height = lines *
1944
+ // line advance = single-line box height (B3-S2).
1945
+ const lines = wrapWords(content, maxWidth, fontsize, measurer);
1946
+ const w = Math.max(0, ...lines.map((l) => measurer.lineWidth(l, fontsize)));
1947
+ return { size: { w, h: lines.length * measurer.lineHeight(fontsize) }, lines };
1948
+ }
1949
+ let w = measurer.lineWidth(content, fontsize);
1950
+ if (maxWidth !== undefined && w > maxWidth) w = maxWidth; // clamp+elide (B3-S1)
1951
+ return { size: { w, h: measurer.lineHeight(fontsize) }, lines: [content] };
1952
+ }
1953
+ if (explicit) {
1954
+ return { size: { w: explicit.w, h: explicit.h }, lines: [content] };
1955
+ }
1956
+ return {
1957
+ size: { w: measurer.lineWidth(content, fontsize), h: measurer.lineHeight(fontsize) },
1958
+ lines: [content],
1959
+ };
1960
+ }
1961
+
1962
+ function textInfo(node: WNode, rect: LayoutRect, measurer: LayoutEnv): TextInfo {
1963
+ const fontsize = num(node, "fontsize") ?? measurer.defaultFontsize ?? 15;
1964
+ const { lines } = textSize(node, measurer);
1965
+ const textW = Math.max(0, ...lines.map((l) => measurer.lineWidth(l, fontsize)));
1966
+ const lineH = measurer.lineHeight(fontsize);
1967
+ const [fx, fy] = anchorFractions(str(node, "align"));
1968
+ // Horizontal align is exact with zero padding: x = f * (W - textwidth);
1969
+ // vertical centers the line box (B4-T6).
1970
+ const raw = rawTextContent(node);
1971
+ const resolved = measurer.resolveText?.(raw);
1972
+ return {
1973
+ text: resolved?.text ?? raw,
1974
+ raw: resolved && resolved.text !== raw ? raw : undefined,
1975
+ segments: resolved?.segments,
1976
+ fontsize,
1977
+ offsetX: fx * (rect.w - textW),
1978
+ offsetY: fy * (rect.h - lines.length * lineH),
1979
+ lines,
1980
+ };
1981
+ }
1982
+
1983
+ function wrapWords(text: string, maxWidth: number, fontsize: number, measurer: TextMeasurer): string[] {
1984
+ const words = text.split(" ");
1985
+ const lines: string[] = [];
1986
+ let line = "";
1987
+ for (const word of words) {
1988
+ const candidate = line.length === 0 ? word : `${line} ${word}`;
1989
+ if (line.length > 0 && measurer.lineWidth(candidate, fontsize) > maxWidth) {
1990
+ lines.push(line);
1991
+ line = word;
1992
+ } else {
1993
+ line = candidate;
1994
+ }
1995
+ }
1996
+ if (line.length > 0) lines.push(line);
1997
+ return lines.length > 0 ? lines : [""];
1998
+ }