@defold-typescript/types 0.39.0 → 0.41.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 (227) hide show
  1. package/api-availability.json +4024 -716
  2. package/api-migrations.json +27 -0
  3. package/api-signatures.json +18118 -10264
  4. package/api-targets.json +448 -11
  5. package/examples/translations.json +619 -148
  6. package/fixtures/messages_doc.json +96 -0
  7. package/generated/b2d.d.ts +680 -4
  8. package/generated/b2d_body.d.ts +29 -103
  9. package/generated/b2d_chain.d.ts +5 -6
  10. package/generated/b2d_fixture.d.ts +34 -38
  11. package/generated/b2d_joint.d.ts +503 -25
  12. package/generated/b2d_shape.d.ts +199 -14
  13. package/generated/b2d_world.d.ts +57 -63
  14. package/generated/buffer.d.ts +33 -29
  15. package/generated/builtin-messages.d.ts +7 -1
  16. package/generated/bullet3d.d.ts +91 -0
  17. package/generated/bullet3d_collision_object.d.ts +433 -0
  18. package/generated/bullet3d_constraint.d.ts +818 -0
  19. package/generated/bullet3d_rigid_body.d.ts +429 -0
  20. package/generated/bullet3d_shape.d.ts +123 -0
  21. package/generated/bullet3d_world.d.ts +492 -0
  22. package/generated/camera.d.ts +31 -34
  23. package/generated/collectionfactory.d.ts +26 -27
  24. package/generated/collectionproxy.d.ts +22 -6
  25. package/generated/compute.d.ts +12 -238
  26. package/generated/crash.d.ts +24 -10
  27. package/generated/editor-vm/http.d.ts +90 -7
  28. package/generated/editor-vm/image.d.ts +7 -7
  29. package/generated/editor-vm/json.d.ts +11 -0
  30. package/generated/editor-vm/localization.d.ts +7 -7
  31. package/generated/editor-vm/tilemap_tiles.d.ts +56 -10
  32. package/generated/editor-vm/zip.d.ts +28 -0
  33. package/generated/editor.d.ts +1496 -430
  34. package/generated/factory.d.ts +21 -25
  35. package/generated/font.d.ts +64 -22
  36. package/generated/go.d.ts +243 -186
  37. package/generated/graphics.d.ts +374 -84
  38. package/generated/gui.d.ts +282 -492
  39. package/generated/html5.d.ts +5 -3
  40. package/generated/http.d.ts +31 -36
  41. package/generated/image.d.ts +127 -85
  42. package/generated/json.d.ts +34 -16
  43. package/generated/kinds/gui-script.d.ts +8 -0
  44. package/generated/kinds/render-script.d.ts +8 -0
  45. package/generated/kinds/script.d.ts +8 -0
  46. package/generated/label.d.ts +77 -7
  47. package/generated/liveupdate.d.ts +13 -10
  48. package/generated/material.d.ts +312 -360
  49. package/generated/model.d.ts +72 -42
  50. package/generated/particlefx.d.ts +19 -28
  51. package/generated/physics.d.ts +378 -144
  52. package/generated/profiler.d.ts +14 -24
  53. package/generated/render.d.ts +294 -297
  54. package/generated/resource.d.ts +753 -641
  55. package/generated/socket.d.ts +131 -134
  56. package/generated/sound.d.ts +65 -34
  57. package/generated/sprite.d.ts +39 -17
  58. package/generated/sys.d.ts +237 -182
  59. package/generated/tilemap.d.ts +21 -16
  60. package/generated/timer.d.ts +34 -21
  61. package/generated/versions/defold-1.12.4/b2d_body.d.ts +0 -7
  62. package/generated/versions/defold-1.12.4/buffer.d.ts +18 -17
  63. package/generated/versions/defold-1.12.4/camera.d.ts +23 -23
  64. package/generated/versions/defold-1.12.4/collectionfactory.d.ts +21 -19
  65. package/generated/versions/defold-1.12.4/collectionproxy.d.ts +2 -2
  66. package/generated/versions/defold-1.12.4/crash.d.ts +2 -2
  67. package/generated/versions/defold-1.12.4/factory.d.ts +15 -16
  68. package/generated/versions/defold-1.12.4/go.d.ts +15 -17
  69. package/generated/versions/defold-1.12.4/gui.d.ts +111 -193
  70. package/generated/versions/defold-1.12.4/html5.d.ts +4 -2
  71. package/generated/versions/defold-1.12.4/http.d.ts +4 -15
  72. package/generated/versions/defold-1.12.4/image.d.ts +38 -46
  73. package/generated/versions/defold-1.12.4/index.d.ts +2 -0
  74. package/generated/versions/defold-1.12.4/json.d.ts +10 -6
  75. package/generated/versions/defold-1.12.4/label.d.ts +2 -3
  76. package/generated/versions/defold-1.12.4/liveupdate.d.ts +5 -3
  77. package/generated/versions/defold-1.12.4/model.d.ts +34 -13
  78. package/generated/versions/defold-1.12.4/particlefx.d.ts +2 -5
  79. package/generated/versions/defold-1.12.4/physics.d.ts +82 -78
  80. package/generated/versions/defold-1.12.4/profiler.d.ts +3 -6
  81. package/generated/versions/defold-1.12.4/render.d.ts +160 -132
  82. package/generated/versions/defold-1.12.4/resource.d.ts +212 -209
  83. package/generated/versions/defold-1.12.4/socket.d.ts +45 -61
  84. package/generated/versions/defold-1.12.4/sound.d.ts +11 -11
  85. package/generated/versions/defold-1.12.4/sprite.d.ts +24 -6
  86. package/generated/versions/defold-1.12.4/sys.d.ts +45 -68
  87. package/generated/versions/defold-1.12.4/tilemap.d.ts +18 -11
  88. package/generated/versions/defold-1.12.4/timer.d.ts +5 -3
  89. package/generated/versions/defold-1.12.4/vmath.d.ts +5 -26
  90. package/generated/versions/defold-1.12.4/window.d.ts +1 -16
  91. package/generated/versions/defold-1.13.0/b2d_body.d.ts +10 -55
  92. package/generated/versions/defold-1.13.0/b2d_fixture.d.ts +25 -25
  93. package/generated/versions/defold-1.13.0/b2d_shape.d.ts +188 -5
  94. package/generated/versions/defold-1.13.0/b2d_world.d.ts +16 -10
  95. package/generated/versions/defold-1.13.0/buffer.d.ts +18 -17
  96. package/generated/versions/defold-1.13.0/camera.d.ts +27 -27
  97. package/generated/versions/defold-1.13.0/collectionfactory.d.ts +21 -19
  98. package/generated/versions/defold-1.13.0/collectionproxy.d.ts +2 -2
  99. package/generated/versions/defold-1.13.0/compute.d.ts +1 -70
  100. package/generated/versions/defold-1.13.0/crash.d.ts +2 -2
  101. package/generated/versions/defold-1.13.0/editor-vm/tilemap_tiles.d.ts +3 -3
  102. package/generated/versions/defold-1.13.0/editor.d.ts +34 -24
  103. package/generated/versions/defold-1.13.0/factory.d.ts +15 -16
  104. package/generated/versions/defold-1.13.0/go.d.ts +15 -17
  105. package/generated/versions/defold-1.13.0/graphics.d.ts +121 -37
  106. package/generated/versions/defold-1.13.0/gui.d.ts +111 -193
  107. package/generated/versions/defold-1.13.0/html5.d.ts +4 -2
  108. package/generated/versions/defold-1.13.0/http.d.ts +4 -15
  109. package/generated/versions/defold-1.13.0/image.d.ts +38 -46
  110. package/generated/versions/defold-1.13.0/index.d.ts +2 -0
  111. package/generated/versions/defold-1.13.0/json.d.ts +10 -6
  112. package/generated/versions/defold-1.13.0/label.d.ts +2 -3
  113. package/generated/versions/defold-1.13.0/liveupdate.d.ts +7 -5
  114. package/generated/versions/defold-1.13.0/material.d.ts +69 -107
  115. package/generated/versions/defold-1.13.0/model.d.ts +34 -13
  116. package/generated/versions/defold-1.13.0/particlefx.d.ts +2 -5
  117. package/generated/versions/defold-1.13.0/physics.d.ts +82 -78
  118. package/generated/versions/defold-1.13.0/profiler.d.ts +3 -6
  119. package/generated/versions/defold-1.13.0/render.d.ts +160 -132
  120. package/generated/versions/defold-1.13.0/resource.d.ts +217 -210
  121. package/generated/versions/defold-1.13.0/socket.d.ts +45 -61
  122. package/generated/versions/defold-1.13.0/sound.d.ts +11 -11
  123. package/generated/versions/defold-1.13.0/sprite.d.ts +24 -6
  124. package/generated/versions/defold-1.13.0/sys.d.ts +45 -68
  125. package/generated/versions/defold-1.13.0/tilemap.d.ts +18 -11
  126. package/generated/versions/defold-1.13.0/timer.d.ts +5 -3
  127. package/generated/versions/defold-1.13.0/vmath.d.ts +5 -26
  128. package/generated/versions/defold-1.13.0/window.d.ts +1 -16
  129. package/generated/versions/defold-1.13.1/b2d.d.ts +33 -0
  130. package/generated/versions/defold-1.13.1/b2d_body.d.ts +636 -0
  131. package/generated/versions/defold-1.13.1/b2d_chain.d.ts +106 -0
  132. package/generated/versions/defold-1.13.1/b2d_fixture.d.ts +157 -0
  133. package/generated/versions/defold-1.13.1/b2d_joint.d.ts +823 -0
  134. package/generated/versions/defold-1.13.1/b2d_shape.d.ts +400 -0
  135. package/generated/versions/defold-1.13.1/b2d_world.d.ts +320 -0
  136. package/generated/versions/defold-1.13.1/buffer.d.ts +189 -0
  137. package/generated/versions/defold-1.13.1/camera.d.ts +303 -0
  138. package/generated/versions/defold-1.13.1/collectionfactory.d.ts +151 -0
  139. package/generated/versions/defold-1.13.1/collectionproxy.d.ts +93 -0
  140. package/generated/versions/defold-1.13.1/compute.d.ts +237 -0
  141. package/generated/versions/defold-1.13.1/crash.d.ts +143 -0
  142. package/generated/versions/defold-1.13.1/editor-vm/http.d.ts +56 -0
  143. package/generated/versions/defold-1.13.1/editor-vm/image.d.ts +48 -0
  144. package/generated/versions/defold-1.13.1/editor-vm/json.d.ts +10 -0
  145. package/generated/versions/defold-1.13.1/editor-vm/localization.d.ts +42 -0
  146. package/generated/versions/defold-1.13.1/editor-vm/tilemap_tiles.d.ts +80 -0
  147. package/generated/versions/defold-1.13.1/editor-vm/zip.d.ts +10 -0
  148. package/generated/versions/defold-1.13.1/editor-vm/zlib.d.ts +24 -0
  149. package/generated/versions/defold-1.13.1/editor.d.ts +1121 -0
  150. package/generated/versions/defold-1.13.1/factory.d.ts +124 -0
  151. package/generated/versions/defold-1.13.1/font.d.ts +81 -0
  152. package/generated/versions/defold-1.13.1/go.d.ts +1107 -0
  153. package/generated/versions/defold-1.13.1/graphics.d.ts +411 -0
  154. package/generated/versions/defold-1.13.1/gui.d.ts +2353 -0
  155. package/generated/versions/defold-1.13.1/html5.d.ts +55 -0
  156. package/generated/versions/defold-1.13.1/http.d.ts +77 -0
  157. package/generated/versions/defold-1.13.1/image.d.ts +131 -0
  158. package/generated/versions/defold-1.13.1/index.d.ts +57 -0
  159. package/generated/versions/defold-1.13.1/json.d.ts +80 -0
  160. package/generated/versions/defold-1.13.1/kinds/editor-script.d.ts +14 -0
  161. package/generated/versions/defold-1.13.1/label.d.ts +93 -0
  162. package/generated/versions/defold-1.13.1/liveupdate.d.ts +142 -0
  163. package/generated/versions/defold-1.13.1/material.d.ts +408 -0
  164. package/generated/versions/defold-1.13.1/model.d.ts +272 -0
  165. package/generated/versions/defold-1.13.1/msg.d.ts +10 -0
  166. package/generated/versions/defold-1.13.1/particlefx.d.ts +163 -0
  167. package/generated/versions/defold-1.13.1/physics.d.ts +559 -0
  168. package/generated/versions/defold-1.13.1/profiler.d.ts +235 -0
  169. package/generated/versions/defold-1.13.1/render.d.ts +1190 -0
  170. package/generated/versions/defold-1.13.1/resource.d.ts +1523 -0
  171. package/generated/versions/defold-1.13.1/socket.d.ts +784 -0
  172. package/generated/versions/defold-1.13.1/sound.d.ts +292 -0
  173. package/generated/versions/defold-1.13.1/sprite.d.ts +175 -0
  174. package/generated/versions/defold-1.13.1/sys.d.ts +678 -0
  175. package/generated/versions/defold-1.13.1/tilemap.d.ts +208 -0
  176. package/generated/versions/defold-1.13.1/timer.d.ts +125 -0
  177. package/generated/versions/defold-1.13.1/types.d.ts +61 -0
  178. package/generated/versions/defold-1.13.1/vmath.d.ts +774 -0
  179. package/generated/versions/defold-1.13.1/window.d.ts +183 -0
  180. package/generated/versions/defold-1.13.1/zlib.d.ts +41 -0
  181. package/generated/vmath.d.ts +6 -26
  182. package/generated/window.d.ts +71 -71
  183. package/identity-vocabulary.json +561 -0
  184. package/index.d.ts +8 -0
  185. package/package.json +13 -2
  186. package/scripts/example-store-io.ts +12 -1
  187. package/scripts/extension-goldens.ts +1 -0
  188. package/scripts/fidelity-baseline.json +146 -46
  189. package/scripts/generate-api-availability.ts +90 -12
  190. package/scripts/generate-api-signatures.ts +14 -3
  191. package/scripts/import-defold-release.ts +85 -24
  192. package/scripts/prose-constants.ts +157 -0
  193. package/scripts/regen.ts +396 -78
  194. package/scripts/shared-handle-methods.ts +53 -0
  195. package/scripts/signature-store-fs.ts +1 -0
  196. package/scripts/sync-api-docs.ts +16 -5
  197. package/src/api-availability.ts +0 -0
  198. package/src/api-doc.ts +201 -3
  199. package/src/api-identity.ts +252 -0
  200. package/src/component-properties.d.ts +33 -0
  201. package/src/core-types.ts +28 -3
  202. package/src/correction-pins.ts +49 -0
  203. package/src/custom-messages.d.ts +4 -3
  204. package/src/doc-comment.ts +139 -23
  205. package/src/doc-corrections.ts +108 -0
  206. package/src/editor-vm-globals.d.ts +5 -2
  207. package/src/emit-dts.ts +2474 -281
  208. package/src/emit-messages.ts +52 -0
  209. package/src/engine-globals.d.ts +2 -2
  210. package/src/example-store.ts +125 -14
  211. package/src/field-correction-coverage.ts +204 -0
  212. package/src/fnv1a.ts +16 -0
  213. package/src/go-overloads.d.ts +33 -13
  214. package/src/index-base-table.ts +145 -0
  215. package/src/index-slot-classifications.ts +588 -0
  216. package/src/index.ts +39 -2
  217. package/src/library-index-slot-classifications.ts +332 -0
  218. package/src/lifecycle.ts +61 -199
  219. package/src/luals-type-expr.ts +354 -0
  220. package/src/msg-overloads.d.ts +0 -1
  221. package/src/param-correction-provenance.ts +64 -0
  222. package/src/return-correction-provenance.ts +58 -16
  223. package/src/script-api.ts +3 -1
  224. package/src/sys-overloads.d.ts +48 -0
  225. package/src/vmath-overloads.d.ts +27 -1
  226. package/src/window-event-guard.d.ts +6 -9
  227. package/url-parameters.json +1 -2
package/src/emit-dts.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import messagesDoc from "../fixtures/messages_doc.json" with { type: "json" };
1
2
  import { type ApiSymbolIdentity, normalizedFunctionSignature } from "./api-availability";
2
3
  import type {
3
4
  ApiConstant,
@@ -5,22 +6,41 @@ import type {
5
6
  ApiModule,
6
7
  ApiParameter,
7
8
  ApiProperty,
9
+ ApiStruct,
10
+ ApiStructMember,
11
+ ApiTypedef,
8
12
  ApiVariable,
9
13
  } from "./api-doc";
10
14
  import { DEFOLD_TYPE_MAP } from "./core-types";
15
+ import { correctionPins, pinMatches, propertyCorrectionPins } from "./correction-pins";
11
16
  import {
12
17
  type DocCommentParts,
13
18
  htmlToCodeText,
14
19
  htmlToDocText,
15
20
  renderDocComment,
16
21
  splitExampleSources,
22
+ summaryFor,
17
23
  } from "./doc-comment";
18
24
  import type { TranslationStore } from "./example-store";
19
- import { hashExampleSource, lookupExampleTranslations, lookupTranslation } from "./example-store";
25
+ import { lookupExampleSegments, translateProseFences } from "./example-store";
26
+ import {
27
+ appendNotes,
28
+ type IndexBaseNoteResolver,
29
+ type IndexBaseSource,
30
+ indexBaseNoteResolver,
31
+ OVERRIDE_RETURN_SLOT,
32
+ } from "./index-slot-classifications";
33
+ import { type LualsPosition, mapLualsExpression, needsArrayParens } from "./luals-type-expr";
20
34
  import { classifyUrlParameter, type UrlParameterTable } from "./url-parameters";
21
35
 
36
+ export { summaryFor };
37
+
38
+ // Maps one ref-doc type token. `position` says whether the caller supplies the
39
+ // value (a parameter) or the engine hands it back; see `LualsPosition`.
40
+ export type DefoldTypeMapper = (token: string, position?: LualsPosition) => string;
41
+
22
42
  export interface EmitOptions {
23
- mapType?: (defoldType: string) => string;
43
+ mapType?: DefoldTypeMapper;
24
44
  // Constant FQNs from *other* modules, so a foreign token like
25
45
  // `graphics.BUFFER_TYPE_COLOR0_BIT` used as a param type inside `render`
26
46
  // brands to the same FQN-keyed type its owning module's `const` emits,
@@ -38,6 +58,23 @@ export interface EmitOptions {
38
58
  // emit. `regen` supplies the committed `url-parameters.json`; the table
39
59
  // arrives as data so this module stays free of `node:fs` (bug-88).
40
60
  urlParameters?: UrlParameterTable;
61
+ // Which classification map the index-base notes read. Defaults to the engine
62
+ // map; a library or extension declaration passes its page keys (`[]` for one
63
+ // no page documents), so it never names Defold as the receiver.
64
+ indexBaseSource?: IndexBaseSource;
65
+ // The TS path of each `ENUM`, `STRUCT` and `TYPEDEF` another module declares
66
+ // (`declaredTypePaths`), so a token naming one resolves instead of widening to
67
+ // `unknown`. The module's own declarations resolve without it. Read only by the
68
+ // default engine mapper; a caller-supplied `mapType` keeps its own vocabulary.
69
+ declaredTypes?: ReadonlyMap<string, string>;
70
+ // Tokens a surface names that the engine vocabulary does not (the editor's
71
+ // handles), resolved ahead of it both as whole tokens and as leaves inside a
72
+ // composite. Read by the default mapper together with the declared types.
73
+ typeLeaves?: Readonly<Record<string, string>>;
74
+ // Constant FQNs the target's own engine bindings register, which gate the
75
+ // `UNDOCUMENTED_CONSTANTS` back-fill. Absent for a surface with no vendored
76
+ // bindings (a ref-doc target), where every entry the ref-doc omits is emitted.
77
+ registeredConstants?: ReadonlySet<string>;
41
78
  }
42
79
 
43
80
  export const TS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
@@ -131,6 +168,8 @@ export const ARBITRARY_TABLE_SLOTS = new Set([
131
168
  // names; bare `on_message`'s receive `message` is the pre-hashed, user-routed
132
169
  // payload (mitigated for authored code by isMessage/onMessage). No static shape
133
170
  // exists, so `Record<string | number, unknown>` is faithful, not a loss.
171
+ // collectionfactory.create curates the instance-id layer of `properties` as a
172
+ // mapping; each per-instance property table stays that faithful `Record`.
134
173
  "factory.create",
135
174
  "collectionfactory.create",
136
175
  "on_message",
@@ -172,11 +211,6 @@ export const ARBITRARY_TABLE_SLOTS = new Set([
172
211
  // not a loss. The audit skips counting these; the emitter already leaves an
173
212
  // uncurated slot as `Record`. Add deliberately, one rationale per line.
174
213
  export const ARBITRARY_TABLE_SLOT_KEYS = new Set([
175
- // "the chain definition" / "the shape definition" / "explosion definition" —
176
- // create-side option tables with no documented field list at this slot.
177
- "b2d.body.create_chain:param:definition",
178
- "b2d.body.create_shape:param:definition",
179
- "b2d.world.explode:param:definition",
180
214
  "b2d.joint.create_filter:param:definition", // "optional definition table"
181
215
  // opaque Box2D result structs: the doc names no fields.
182
216
  "b2d.chain.get_geometry:return:geometry", // "chain geometry table"
@@ -194,6 +228,18 @@ export const ARBITRARY_TABLE_SLOT_KEYS = new Set([
194
228
  // set to keep the `droppedMembers` count honest; an uncovered skip (e.g. a new
195
229
  // `skipFunctions` entry with no matching overload) still counts, so the gate
196
230
  // stays a real signal. Mirrors the ARBITRARY_TABLE_SLOTS audit-honesty pattern.
231
+ // A slot the engine treats as omissible on a function OVERLOAD_COVERED_SKIPS
232
+ // hands to a `*-overloads.d.ts` form without it, where no generated declaration
233
+ // exists for OPTIONAL_SLOT_CORRECTIONS to reach. Availability identity reads it
234
+ // as optional, so a ref-doc that starts marking it is no change. The value is
235
+ // the evidence, as in OPTIONAL_SLOT_CORRECTIONS.
236
+ export const HAND_DECLARED_OPTIONAL_SLOTS: ReadonlyMap<string, string> = new Map(
237
+ (["y", "z"] as const).map((slot) => [
238
+ `vmath.euler_to_quat:param:${slot}`,
239
+ "example `vmath.euler_to_quat(v)` passes a vector3 alone; vmath-overloads.d.ts declares that form",
240
+ ]),
241
+ );
242
+
197
243
  export const OVERLOAD_COVERED_SKIPS = new Set([
198
244
  // go-overloads.d.ts supplies the typed go.get / go.set / go.property shapes.
199
245
  "go.get",
@@ -208,14 +254,55 @@ export const OVERLOAD_COVERED_SKIPS = new Set([
208
254
  "render.render_target",
209
255
  // vmath-overloads.d.ts supplies the vector3 and three-number euler_to_quat arities.
210
256
  "vmath.euler_to_quat",
257
+ // vmath-overloads.d.ts supplies the empty form and the table form; the engine
258
+ // raises on an explicit nil, so no optional parameter admits undefined.
259
+ "vmath.vector",
211
260
  // vmath-overloads.d.ts supplies the generic covariant signatures.
212
261
  "vmath.clamp",
213
262
  "vmath.lerp",
214
263
  "vmath.slerp",
215
264
  "vmath.mul_per_elem",
216
265
  "vmath.normalize",
266
+ // sys-overloads.d.ts supplies the disabling form and the form that takes the
267
+ // cooldown the engine requires when enabling.
268
+ "sys.set_engine_throttle",
269
+ // sys-overloads.d.ts supplies the form without a default, which returns nil
270
+ // for a missing key, and the form with one, which always returns a string.
271
+ "sys.get_config_string",
217
272
  ]);
218
273
 
274
+ // A declaration upstream files under another function's name: its return slot
275
+ // and prose are the named sibling's, so the element keeps only its own
276
+ // declarations. Keyed `<fqn>:<first return name>`, which tells the misfiled
277
+ // declaration from the real one. `return-correction-provenance.test.ts` reds
278
+ // once no retained target still files it, and the entry is then deleted.
279
+ export const MISFILED_DECLARATIONS: ReadonlyMap<string, string> = new Map([
280
+ // b2d_body_doc.json repeats `get_angle`'s `angle` return under `get_world_center`.
281
+ ["b2d.body.get_world_center:angle", "b2d.body.get_angle"],
282
+ ]);
283
+
284
+ function isMisfiled(fn: ApiFunction): boolean {
285
+ return MISFILED_DECLARATIONS.has(`${fn.name}:${fn.returnValues[0]?.name ?? ""}`);
286
+ }
287
+
288
+ // skipFunction FQNs withheld because no engine binding registers them in the
289
+ // target that documents them, so the declared call would raise. Like
290
+ // OVERLOAD_COVERED_SKIPS, the audit leaves them out of `droppedMembers`: the
291
+ // surface loses nothing a script could call. The engine-binding diff reports a
292
+ // missing binding if one is declared again.
293
+ export const UNBOUND_SKIPS = new Set([
294
+ "b2d.body.get_contact_list",
295
+ "b2d.body.get_user_data",
296
+ "b2d.body.set_user_data",
297
+ "b2d.body.reset_mass_data",
298
+ ]);
299
+
300
+ // skipFunction FQNs withheld because another lane's declaration takes the same
301
+ // global name: the runtime `http.response` table beside the editor VM's
302
+ // `http.response` handle. The audit leaves them out of `droppedMembers`, since
303
+ // a slot that needs the shape types it inline.
304
+ export const NAME_CLASH_SKIPS = new Set(["http.response"]);
305
+
219
306
  // Element names whose `table` slot is a prose-only `a table mapping X to Y`
220
307
  // shape the field-list parser cannot read, but whose key/value a human curated
221
308
  // from the doc. Emitted as `LuaMap<K, V>` because the key is a branded `Hash`,
@@ -244,6 +331,17 @@ export const RETURN_TYPE_OVERRIDES: ReadonlyMap<string, string> = new Map([
244
331
  // `returnvalues` is empty while every sibling `editor.tx.*` builder declares
245
332
  // one, and `editor.transact()` takes exactly that.
246
333
  ["editor.tx.add", 'Opaque<"transaction_step">'],
334
+ // The engine binding pushes a value the ref-doc never documents:
335
+ // script_sys.cpp:Sys_Save pushes whether the write succeeded,
336
+ // script_tilemap.cpp:TileMap_SetTile pushes false for an unknown layer or an
337
+ // out-of-range cell, and script_box2d_body_v3.cpp:Body_CreateShape pushes the
338
+ // created shape's info table through PushShapeInfo.
339
+ ["sys.save", "boolean"],
340
+ ["tilemap.set_tile", "boolean"],
341
+ [
342
+ "b2d.body.create_shape",
343
+ '{ index: number; shape_id: Opaque<"b2Shape">; type: number; sensor: boolean; density: number; friction: number; restitution: number; material: number; child_count: number; is_chain_segment: boolean }',
344
+ ],
247
345
  ]);
248
346
 
249
347
  // A function whose `returnvalues` declare a concrete type while the same slot's
@@ -254,10 +352,25 @@ export const RETURN_TYPE_OVERRIDES: ReadonlyMap<string, string> = new Map([
254
352
  // ref-doc. Upstream adding `nil` to `types` hands the case to the top-level
255
353
  // `nil` projection in `mapSlotUnion` and reds the entry, which is then deleted
256
354
  // rather than re-pinned.
355
+ //
356
+ // The same table holds a `function` return whose prose documents the returned
357
+ // function's contract, which the bare token cannot carry: `socket.protect`'s
358
+ // safe function "returns nil followed by an error message", and
359
+ // `socket.newtry`'s try function returns its first argument when it is truthy
360
+ // and throws otherwise, the LuaSocket contract its example relies on to unwrap
361
+ // `socket.connect`'s `client, err` pair. The try function takes that pair
362
+ // destructured into separate arguments; a boxed `LuaMultiReturn` is rejected
363
+ // because TSTL passes it as one table, which is always truthy. `reason` quotes
364
+ // the slot prose the provenance test searches for, as for the `nil` entries.
257
365
  export interface ReturnTypeCorrection {
258
366
  readonly ts: string;
259
367
  readonly upstream: readonly string[];
368
+ // A later release's spelling of the same defect; see `correction-pins.ts`.
369
+ readonly retypedUpstream?: readonly (readonly string[])[];
260
370
  readonly reason: string;
371
+ // The named value of a multi-return the correction rewrites; `ts` is then
372
+ // that value's type. Absent, the correction rewrites a single return.
373
+ readonly slot?: string;
261
374
  }
262
375
 
263
376
  // Keyed by FQN, mirroring PROPERTY_TYPE_CORRECTIONS.
@@ -270,6 +383,374 @@ export const RETURN_TYPE_CORRECTIONS: ReadonlyMap<string, ReturnTypeCorrection>
270
383
  reason: "the body if successful. Otherwise nil.",
271
384
  },
272
385
  ],
386
+ [
387
+ "b2d.shape.ray_cast",
388
+ {
389
+ ts: "{ point: Vector3; normal: Vector3; fraction: number; iterations: number } | undefined",
390
+ upstream: ["table"],
391
+ reason: "and iterations, or nil",
392
+ },
393
+ ],
394
+ [
395
+ "b2d.world.cast_ray_closest",
396
+ {
397
+ ts: "{ fixture: number; shape: number; point: Vector3; normal: Vector3; fraction: number; node_visits: number; leaf_visits: number } | undefined",
398
+ upstream: ["table"],
399
+ reason: "and leaf_visits, or nil",
400
+ },
401
+ ],
402
+ [
403
+ "gui.new_texture",
404
+ {
405
+ ts: "number | undefined",
406
+ upstream: ["number"],
407
+ reason: "codes if unsuccessful",
408
+ slot: "code",
409
+ },
410
+ ],
411
+ // 1.13.2 declares `render_predicate`, whose TYPEDEF aliases `userdata`, and
412
+ // that still emits `number`.
413
+ [
414
+ "render.predicate",
415
+ {
416
+ ts: 'Opaque<"render_predicate">',
417
+ upstream: ["number"],
418
+ retypedUpstream: [["render_predicate"]],
419
+ reason: "new predicate",
420
+ },
421
+ ],
422
+ // 1.13.2 declares both as `fun(...:any):any`, which emits
423
+ // `(...args: unknown[]) => unknown` and still drops the contract.
424
+ [
425
+ "socket.protect",
426
+ {
427
+ ts: "(...args: unknown[]) => LuaMultiReturn<[unknown, string | undefined]>",
428
+ upstream: ["function(function())"],
429
+ retypedUpstream: [["fun(...:any):any"]],
430
+ reason: "returns nil followed by an error message.",
431
+ },
432
+ ],
433
+ [
434
+ "socket.newtry",
435
+ {
436
+ ts: "<T>(value: T | undefined, ...rest: T extends { readonly __tstlMultiReturn: unknown } ? [boxedMultiReturn: never] : unknown[]) => T",
437
+ upstream: ["function"],
438
+ retypedUpstream: [["fun(...:any):any"]],
439
+ reason: "the customized try function.",
440
+ },
441
+ ],
442
+ ]);
443
+
444
+ // A parameter whose declared `types` disagree with what the same element's
445
+ // prose, an upstream example, or the engine binding demonstrably takes — a render
446
+ // target by resource name, a texture unit, a Lua sequence where `table` is
447
+ // declared, a hash the binding refuses as a string. Admission rule: only a slot
448
+ // that evidence contradicts; a slot that is merely imprecise is not a candidate.
449
+ // The correction edits the mapped union rather than replacing it: `adds` appends
450
+ // what the slot also takes and `removes` drops members the binding raises on, so
451
+ // the URL address alias, array curation and constant mapping stay live, and
452
+ // optionality stays with `isDocOptional`. Each entry records the upstream tokens
453
+ // it contradicts, plus any later spelling of the same defect in
454
+ // `retypedUpstream`, and applies only to a slot still declaring one of them;
455
+ // `param-correction-provenance.test.ts` pins them against every retained
456
+ // vendored ref-doc. Once no retained target still declares any of them the
457
+ // entry is deleted, never re-pinned.
458
+ export interface ParamTypeCorrection {
459
+ readonly adds?: string;
460
+ readonly removes?: readonly string[];
461
+ readonly upstream: readonly string[];
462
+ readonly retypedUpstream?: readonly (readonly string[])[];
463
+ readonly reason: string;
464
+ }
465
+
466
+ const CAMERA_ADDRESS_REASON =
467
+ "render_script_camera.cpp:CheckRenderCamera resolves anything but a number handle through ResolveURL, which takes a string or a hash";
468
+
469
+ const CAMERA_SLOT_FUNCTIONS = [
470
+ "get_aspect_ratio",
471
+ "get_auto_aspect_ratio",
472
+ "get_enabled",
473
+ "get_far_z",
474
+ "get_fov",
475
+ "get_near_z",
476
+ "get_orthographic_auto_zoom",
477
+ "get_orthographic_mode",
478
+ "get_orthographic_zoom",
479
+ "get_projection",
480
+ "get_view",
481
+ "screen_to_world",
482
+ "screen_xy_to_world",
483
+ "set_aspect_ratio",
484
+ "set_auto_aspect_ratio",
485
+ "set_far_z",
486
+ "set_fov",
487
+ "set_near_z",
488
+ "set_orthographic_mode",
489
+ "set_orthographic_zoom",
490
+ "world_to_screen",
491
+ ] as const;
492
+
493
+ // Keyed `<element>:param:<slot>` like OPTIONAL_SLOT_CORRECTIONS, so a
494
+ // same-named slot on another element is untouched.
495
+ export const PARAM_TYPE_CORRECTIONS: ReadonlyMap<string, ParamTypeCorrection> = new Map<
496
+ string,
497
+ ParamTypeCorrection
498
+ >([
499
+ ...CAMERA_SLOT_FUNCTIONS.map(
500
+ (name) =>
501
+ [
502
+ `camera.${name}:param:camera`,
503
+ {
504
+ adds: "string | Hash",
505
+ upstream: ["url", "number", "nil"],
506
+ reason: CAMERA_ADDRESS_REASON,
507
+ },
508
+ ] as const,
509
+ ),
510
+ ...(["set_group", "get_maskbit", "set_maskbit"] as const).map(
511
+ (name) =>
512
+ [
513
+ `physics.${name}:param:group`,
514
+ {
515
+ adds: "Hash",
516
+ upstream: ["string"],
517
+ reason: `script_physics.cpp reads the ${name} group with CheckHashOrString(L, 2)`,
518
+ },
519
+ ] as const,
520
+ ),
521
+ [
522
+ "resource.get_text_metrics:param:url",
523
+ {
524
+ adds: "string",
525
+ upstream: ["hash"],
526
+ reason: "script_resource.cpp:GetTextMetrics reads the font path with CheckHashOrString(L, 1)",
527
+ },
528
+ ],
529
+ [
530
+ "collectionproxy.get_resources:param:collectionproxy",
531
+ {
532
+ adds: "string | Hash",
533
+ upstream: ["url"],
534
+ reason:
535
+ "script_collectionproxy.cpp:CollectionProxy_GetResourcesInternal resolves the proxy through GetComponentFromLua, which takes a string or a hash",
536
+ },
537
+ ],
538
+ ...(["screen_to_local", "set_screen_position"] as const).map(
539
+ (name) =>
540
+ [
541
+ `gui.${name}:param:screen_position`,
542
+ {
543
+ adds: "Vector4",
544
+ upstream: ["vector3"],
545
+ reason: "gui_script.cpp:GetPositionFromArgumentIndex takes a vector3 or a vector4",
546
+ },
547
+ ] as const,
548
+ ),
549
+ [
550
+ "particlefx.set_constant:param:value",
551
+ {
552
+ adds: "Matrix4",
553
+ upstream: ["vector4"],
554
+ reason: "script_particlefx.cpp:ParticleFX_SetConstant takes a vector4 or a matrix4 value",
555
+ },
556
+ ],
557
+ ...(["animate", "cancel_animations"] as const).map(
558
+ (name) =>
559
+ [
560
+ `gui.${name}:param:property`,
561
+ {
562
+ adds: "Hash",
563
+ upstream: name === "animate" ? ["string", "constant"] : ["nil", "string", "constant"],
564
+ reason: `gui_script.cpp reads the ${name} property with CheckHashOrString(L, 2)`,
565
+ },
566
+ ] as const,
567
+ ),
568
+ [
569
+ "sound.get_group_name:param:group",
570
+ {
571
+ removes: ["string"],
572
+ upstream: ["string", "hash"],
573
+ reason: "script_sound.cpp:Sound_GetGroupName reads CheckHash(L, 1), which refuses a string",
574
+ },
575
+ ],
576
+ ...(["get_mesh_enabled", "set_mesh_enabled"] as const).map(
577
+ (name) =>
578
+ [
579
+ `model.${name}:param:mesh_id`,
580
+ {
581
+ removes: ["Url"],
582
+ upstream: ["string", "hash", "url"],
583
+ reason: `script_model.cpp reads the ${name} mesh id with CheckHashOrString(L, 2), which refuses a url`,
584
+ },
585
+ ] as const,
586
+ ),
587
+ [
588
+ "resource.create_texture_async:param:path",
589
+ {
590
+ removes: ["Hash"],
591
+ upstream: ["string", "hash"],
592
+ reason:
593
+ "script_resource.cpp:CheckCreateTextureResourceParams reads the path with luaL_checkstring(L, 1), which refuses a hash",
594
+ },
595
+ ],
596
+ [
597
+ "render.set_render_target:param:render_target",
598
+ {
599
+ adds: "string | Hash | typeof render.RENDER_TARGET_DEFAULT",
600
+ upstream: ["render_target"],
601
+ retypedUpstream: [["render_target", "string", "hash", "nil"]],
602
+ reason:
603
+ "\"render.RENDER_TARGET_DEFAULT to set the default render target\"; example `render.set_render_target('my_rt_resource')`; 1.13.2 adds the names but still omits the constant",
604
+ },
605
+ ],
606
+ [
607
+ "render.get_render_target_width:param:render_target",
608
+ {
609
+ adds: "string | Hash",
610
+ upstream: ["render_target"],
611
+ reason:
612
+ "example `render.get_render_target_width('my_rt_resource', graphics.BUFFER_TYPE_COLOR0_BIT)`",
613
+ },
614
+ ],
615
+ [
616
+ "render.get_render_target_height:param:render_target",
617
+ {
618
+ adds: "string | Hash",
619
+ upstream: ["render_target"],
620
+ reason:
621
+ "example `render.get_render_target_height('my_rt_resource', graphics.BUFFER_TYPE_COLOR0_BIT)`",
622
+ },
623
+ ],
624
+ [
625
+ "render.set_render_target_size:param:render_target",
626
+ {
627
+ adds: "string | Hash",
628
+ upstream: ["render_target"],
629
+ reason:
630
+ "\"a render target created from either a render script, or from a render target resource\"; example `render.set_render_target_size('my_rt_resource', ...)`",
631
+ },
632
+ ],
633
+ [
634
+ "render.enable_texture:param:handle_or_name",
635
+ {
636
+ adds: 'Opaque<"render_target"> | number',
637
+ upstream: ["texture", "string", "hash"],
638
+ reason:
639
+ "\"render target or texture handle that should be bound, or a named resource\"; examples `render.enable_texture(0, self.my_render_target, ...)` and `render.enable_texture(0, 'my_rt_resource', ...)`; `resource.get_texture_info` returns the handle as a number",
640
+ },
641
+ ],
642
+ [
643
+ "render.disable_texture:param:binding",
644
+ {
645
+ adds: "number",
646
+ upstream: ["texture", "string", "hash"],
647
+ reason:
648
+ '"texture binding, either by texture unit, string or hash"; example `render.disable_texture(0)`',
649
+ },
650
+ ],
651
+ [
652
+ "render.set_camera:param:camera",
653
+ {
654
+ adds: "string | Hash",
655
+ upstream: ["url", "number", "nil"],
656
+ reason:
657
+ 'example `render.set_camera("main:/my_go#camera")`; render_script.cpp:RenderScript_SetCamera resolves a non-number camera through ResolveURL, which also takes a hash',
658
+ },
659
+ ],
660
+ [
661
+ "render.draw:param:predicate",
662
+ {
663
+ adds: 'Opaque<"render_predicate">',
664
+ removes: ["number"],
665
+ upstream: ["number"],
666
+ retypedUpstream: [["render_predicate"]],
667
+ reason:
668
+ "render_script.cpp:RenderScript_Draw reads the predicate with RenderScriptPredicate_Check, the userdata render.predicate returns; 1.13.2's `render_predicate` still emits `number`",
669
+ },
670
+ ],
671
+ [
672
+ "go.delete:param:id",
673
+ {
674
+ adds: "boolean",
675
+ upstream: ["string", "hash", "url", "table"],
676
+ retypedUpstream: [["string", "hash", "url", "(string|hash|url)[]"]],
677
+ reason:
678
+ "example `go.delete(true)` deletes the script game object and its children; 1.13.2 types the id list but still omits `boolean`",
679
+ },
680
+ ],
681
+ [
682
+ "buffer.get_bytes:param:stream_name",
683
+ {
684
+ adds: "string",
685
+ upstream: ["hash"],
686
+ reason: 'sys.load_buffer example `buffer.get_bytes(my_buffer, "data")`',
687
+ },
688
+ ],
689
+ [
690
+ "gui.set:param:value",
691
+ {
692
+ adds: "Matrix4 | Hash",
693
+ upstream: ["number", "vector4", "vector3", "quaternion"],
694
+ reason:
695
+ 'example `gui.set(node, "light_matrix", vmath.matrix4())` ("matrix4 is also supported"); example `gui.set(msg.url(), "textures", atlas_id, {key = "runtime_texture"})` passes the hash `resource.create_atlas` returns',
696
+ },
697
+ ],
698
+ [
699
+ "gui.set_rotation:param:rotation",
700
+ {
701
+ adds: "Vector3",
702
+ upstream: ["quaternion", "vector4"],
703
+ reason: "gui.set example `gui.set_rotation(node, vmath.vector3(0,0,45))`",
704
+ },
705
+ ],
706
+ [
707
+ "sys.save:param:table",
708
+ {
709
+ adds: "readonly unknown[]",
710
+ upstream: ["table"],
711
+ retypedUpstream: [["table<any, any>"]],
712
+ reason:
713
+ 'example builds `my_table` with `table.insert(my_table, "my_value")` and passes it to `sys.save`; 1.13.2\'s `table<any, any>` emits a `LuaTable`, which refuses an array',
714
+ },
715
+ ],
716
+ [
717
+ "sys.serialize:param:table",
718
+ {
719
+ adds: "readonly unknown[]",
720
+ upstream: ["table"],
721
+ retypedUpstream: [["table<any, any>"]],
722
+ reason:
723
+ 'example builds `my_table` with `table.insert(my_table, "my_value")` and passes it to `sys.serialize`; 1.13.2\'s `table<any, any>` emits a `LuaTable`, which refuses an array',
724
+ },
725
+ ],
726
+ [
727
+ "window.set_dim_mode:param:mode",
728
+ {
729
+ removes: ["window.DIMMING"],
730
+ adds: "window.DimModeStateSettable",
731
+ upstream: ["window.DIMMING"],
732
+ reason:
733
+ 'script_window.cpp:SetDimMode raises "The dim mode specified is not supported." for anything but DIMMING_ON and DIMMING_OFF',
734
+ },
735
+ ],
736
+ ...(["texture", "render_target"] as const).map((handle): [string, ParamTypeCorrection] => [
737
+ `resource.get_${handle}_info:param:path`,
738
+ {
739
+ adds: "number",
740
+ upstream: ["hash", "string", handle],
741
+ reason:
742
+ "script_resource.cpp reads any number in slot 1 as the handle, which `info.handle` returns and older targets declared as `number`",
743
+ },
744
+ ]),
745
+ [
746
+ "tilemap.set_tile:param:transform_bitmask",
747
+ {
748
+ adds: "number",
749
+ upstream: ["tilemap.TRANSFORM"],
750
+ reason:
751
+ "a bitmask: the ref-doc example passes tilemap.H_FLIP + tilemap.V_FLIP + tilemap.ROTATE_90, a sum no single TRANSFORM member names",
752
+ },
753
+ ],
273
754
  ]);
274
755
 
275
756
  // A property whose upstream `<span class="type">` states a type the engine does
@@ -285,6 +766,8 @@ export const RETURN_TYPE_CORRECTIONS: ReadonlyMap<string, ReturnTypeCorrection>
285
766
  export interface PropertyTypeCorrection {
286
767
  readonly ts: string;
287
768
  readonly upstream: string;
769
+ // A later release's span for the same defect; see `correction-pins.ts`.
770
+ readonly retypedUpstream?: readonly string[];
288
771
  readonly reason: string;
289
772
  }
290
773
 
@@ -300,6 +783,24 @@ export const PROPERTY_TYPE_CORRECTIONS: ReadonlyMap<string, PropertyTypeCorrecti
300
783
  "a count of animation frames, which the prose confirms; `hash` is a copy-paste of the sibling animation-id spans",
301
784
  },
302
785
  ],
786
+ [
787
+ "go.scale",
788
+ {
789
+ ts: "number | Vector3",
790
+ upstream: "number",
791
+ reason:
792
+ "the engine reads a vector3 back from `go.get`, and `go.set` also takes a number, which it applies as a uniform scale",
793
+ },
794
+ ],
795
+ [
796
+ "label.scale",
797
+ {
798
+ ts: "Vector3",
799
+ upstream: "number | vector3",
800
+ reason:
801
+ 'the engine reads a vector3 back from `go.get`, and `go.set` raises "must be a vmath.vector3" when given a number',
802
+ },
803
+ ],
303
804
  [
304
805
  "camera.projection",
305
806
  {
@@ -320,15 +821,36 @@ export const PROPERTY_TYPE_CORRECTIONS: ReadonlyMap<string, PropertyTypeCorrecti
320
821
  ],
321
822
  ]);
322
823
 
824
+ // A property upstream names with a placeholder standing for a numbered family of
825
+ // keys, so its literal name is not a key the engine accepts. The emitted members
826
+ // are one literal key per number in `first..last`, each typed like the
827
+ // placeholder. `evidence` is the brief phrase that defines the range;
828
+ // `property-correction-provenance.test.ts` reds when a vendored ref-doc stops
829
+ // saying it, or when the phrase stops stating `first-last`. Curated rather than
830
+ // matched on a trailing `N`, so each new upstream placeholder is a deliberate
831
+ // addition.
832
+ export interface PropertyKeyRange {
833
+ readonly prefix: string;
834
+ readonly first: number;
835
+ readonly last: number;
836
+ readonly evidence: string;
837
+ }
838
+
839
+ // Keyed `<namespace>.<property>`, mirroring PROPERTY_TYPE_CORRECTIONS.
840
+ export const PROPERTY_KEY_RANGES: ReadonlyMap<string, PropertyKeyRange> = new Map([
841
+ ["model.textureN", { prefix: "texture", first: 0, last: 7, evidence: "textureN where N is 0-7" }],
842
+ ]);
843
+
323
844
  // A parameter the ref-doc metadata marks required while the same element's prose
324
845
  // or examples show it being omitted. `is_optional` is the only optionality
325
846
  // signal the emitter has, and upstream leaves it `False` on slots its own
326
847
  // examples skip — so the emitted declaration rejects the call the docs teach.
327
848
  //
328
849
  // Keyed `<element>:param:<slot>`, the shape `tableSlotKey` builds, so a
329
- // same-named slot on another element is untouched. The value is the upstream
330
- // evidence for that one entry: the example call that omits it, or the doc phrase
331
- // that names it optional. Both emitted shapes follow from one predicate — a
850
+ // same-named slot on another element is untouched. The value is the evidence for
851
+ // that one entry: the example call that omits it, the doc phrase that names it
852
+ // optional, or the engine binding that reads it as omissible
853
+ // (`<file>:<cFunction>`). Both emitted shapes follow from one predicate — a
332
854
  // trailing slot becomes `slot?:`, an interior one `slot: T | undefined`.
333
855
  //
334
856
  // Demonstrated omissions only. `resource.set_texture`'s buffer and
@@ -340,10 +862,6 @@ export const OPTIONAL_SLOT_CORRECTIONS: ReadonlyMap<string, string> = new Map([
340
862
  'example `resource.create_texture("/my_custom_texture.texturec", tparams)`; "optional buffer of precreated pixel data"',
341
863
  ],
342
864
  ["resource.create_texture_async:param:buffer", '"optional buffer of precreated pixel data"'],
343
- [
344
- "resource.create_texture_async:param:callback",
345
- 'example `resource.create_texture_async("/my_texture.texturec", tparams, tbuffer)`',
346
- ],
347
865
  [
348
866
  "gui.new_texture:param:flip",
349
867
  'example `gui.new_texture("orange_tx", w, h, "rgb", string.rep(orange, w * h))`',
@@ -352,16 +870,40 @@ export const OPTIONAL_SLOT_CORRECTIONS: ReadonlyMap<string, string> = new Map([
352
870
  "gui.set_texture_data:param:flip",
353
871
  'example `gui.set_texture_data("dynamic_tx", w, h, "rgb", string.rep(orange, w * h))`',
354
872
  ],
355
- ["sys.set_engine_throttle:param:cooldown", "example `sys.set_engine_throttle(false)`"],
356
873
  [
357
874
  "b2d.fixture.set_shape:param:update_mass",
358
875
  '2 of 3 examples omit it; "The body mass is not updated unless update_mass is true"',
359
876
  ],
877
+ [
878
+ "b2d.shape.set_shape:param:update_mass",
879
+ '1 of 3 examples omits it; "The body mass is not updated unless update_mass is true"',
880
+ ],
360
881
  [
361
882
  "b2d.shape.ray_cast:param:max_fraction",
362
883
  '"optional maximum translation fraction, defaults to 1"',
363
884
  ],
364
885
  ["iap.buy:param:options", '"optional parameters as properties"'],
886
+ [
887
+ "go.world_to_local_position:param:url",
888
+ "gameobject_script.cpp:Script_WorldToLocalPosition resolves argument 2 with ResolveInstance, which takes the calling instance when it is absent",
889
+ ],
890
+ [
891
+ "go.world_to_local_transform:param:url",
892
+ "gameobject_script.cpp:Script_WorldToLocalTransfrom resolves argument 2 with ResolveInstance, which takes the calling instance when it is absent",
893
+ ],
894
+ [
895
+ "go.cancel_animations:param:url",
896
+ "gameobject_script.cpp:Script_CancelAnimations resolves argument 1 with ResolveURL, which takes the calling instance when it is absent",
897
+ ],
898
+ [
899
+ "b2d.fixture.set_density:param:update_mass",
900
+ "script_box2d_fixture_v2.cpp:Fixture_SetDensity reads argument 4 only when lua_gettop(L) >= 4",
901
+ ],
902
+ [
903
+ "render.set_render_target:param:render_target",
904
+ "render_script.cpp:RenderScript_SetRenderTarget sets the default target when lua_gettop(L) == 0",
905
+ ],
906
+ ["socket.newtry:param:finalizer", "socket.protect's example `local try = socket.newtry()`"],
365
907
  ...(
366
908
  [
367
909
  "create_distance",
@@ -383,6 +925,222 @@ export const OPTIONAL_SLOT_CORRECTIONS: ReadonlyMap<string, string> = new Map([
383
925
  ),
384
926
  ]);
385
927
 
928
+ // A parameter the ref-doc marks omissible — `[name]` or a `nil` type — that the
929
+ // engine binding refuses to go without, so the emitted declaration accepts a call
930
+ // that raises. It applies only where a ref-doc marks the slot, so it follows each
931
+ // target's own markup, and it overrides OPTIONAL_SLOT_CORRECTIONS: the slot is
932
+ // never emitted with `?`. `nil` records whether the binding still takes an
933
+ // explicit nil there — `lua_isnil` answers false past the stack top, so such a
934
+ // slot must be passed yet may be nil — which emits `slot: T | undefined`.
935
+ //
936
+ // Keyed `<element>:param:<slot>` like OPTIONAL_SLOT_CORRECTIONS. `evidence` is the
937
+ // C++ binding, `<file>:<cFunction>`, and what it does with the slot;
938
+ // `required-correction-provenance.test.ts` deletes an entry once no retained
939
+ // target marks the slot omissible.
940
+ export interface RequiredSlotCorrection {
941
+ readonly nil: boolean;
942
+ readonly evidence: string;
943
+ }
944
+
945
+ const COMPONENT_URL_EVIDENCE =
946
+ "resolves the url through GetComponentFromLua, which refuses the calling script's own url";
947
+
948
+ export const REQUIRED_SLOT_CORRECTIONS: ReadonlyMap<string, RequiredSlotCorrection> = new Map<
949
+ string,
950
+ RequiredSlotCorrection
951
+ >([
952
+ [
953
+ "resource.create_texture_async:param:callback",
954
+ {
955
+ nil: false,
956
+ evidence:
957
+ "script_resource.cpp:CreateTextureAsync hands argument 4 to dmScript::CreateCallback, which raises through luaL_checktype(L, 4, LUA_TFUNCTION)",
958
+ },
959
+ ],
960
+ ...(["factory", "collectionfactory"] as const).flatMap((namespace) => {
961
+ const file = namespace === "factory" ? "script_factory.cpp" : "script_collection_factory.cpp";
962
+ const prefix = namespace === "factory" ? "FactoryComp" : "CollectionFactoryComp";
963
+ return [
964
+ ...(
965
+ [
966
+ ["get_status", "GetStatus"],
967
+ ["unload", "Unload"],
968
+ ["set_prototype", "SetPrototype"],
969
+ ["load", "Load"],
970
+ ] as const
971
+ ).map(
972
+ ([name, cFunction]) =>
973
+ [
974
+ `${namespace}.${name}:param:url`,
975
+ { nil: false, evidence: `${file}:${prefix}_${cFunction} ${COMPONENT_URL_EVIDENCE}` },
976
+ ] as const,
977
+ ),
978
+ [
979
+ `${namespace}.load:param:complete_function`,
980
+ {
981
+ nil: false,
982
+ evidence: `${file}:${prefix}_Load raises unless argument 2 is a function`,
983
+ },
984
+ ] as const,
985
+ [
986
+ `${namespace}.set_prototype:param:prototype`,
987
+ {
988
+ nil: true,
989
+ evidence: `${file}:${prefix}_SetPrototype reads luaL_checkstring(L, 2) unless lua_isnil(L, 2)`,
990
+ },
991
+ ] as const,
992
+ ];
993
+ }),
994
+ [
995
+ "collectionproxy.set_collection:param:url",
996
+ {
997
+ nil: false,
998
+ evidence: `script_collectionproxy.cpp:CollectionProxy_SetCollection ${COMPONENT_URL_EVIDENCE}`,
999
+ },
1000
+ ],
1001
+ [
1002
+ "collectionproxy.set_collection:param:prototype",
1003
+ {
1004
+ nil: true,
1005
+ evidence:
1006
+ "script_collectionproxy.cpp:CollectionProxy_SetCollection reads luaL_checkstring(L, 2) unless lua_isnil(L, 2)",
1007
+ },
1008
+ ],
1009
+ [
1010
+ "sound.set_gain:param:gain",
1011
+ { nil: false, evidence: "script_sound.cpp:Sound_SetGain reads luaL_checknumber(L, 2)" },
1012
+ ],
1013
+ [
1014
+ "sound.set_pan:param:pan",
1015
+ { nil: false, evidence: "script_sound.cpp:Sound_SetPan reads luaL_checknumber(L, 2)" },
1016
+ ],
1017
+ [
1018
+ "resource.create_buffer:param:table",
1019
+ {
1020
+ nil: false,
1021
+ evidence: "script_resource.cpp:CreateBuffer checks luaL_checktype(L, 2, LUA_TTABLE)",
1022
+ },
1023
+ ],
1024
+ [
1025
+ "resource.create_sound_data:param:options",
1026
+ {
1027
+ nil: false,
1028
+ evidence: "script_resource.cpp:CreateSoundData checks luaL_checktype(L, 2, LUA_TTABLE)",
1029
+ },
1030
+ ],
1031
+ ...(["move_above", "move_below"] as const).map(
1032
+ (name) =>
1033
+ [
1034
+ `gui.${name}:param:reference`,
1035
+ {
1036
+ nil: true,
1037
+ evidence: `gui_script.cpp:Lua${name === "move_above" ? "MoveAbove" : "MoveBelow"} reads a node from argument 2 unless lua_isnil(L, 2)`,
1038
+ },
1039
+ ] as const,
1040
+ ),
1041
+ [
1042
+ "gui.set_parent:param:parent",
1043
+ {
1044
+ nil: true,
1045
+ evidence: "gui_script.cpp:LuaSetParent reads a node from argument 2 unless lua_isnil(L, 2)",
1046
+ },
1047
+ ],
1048
+ [
1049
+ "gui.set:param:value",
1050
+ {
1051
+ nil: true,
1052
+ evidence: "gui_script.cpp:LuaSet converts argument 3 with LuaToVar unless lua_isnil(L, 3)",
1053
+ },
1054
+ ],
1055
+ [
1056
+ "window.set_listener:param:callback",
1057
+ {
1058
+ nil: true,
1059
+ evidence: "script_window.cpp:SetListener checks luaL_checkany(L, 1); nil clears the listener",
1060
+ },
1061
+ ],
1062
+ [
1063
+ "html5.set_interaction_listener:param:callback",
1064
+ {
1065
+ nil: true,
1066
+ evidence:
1067
+ "script_html5_js.cpp:Html5_SetInteractionListener checks luaL_checkany(L, 1); nil clears the listener",
1068
+ },
1069
+ ],
1070
+ ]);
1071
+
1072
+ function requiredSlotCorrection(
1073
+ p: ApiParameter,
1074
+ elementName: string,
1075
+ ): RequiredSlotCorrection | undefined {
1076
+ return isMarkedOptional(p)
1077
+ ? REQUIRED_SLOT_CORRECTIONS.get(tableSlotKey(elementName, "param", p.name))
1078
+ : undefined;
1079
+ }
1080
+
1081
+ // A first slot upstream documents as standing in for a second argument list:
1082
+ // every `b2d.shape` function takes a shape handle "or pass `body, shape_index`".
1083
+ // The element keeps its declared signature and gains an overload with
1084
+ // `parameters` in that slot's place. Keyed `<namespace>:param:<slot>`, so only a
1085
+ // curated entry adds an overload: a reworded doc cannot add or drop one, and the
1086
+ // same prose in another namespace adds nothing.
1087
+ //
1088
+ // `evidence` is the upstream slot-doc fragment that names the alternative. The
1089
+ // emitter throws once a curated slot stops carrying it, so upstream dropping the
1090
+ // alternative reds here rather than shipping an overload nothing documents.
1091
+ export interface FirstSlotAlternative {
1092
+ readonly parameters: readonly ApiParameter[];
1093
+ readonly evidence: string;
1094
+ }
1095
+
1096
+ export const FIRST_SLOT_ALTERNATIVES: ReadonlyMap<string, FirstSlotAlternative> = new Map([
1097
+ [
1098
+ "b2d.shape:param:shape_id",
1099
+ {
1100
+ parameters: [
1101
+ {
1102
+ name: "body",
1103
+ doc: "The body that owns the shape.",
1104
+ types: ["b2Body"],
1105
+ isOptional: false,
1106
+ },
1107
+ {
1108
+ name: "shape_index",
1109
+ doc: "The shape's position on that body, in the order <code>b2d.body.get_shapes</code> lists them.",
1110
+ types: ["number"],
1111
+ isOptional: false,
1112
+ },
1113
+ ],
1114
+ evidence: "or pass <code>body, shape_index</code>",
1115
+ },
1116
+ ],
1117
+ ]);
1118
+
1119
+ // The alternative signature FIRST_SLOT_ALTERNATIVES gives `fn`, or null when
1120
+ // its first slot is not curated. Shared with the fidelity audit so the arity it
1121
+ // measures is the arity the emitter declares.
1122
+ export function firstSlotAlternativeOf(fn: ApiFunction, namespace: string): ApiFunction | null {
1123
+ const [first, ...rest] = fn.parameters;
1124
+ if (first === undefined) return null;
1125
+ const key = `${namespace}:param:${first.name}`;
1126
+ const alternative = FIRST_SLOT_ALTERNATIVES.get(key);
1127
+ if (alternative === undefined) return null;
1128
+ if (!first.doc.includes(alternative.evidence)) {
1129
+ throw new Error(
1130
+ `first-slot alternative ${key}: ${fn.name} no longer documents "${alternative.evidence}"`,
1131
+ );
1132
+ }
1133
+ return { ...fn, parameters: [...alternative.parameters, ...rest] };
1134
+ }
1135
+
1136
+ function firstSlotAlternative(
1137
+ prepared: PreparedFunction,
1138
+ namespace: string,
1139
+ ): PreparedFunction | null {
1140
+ const original = firstSlotAlternativeOf(prepared.original, namespace);
1141
+ return original === null ? null : { ...prepared, original };
1142
+ }
1143
+
386
1144
  export type ConstantSlotResolution =
387
1145
  | { readonly borrow: string }
388
1146
  | { readonly family: string; readonly evidence: string }
@@ -782,9 +1540,17 @@ export function constantUnionAlias(fqns: readonly string[]): ConstantUnionAlias
782
1540
  export function constantUnionAliasDeclarations(namespace: string): string[] {
783
1541
  return CONSTANT_UNION_ALIASES.filter((row) => row.home === namespace)
784
1542
  .sort((a, b) => a.name.localeCompare(b.name))
785
- .map(
786
- (row) => `type ${row.name} = ${row.members.map((member) => `typeof ${member}`).join(" | ")};`,
787
- );
1543
+ .map((row) => unionAliasDeclaration(row.name, row.members));
1544
+ }
1545
+
1546
+ function unionAliasDeclaration(
1547
+ name: string,
1548
+ members: readonly string[],
1549
+ nilable: ReadonlySet<string> = new Set(),
1550
+ ): string {
1551
+ const arm = (member: string) =>
1552
+ nilable.has(member) ? `NonNullable<typeof ${member}>` : `typeof ${member}`;
1553
+ return `type ${name} = ${members.map(arm).join(" | ")};`;
788
1554
  }
789
1555
 
790
1556
  const DOC_CONSTANT_TOKEN =
@@ -934,7 +1700,11 @@ export const HOMOGENEOUS_ARRAY_SLOTS: ReadonlyMap<string, string | readonly stri
934
1700
  // (`tilemap.get_tiles` → `tiles[row][col]`).
935
1701
  export type NestedMapping = { key: string; value: string };
936
1702
 
937
- export type TableSlotCuration =
1703
+ // A curation applies where the slot declares the prose `table` token, or a later
1704
+ // spelling a newer release retypes the same slot to without fixing it
1705
+ // (`retypedUpstream`, matched whole): 1.13.2's `table<hash, table<string|hash, any>>`
1706
+ // for `collectionfactory.create`'s properties.
1707
+ export type TableSlotCuration = (
938
1708
  | { kind: "mapping"; key: string; value: string | readonly TableField[] | NestedMapping }
939
1709
  | { kind: "array"; element: string | readonly string[] }
940
1710
  | { kind: "object"; fields: readonly TableField[] }
@@ -943,7 +1713,11 @@ export type TableSlotCuration =
943
1713
  // keyed by an arbitrary name (constant/sampler/attribute). The parser recovers
944
1714
  // the inner args fields; emit wraps them as `Record<string, { <fields> }>`,
945
1715
  // restoring the keyed-by-name layer the flat object curation drops.
946
- | { kind: "keyed-object" };
1716
+ | { kind: "keyed-object" }
1717
+ // A slot whose faithful shape no field list can carry, such as a union
1718
+ // discriminated on `type`. `ts` is emitted verbatim, already mapped.
1719
+ | { kind: "verbatim"; ts: string }
1720
+ ) & { readonly retypedUpstream?: readonly string[] };
947
1721
 
948
1722
  export const SOCKET_HANDLE_TOKENS = ["client", "master", "unconnected"] as const;
949
1723
 
@@ -952,8 +1726,10 @@ export const SOCKET_HANDLE_TOKENS = ["client", "master", "unconnected"] as const
952
1726
  // b2d submodule curations below so a single shape is defined once.
953
1727
  //
954
1728
  // The shape table is a discriminated union of circle/edge/polygon/box/chain
955
- // forms keyed by `type` (a b2d.shape.SHAPE_TYPE_* constant); every kind-specific
956
- // field is optional because only one form's fields are present per value.
1729
+ // forms, plus the Box2D v3 capsule, keyed by `type` (a b2d.shape.SHAPE_TYPE_*
1730
+ // constant); every kind-specific field is optional because only one form's
1731
+ // fields are present per value. The capsule's `center1`/`center2` come from
1732
+ // script_box2d_shape_v3.cpp, which reads them; the ref-doc prose never names them.
957
1733
  const B2D_SHAPE_TABLE_FIELDS: readonly TableField[] = [
958
1734
  { name: "type", types: ["number"] },
959
1735
  { name: "radius", types: ["number"], optional: true },
@@ -969,7 +1745,56 @@ const B2D_SHAPE_TABLE_FIELDS: readonly TableField[] = [
969
1745
  { name: "loop", types: ["boolean"], optional: true },
970
1746
  { name: "prev_vertex", types: ["vector3"], optional: true },
971
1747
  { name: "next_vertex", types: ["vector3"], optional: true },
1748
+ { name: "center1", types: ["vector3"], optional: true },
1749
+ { name: "center2", types: ["vector3"], optional: true },
972
1750
  ];
1751
+ // The shape table a Box2D binding reads, one arm per `type`. CheckShapeDef
1752
+ // (v2/script_box2d_shape_v2.cpp, v3/script_box2d_shape_v3.cpp) raises without
1753
+ // `type` and without the fields that type reads with luaL_checknumber or
1754
+ // CheckVec2; the rest go through TryGet*Field. A polygon takes `hx`/`hy` or
1755
+ // `vertices`. `SHAPE_TYPE_BOX` is the polygon value and `SHAPE_TYPE_EDGE` the v3
1756
+ // segment value, so each shares its arm. Each backend raises "Unsupported shape
1757
+ // type" on the kinds it does not build: `create_fixture` and `fixture.set_shape`
1758
+ // are registered only by v2/script_box2d_*_v2.cpp and take B2D_V2_SHAPE_TS (no
1759
+ // capsule, and an edge typed `SHAPE_TYPE_EDGE` with optional ghost vertices);
1760
+ // `create_shape` and `shape.set_shape` are registered only by
1761
+ // v3/script_box2d_*_v3.cpp and take B2D_V3_SHAPE_TS (no chain, and a segment
1762
+ // that reads no ghost vertices). `world.cast_shape` and `world.overlap_shape`
1763
+ // are registered by both, so they keep the full union.
1764
+ // The box arm leads: a v2 fixture keeps its shape type, and the runtime probe
1765
+ // witnesses a union by its first arm against a box collision shape.
1766
+ const B2D_SHAPE = "typeof b2d.shape";
1767
+ const B2D_BOX_ARM = `{ type: ${B2D_SHAPE}.SHAPE_TYPE_POLYGON | ${B2D_SHAPE}.SHAPE_TYPE_BOX; hx: number; hy: number; center?: Vector3; angle?: number }`;
1768
+ const B2D_POLYGON_ARM = `{ type: ${B2D_SHAPE}.SHAPE_TYPE_POLYGON | ${B2D_SHAPE}.SHAPE_TYPE_BOX; vertices: Vector3[] }`;
1769
+ const B2D_CIRCLE_ARM = `{ type: ${B2D_SHAPE}.SHAPE_TYPE_CIRCLE; radius: number; center?: Vector3 }`;
1770
+ const B2D_CAPSULE_ARM = `{ type: ${B2D_SHAPE}.SHAPE_TYPE_CAPSULE; radius: number; center1: Vector3; center2: Vector3 }`;
1771
+ const B2D_EDGE_ARM = `{ type: ${B2D_SHAPE}.SHAPE_TYPE_EDGE | ${B2D_SHAPE}.SHAPE_TYPE_SEGMENT; v1: Vector3; v2: Vector3; v0?: Vector3; v3?: Vector3 }`;
1772
+ const B2D_V2_EDGE_ARM = `{ type: ${B2D_SHAPE}.SHAPE_TYPE_EDGE; v1: Vector3; v2: Vector3; v0?: Vector3; v3?: Vector3 }`;
1773
+ const B2D_V3_SEGMENT_ARM = `{ type: ${B2D_SHAPE}.SHAPE_TYPE_EDGE | ${B2D_SHAPE}.SHAPE_TYPE_SEGMENT; v1: Vector3; v2: Vector3 }`;
1774
+ const B2D_CHAIN_ARM = `{ type: ${B2D_SHAPE}.SHAPE_TYPE_CHAIN; vertices: Vector3[]; loop?: boolean; prev_vertex?: Vector3; next_vertex?: Vector3 }`;
1775
+ const B2D_SHAPE_DEFINITION_TS = [
1776
+ B2D_BOX_ARM,
1777
+ B2D_POLYGON_ARM,
1778
+ B2D_CIRCLE_ARM,
1779
+ B2D_CAPSULE_ARM,
1780
+ B2D_EDGE_ARM,
1781
+ B2D_CHAIN_ARM,
1782
+ ].join(" | ");
1783
+ const B2D_V2_SHAPE_TS = [
1784
+ B2D_BOX_ARM,
1785
+ B2D_POLYGON_ARM,
1786
+ B2D_CIRCLE_ARM,
1787
+ B2D_V2_EDGE_ARM,
1788
+ B2D_CHAIN_ARM,
1789
+ ].join(" | ");
1790
+ const B2D_V3_SHAPE_TS = [
1791
+ B2D_BOX_ARM,
1792
+ B2D_POLYGON_ARM,
1793
+ B2D_CIRCLE_ARM,
1794
+ B2D_CAPSULE_ARM,
1795
+ B2D_V3_SEGMENT_ARM,
1796
+ ].join(" | ");
1797
+ const B2D_SHAPE_DEFINITION: TableSlotCuration = { kind: "verbatim", ts: B2D_SHAPE_DEFINITION_TS };
973
1798
  // A fixture info entry (index/type/sensor/density/friction/restitution/child_count).
974
1799
  const B2D_FIXTURE_INFO_FIELDS: readonly TableField[] = [
975
1800
  { name: "index", types: ["number"] },
@@ -1045,8 +1870,64 @@ const PHYSICS_SHAPE_TABLE_FIELDS: readonly TableField[] = [
1045
1870
  { name: "dimensions", types: ["vector3"], optional: true },
1046
1871
  { name: "height", types: ["number"], optional: true },
1047
1872
  ];
1048
-
1049
- export const TABLE_SLOT_CURATIONS: ReadonlyMap<string, TableSlotCuration> = new Map([
1873
+ // The same record as `physics.set_shape` takes it: script_physics.cpp:
1874
+ // Physics_SetShape reads `type` and then each field its kind needs with
1875
+ // luaL_checknumber or CheckVector3, so each kind is its own arm.
1876
+ const PHYSICS_SHAPE_DEFINITION_TS = [
1877
+ "{ type: typeof physics.SHAPE_TYPE_SPHERE; diameter: number }",
1878
+ "{ type: typeof physics.SHAPE_TYPE_BOX; dimensions: Vector3 }",
1879
+ "{ type: typeof physics.SHAPE_TYPE_CAPSULE; diameter: number; height: number }",
1880
+ ].join(" | ");
1881
+
1882
+ export const TABLE_SLOT_CURATIONS: ReadonlyMap<string, TableSlotCuration> = new Map<
1883
+ string,
1884
+ TableSlotCuration
1885
+ >([
1886
+ // gui_script.cpp:LuaGet and LuaSet read the `key` and `keys` options. On a
1887
+ // prose `table` slot TABLE_SLOT_FIELD_ADDITIONS supplies them; 1.13.2 types the
1888
+ // slots as an inline record and a struct that omit them.
1889
+ ...(
1890
+ [
1891
+ ["gui.get:param:options", "{ index?:integer }"],
1892
+ ["gui.set:param:options", "gui.set_options"],
1893
+ ] as const
1894
+ ).map(
1895
+ ([key, retyped]) =>
1896
+ [
1897
+ key,
1898
+ {
1899
+ kind: "verbatim",
1900
+ ts: "{ index?: number; key?: string | Hash; keys?: (Hash | string)[] }",
1901
+ retypedUpstream: [retyped],
1902
+ },
1903
+ ] as const,
1904
+ ),
1905
+ // buffer.create's `declaration` is "a table where each entry (table) describes
1906
+ // a stream", and upstream's example passes a list of stream records whose
1907
+ // `type` is a buffer.VALUE_TYPE_* constant.
1908
+ [
1909
+ "buffer.create:param:declaration",
1910
+ {
1911
+ kind: "array-object",
1912
+ fields: [
1913
+ { name: "name", types: ["hash", "string"] },
1914
+ { name: "type", types: ["constant"], tsType: "buffer.ValueType" },
1915
+ { name: "count", types: ["number"] },
1916
+ ],
1917
+ },
1918
+ ],
1919
+ // collectionfactory.create's `properties` is keyed by "game object ids (hash)",
1920
+ // which upstream's example builds with `hash("/go")`. The `string` key keeps
1921
+ // the object-literal form this slot accepted as a bare `table`.
1922
+ [
1923
+ "collectionfactory.create:param:properties",
1924
+ {
1925
+ kind: "mapping",
1926
+ key: "hash | string",
1927
+ value: "table",
1928
+ retypedUpstream: ["table<hash, table<string|hash, any>>"],
1929
+ },
1930
+ ],
1050
1931
  ["collectionfactory.create:return:ids", { kind: "mapping", key: "hash", value: "hash" }],
1051
1932
  // font.get_info's `info` return is a `<dl>` with `path: hash` and a nested
1052
1933
  // `fonts` field whose `<dd>` is itself a `<dl>` (`path: string`, `path_hash:
@@ -1176,8 +2057,22 @@ export const TABLE_SLOT_CURATIONS: ReadonlyMap<string, TableSlotCuration> = new
1176
2057
  ],
1177
2058
  ["socket.select:param:recvt", { kind: "array", element: SOCKET_HANDLE_TOKENS }],
1178
2059
  ["socket.select:param:sendt", { kind: "array", element: SOCKET_HANDLE_TOKENS }],
1179
- ["socket.select:return:sockets_r", { kind: "array", element: SOCKET_HANDLE_TOKENS }],
1180
- ["socket.select:return:sockets_w", { kind: "array", element: SOCKET_HANDLE_TOKENS }],
2060
+ [
2061
+ "socket.select:return:sockets_r",
2062
+ {
2063
+ kind: "array",
2064
+ element: SOCKET_HANDLE_TOKENS,
2065
+ retypedUpstream: ["table<integer|socket_selectable, socket_selectable|integer>"],
2066
+ },
2067
+ ],
2068
+ [
2069
+ "socket.select:return:sockets_w",
2070
+ {
2071
+ kind: "array",
2072
+ element: SOCKET_HANDLE_TOKENS,
2073
+ retypedUpstream: ["table<integer|socket_selectable, socket_selectable|integer>"],
2074
+ },
2075
+ ],
1181
2076
  [
1182
2077
  "tilemap.get_tile_info:return:tile_info",
1183
2078
  {
@@ -1269,8 +2164,10 @@ export const TABLE_SLOT_CURATIONS: ReadonlyMap<string, TableSlotCuration> = new
1269
2164
  // `headers`; the sibling `options` table stays parser-recovered.
1270
2165
  ["http.request:param:headers", { kind: "mapping", key: "string", value: "string" }],
1271
2166
  // collectionproxy.get_resources returns "the resources, or an empty list", a
1272
- // homogeneous list of resource-path hashes (prose only, no field list).
1273
- ["collectionproxy.get_resources:return:resources", { kind: "array", element: "hash" }],
2167
+ // homogeneous list of resource paths (prose only, no field list), each a
2168
+ // hexadecimal string: script_collectionproxy.cpp:GetResourceHashCallback pushes
2169
+ // every entry with lua_pushlstring.
2170
+ ["collectionproxy.get_resources:return:resources", { kind: "array", element: "string" }],
1274
2171
  // render.predicate's `tags` is "table of tags ... can be of either hash or
1275
2172
  // string type", a homogeneous array whose element is the string|hash union.
1276
2173
  ["render.predicate:param:tags", { kind: "array", element: ["string", "hash"] }],
@@ -1283,6 +2180,7 @@ export const TABLE_SLOT_CURATIONS: ReadonlyMap<string, TableSlotCuration> = new
1283
2180
  [
1284
2181
  "render.clear:param:buffers",
1285
2182
  {
2183
+ retypedUpstream: ["table<graphics.BUFFER_TYPE, number|vector4>"],
1286
2184
  kind: "mapping",
1287
2185
  key: "graphics.BUFFER_TYPE_COLOR0_BIT | graphics.BUFFER_TYPE_DEPTH_BIT | graphics.BUFFER_TYPE_STENCIL_BIT",
1288
2186
  value: "number | vector4",
@@ -1474,14 +2372,63 @@ export const TABLE_SLOT_CURATIONS: ReadonlyMap<string, TableSlotCuration> = new
1474
2372
  [
1475
2373
  "b2d.body.create_fixture:param:definition",
1476
2374
  {
2375
+ retypedUpstream: ["b2d.fixture_definition"],
1477
2376
  kind: "object",
1478
2377
  fields: [
1479
- { name: "shape", types: ["table"], fields: [...B2D_SHAPE_TABLE_FIELDS] },
2378
+ { name: "shape", types: ["table"], tsType: B2D_V2_SHAPE_TS },
1480
2379
  { name: "friction", types: ["number"] },
1481
2380
  { name: "restitution", types: ["number"] },
1482
2381
  { name: "density", types: ["number"] },
1483
2382
  { name: "sensor", types: ["boolean"] },
2383
+ // v2/script_box2d_fixture_v2.cpp:CheckFixtureDef reads is_sensor when
2384
+ // sensor is absent.
2385
+ { name: "is_sensor", types: ["boolean"] },
2386
+ { name: "filter", types: ["table"], fields: [...B2D_FILTER_DATA_FIELDS] },
2387
+ ],
2388
+ },
2389
+ ],
2390
+ // The creation-time definitions the v3 bindings document in their C++ doc
2391
+ // comments rather than the ref-doc: Body_CreateChain
2392
+ // (v3/script_box2d_chain_v3.cpp), CheckShapeCreateDef
2393
+ // (v3/script_box2d_shape_v3.cpp) and World_Explode
2394
+ // (v3/script_box2d_world_v3.cpp).
2395
+ [
2396
+ "b2d.body.create_chain:param:definition",
2397
+ {
2398
+ kind: "object",
2399
+ fields: [
2400
+ { name: "vertices", types: ["table"], tsType: "Vector3[]" },
2401
+ { name: "loop", types: ["boolean"] },
2402
+ { name: "prev_vertex", types: ["vector3"] },
2403
+ { name: "next_vertex", types: ["vector3"] },
2404
+ { name: "friction", types: ["number"] },
2405
+ { name: "restitution", types: ["number"] },
2406
+ { name: "material", types: ["number"] },
1484
2407
  { name: "filter", types: ["table"], fields: [...B2D_FILTER_DATA_FIELDS] },
2408
+ { name: "enable_sensor_events", types: ["boolean"] },
2409
+ ],
2410
+ },
2411
+ ],
2412
+ // The shape table sits in `definition.shape` or is the definition itself, and
2413
+ // a filter, when given, needs all three members CheckFilterData reads.
2414
+ [
2415
+ "b2d.body.create_shape:param:definition",
2416
+ {
2417
+ retypedUpstream: ["b2d.shape_create_definition"],
2418
+ kind: "verbatim",
2419
+ ts: `({ shape: ${B2D_V3_SHAPE_TS} } | ${B2D_V3_SHAPE_TS}) & { density?: number; friction?: number; restitution?: number; material?: number; sensor?: boolean; is_sensor?: boolean; filter?: { category_bits: number; mask_bits: number; group_index: number } }`,
2420
+ },
2421
+ ],
2422
+ [
2423
+ "b2d.world.explode:param:definition",
2424
+ {
2425
+ kind: "object",
2426
+ fields: [
2427
+ { name: "position", types: ["vector3"] },
2428
+ { name: "radius", types: ["number"] },
2429
+ { name: "falloff", types: ["number"] },
2430
+ { name: "impulse_per_length", types: ["number"] },
2431
+ { name: "mask_bits", types: ["number"] },
1485
2432
  ],
1486
2433
  },
1487
2434
  ],
@@ -1519,10 +2466,16 @@ export const TABLE_SLOT_CURATIONS: ReadonlyMap<string, TableSlotCuration> = new
1519
2466
  ["b2d.fixture.get_filter_data:return:filter", { kind: "object", fields: B2D_FILTER_DATA_FIELDS }],
1520
2467
  ["b2d.fixture.set_filter_data:param:filter", { kind: "object", fields: B2D_FILTER_DATA_FIELDS }],
1521
2468
  ["b2d.fixture.get_shape:return:shape", { kind: "object", fields: B2D_SHAPE_TABLE_FIELDS }],
1522
- ["b2d.fixture.set_shape:param:shape", { kind: "object", fields: B2D_SHAPE_TABLE_FIELDS }],
2469
+ [
2470
+ "b2d.fixture.set_shape:param:shape",
2471
+ { kind: "verbatim", ts: B2D_V2_SHAPE_TS, retypedUpstream: ["b2d.shape.definition"] },
2472
+ ],
1523
2473
  // b2d.shape
1524
2474
  ["b2d.shape.get_shape:return:shape", { kind: "object", fields: B2D_SHAPE_TABLE_FIELDS }],
1525
- ["b2d.shape.set_shape:param:definition", { kind: "object", fields: B2D_SHAPE_TABLE_FIELDS }],
2475
+ [
2476
+ "b2d.shape.set_shape:param:definition",
2477
+ { kind: "verbatim", ts: B2D_V3_SHAPE_TS, retypedUpstream: ["b2d.shape.definition"] },
2478
+ ],
1526
2479
  ["b2d.shape.get_mass_data:return:data", { kind: "object", fields: B2D_MASS_DATA_FIELDS }],
1527
2480
  [
1528
2481
  "b2d.shape.get_sensor_overlaps:return:overlaps",
@@ -1705,7 +2658,10 @@ export const TABLE_SLOT_CURATIONS: ReadonlyMap<string, TableSlotCuration> = new
1705
2658
  ],
1706
2659
  ["b2d.world.overlap_aabb:return:hits", { kind: "array-object", fields: B2D_SHAPE_INFO_FIELDS }],
1707
2660
  ["b2d.world.overlap_aabb:return:stats", { kind: "object", fields: B2D_QUERY_STATS_FIELDS }],
1708
- ["b2d.world.overlap_shape:param:shape", { kind: "object", fields: B2D_SHAPE_TABLE_FIELDS }],
2661
+ [
2662
+ "b2d.world.overlap_shape:param:shape",
2663
+ { ...B2D_SHAPE_DEFINITION, retypedUpstream: ["b2d.shape.definition"] },
2664
+ ],
1709
2665
  ["b2d.world.overlap_shape:param:filter", { kind: "object", fields: B2D_QUERY_FILTER_FIELDS }],
1710
2666
  [
1711
2667
  "b2d.world.overlap_shape:return:fixtures",
@@ -1732,7 +2688,10 @@ export const TABLE_SLOT_CURATIONS: ReadonlyMap<string, TableSlotCuration> = new
1732
2688
  ],
1733
2689
  },
1734
2690
  ],
1735
- ["b2d.world.cast_shape:param:shape", { kind: "object", fields: B2D_SHAPE_TABLE_FIELDS }],
2691
+ [
2692
+ "b2d.world.cast_shape:param:shape",
2693
+ { ...B2D_SHAPE_DEFINITION, retypedUpstream: ["b2d.shape.definition"] },
2694
+ ],
1736
2695
  ["b2d.world.cast_shape:param:filter", { kind: "object", fields: B2D_QUERY_FILTER_FIELDS }],
1737
2696
  ["b2d.world.cast_shape:return:hits", { kind: "array-object", fields: B2D_CAST_HIT_FIELDS }],
1738
2697
  ["b2d.world.cast_shape:return:stats", { kind: "object", fields: B2D_QUERY_STATS_FIELDS }],
@@ -1741,21 +2700,45 @@ export const TABLE_SLOT_CURATIONS: ReadonlyMap<string, TableSlotCuration> = new
1741
2700
  ["b2d.world.collide_mover:param:capsule", { kind: "object", fields: B2D_CAPSULE_FIELDS }],
1742
2701
  ["b2d.world.collide_mover:param:filter", { kind: "object", fields: B2D_MOVER_FILTER_FIELDS }],
1743
2702
  ["physics.get_shape:return:table", { kind: "object", fields: PHYSICS_SHAPE_TABLE_FIELDS }],
1744
- ["physics.set_shape:param:table", { kind: "object", fields: PHYSICS_SHAPE_TABLE_FIELDS }],
2703
+ ["physics.set_shape:param:table", { kind: "verbatim", ts: PHYSICS_SHAPE_DEFINITION_TS }],
1745
2704
  ]);
1746
2705
 
1747
2706
  // Slot-keyed (`element:param:name`, mirroring TABLE_SLOT_CURATIONS) replacements
1748
2707
  // for a callback parameter's recovered signature, used where the generic
1749
2708
  // `recoverCallbackSignature` `unknown`-everywhere form leaves real engine type
1750
2709
  // information on the table. The value is the full emitted function type. Honest
1751
- // only: `window.set_listener` discriminates `event` by a known constant union and
2710
+ // only: `window.set_listener` discriminates `event` by the union of the
2711
+ // `WINDOW_EVENT_*` constants the surface's own `window` namespace declares, and
1752
2712
  // its `data` is a bare record (only resize carries fields — `isWindowEvent` is the
1753
2713
  // path to typed `data`), mirroring the `msg.post` (send, typed) / `isMessage`
1754
2714
  // (receive, narrow) split.
2715
+ //
2716
+ // The other entries type what the slot's own prose lists and nothing more.
2717
+ // `http.request`'s response fields are the `<ul>` under "The response data.
2718
+ // Contains the fields:"; each is conditional ("if not saved on disc", "only if
2719
+ // option report_progress is true") except `status`, and `headers` is the same
2720
+ // string map the request sends. `sprite.play_flipbook`'s message is the
2721
+ // "Information about the completion" list. `collectionproxy.load` documents
2722
+ // only that the callback receives `proxy_loading`, `proxy_ready` or
2723
+ // `proxy_error` messages and lists no fields, so its message is the bare record
2724
+ // `window.set_listener`'s `data` is.
1755
2725
  export const CALLBACK_SIGNATURE_CURATIONS: ReadonlyMap<string, string> = new Map([
1756
2726
  [
1757
2727
  "window.set_listener:param:callback",
1758
- "(self: unknown, event: typeof WINDOW_EVENT_FOCUS_LOST | typeof WINDOW_EVENT_FOCUS_GAINED | typeof WINDOW_EVENT_RESIZED | typeof WINDOW_EVENT_ICONFIED | typeof WINDOW_EVENT_DEICONIFIED, data: Record<string | number, unknown>) => void",
2728
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: emitted verbatim as a TS template literal type
2729
+ "(self: unknown, event: (typeof window)[Extract<keyof typeof window, `WINDOW_EVENT_${string}`>], data: Record<string | number, unknown>) => void",
2730
+ ],
2731
+ [
2732
+ "http.request:param:callback",
2733
+ "(self: unknown, id: Hash, response: { status: number; response?: string; headers?: LuaMap<string, string>; path?: string; error?: string; bytes_received?: number; bytes_total?: number; range_start?: number; range_end?: number; document_size?: number }) => void",
2734
+ ],
2735
+ [
2736
+ "sprite.play_flipbook:param:complete_function",
2737
+ "(self: unknown, message_id: Hash, message: { current_tile: number; id: Hash }, sender: Url) => void",
2738
+ ],
2739
+ [
2740
+ "collectionproxy.load:param:callback",
2741
+ "(self: unknown, message_id: Hash, message: Record<string | number, unknown>, sender: Url) => void",
1759
2742
  ],
1760
2743
  ]);
1761
2744
 
@@ -1768,9 +2751,11 @@ export const CALLBACK_SIGNATURE_CURATIONS: ReadonlyMap<string, string> = new Map
1768
2751
  // hand-curated. Each geometry is the triangle data (the create-input form
1769
2752
  // get_atlas mirrors via its cross-ref), and `vertices`/`uvs`/`indices` are
1770
2753
  // brace-form number-lists, hence `numberList: true` (emitted `number[]`).
1771
- // Injected onto the already-parsed field list (every sibling stays
1772
- // parser-authoritative), so this is a nested-field curation, not a whole-slot
1773
- // object curation.
2754
+ // resource.create_atlas documents a geometry's `id`/`width`/`height`/pivot
2755
+ // members, so the three arrays join those: script_resource.cpp reads them from
2756
+ // each `geometries` entry. Injected onto the already-parsed field list (every
2757
+ // sibling stays parser-authoritative), so this is a nested-field curation, not a
2758
+ // whole-slot object curation.
1774
2759
  const ATLAS_GEOMETRY_MEMBERS: readonly TableField[] = [
1775
2760
  { name: "vertices", types: ["table"], numberList: true },
1776
2761
  { name: "uvs", types: ["table"], numberList: true },
@@ -1779,6 +2764,7 @@ const ATLAS_GEOMETRY_MEMBERS: readonly TableField[] = [
1779
2764
 
1780
2765
  export const NESTED_FIELD_CURATIONS: ReadonlyMap<string, readonly TableField[]> = new Map([
1781
2766
  ["resource.set_atlas:param:table:geometries", ATLAS_GEOMETRY_MEMBERS],
2767
+ ["resource.create_atlas:param:table:geometries", ATLAS_GEOMETRY_MEMBERS],
1782
2768
  ["resource.get_atlas:return:data:geometries", ATLAS_GEOMETRY_MEMBERS],
1783
2769
  ]);
1784
2770
 
@@ -1789,7 +2775,22 @@ export const NESTED_FIELD_CURATIONS: ReadonlyMap<string, readonly TableField[]>
1789
2775
  // parser recovers every other field faithfully but leaves that `table` token as
1790
2776
  // a bare `Record`; this pins only the `value` field's TS type to the documented
1791
2777
  // array union while every sibling field stays parser-authoritative. Keyed by
1792
- // `<element>:<kind>:<slot>:<field>`, mirroring NESTED_FIELD_CURATIONS.
2778
+ // `<element>:<kind>:<slot>:<field>`, mirroring NESTED_FIELD_CURATIONS; a field
2779
+ // one level down is `<field>.<member>`, or `<field>[].<member>` inside a list.
2780
+ //
2781
+ // The atlas animation `playback` field is documented as `constant` with the
2782
+ // prose "the default value is go.PLAYBACK_ONCE_FORWARD", which names the enum
2783
+ // the bare token cannot.
2784
+ //
2785
+ // `gui.set`'s `options.key` is documented as `hash`, while upstream's own
2786
+ // example sets a named font with `{ key = "my_font_name" }`.
2787
+ //
2788
+ // `render.set_render_target`'s `options.transient` is documented as `table`
2789
+ // with the prose "a list of buffer types", and upstream's example passes
2790
+ // graphics.BUFFER_TYPE_* constants.
2791
+ //
2792
+ // `resource.create_sound_data`'s `options.data` is documented as `string`, while
2793
+ // script_resource.cpp:CreateSoundData reads it with CheckBufferOrString.
1793
2794
  const CONSTANT_VALUE_TS = "Vector4 | Vector3 | Matrix4 | number | (Vector4 | Matrix4)[]";
1794
2795
  const ATTRIBUTE_VALUE_TS = "Vector4 | Vector3 | Matrix4 | number | number[]";
1795
2796
  export const TABLE_FIELD_TYPE_OVERRIDES: ReadonlyMap<string, string> = new Map([
@@ -1797,6 +2798,28 @@ export const TABLE_FIELD_TYPE_OVERRIDES: ReadonlyMap<string, string> = new Map([
1797
2798
  ["material.set_constants:param:constants:value", CONSTANT_VALUE_TS],
1798
2799
  ["material.set_vertex_attributes:param:attributes:value", ATTRIBUTE_VALUE_TS],
1799
2800
  ["material.get_vertex_attributes:return:table:value", ATTRIBUTE_VALUE_TS],
2801
+ ["resource.set_atlas:param:table:animations[].playback", "go.Playback"],
2802
+ ["resource.create_atlas:param:table:animations[].playback", "go.Playback"],
2803
+ ["resource.get_atlas:return:data:animations[].playback", "go.Playback"],
2804
+ ["gui.set:param:options:key", "string | Hash"],
2805
+ ["render.set_render_target:param:options:transient", "graphics.BufferType[]"],
2806
+ ["resource.create_sound_data:param:options:data", 'string | Opaque<"buffer">'],
2807
+ // The same two slots where 1.13.2 types the option bag as a STRUCT, whose
2808
+ // members still carry the narrower upstream types.
2809
+ ["gui.set_options:member:key", "string | Hash"],
2810
+ ["resource.sound_data_options:member:data", 'string | Opaque<"buffer">'],
2811
+ // Bitmasks typed as their flag enum: the create_texture example passes
2812
+ // TEXTURE_USAGE_FLAG_STORAGE + TEXTURE_USAGE_FLAG_SAMPLE, a sum no single member
2813
+ // names, and a reported value combines them the same way.
2814
+ ...(
2815
+ [
2816
+ "resource.texture_creation_params",
2817
+ "resource.texture_info",
2818
+ "resource.render_target_attachment_info",
2819
+ "material.texture_info",
2820
+ ] as const
2821
+ ).map((struct) => [`${struct}:member:flags`, "graphics.TEXTURE_USAGE_FLAG | number"] as const),
2822
+ ["render.render_target_buffer_params:member:flags", "render.RENDER_TARGET_FLAG | number"],
1800
2823
  ]);
1801
2824
 
1802
2825
  // A field the engine accepts on an option bag whose `<dl>` upstream otherwise
@@ -1805,27 +2828,56 @@ export const TABLE_FIELD_TYPE_OVERRIDES: ReadonlyMap<string, string> = new Map([
1805
2828
  // upstream omits, leaving the documented fields parser-authoritative so an
1806
2829
  // upstream edit to any of them lands without a hand-maintained restatement.
1807
2830
  //
1808
- // `evidence` pins where upstream itself shows the field. applyFieldAdditions
1809
- // throws once the parse recovers it, so upstream documenting the field reds
1810
- // here and the entry is deleted rather than re-pinned.
2831
+ // `evidence` pins where upstream itself shows each field, or the engine binding
2832
+ // that reads it. applyFieldAdditions throws once the parse recovers one, so
2833
+ // upstream documenting a field reds here and the entry is deleted rather than
2834
+ // re-pinned.
1811
2835
  //
1812
2836
  // Keyed `<element>:<kind>:<slot>`, the TABLE_SLOT_CURATIONS shape.
1813
2837
  export interface TableFieldAddition {
1814
- readonly field: TableField;
2838
+ readonly fields: readonly TableField[];
1815
2839
  readonly evidence: readonly string[];
1816
2840
  }
1817
2841
 
1818
- export const TABLE_SLOT_FIELD_ADDITIONS: ReadonlyMap<string, TableFieldAddition> = new Map([
2842
+ const PROPERTY_OPTION_KEYS: TableField = {
2843
+ name: "keys",
2844
+ types: ["table"],
2845
+ optional: true,
2846
+ tsType: "(Hash | string)[]",
2847
+ };
2848
+
2849
+ export const TABLE_SLOT_FIELD_ADDITIONS: ReadonlyMap<string, TableFieldAddition> = new Map<
2850
+ string,
2851
+ TableFieldAddition
2852
+ >([
1819
2853
  [
1820
2854
  "resource.create_texture:param:table",
1821
2855
  {
1822
- field: { name: "page_count", types: ["number"], optional: true },
2856
+ fields: [{ name: "page_count", types: ["number"], optional: true }],
1823
2857
  evidence: [
1824
2858
  "the array-texture example on this element creates with `page_count = 5`",
1825
2859
  "resource.get_texture_info documents `page_count` as the texture array's page count",
1826
2860
  ],
1827
2861
  },
1828
2862
  ],
2863
+ [
2864
+ "gui.get:param:options",
2865
+ {
2866
+ fields: [{ name: "key", types: ["string", "hash"], optional: true }, PROPERTY_OPTION_KEYS],
2867
+ evidence: [
2868
+ "gui_script.cpp:LuaGet reads its options through dmGameObject::LuaToPropertyOptions, which reads index, key and keys",
2869
+ ],
2870
+ },
2871
+ ],
2872
+ [
2873
+ "gui.set:param:options",
2874
+ {
2875
+ fields: [PROPERTY_OPTION_KEYS],
2876
+ evidence: [
2877
+ "gui_script.cpp:LuaSet reads its options through dmGameObject::LuaToPropertyOptions, which reads index, key and keys",
2878
+ ],
2879
+ },
2880
+ ],
1829
2881
  ]);
1830
2882
 
1831
2883
  // A return-slot field upstream documents as platform-gated or nil-valued while
@@ -1917,6 +2969,247 @@ export function applyFieldOptionalityCorrections(
1917
2969
  return corrected;
1918
2970
  }
1919
2971
 
2972
+ // A param-side table field the engine binding refuses to go without. A `<dl>`
2973
+ // carries no per-field optionality, so every field of an input option bag is
2974
+ // emitted `?`; an entry here keeps one field required. Keyed
2975
+ // `<element>:param:<slot>:<field>`, the TABLE_FIELD_TYPE_OVERRIDES shape, with
2976
+ // `<field>.<member>` or `<field>[].<member>` one level down. A `STRUCT` member
2977
+ // is keyed `<struct>:member:<field>`, for a target whose slot names that struct
2978
+ // (`field-correction-coverage.ts` pairs the two). The value is the evidence: the
2979
+ // C++ binding, `<file>:<cFunction>`, and how it reads the field.
2980
+ const CHECK_SHAPE_DEF = "CheckShapeDef reads the shape table with luaL_checkinteger(type)";
2981
+ const V2_FILTER_DATA =
2982
+ "v2/script_box2d_fixture_v2.cpp:CheckFilterData reads each with luaL_checkinteger";
2983
+ const TEXTURE_PARAMS =
2984
+ "script_resource.cpp:CheckCreateTextureResourceParams reads CheckTableInteger";
2985
+ const ATLAS_ARGUMENTS =
2986
+ "script_resource.cpp:CheckAtlasArguments raises unless the atlas has at least one geometry and one animation";
2987
+
2988
+ const filterDataFields = (slot: string, evidence: string) =>
2989
+ (["category_bits", "mask_bits", "group_index"] as const).map(
2990
+ (field) => [`${slot}:filter.${field}`, evidence] as const,
2991
+ );
2992
+
2993
+ // A `STRUCT` member upstream marks required while the engine binding reads it
2994
+ // only when present, keyed `<struct>:member:<field>` like REQUIRED_FIELD_CORRECTIONS
2995
+ // and valued with the same binding evidence.
2996
+ export const OPTIONAL_MEMBER_CORRECTIONS: ReadonlyMap<string, string> = new Map([
2997
+ [
2998
+ "sound.stop_properties:member:play_id",
2999
+ "script_sound.cpp:Sound_Stop reads play_id with luaL_checknumber only when it is not nil",
3000
+ ],
3001
+ ]);
3002
+
3003
+ export const REQUIRED_FIELD_CORRECTIONS: ReadonlyMap<string, string> = new Map<string, string>([
3004
+ ...(["mass", "center", "inertia"] as const).map(
3005
+ (field) =>
3006
+ [
3007
+ `b2d.body.set_mass_data:param:data:${field}`,
3008
+ "script_box2d_body_v2/v3.cpp:CheckMassData reads mass and inertia with luaL_checknumber and center with CheckVec2",
3009
+ ] as const,
3010
+ ),
3011
+ ...(["category_bits", "mask_bits", "group_index"] as const).map(
3012
+ (field) => [`b2d.fixture.set_filter_data:param:filter:${field}`, V2_FILTER_DATA] as const,
3013
+ ),
3014
+ [
3015
+ "b2d.body.create_fixture:param:definition:shape",
3016
+ `v2/script_box2d_fixture_v2.cpp:CheckFixtureDef passes definition.shape to ${CHECK_SHAPE_DEF}`,
3017
+ ],
3018
+ ...filterDataFields("b2d.body.create_fixture:param:definition", V2_FILTER_DATA),
3019
+ [
3020
+ "b2d.body.create_chain:param:definition:vertices",
3021
+ "v3/script_box2d_chain_v3.cpp:Body_CreateChain reads vertices with CheckVerticesTable, at least 2 (4 for a loop)",
3022
+ ],
3023
+ ...(["cast_mover", "collide_mover"] as const).flatMap((name) =>
3024
+ (["center1", "center2", "radius"] as const).map(
3025
+ (field) =>
3026
+ [
3027
+ `b2d.world.${name}:param:capsule:${field}`,
3028
+ "v3/script_box2d_world_v3.cpp:CheckCapsule reads center1 and center2 with CheckVec2 and radius with luaL_checknumber",
3029
+ ] as const,
3030
+ ),
3031
+ ),
3032
+ ...(["lower", "upper"] as const).map(
3033
+ (field) =>
3034
+ [
3035
+ `b2d.world.overlap_aabb:param:aabb:${field}`,
3036
+ "script_box2d_world_v2/v3.cpp:CheckAABB reads lower and upper with CheckVec2",
3037
+ ] as const,
3038
+ ),
3039
+ ...(["position", "radius", "falloff", "impulse_per_length"] as const).map(
3040
+ (field) =>
3041
+ [
3042
+ `b2d.world.explode:param:definition:${field}`,
3043
+ "v3/script_box2d_world_v3.cpp:World_Explode reads position with CheckVec2 and the rest with luaL_checknumber",
3044
+ ] as const,
3045
+ ),
3046
+ [
3047
+ "buffer.create:param:declaration:type",
3048
+ "script_buffer.cpp:ParseStreamDeclaration raises on a stream with no value type",
3049
+ ],
3050
+ ...(["create_texture", "create_texture_async"] as const).flatMap((name) =>
3051
+ (["type", "format", "width", "height"] as const).map(
3052
+ (field) => [`resource.${name}:param:table:${field}`, TEXTURE_PARAMS] as const,
3053
+ ),
3054
+ ),
3055
+ ...(["type", "format", "width", "height"] as const).map(
3056
+ (field) =>
3057
+ [
3058
+ `resource.set_texture:param:table:${field}`,
3059
+ "script_resource.cpp:SetTexture reads CheckTableInteger",
3060
+ ] as const,
3061
+ ),
3062
+ ...(["create_atlas", "set_atlas"] as const).flatMap((name) => [
3063
+ [
3064
+ `resource.${name}:param:table:texture`,
3065
+ "script_resource.cpp:CheckTextureResource reads the texture with CheckHashOrString",
3066
+ ] as const,
3067
+ [`resource.${name}:param:table:geometries`, ATLAS_ARGUMENTS] as const,
3068
+ [`resource.${name}:param:table:animations`, ATLAS_ARGUMENTS] as const,
3069
+ ...(["vertices", "uvs", "indices"] as const).map(
3070
+ (field) =>
3071
+ [
3072
+ `resource.${name}:param:table:geometries[].${field}`,
3073
+ "script_resource.cpp:CheckAtlasArguments checks each geometry stream with luaL_checktype(LUA_TTABLE)",
3074
+ ] as const,
3075
+ ),
3076
+ ...(["id", "width", "height"] as const).map(
3077
+ (field) =>
3078
+ [
3079
+ `resource.${name}:param:table:animations[].${field}`,
3080
+ "script_resource.cpp:CheckAtlasArguments raises on an animation with no id, width or height",
3081
+ ] as const,
3082
+ ),
3083
+ ]),
3084
+ [
3085
+ "resource.create_buffer:param:table:buffer",
3086
+ "script_resource.cpp:CreateBuffer reads the buffer with dmScript::CheckBuffer",
3087
+ ],
3088
+ [
3089
+ "resource.create_sound_data:param:options:data",
3090
+ "script_resource.cpp:CreateSoundData reads the data with CheckBufferOrString",
3091
+ ],
3092
+ [
3093
+ "resource.sound_data_options:member:data",
3094
+ "script_resource.cpp:CreateSoundData reads the data with CheckBufferOrString",
3095
+ ],
3096
+ ]);
3097
+
3098
+ // Mark the fields REQUIRED_FIELD_CORRECTIONS names for this slot required,
3099
+ // leaving every other field and every type untouched. An entry keyed to this
3100
+ // slot that names no field throws, so a renamed field cannot leave a stale
3101
+ // correction looking applied. Returns a new array; never mutates its input.
3102
+ export function applyRequiredFieldCorrections(
3103
+ elementName: string,
3104
+ slotKind: "param" | "return" | undefined,
3105
+ slotName: string | undefined,
3106
+ fields: readonly TableField[],
3107
+ ): TableField[] {
3108
+ if (slotKind !== "param" || slotName === undefined) return [...fields];
3109
+ const prefix = `${tableSlotKey(elementName, slotKind, slotName)}:`;
3110
+ const applied = new Set<string>();
3111
+ const mark = (field: TableField, path: string): TableField => {
3112
+ if (!REQUIRED_FIELD_CORRECTIONS.has(`${prefix}${path}`)) return field;
3113
+ applied.add(path);
3114
+ return { ...field, required: true };
3115
+ };
3116
+ const corrected = fields.map((field) => {
3117
+ const own = mark(field, field.name);
3118
+ if (own.fields === undefined) return own;
3119
+ const parent = `${field.name}${field.isList === true ? "[]" : ""}`;
3120
+ return { ...own, fields: own.fields.map((child) => mark(child, `${parent}.${child.name}`)) };
3121
+ });
3122
+ const stale = [...REQUIRED_FIELD_CORRECTIONS.keys()].filter(
3123
+ (key) => key.startsWith(prefix) && !applied.has(key.slice(prefix.length)),
3124
+ );
3125
+ if (stale.length > 0) {
3126
+ throw new Error(
3127
+ `required-field correction names no recovered field: ${stale.sort().join(", ")}`,
3128
+ );
3129
+ }
3130
+ return corrected;
3131
+ }
3132
+
3133
+ // A param-side list field the engine binding refuses empty. Every entry emits
3134
+ // the list as `[E, ...E[]]`; `replaces` drops members the binding reads only as
3135
+ // one of several alternatives, and `alternatives` restores them as a union the
3136
+ // element is intersected with. Keyed `<element>:param:<slot>:<field>` or
3137
+ // `<struct>:member:<field>`, the REQUIRED_FIELD_CORRECTIONS shapes.
3138
+ export interface ListFieldCorrection {
3139
+ readonly evidence: string;
3140
+ readonly replaces?: readonly string[];
3141
+ readonly alternatives?: string;
3142
+ }
3143
+
3144
+ const ATLAS_FRAMES =
3145
+ "script_resource.cpp:CheckAtlasArguments reads a non-empty frames table, else both frame_start and frame_end with CheckFieldValue<int>";
3146
+
3147
+ const ATLAS_GEOMETRIES: ListFieldCorrection = { evidence: ATLAS_ARGUMENTS };
3148
+ const ATLAS_ANIMATIONS: ListFieldCorrection = {
3149
+ evidence: `${ATLAS_ARGUMENTS}; ${ATLAS_FRAMES}`,
3150
+ replaces: ["frame_start", "frame_end"],
3151
+ alternatives: "{ frames: [number, ...number[]] } | { frame_start: number; frame_end: number }",
3152
+ };
3153
+
3154
+ export const LIST_FIELD_CORRECTIONS: ReadonlyMap<string, ListFieldCorrection> = new Map<
3155
+ string,
3156
+ ListFieldCorrection
3157
+ >([
3158
+ ...(["create_atlas", "set_atlas"] as const).flatMap((name) => [
3159
+ [`resource.${name}:param:table:geometries`, ATLAS_GEOMETRIES] as const,
3160
+ [`resource.${name}:param:table:animations`, ATLAS_ANIMATIONS] as const,
3161
+ ]),
3162
+ ["resource.atlas:member:geometries", ATLAS_GEOMETRIES],
3163
+ ["resource.atlas:member:animations", ATLAS_ANIMATIONS],
3164
+ ]);
3165
+
3166
+ // Apply LIST_FIELD_CORRECTIONS to this slot's fields. An entry keyed to this
3167
+ // slot that names no field, names a field that is not a list, or replaces a
3168
+ // member the field does not carry throws, so a renamed field or member cannot
3169
+ // leave a stale correction looking applied. Returns a new array; never mutates
3170
+ // its input.
3171
+ export function applyListFieldCorrections(
3172
+ elementName: string,
3173
+ slotKind: "param" | "return" | undefined,
3174
+ slotName: string | undefined,
3175
+ fields: readonly TableField[],
3176
+ ): TableField[] {
3177
+ if (slotKind !== "param" || slotName === undefined) return [...fields];
3178
+ const prefix = `${tableSlotKey(elementName, slotKind, slotName)}:`;
3179
+ const applied = new Set<string>();
3180
+ const corrected = fields.map((field): TableField => {
3181
+ const key = `${prefix}${field.name}`;
3182
+ const correction = LIST_FIELD_CORRECTIONS.get(key);
3183
+ if (correction === undefined) return field;
3184
+ applied.add(field.name);
3185
+ if (field.isList !== true) {
3186
+ throw new Error(`list-field correction names a field that is not a list: ${key}`);
3187
+ }
3188
+ const replaces = correction.replaces ?? [];
3189
+ const missing = replaces.filter(
3190
+ (name) => !(field.fields ?? []).some((member) => member.name === name),
3191
+ );
3192
+ if (missing.length > 0) {
3193
+ throw new Error(`list-field correction ${key} replaces no member: ${missing.join(", ")}`);
3194
+ }
3195
+ return {
3196
+ ...field,
3197
+ nonEmpty: true,
3198
+ ...(field.fields === undefined
3199
+ ? {}
3200
+ : { fields: field.fields.filter((member) => !replaces.includes(member.name)) }),
3201
+ ...(correction.alternatives === undefined ? {} : { alternatives: correction.alternatives }),
3202
+ };
3203
+ });
3204
+ const stale = [...LIST_FIELD_CORRECTIONS.keys()].filter(
3205
+ (key) => key.startsWith(prefix) && !applied.has(key.slice(prefix.length)),
3206
+ );
3207
+ if (stale.length > 0) {
3208
+ throw new Error(`list-field correction names no recovered field: ${stale.sort().join(", ")}`);
3209
+ }
3210
+ return corrected;
3211
+ }
3212
+
1920
3213
  // Append the member TABLE_SLOT_FIELD_ADDITIONS supplies for this slot, after
1921
3214
  // every field the parser recovered. Throws on an addition whose field the parse
1922
3215
  // already produced: upstream has documented it, and the entry is stale.
@@ -1931,10 +3224,12 @@ export function applyFieldAdditions(
1931
3224
  const key = tableSlotKey(elementName, slotKind, slotName);
1932
3225
  const addition = TABLE_SLOT_FIELD_ADDITIONS.get(key);
1933
3226
  if (addition === undefined) return [...fields];
1934
- if (fields.some((field) => field.name === addition.field.name)) {
1935
- throw new Error(`field addition names a recovered field: ${key}:${addition.field.name}`);
3227
+ for (const added of addition.fields) {
3228
+ if (fields.some((field) => field.name === added.name)) {
3229
+ throw new Error(`field addition names a recovered field: ${key}:${added.name}`);
3230
+ }
1936
3231
  }
1937
- return [...fields, addition.field];
3232
+ return [...fields, ...addition.fields];
1938
3233
  }
1939
3234
 
1940
3235
  // Pin a parser-recovered field's TS type from TABLE_FIELD_TYPE_OVERRIDES,
@@ -1949,11 +3244,17 @@ export function applyFieldTypeOverrides(
1949
3244
  fields: readonly TableField[],
1950
3245
  ): TableField[] {
1951
3246
  if (slotKind === undefined || slotName === undefined) return [...fields];
1952
- return fields.map((field) => {
1953
- const override = TABLE_FIELD_TYPE_OVERRIDES.get(
1954
- `${elementName}:${slotKind}:${slotName}:${field.name}`,
1955
- );
3247
+ const prefix = `${tableSlotKey(elementName, slotKind, slotName)}:`;
3248
+ const typed = (field: TableField, path: string): TableField => {
3249
+ const override = TABLE_FIELD_TYPE_OVERRIDES.get(`${prefix}${path}`);
1956
3250
  return override === undefined ? field : { ...field, tsType: override };
3251
+ };
3252
+ return fields.map((field) => {
3253
+ const own = typed(field, field.name);
3254
+ if (own.fields === undefined) return own;
3255
+ const parent = `${field.name}${field.isList === true ? "[]" : ""}`;
3256
+ const nested = own.fields.map((child) => typed(child, `${parent}.${child.name}`));
3257
+ return nested.every((child, i) => child === own.fields?.[i]) ? own : { ...own, fields: nested };
1957
3258
  });
1958
3259
  }
1959
3260
 
@@ -2097,6 +3398,14 @@ export interface TableField {
2097
3398
  isList?: boolean;
2098
3399
  numberList?: boolean;
2099
3400
  optional?: boolean;
3401
+ // Set only by REQUIRED_FIELD_CORRECTIONS: the engine raises without the field,
3402
+ // so it keeps no `?` even on a param-side option bag.
3403
+ required?: boolean;
3404
+ // Set only by LIST_FIELD_CORRECTIONS: the engine raises on an empty list, so
3405
+ // the list emits as `[E, ...E[]]`, and its element is intersected with the
3406
+ // alternative field sets the binding accepts.
3407
+ nonEmpty?: boolean;
3408
+ alternatives?: string;
2100
3409
  // A hand-curated field whose faithful TS shape is not a plain token union
2101
3410
  // (e.g. a nested vector array, or a documented value union with an array
2102
3411
  // branch): inlineTableType emits this verbatim and the audit treats it as
@@ -2259,47 +3568,378 @@ export function parseTableFields(
2259
3568
  }
2260
3569
  }
2261
3570
  }
2262
- return null;
3571
+ return null;
3572
+ }
3573
+
3574
+ function isTableField(field: TableField): boolean {
3575
+ return field.types.length === 1 && field.types[0] === "table";
3576
+ }
3577
+
3578
+ function groupFlattenedTableFields(fields: readonly TableField[]): TableField[] {
3579
+ const grouped: TableField[] = [];
3580
+ let open: TableField | undefined;
3581
+ for (const field of fields) {
3582
+ if (isTableField(field)) {
3583
+ grouped.push(field);
3584
+ open = field;
3585
+ continue;
3586
+ }
3587
+ if (open === undefined) {
3588
+ grouped.push(field);
3589
+ continue;
3590
+ }
3591
+ open.fields ??= [];
3592
+ open.fields.push(field);
3593
+ }
3594
+ return grouped;
3595
+ }
3596
+
3597
+ // A handle the type map widens (`constant_buffer` takes named constants) keeps
3598
+ // that shape, so annotating with the alias loses nothing the handle-returning
3599
+ // function yields.
3600
+ //
3601
+ // An engine typedef that names its aliased type: a `userdata`/`number` handle the
3602
+ // type map resolves (`vector`, `socket_client`) aliases that mapping, an
3603
+ // unresolved one keeps the brand, and any other aliased type (a union, literals, a
3604
+ // record) aliases the type itself.
3605
+ function typedefAlias(
3606
+ typedef: ApiTypedef,
3607
+ local: string,
3608
+ rawMapType: (token: string) => string,
3609
+ mapType: DefoldTypeMapper,
3610
+ ): string {
3611
+ const brand = `Opaque<"${typedef.name}">`;
3612
+ const aliasOf = typedef.aliasOf ?? [];
3613
+ if (aliasOf.length > 0 && !isHandleAlias(aliasOf)) {
3614
+ return `type ${local} = ${unionFromTokens(aliasOf, mapType)};`;
3615
+ }
3616
+ const mapped = rawMapType(typedef.name);
3617
+ if (mapped.startsWith(brand)) return `type ${local} = ${mapped};`;
3618
+ const resolved = aliasOf.length > 0 && mapped !== "unknown" && mapped !== local;
3619
+ return `type ${local} = ${resolved ? mapped : brand};`;
3620
+ }
3621
+
3622
+ function isHandleAlias(aliasOf: readonly string[]): boolean {
3623
+ return aliasOf.length === 1 && (aliasOf[0] === "userdata" || aliasOf[0] === "number");
3624
+ }
3625
+
3626
+ // A `STRUCT` or aliasing `TYPEDEF` placed at its local path (`shape_data`,
3627
+ // `on_input.action`) under the module that emits it.
3628
+ interface TypeDeclaration {
3629
+ name: string;
3630
+ path: string;
3631
+ // The declaration's lines at `indent`, its keyword line led by `decl`.
3632
+ lines: (indent: string, decl: string) => string[];
3633
+ signature: string;
3634
+ }
3635
+
3636
+ // A typedef with no aliased type keeps the flat-name rule it always had; one that
3637
+ // names its aliased type, and every struct, sits at its name less the module
3638
+ // prefix, nested when that local is dotted. Typedefs come before structs, each in
3639
+ // document order.
3640
+ function collectTypeDeclarations(
3641
+ module: ApiModule,
3642
+ skip: ReadonlySet<string>,
3643
+ rawMapType: (token: string) => string,
3644
+ mapType: DefoldTypeMapper,
3645
+ ): TypeDeclaration[] {
3646
+ const prefix = `${module.namespace}.`;
3647
+ const out: TypeDeclaration[] = [];
3648
+ const localOf = (name: string): string | null => {
3649
+ const local = stripPrefix(name, prefix);
3650
+ return local.split(".").every((segment) => TS_IDENTIFIER.test(segment)) ? local : null;
3651
+ };
3652
+ for (const t of module.typedefs) {
3653
+ const local =
3654
+ t.aliasOf === undefined ? (TS_IDENTIFIER.test(t.name) ? t.name : null) : localOf(t.name);
3655
+ if (local === null || skip.has(local)) continue;
3656
+ const name = local.slice(local.lastIndexOf(".") + 1);
3657
+ const signature = typedefAlias(t, name, rawMapType, mapType);
3658
+ out.push({
3659
+ name,
3660
+ path: local,
3661
+ lines: (indent, decl) => [`${indent}${decl}${signature}`],
3662
+ signature,
3663
+ });
3664
+ }
3665
+ for (const struct of module.structs ?? []) {
3666
+ const local = localOf(struct.name);
3667
+ if (local === null || skip.has(local)) continue;
3668
+ out.push(structDeclaration(struct, local, mapType, module.structs ?? []));
3669
+ }
3670
+ return out;
3671
+ }
3672
+
3673
+ export function memberCorrectionKey(struct: string, field: string): string {
3674
+ return `${struct}:member:${field}`;
3675
+ }
3676
+
3677
+ // A member-keyed LIST_FIELD_CORRECTIONS entry over a `T[]` member: `T` with the
3678
+ // replaced members omitted, rendered by the prose-field list renderer.
3679
+ function correctedListMemberType(
3680
+ struct: ApiStruct,
3681
+ member: ApiStructMember,
3682
+ correction: ListFieldCorrection,
3683
+ structs: readonly ApiStruct[],
3684
+ mapType: DefoldTypeMapper,
3685
+ ): string {
3686
+ const key = memberCorrectionKey(struct.name, member.name);
3687
+ const element = member.type.endsWith("[]") ? member.type.slice(0, -2) : undefined;
3688
+ if (element === undefined || element.length === 0) {
3689
+ throw new Error(`list-field correction names a member that is not a list: ${key}`);
3690
+ }
3691
+ const replaces = correction.replaces ?? [];
3692
+ const elementStruct = structs.find((candidate) => candidate.name === element);
3693
+ const missing = replaces.filter(
3694
+ (name) => !(elementStruct?.members ?? []).some((candidate) => candidate.name === name),
3695
+ );
3696
+ if (missing.length > 0) {
3697
+ throw new Error(`list-field correction ${key} replaces no member: ${missing.join(", ")}`);
3698
+ }
3699
+ const mapped = mapType(element);
3700
+ const object =
3701
+ replaces.length === 0
3702
+ ? mapped
3703
+ : `Omit<${mapped}, ${replaces.map((name) => JSON.stringify(name)).join(" | ")}>`;
3704
+ return nestedFieldType(
3705
+ {
3706
+ name: member.name,
3707
+ types: [],
3708
+ isList: true,
3709
+ nonEmpty: true,
3710
+ ...(correction.alternatives === undefined ? {} : { alternatives: correction.alternatives }),
3711
+ },
3712
+ object,
3713
+ );
3714
+ }
3715
+
3716
+ function structDeclaration(
3717
+ struct: ApiStruct,
3718
+ path: string,
3719
+ mapType: DefoldTypeMapper,
3720
+ structs: readonly ApiStruct[],
3721
+ ): TypeDeclaration {
3722
+ const name = path.slice(path.lastIndexOf(".") + 1);
3723
+ const memberPrefix = memberCorrectionKey(struct.name, "");
3724
+ const declared = new Set(struct.members.map((member) => member.name));
3725
+ const stale = [
3726
+ ...REQUIRED_FIELD_CORRECTIONS.keys(),
3727
+ ...LIST_FIELD_CORRECTIONS.keys(),
3728
+ ...TABLE_FIELD_TYPE_OVERRIDES.keys(),
3729
+ ...OPTIONAL_MEMBER_CORRECTIONS.keys(),
3730
+ ].filter((key) => key.startsWith(memberPrefix) && !declared.has(key.slice(memberPrefix.length)));
3731
+ if (stale.length > 0) {
3732
+ throw new Error(`member-keyed field correction names no member: ${stale.sort().join(", ")}`);
3733
+ }
3734
+ const members = struct.members.map((member) => {
3735
+ const key = memberCorrectionKey(struct.name, member.name);
3736
+ const list = LIST_FIELD_CORRECTIONS.get(key);
3737
+ const retyped = TABLE_FIELD_TYPE_OVERRIDES.get(key);
3738
+ const optional =
3739
+ (member.isOptional || OPTIONAL_MEMBER_CORRECTIONS.has(key)) &&
3740
+ !REQUIRED_FIELD_CORRECTIONS.has(key);
3741
+ const type =
3742
+ list !== undefined
3743
+ ? correctedListMemberType(struct, member, list, structs, mapType)
3744
+ : retyped !== undefined
3745
+ ? retyped
3746
+ : member.type === ""
3747
+ ? "unknown"
3748
+ : mapType(member.type);
3749
+ return {
3750
+ doc: member.doc,
3751
+ line: `${TS_IDENTIFIER.test(member.name) ? member.name : JSON.stringify(member.name)}${optional ? "?" : ""}: ${type};`,
3752
+ };
3753
+ });
3754
+ return {
3755
+ name,
3756
+ path,
3757
+ lines: (indent, decl) => {
3758
+ const memberIndent = `${indent}${INDENT}`;
3759
+ return [
3760
+ ...summaryDocLines(struct.brief, struct.description, indent),
3761
+ `${indent}${decl}interface ${name} {`,
3762
+ ...members.flatMap((member) => [
3763
+ ...summaryDocLines(member.doc, "", memberIndent),
3764
+ `${memberIndent}${member.line}`,
3765
+ ]),
3766
+ `${indent}}`,
3767
+ ];
3768
+ },
3769
+ signature: `interface ${name} { ${members.map((member) => `${member.line} `).join("")}}`,
3770
+ };
3771
+ }
3772
+
3773
+ // Nested declarations join the group at their path, which is created when no
3774
+ // member lives there; the flat ones are returned in name order.
3775
+ function placeTypeDeclarations(
3776
+ declarations: readonly TypeDeclaration[],
3777
+ nestedRoot: Map<string, NestedGroup>,
3778
+ ): TypeDeclaration[] {
3779
+ const byName = (a: TypeDeclaration, b: TypeDeclaration) => a.name.localeCompare(b.name);
3780
+ const topLevel: TypeDeclaration[] = [];
3781
+ for (const declaration of declarations) {
3782
+ if (declaration.path === declaration.name) {
3783
+ topLevel.push(declaration);
3784
+ continue;
3785
+ }
3786
+ const group = nestedGroupAt(nestedRoot, declaration.path.split(".").slice(0, -1));
3787
+ group.typeDeclarations.push(declaration);
3788
+ group.typeDeclarations.sort(byName);
3789
+ }
3790
+ return topLevel.sort(byName);
2263
3791
  }
2264
3792
 
2265
- function isTableField(field: TableField): boolean {
2266
- return field.types.length === 1 && field.types[0] === "table";
3793
+ // The mappers both emit surfaces read. `mapType` is the engine vocabulary with
3794
+ // the surface's leaves, the module's own declared types, those `declaredTypes`
3795
+ // names from other modules, and constant tokens branded. `rawMapType`, read only
3796
+ // for a typedef's own name, leaves the declared paths out: a handle typedef
3797
+ // (`editor.schema`) would otherwise map to its own path and alias itself.
3798
+ function moduleTypeMappers(
3799
+ module: ApiModule,
3800
+ constantsByFqn: ReadonlyMap<string, ApiConstant>,
3801
+ options: EmitOptions | undefined,
3802
+ ): { rawMapType: (token: string) => string; mapType: DefoldTypeMapper } {
3803
+ const leaves = options?.typeLeaves;
3804
+ const rawMapType = options?.mapType ?? mapTypeWithVocabulary({ leaves });
3805
+ const baseMapType =
3806
+ options?.mapType ??
3807
+ mapTypeWithVocabulary({
3808
+ leaves,
3809
+ declared: new Map([...declaredTypePaths([module]), ...(options?.declaredTypes ?? [])]),
3810
+ });
3811
+ const knownConstantFqns = options?.knownConstantFqns;
3812
+ const mapType: DefoldTypeMapper = (token, position) =>
3813
+ constantsByFqn.has(token) || knownConstantFqns?.has(token)
3814
+ ? brandType(token, constantsByFqn.get(token))
3815
+ : baseMapType(token, position);
3816
+ return { rawMapType, mapType };
2267
3817
  }
2268
3818
 
2269
- function groupFlattenedTableFields(fields: readonly TableField[]): TableField[] {
2270
- const grouped: TableField[] = [];
2271
- let open: TableField | undefined;
2272
- for (const field of fields) {
2273
- if (isTableField(field)) {
2274
- grouped.push(field);
2275
- open = field;
2276
- continue;
3819
+ /**
3820
+ * Move each `STRUCT` and aliasing `TYPEDEF` to the module whose namespace is the
3821
+ * longest prefix of its name (`b2d.joint.revolute_definition`, declared in the
3822
+ * `b2d` doc, moves to `b2d.joint`). A name no module prefixes stays with the
3823
+ * module that declares it. A declaration the home already declares under the
3824
+ * same kind and name, or that an earlier module already routed there, is
3825
+ * dropped, so the home's own copy wins; a type sharing a value's name
3826
+ * (`resource.atlas`) is untouched. A function moves the same way (1.13.2 files
3827
+ * `bullet3d.collision_object.get_shapes` in the shape doc), since the emitter
3828
+ * declares only the functions under a module's own namespace. Modules with
3829
+ * nothing moved in or out are returned as-is.
3830
+ */
3831
+ export function routeTypeDeclarations(modules: readonly ApiModule[]): ApiModule[] {
3832
+ const homeOf = (name: string, declaring: string): string => {
3833
+ let home = declaring;
3834
+ let longest = -1;
3835
+ for (const { namespace } of modules) {
3836
+ if (name.startsWith(`${namespace}.`) && namespace.length > longest) {
3837
+ home = namespace;
3838
+ longest = namespace.length;
3839
+ }
2277
3840
  }
2278
- if (open === undefined) {
2279
- grouped.push(field);
2280
- continue;
3841
+ return home;
3842
+ };
3843
+ const incoming = new Map<
3844
+ string,
3845
+ { structs: ApiStruct[]; typedefs: ApiTypedef[]; functions: ApiFunction[] }
3846
+ >();
3847
+ const into = (namespace: string) => {
3848
+ let bucket = incoming.get(namespace);
3849
+ if (bucket === undefined) {
3850
+ bucket = { structs: [], typedefs: [], functions: [] };
3851
+ incoming.set(namespace, bucket);
3852
+ }
3853
+ return bucket;
3854
+ };
3855
+ const kept = modules.map((module) => {
3856
+ const structs = (module.structs ?? []).filter((struct) => {
3857
+ const home = homeOf(struct.name, module.namespace);
3858
+ if (home === module.namespace) return true;
3859
+ into(home).structs.push(struct);
3860
+ return false;
3861
+ });
3862
+ const typedefs = module.typedefs.filter((typedef) => {
3863
+ if (typedef.aliasOf === undefined) return true;
3864
+ const home = homeOf(typedef.name, module.namespace);
3865
+ if (home === module.namespace) return true;
3866
+ into(home).typedefs.push(typedef);
3867
+ return false;
3868
+ });
3869
+ const functions = module.functions.filter((fn) => {
3870
+ const home = homeOf(fn.name, module.namespace);
3871
+ if (home === module.namespace) return true;
3872
+ into(home).functions.push(fn);
3873
+ return false;
3874
+ });
3875
+ return structs.length === (module.structs ?? []).length &&
3876
+ typedefs.length === module.typedefs.length &&
3877
+ functions.length === module.functions.length
3878
+ ? module
3879
+ : { ...module, structs, typedefs, functions };
3880
+ });
3881
+ return kept.map((module) => {
3882
+ const bucket = incoming.get(module.namespace);
3883
+ if (bucket === undefined) return module;
3884
+ const unclaimed = (names: Set<string>) => (declaration: { name: string }) => {
3885
+ if (names.has(declaration.name)) return false;
3886
+ names.add(declaration.name);
3887
+ return true;
3888
+ };
3889
+ const structNames = new Set((module.structs ?? []).map((struct) => struct.name));
3890
+ const typedefNames = new Set(module.typedefs.map((typedef) => typedef.name));
3891
+ // A function keeps every overload one doc files, so the home's own name wins
3892
+ // over another doc's copy of it without collapsing the incoming overloads.
3893
+ const functionNames = new Set(module.functions.map((fn) => fn.name));
3894
+ return {
3895
+ ...module,
3896
+ structs: [...(module.structs ?? []), ...bucket.structs.filter(unclaimed(structNames))],
3897
+ typedefs: [...module.typedefs, ...bucket.typedefs.filter(unclaimed(typedefNames))],
3898
+ functions: [
3899
+ ...module.functions,
3900
+ ...bucket.functions.filter((fn) => !functionNames.has(fn.name)),
3901
+ ],
3902
+ };
3903
+ });
3904
+ }
3905
+
3906
+ /**
3907
+ * The TS path of every `ENUM`, `STRUCT` and aliasing `TYPEDEF` the (routed)
3908
+ * modules declare, keyed by its Lua name: the name itself when it sits under its
3909
+ * module's namespace, else that name nested under the module
3910
+ * (`on_input.action` in `go` is `go.on_input.action`).
3911
+ */
3912
+ export function declaredTypePaths(modules: readonly ApiModule[]): Map<string, string> {
3913
+ const paths = new Map<string, string>();
3914
+ for (const module of modules) {
3915
+ const names = [
3916
+ ...(module.enums ?? []).map((e) => e.name),
3917
+ ...(module.structs ?? []).map((struct) => struct.name),
3918
+ ...module.typedefs.filter((t) => t.aliasOf !== undefined).map((t) => t.name),
3919
+ ];
3920
+ for (const name of names) {
3921
+ paths.set(
3922
+ name,
3923
+ name.startsWith(`${module.namespace}.`) ? name : `${module.namespace}.${name}`,
3924
+ );
2281
3925
  }
2282
- open.fields ??= [];
2283
- open.fields.push(field);
2284
3926
  }
2285
- return grouped;
3927
+ return paths;
2286
3928
  }
2287
3929
 
2288
- export function emitDeclarations(module: ApiModule, options?: EmitOptions): string {
3930
+ export function emitDeclarations(documented: ApiModule, options?: EmitOptions): string {
3931
+ const module = withUndocumentedConstants(documented, options?.registeredConstants);
2289
3932
  const prefix = `${module.namespace}.`;
2290
3933
 
2291
- const constantFqns = new Set(module.constants.map((c) => c.name));
3934
+ const constantsByFqn = new Map(module.constants.map((c) => [c.name, c]));
2292
3935
  const knownConstantFqns = options?.knownConstantFqns;
2293
3936
  const translations = options?.translations ?? {};
2294
3937
  const urlParameters = options?.urlParameters ?? [];
2295
- const baseMapType = options?.mapType ?? defaultMapType;
2296
- const mapType = (token: string): string =>
2297
- constantFqns.has(token) || knownConstantFqns?.has(token)
2298
- ? brandType(token)
2299
- : baseMapType(token);
3938
+ const baseNotes = indexBaseNoteResolver(options?.indexBaseSource ?? "engine");
3939
+ const { rawMapType, mapType } = moduleTypeMappers(module, constantsByFqn, options);
2300
3940
  const constantTokens = constantSlotTokenResolver(
2301
3941
  module,
2302
- new Set([...constantFqns, ...(knownConstantFqns ?? [])]),
3942
+ new Set([...constantsByFqn.keys(), ...(knownConstantFqns ?? [])]),
2303
3943
  );
2304
3944
 
2305
3945
  const constants = module.constants
@@ -2313,6 +3953,7 @@ export function emitDeclarations(module: ApiModule, options?: EmitOptions): stri
2313
3953
  .sort((a, b) => a.name.localeCompare(b.name));
2314
3954
 
2315
3955
  const functions = module.functions
3956
+ .filter((fn) => !isMisfiled(fn))
2316
3957
  .map((fn) => prepareFunction(fn, prefix))
2317
3958
  .filter((entry): entry is PreparedFunction => entry !== null)
2318
3959
  .sort((a, b) =>
@@ -2329,6 +3970,7 @@ export function emitDeclarations(module: ApiModule, options?: EmitOptions): stri
2329
3970
  // segment. One or two leading identifier segments qualify; anything deeper,
2330
3971
  // and any non-identifier segment, stays dropped.
2331
3972
  const nestedRoot = collectNestedGroups(module, prefix);
3973
+ const enumAliases = collectEnumAliases(module, constants, nestedRoot);
2332
3974
 
2333
3975
  // Colon methods (`client:send`) are FUNCTION elements named `<receiver>:<method>`
2334
3976
  // and are NOT namespace-prefixed, so they fail the flat-identifier test in
@@ -2346,9 +3988,12 @@ export function emitDeclarations(module: ApiModule, options?: EmitOptions): stri
2346
3988
  })),
2347
3989
  );
2348
3990
 
2349
- const typedefs = module.typedefs
2350
- .filter((t) => TS_IDENTIFIER.test(t.name))
2351
- .sort((a, b) => a.name.localeCompare(b.name));
3991
+ // A typedef that also has colon methods is emitted as a method-bearing
3992
+ // interface below, not an opaque brand alias.
3993
+ const typeDeclarations = placeTypeDeclarations(
3994
+ collectTypeDeclarations(module, new Set(handleGroups.keys()), rawMapType, mapType),
3995
+ nestedRoot,
3996
+ );
2352
3997
 
2353
3998
  // A re-export alias (`export { _x as x }`) switches the ambient namespace out
2354
3999
  // of its implicit-export mode, so once any alias is present every sibling
@@ -2356,28 +4001,29 @@ export function emitDeclarations(module: ApiModule, options?: EmitOptions): stri
2356
4001
  // keyword is otherwise omitted to keep every alias-free module byte-identical.
2357
4002
  const hasAliases =
2358
4003
  variables.some((v) => TS_RESERVED_NAMES.has(v.name)) ||
2359
- functions.some((fn) => TS_RESERVED_NAMES.has(fn.name));
4004
+ functions.some((fn) => TS_RESERVED_NAMES.has(fn.name)) ||
4005
+ [...nestedRoot.keys()].some((segment) => TS_RESERVED_NAMES.has(segment));
2360
4006
  const decl = hasAliases ? "export " : "";
2361
4007
 
2362
4008
  const lines: string[] = [];
2363
- for (const docLine of namespaceDocLines(module)) lines.push(docLine);
4009
+ for (const docLine of namespaceDocLines(module, translations)) lines.push(docLine);
2364
4010
  lines.push(`declare namespace ${module.namespace} {`);
2365
4011
 
2366
- for (const t of typedefs) {
2367
- // A typedef that also has colon methods is emitted as a method-bearing
2368
- // interface below, not an opaque brand alias.
2369
- if (handleGroups.has(t.name)) continue;
2370
- lines.push(`${INDENT}${decl}type ${t.name} = Opaque<"${t.name}">;`);
2371
- }
4012
+ for (const declaration of typeDeclarations) lines.push(...declaration.lines(INDENT, decl));
2372
4013
  for (const aliasDecl of constantUnionAliasDeclarations(module.namespace)) {
2373
4014
  lines.push(`${INDENT}${decl}${aliasDecl}`);
2374
4015
  }
4016
+ for (const alias of enumAliases.topLevel) {
4017
+ lines.push(
4018
+ `${INDENT}${decl}${unionAliasDeclaration(alias.name, alias.members, alias.nilable)}`,
4019
+ );
4020
+ }
2375
4021
  const handleIndent = `${INDENT}${INDENT}`;
2376
4022
  for (const receiver of handleReceivers) {
2377
4023
  const group = handleGroups.get(receiver) ?? [];
2378
4024
  lines.push(`${INDENT}${decl}interface ${receiver} {`);
2379
4025
  for (const fn of group) {
2380
- for (const docLine of functionDocLines(fn.original, translations, handleIndent)) {
4026
+ for (const docLine of functionDocLines(fn.original, translations, baseNotes, handleIndent)) {
2381
4027
  lines.push(docLine);
2382
4028
  }
2383
4029
  lines.push(
@@ -2390,7 +4036,7 @@ export function emitDeclarations(module: ApiModule, options?: EmitOptions): stri
2390
4036
  for (const docLine of summaryDocLines(c.original.brief, c.original.description, INDENT)) {
2391
4037
  lines.push(docLine);
2392
4038
  }
2393
- lines.push(`${INDENT}${decl}const ${c.name}: ${brandType(c.fqn)};`);
4039
+ lines.push(`${INDENT}${decl}const ${c.name}: ${constantType(c)};`);
2394
4040
  }
2395
4041
  const aliases: { internal: string; public: string }[] = [];
2396
4042
  for (const v of variables) {
@@ -2410,11 +4056,30 @@ export function emitDeclarations(module: ApiModule, options?: EmitOptions): stri
2410
4056
  for (const fn of functions) {
2411
4057
  const reserved = TS_RESERVED_NAMES.has(fn.name);
2412
4058
  const emitName = aliasName(fn.name, aliases);
2413
- for (const docLine of functionDocLines(fn.original, translations)) lines.push(docLine);
4059
+ for (const docLine of functionDocLines(fn.original, translations, baseNotes))
4060
+ lines.push(docLine);
2414
4061
  const line = emitFunction(fn, emitName, mapType, resolver, constantTokens, urlParameters);
2415
4062
  lines.push(`${INDENT}${reserved ? "" : decl}${line}`);
4063
+ const alternative = firstSlotAlternative(fn, module.namespace);
4064
+ if (alternative !== null) {
4065
+ // The overload documents its own parameters, so its hover names the base
4066
+ // of the index it takes. Its examples are the primary signature's.
4067
+ const { examples: _examples, ...overloadDoc } = alternative.original;
4068
+ for (const docLine of functionDocLines(overloadDoc, translations, baseNotes))
4069
+ lines.push(docLine);
4070
+ const overload = emitFunction(
4071
+ alternative,
4072
+ emitName,
4073
+ mapType,
4074
+ resolver,
4075
+ constantTokens,
4076
+ urlParameters,
4077
+ );
4078
+ lines.push(`${INDENT}${reserved ? "" : decl}${overload}`);
4079
+ }
2416
4080
  }
2417
4081
 
4082
+ aliasReservedSegments(nestedRoot.keys(), aliases);
2418
4083
  for (const alias of [...aliases].sort((a, b) => a.public.localeCompare(b.public))) {
2419
4084
  lines.push(`${INDENT}export { ${alias.internal} as ${alias.public} };`);
2420
4085
  }
@@ -2430,14 +4095,38 @@ export function emitDeclarations(module: ApiModule, options?: EmitOptions): stri
2430
4095
  // A reserved-name member inside a nested namespace gets the same recovery as
2431
4096
  // a top-level one: emitted un-exported as `_<name>` and re-exported under the
2432
4097
  // reserved name. The alias switches this namespace out of implicit-export mode,
2433
- // so its siblings then need an explicit `export` to stay reachable.
4098
+ // so its siblings then need an explicit `export` to stay reachable. A
4099
+ // reserved segment is itself declared as `_<segment>`, merging with a
4100
+ // same-named function, and reached through the parent's alias.
2434
4101
  const segmentAliases: { internal: string; public: string }[] = [];
2435
4102
  const segmentDecl =
2436
4103
  group.variables.some((v) => TS_RESERVED_NAMES.has(v.name)) ||
2437
- group.functions.some((fn) => TS_RESERVED_NAMES.has(fn.name))
4104
+ group.functions.some((fn) => TS_RESERVED_NAMES.has(fn.name)) ||
4105
+ [...group.children.keys()].some((child) => TS_RESERVED_NAMES.has(child))
2438
4106
  ? "export "
2439
4107
  : "";
2440
- lines.push(`${indent}${outerDecl}namespace ${segment} {`);
4108
+ const reservedSegment = TS_RESERVED_NAMES.has(segment);
4109
+ lines.push(
4110
+ `${indent}${reservedSegment ? "" : outerDecl}namespace ${reservedSegment ? `_${segment}` : segment} {`,
4111
+ );
4112
+ for (const declaration of group.typeDeclarations) {
4113
+ lines.push(...declaration.lines(bodyIndent, segmentDecl));
4114
+ }
4115
+ for (const alias of group.enumAliases) {
4116
+ lines.push(
4117
+ `${bodyIndent}${segmentDecl}${unionAliasDeclaration(alias.name, alias.members, alias.nilable)}`,
4118
+ );
4119
+ }
4120
+ for (const c of group.constants) {
4121
+ for (const docLine of summaryDocLines(
4122
+ c.original.brief,
4123
+ c.original.description,
4124
+ bodyIndent,
4125
+ )) {
4126
+ lines.push(docLine);
4127
+ }
4128
+ lines.push(`${bodyIndent}${segmentDecl}const ${c.name}: ${constantType(c)};`);
4129
+ }
2441
4130
  for (const v of group.variables) {
2442
4131
  const reserved = TS_RESERVED_NAMES.has(v.name);
2443
4132
  const emitName = aliasName(v.name, segmentAliases);
@@ -2455,13 +4144,14 @@ export function emitDeclarations(module: ApiModule, options?: EmitOptions): stri
2455
4144
  for (const fn of group.functions) {
2456
4145
  const reserved = TS_RESERVED_NAMES.has(fn.name);
2457
4146
  const emitName = aliasName(fn.name, segmentAliases);
2458
- for (const docLine of functionDocLines(fn.original, translations, bodyIndent)) {
4147
+ for (const docLine of functionDocLines(fn.original, translations, baseNotes, bodyIndent)) {
2459
4148
  lines.push(docLine);
2460
4149
  }
2461
4150
  lines.push(
2462
4151
  `${bodyIndent}${reserved ? "" : segmentDecl}${emitFunction(fn, emitName, mapType, resolver, constantTokens, urlParameters)}`,
2463
4152
  );
2464
4153
  }
4154
+ aliasReservedSegments(group.children.keys(), segmentAliases);
2465
4155
  for (const alias of [...segmentAliases].sort((a, b) => a.public.localeCompare(b.public))) {
2466
4156
  lines.push(`${bodyIndent}export { ${alias.internal} as ${alias.public} };`);
2467
4157
  }
@@ -2475,10 +4165,12 @@ export function emitDeclarations(module: ApiModule, options?: EmitOptions): stri
2475
4165
  const members = [...module.properties].sort((a, b) => a.name.localeCompare(b.name));
2476
4166
  lines.push(`${INDENT}${decl}interface properties {`);
2477
4167
  for (const p of members) {
2478
- for (const docLine of summaryDocLines(p.brief, p.description, `${INDENT}${INDENT}`)) {
2479
- lines.push(docLine);
4168
+ for (const member of emitPropertyMembers(p, mapType, module.namespace)) {
4169
+ for (const docLine of summaryDocLines(p.brief, p.description, `${INDENT}${INDENT}`)) {
4170
+ lines.push(docLine);
4171
+ }
4172
+ lines.push(`${INDENT}${INDENT}${member}`);
2480
4173
  }
2481
- lines.push(`${INDENT}${INDENT}${emitPropertyMember(p, mapType, module.namespace)}`);
2482
4174
  }
2483
4175
  lines.push(`${INDENT}}`);
2484
4176
  }
@@ -2488,14 +4180,22 @@ export function emitDeclarations(module: ApiModule, options?: EmitOptions): stri
2488
4180
  }
2489
4181
 
2490
4182
  /**
2491
- * Every documented slot's rendered TS type, keyed `param|return:<position>:<raw
2492
- * ref-doc name>`. The position is part of the key because a multi-return can
2493
- * carry several unnamed slots (`push.schedule` renders two), which a name-only
2494
- * key would collapse into one. Each value appears verbatim inside the symbol's
2495
- * own `tsSignature`, so a consumer rendering a slot's type beside that signature
2496
- * cannot contradict it.
4183
+ * Every documented slot's rendered TS type and optionality, keyed
4184
+ * `param|return:<position>:<raw ref-doc name>`. The position is part of the key
4185
+ * because a multi-return can carry several unnamed slots (`push.schedule`
4186
+ * renders two), which a name-only key would collapse into one. Each `type`
4187
+ * appears verbatim inside the symbol's own `tsSignature` as the slot's printed
4188
+ * operand (an interior omissible slot keeps its `| undefined` arm), and
4189
+ * `optional` is whether the name carries `?` there, so a consumer rendering a
4190
+ * slot beside that signature cannot contradict it. Returns and slots folded into
4191
+ * a rest element are never `optional`.
2497
4192
  */
2498
- export type SlotTypes = Readonly<Record<string, string>>;
4193
+ export interface SlotType {
4194
+ readonly type: string;
4195
+ readonly optional: boolean;
4196
+ }
4197
+
4198
+ export type SlotTypes = Readonly<Record<string, SlotType>>;
2499
4199
 
2500
4200
  function slotKey(kind: "param" | "return", position: number, name: string): string {
2501
4201
  return `${kind}:${position}:${name}`;
@@ -2518,21 +4218,24 @@ export interface SymbolSignature {
2518
4218
  * {@link collectNestedGroups} tree the declaration emitter walks, so neither
2519
4219
  * surface can decide on its own which members are nested or how deep they go.
2520
4220
  * The caller applies the same `skipFunctions` filter the `.d.ts` generation
2521
- * does, so a dropped member never yields a signature.
4221
+ * does, so a dropped member never yields a signature. A top-level function the
4222
+ * declaration emits with a first-slot alternative overload carries one arm per
4223
+ * line, primary first, the same convention as the authored fold; its
4224
+ * `slotTypes` describe the primary arm only.
2522
4225
  */
2523
- export function emitSymbolSignatures(module: ApiModule, options?: EmitOptions): SymbolSignature[] {
4226
+ export function emitSymbolSignatures(
4227
+ documented: ApiModule,
4228
+ options?: EmitOptions,
4229
+ ): SymbolSignature[] {
4230
+ const module = withUndocumentedConstants(documented, options?.registeredConstants);
2524
4231
  const prefix = `${module.namespace}.`;
2525
- const constantFqns = new Set(module.constants.map((c) => c.name));
4232
+ const constantsByFqn = new Map(module.constants.map((c) => [c.name, c]));
2526
4233
  const knownConstantFqns = options?.knownConstantFqns;
2527
4234
  const urlParameters = options?.urlParameters ?? [];
2528
- const baseMapType = options?.mapType ?? defaultMapType;
2529
- const mapType = (token: string): string =>
2530
- constantFqns.has(token) || knownConstantFqns?.has(token)
2531
- ? brandType(token)
2532
- : baseMapType(token);
4235
+ const { rawMapType, mapType } = moduleTypeMappers(module, constantsByFqn, options);
2533
4236
  const constantTokens = constantSlotTokenResolver(
2534
4237
  module,
2535
- new Set([...constantFqns, ...(knownConstantFqns ?? [])]),
4238
+ new Set([...constantsByFqn.keys(), ...(knownConstantFqns ?? [])]),
2536
4239
  );
2537
4240
  const resolver = buildTableDocResolver(
2538
4241
  module.functions.map((fn) => ({
@@ -2555,25 +4258,48 @@ export function emitSymbolSignatures(module: ApiModule, options?: EmitOptions):
2555
4258
  const NO_SLOTS: SlotTypes = {};
2556
4259
 
2557
4260
  for (const fn of module.functions) {
4261
+ if (isMisfiled(fn)) continue;
2558
4262
  const prepared = prepareFunction(fn, prefix);
2559
4263
  if (prepared === null) continue;
2560
- const slotTypes: Record<string, string> = {};
2561
- out.push({
2562
- identity: fnIdentity(fn),
2563
- tsSignature: emitFunction(
2564
- prepared,
2565
- emitName(prepared.name),
2566
- mapType,
2567
- resolver,
2568
- constantTokens,
2569
- urlParameters,
2570
- slotTypes,
2571
- ),
4264
+ const slotTypes: Record<string, SlotType> = {};
4265
+ const primary = emitFunction(
4266
+ prepared,
4267
+ emitName(prepared.name),
4268
+ mapType,
4269
+ resolver,
4270
+ constantTokens,
4271
+ urlParameters,
2572
4272
  slotTypes,
2573
- });
4273
+ );
4274
+ const alternative = firstSlotAlternative(prepared, module.namespace);
4275
+ const tsSignature =
4276
+ alternative === null
4277
+ ? primary
4278
+ : `${primary}\n${emitFunction(
4279
+ alternative,
4280
+ emitName(prepared.name),
4281
+ mapType,
4282
+ resolver,
4283
+ constantTokens,
4284
+ urlParameters,
4285
+ )}`;
4286
+ out.push({ identity: fnIdentity(fn), tsSignature, slotTypes });
2574
4287
  }
2575
4288
 
2576
- const nested = flattenNestedGroups(collectNestedGroups(module, prefix));
4289
+ const nestedRoot = collectNestedGroups(module, prefix);
4290
+ const nested = flattenNestedGroups(nestedRoot);
4291
+ for (const c of nested.constants) {
4292
+ out.push({
4293
+ identity: {
4294
+ namespace: module.namespace,
4295
+ kind: "CONSTANT",
4296
+ name: c.fqn,
4297
+ signature: "",
4298
+ },
4299
+ tsSignature: `const ${c.name}: ${constantType(c)};`,
4300
+ slotTypes: NO_SLOTS,
4301
+ });
4302
+ }
2577
4303
  for (const v of nested.variables) {
2578
4304
  out.push({
2579
4305
  identity: {
@@ -2587,7 +4313,7 @@ export function emitSymbolSignatures(module: ApiModule, options?: EmitOptions):
2587
4313
  });
2588
4314
  }
2589
4315
  for (const fn of nested.functions) {
2590
- const slotTypes: Record<string, string> = {};
4316
+ const slotTypes: Record<string, SlotType> = {};
2591
4317
  out.push({
2592
4318
  identity: fnIdentity(fn.original),
2593
4319
  tsSignature: emitFunction(
@@ -2606,7 +4332,7 @@ export function emitSymbolSignatures(module: ApiModule, options?: EmitOptions):
2606
4332
  const handleGroups = collectHandleMethodGroups(module);
2607
4333
  for (const group of handleGroups.values()) {
2608
4334
  for (const prepared of group) {
2609
- const slotTypes: Record<string, string> = {};
4335
+ const slotTypes: Record<string, SlotType> = {};
2610
4336
  out.push({
2611
4337
  identity: fnIdentity(prepared.original),
2612
4338
  tsSignature: emitMethod(
@@ -2622,12 +4348,14 @@ export function emitSymbolSignatures(module: ApiModule, options?: EmitOptions):
2622
4348
  }
2623
4349
  }
2624
4350
 
4351
+ const constants: PreparedConstant[] = [];
2625
4352
  for (const c of module.constants) {
2626
4353
  const prepared = prepareConstant(c, prefix);
2627
4354
  if (prepared === null) continue;
4355
+ constants.push(prepared);
2628
4356
  out.push({
2629
4357
  identity: { namespace: module.namespace, kind: "CONSTANT", name: c.name, signature: "" },
2630
- tsSignature: `const ${prepared.name}: ${brandType(prepared.fqn)};`,
4358
+ tsSignature: `const ${prepared.name}: ${constantType(prepared)};`,
2631
4359
  slotTypes: NO_SLOTS,
2632
4360
  });
2633
4361
  }
@@ -2642,19 +4370,34 @@ export function emitSymbolSignatures(module: ApiModule, options?: EmitOptions):
2642
4370
  });
2643
4371
  }
2644
4372
 
4373
+ // A key-range property emits several members that share one type; its entry
4374
+ // carries the first, which is verbatim in the `.d.ts` and is what the `/api`
4375
+ // render layer reduces to `<placeholder>: <type>`.
2645
4376
  for (const p of module.properties) {
4377
+ const [firstMember = ""] = emitPropertyMembers(p, mapType, module.namespace);
2646
4378
  out.push({
2647
4379
  identity: { namespace: module.namespace, kind: "PROPERTY", name: p.name, signature: "" },
2648
- tsSignature: emitPropertyMember(p, mapType, module.namespace),
4380
+ tsSignature: firstMember,
2649
4381
  slotTypes: NO_SLOTS,
2650
4382
  });
2651
4383
  }
2652
4384
 
2653
- for (const t of module.typedefs) {
2654
- if (!TS_IDENTIFIER.test(t.name) || handleGroups.has(t.name)) continue;
4385
+ // A struct signs as its interface on one line; each member line is verbatim in
4386
+ // the declarations.
4387
+ for (const declaration of collectTypeDeclarations(
4388
+ module,
4389
+ new Set(handleGroups.keys()),
4390
+ rawMapType,
4391
+ mapType,
4392
+ )) {
2655
4393
  out.push({
2656
- identity: { namespace: module.namespace, kind: "TYPEDEF", name: t.name, signature: "" },
2657
- tsSignature: `type ${t.name} = Opaque<"${t.name}">;`,
4394
+ identity: {
4395
+ namespace: module.namespace,
4396
+ kind: "TYPEDEF",
4397
+ name: declaration.path,
4398
+ signature: "",
4399
+ },
4400
+ tsSignature: declaration.signature,
2658
4401
  slotTypes: NO_SLOTS,
2659
4402
  });
2660
4403
  }
@@ -2665,9 +4408,18 @@ export function emitSymbolSignatures(module: ApiModule, options?: EmitOptions):
2665
4408
  for (const row of CONSTANT_UNION_ALIASES.filter((r) => r.home === module.namespace)) {
2666
4409
  out.push({
2667
4410
  identity: { namespace: module.namespace, kind: "TYPEDEF", name: row.name, signature: "" },
2668
- tsSignature: `type ${row.name} = ${row.members
2669
- .map((member) => `typeof ${member}`)
2670
- .join(" | ")};`,
4411
+ tsSignature: unionAliasDeclaration(row.name, row.members),
4412
+ slotTypes: NO_SLOTS,
4413
+ });
4414
+ }
4415
+
4416
+ // An enum alias is keyed by its local path (`STATUS`, `ui.COLOR`), as the
4417
+ // constant-union aliases above are keyed by their local name.
4418
+ const enumAliases = collectEnumAliases(module, constants, nestedRoot);
4419
+ for (const alias of enumAliases.all) {
4420
+ out.push({
4421
+ identity: { namespace: module.namespace, kind: "TYPEDEF", name: alias.path, signature: "" },
4422
+ tsSignature: unionAliasDeclaration(alias.name, alias.members, alias.nilable),
2671
4423
  slotTypes: NO_SLOTS,
2672
4424
  });
2673
4425
  }
@@ -2694,35 +4446,67 @@ interface PreparedVariable {
2694
4446
  // One level of the nested-member tree: the members declared directly under a
2695
4447
  // segment, plus the segments declared beneath it.
2696
4448
  interface NestedGroup {
4449
+ constants: PreparedConstant[];
2697
4450
  variables: PreparedVariable[];
2698
4451
  functions: PreparedFunction[];
4452
+ enumAliases: EnumAlias[];
4453
+ typeDeclarations: TypeDeclaration[];
2699
4454
  children: Map<string, NestedGroup>;
2700
4455
  }
2701
4456
 
4457
+ // One `ENUM`'s union alias: `name` is the declared identifier, `path` the local
4458
+ // path under the module namespace (`ui.COLOR`), `members` the member FQNs.
4459
+ interface EnumAlias {
4460
+ name: string;
4461
+ path: string;
4462
+ members: readonly string[];
4463
+ // Members typed `<ENUM>|nil`, whose arm drops the `undefined` their `const`
4464
+ // admits, as a slot naming one does.
4465
+ nilable: ReadonlySet<string>;
4466
+ }
4467
+
2702
4468
  // A stripped local carrying one or two leading identifier segments before the
2703
4469
  // member name (`dns.toip`, `COLOR.TEXT`, `schema.integer`). Three levels deep is
2704
4470
  // beyond anything the vendored documents describe, so it stays dropped rather
2705
4471
  // than half-emitted.
2706
4472
  const NESTED_MEMBER_LOCAL = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*){1,2}$/;
2707
4473
 
4474
+ // The group at `segments` under `root`, creating each missing level.
4475
+ function nestedGroupAt(root: Map<string, NestedGroup>, segments: readonly string[]): NestedGroup {
4476
+ let level = root;
4477
+ let group: NestedGroup | undefined;
4478
+ for (const segment of segments) {
4479
+ let next = level.get(segment);
4480
+ if (next === undefined) {
4481
+ next = {
4482
+ constants: [],
4483
+ variables: [],
4484
+ functions: [],
4485
+ enumAliases: [],
4486
+ typeDeclarations: [],
4487
+ children: new Map(),
4488
+ };
4489
+ level.set(segment, next);
4490
+ }
4491
+ group = next;
4492
+ level = next.children;
4493
+ }
4494
+ return group as NestedGroup;
4495
+ }
4496
+
2708
4497
  function collectNestedGroups(module: ApiModule, prefix: string): Map<string, NestedGroup> {
2709
4498
  const root = new Map<string, NestedGroup>();
2710
- const groupAt = (segments: readonly string[]): NestedGroup => {
2711
- let level = root;
2712
- let group: NestedGroup | undefined;
2713
- for (const segment of segments) {
2714
- let next = level.get(segment);
2715
- if (next === undefined) {
2716
- next = { variables: [], functions: [], children: new Map() };
2717
- level.set(segment, next);
2718
- }
2719
- group = next;
2720
- level = next.children;
2721
- }
2722
- return group as NestedGroup;
2723
- };
4499
+ const groupAt = (segments: readonly string[]): NestedGroup => nestedGroupAt(root, segments);
2724
4500
  const pathOf = (local: string): string[] => local.split(".").slice(0, -1);
2725
4501
 
4502
+ for (const c of module.constants) {
4503
+ const local = stripPrefix(c.name, prefix);
4504
+ if (!NESTED_MEMBER_LOCAL.test(local)) continue;
4505
+ const segments = pathOf(local);
4506
+ const prepared = prepareConstant(c, `${module.namespace}.${segments.join(".")}.`);
4507
+ if (prepared === null) continue;
4508
+ groupAt(segments).constants.push(prepared);
4509
+ }
2726
4510
  for (const v of module.variables) {
2727
4511
  const local = stripPrefix(v.name, prefix);
2728
4512
  if (!NESTED_MEMBER_LOCAL.test(local)) continue;
@@ -2742,6 +4526,7 @@ function collectNestedGroups(module: ApiModule, prefix: string): Map<string, Nes
2742
4526
 
2743
4527
  const sortLevel = (level: Map<string, NestedGroup>): void => {
2744
4528
  for (const group of level.values()) {
4529
+ group.constants.sort((a, b) => a.name.localeCompare(b.name));
2745
4530
  group.variables.sort((a, b) => a.name.localeCompare(b.name));
2746
4531
  group.functions.sort((a, b) =>
2747
4532
  a.name === b.name
@@ -2760,21 +4545,71 @@ function collectNestedGroups(module: ApiModule, prefix: string): Map<string, Nes
2760
4545
  // children). Callers that need no per-segment container read the tree through
2761
4546
  // this rather than re-deriving which members are nested and how deep.
2762
4547
  function flattenNestedGroups(level: ReadonlyMap<string, NestedGroup>): {
4548
+ constants: PreparedConstant[];
2763
4549
  variables: PreparedVariable[];
2764
4550
  functions: PreparedFunction[];
2765
4551
  } {
4552
+ const constants: PreparedConstant[] = [];
2766
4553
  const variables: PreparedVariable[] = [];
2767
4554
  const functions: PreparedFunction[] = [];
2768
4555
  const walk = (current: ReadonlyMap<string, NestedGroup>): void => {
2769
4556
  for (const segment of [...current.keys()].sort((a, b) => a.localeCompare(b))) {
2770
4557
  const group = current.get(segment) as NestedGroup;
4558
+ constants.push(...group.constants);
2771
4559
  variables.push(...group.variables);
2772
4560
  functions.push(...group.functions);
2773
4561
  walk(group.children);
2774
4562
  }
2775
4563
  };
2776
4564
  walk(level);
2777
- return { variables, functions };
4565
+ return { constants, variables, functions };
4566
+ }
4567
+
4568
+ // Each `ENUM`'s union alias, placed where its name lands: a flat local
4569
+ // (`STATUS`) at the top of the namespace, a nested one (`ui.COLOR`) in the
4570
+ // nested group `ui`, which the tree already holds when the members are nested
4571
+ // beside it. An enum is aliased only when every member is declared, so the
4572
+ // alias never names a constant the module does not emit. The nested groups
4573
+ // gain their aliases in place; `topLevel` and `all` are in name order.
4574
+ function collectEnumAliases(
4575
+ module: ApiModule,
4576
+ topLevelConstants: readonly PreparedConstant[],
4577
+ nestedRoot: Map<string, NestedGroup>,
4578
+ ): { topLevel: EnumAlias[]; all: EnumAlias[] } {
4579
+ const prefix = `${module.namespace}.`;
4580
+ const declared = new Set([
4581
+ ...topLevelConstants.map((c) => c.fqn),
4582
+ ...flattenNestedGroups(nestedRoot).constants.map((c) => c.fqn),
4583
+ ]);
4584
+ const nilable = new Set(module.constants.filter((c) => c.nilable).map((c) => c.name));
4585
+ const topLevel: EnumAlias[] = [];
4586
+ const all: EnumAlias[] = [];
4587
+ const enums = [...(module.enums ?? [])].sort((a, b) => a.name.localeCompare(b.name));
4588
+ for (const e of enums) {
4589
+ if (e.members.length === 0 || !e.members.every((m) => declared.has(m))) continue;
4590
+ const path = stripPrefix(e.name, prefix);
4591
+ if (TS_IDENTIFIER.test(path)) {
4592
+ const alias = { name: path, path, members: e.members, nilable };
4593
+ topLevel.push(alias);
4594
+ all.push(alias);
4595
+ continue;
4596
+ }
4597
+ if (!NESTED_MEMBER_LOCAL.test(path)) continue;
4598
+ const segments = path.split(".");
4599
+ const name = segments.pop() as string;
4600
+ let level = nestedRoot;
4601
+ let group: NestedGroup | undefined;
4602
+ for (const segment of segments) {
4603
+ group = level.get(segment);
4604
+ if (group === undefined) break;
4605
+ level = group.children;
4606
+ }
4607
+ if (group === undefined) continue;
4608
+ const alias = { name, path, members: e.members, nilable };
4609
+ group.enumAliases.push(alias);
4610
+ all.push(alias);
4611
+ }
4612
+ return { topLevel, all };
2778
4613
  }
2779
4614
 
2780
4615
  function prepareFunction(fn: ApiFunction, prefix: string): PreparedFunction | null {
@@ -2820,8 +4655,137 @@ function prepareConstant(c: ApiConstant, prefix: string): PreparedConstant | nul
2820
4655
  return { name: stripped, fqn: c.name, original: c };
2821
4656
  }
2822
4657
 
2823
- function brandType(fqn: string): string {
2824
- return `number & { readonly __brand: "${fqn}" }`;
4658
+ // Where the engine registers a constant as a string: the vendored binding file
4659
+ // and the call there whose macro pushes the literal.
4660
+ export interface StringConstantSource {
4661
+ readonly binding: string;
4662
+ readonly macro: string;
4663
+ readonly call: string;
4664
+ }
4665
+
4666
+ const GUI_SCRIPT = "engine/gui/src/gui_script.cpp";
4667
+ const IMAGE_SCRIPT = "engine/gamesys/src/gamesys/scripts/script_image.cpp";
4668
+
4669
+ // Constants the engine registers with `lua_pushliteral`, keyed by FQN. The
4670
+ // ref-docs give constants no type, and every other constant is a number;
4671
+ // `string-constant-provenance.test.ts` reds when a vendored binding stops
4672
+ // pushing one as a string.
4673
+ export const STRING_CONSTANTS: ReadonlyMap<string, StringConstantSource> = new Map([
4674
+ ...[
4675
+ "position",
4676
+ "rotation",
4677
+ "euler",
4678
+ "scale",
4679
+ "color",
4680
+ "outline",
4681
+ "shadow",
4682
+ "size",
4683
+ "fill_angle",
4684
+ "inner_radius",
4685
+ "leading",
4686
+ "tracking",
4687
+ "slice9",
4688
+ ].map((prop): [string, StringConstantSource] => {
4689
+ const name = prop.toUpperCase();
4690
+ const call = `SETPROP(${prop}, ${name})`;
4691
+ return [`gui.PROP_${name}`, { binding: GUI_SCRIPT, macro: "SETPROP", call }];
4692
+ }),
4693
+ ...(
4694
+ [
4695
+ ["TYPE_RGB", "rgb"],
4696
+ ["TYPE_RGBA", "rgba"],
4697
+ ["TYPE_LUMINANCE", "l"],
4698
+ ["TYPE_LUMINANCE_ALPHA", "la"],
4699
+ ] as const
4700
+ ).map(([name, value]): [string, StringConstantSource] => [
4701
+ `image.${name}`,
4702
+ { binding: IMAGE_SCRIPT, macro: "SETCONSTANT", call: `SETCONSTANT(${name}, "${value}")` },
4703
+ ]),
4704
+ ]);
4705
+
4706
+ // Documented constants the engine never registers, keyed by FQN with the
4707
+ // reason, so Lua reads each as nil. `nil-constant-provenance.test.ts` pins the
4708
+ // keys to the accepted `constant-missing-binding` binding verdicts.
4709
+ export const NIL_CONSTANTS: ReadonlyMap<string, string> = new Map([
4710
+ [
4711
+ "render.RENDER_TARGET_DEFAULT",
4712
+ "render_script.cpp registers no such field; render.set_render_target reads the nil as the default target",
4713
+ ],
4714
+ ]);
4715
+
4716
+ // Constants the engine registers but some release's ref-doc omits, keyed by FQN
4717
+ // with the doc the emit carries. A surface gains an entry only where its ref-doc
4718
+ // leaves the constant out and its engine bindings register it.
4719
+ // `undocumented-constant-provenance.test.ts` reds an entry no committed target
4720
+ // still needs. `sameValueAs` names the documented constant the engine registers
4721
+ // with the same value; where the surface declares it, the back-fill takes its
4722
+ // type, so a value compares equal to either spelling.
4723
+ export const UNDOCUMENTED_CONSTANTS: ReadonlyMap<
4724
+ string,
4725
+ { readonly doc: string; readonly sameValueAs?: string }
4726
+ > = new Map([
4727
+ [
4728
+ "render.CONTEXT_EVENT_CONTEXT_LOST",
4729
+ {
4730
+ doc: "The rendering context was lost. Rendering is paused and all graphics resources become invalid. Passed to the `render.set_listener` callback.",
4731
+ },
4732
+ ],
4733
+ [
4734
+ "render.CONTEXT_EVENT_CONTEXT_RESTORED",
4735
+ {
4736
+ doc: "The rendering context was restored. Rendering is still paused and graphics resources are still invalid, but can be reloaded. Passed to the `render.set_listener` callback.",
4737
+ },
4738
+ ],
4739
+ [
4740
+ "window.WINDOW_EVENT_ICONFIED",
4741
+ {
4742
+ doc: "Deprecated misspelling of `window.WINDOW_EVENT_ICONIFIED`, with the same value. The engine still registers it.",
4743
+ sameValueAs: "window.WINDOW_EVENT_ICONIFIED",
4744
+ },
4745
+ ],
4746
+ ]);
4747
+
4748
+ function withUndocumentedConstants(
4749
+ module: ApiModule,
4750
+ registered: ReadonlySet<string> | undefined,
4751
+ ): ApiModule {
4752
+ const prefix = `${module.namespace}.`;
4753
+ const documented = new Set(module.constants.map((c) => c.name));
4754
+ const backfill: ApiConstant[] = [];
4755
+ for (const [fqn, { doc, sameValueAs }] of UNDOCUMENTED_CONSTANTS) {
4756
+ if (!fqn.startsWith(prefix) || fqn.slice(prefix.length).includes(".")) continue;
4757
+ if (documented.has(fqn)) continue;
4758
+ if (registered !== undefined && !registered.has(fqn)) continue;
4759
+ const aliasOf =
4760
+ sameValueAs !== undefined && documented.has(sameValueAs) ? sameValueAs : undefined;
4761
+ backfill.push({
4762
+ name: fqn,
4763
+ brief: doc,
4764
+ description: "",
4765
+ ...(aliasOf === undefined ? {} : { aliasOf }),
4766
+ });
4767
+ }
4768
+ return backfill.length === 0
4769
+ ? module
4770
+ : { ...module, constants: [...module.constants, ...backfill] };
4771
+ }
4772
+
4773
+ // `constant` is the module's own element for `fqn`, absent for a constant another
4774
+ // module declares; an `ENUM` member's value type rides on it.
4775
+ function brandType(fqn: string, constant?: ApiConstant): string {
4776
+ // `undefined & { __brand }` collapses to `never`, so a nil constant takes no brand.
4777
+ if (NIL_CONSTANTS.has(fqn)) return "undefined";
4778
+ const base = constant?.valueType === "string" || STRING_CONSTANTS.has(fqn) ? "string" : "number";
4779
+ return `${base} & { readonly __brand: "${fqn}" }`;
4780
+ }
4781
+
4782
+ // A declared constant's type. A nilable `ENUM` member admits `undefined` here
4783
+ // only: a slot naming it still takes the brand, as the value it is documented to
4784
+ // accept.
4785
+ function constantType(c: PreparedConstant): string {
4786
+ if (c.original.aliasOf !== undefined) return `typeof ${c.original.aliasOf}`;
4787
+ const brand = brandType(c.fqn, c.original);
4788
+ return c.original.nilable === true && brand !== "undefined" ? `${brand} | undefined` : brand;
2825
4789
  }
2826
4790
 
2827
4791
  function prepareVariable(v: ApiVariable, prefix: string): PreparedVariable | null {
@@ -2845,26 +4809,44 @@ function aliasName(name: string, aliases: { internal: string; public: string }[]
2845
4809
  return internal;
2846
4810
  }
2847
4811
 
2848
- function emitVariable(
2849
- prepared: PreparedVariable,
2850
- name: string,
2851
- mapType: (t: string) => string,
2852
- ): string {
4812
+ // A nested namespace named by a reserved word is emitted as `_<segment>`; its
4813
+ // parent re-exports it under the reserved name, sharing the alias a same-named
4814
+ // function already recorded.
4815
+ function aliasReservedSegments(
4816
+ segments: Iterable<string>,
4817
+ aliases: { internal: string; public: string }[],
4818
+ ): void {
4819
+ for (const segment of segments) {
4820
+ if (!TS_RESERVED_NAMES.has(segment)) continue;
4821
+ if (aliases.some((alias) => alias.public === segment)) continue;
4822
+ aliases.push({ internal: `_${segment}`, public: segment });
4823
+ }
4824
+ }
4825
+
4826
+ // A variable whose ref-doc states its type only in prose, keyed by FQN. Each
4827
+ // entry fills a slot upstream left empty; `emit-dts.test.ts` reds once the
4828
+ // default target's ref-doc types it.
4829
+ export const VARIABLE_TYPE_OVERRIDES: ReadonlyMap<string, string> = new Map([
4830
+ // "A `string`, either:" followed by the four platform ids.
4831
+ ["editor.platform", '"x86_64-win32" | "x86_64-macos" | "arm64-macos" | "x86_64-linux"'],
4832
+ ]);
4833
+
4834
+ function emitVariable(prepared: PreparedVariable, name: string, mapType: DefoldTypeMapper): string {
2853
4835
  const ts =
2854
4836
  prepared.original.types.length > 0
2855
4837
  ? unionFromTokens(prepared.original.types, mapType)
2856
- : "unknown";
4838
+ : (VARIABLE_TYPE_OVERRIDES.get(prepared.original.name) ?? "unknown");
2857
4839
  return `const ${name}: ${ts};`;
2858
4840
  }
2859
4841
 
2860
4842
  function memberSignature(
2861
4843
  prepared: PreparedFunction,
2862
4844
  name: string,
2863
- mapType: (t: string) => string,
4845
+ mapType: DefoldTypeMapper,
2864
4846
  resolver: TableDocResolver,
2865
4847
  constantTokens: ConstantSlotTokens,
2866
4848
  urlParameters: UrlParameterTable,
2867
- slotSink?: Record<string, string>,
4849
+ slotSink?: Record<string, SlotType>,
2868
4850
  ): string {
2869
4851
  const original = prepared.original.parameters;
2870
4852
  const elementName = prepared.original.name;
@@ -2903,16 +4885,16 @@ function memberSignature(
2903
4885
  if (slotSink !== undefined) {
2904
4886
  for (const [i, p] of positional.entries()) {
2905
4887
  const raw = original[i];
2906
- if (raw) slotSink[slotKey("param", i, raw.name)] = p.ts;
4888
+ if (raw) slotSink[slotKey("param", i, raw.name)] = { type: p.type, optional: p.optional };
2907
4889
  }
2908
4890
  for (const slot of rest?.slots ?? []) {
2909
- slotSink[slotKey("param", slot.position, slot.name)] = slot.ts;
4891
+ slotSink[slotKey("param", slot.position, slot.name)] = { type: slot.ts, optional: false };
2910
4892
  }
2911
4893
  // A predicate renders `arg is T` in place of the mapped `boolean`, so its
2912
4894
  // return slot has no text in the signature to stand on and is not reported.
2913
4895
  if (!isPredicate) {
2914
4896
  for (const slot of ret.slots) {
2915
- slotSink[slotKey("return", slot.position, slot.name)] = slot.ts;
4897
+ slotSink[slotKey("return", slot.position, slot.name)] = { type: slot.ts, optional: false };
2916
4898
  }
2917
4899
  }
2918
4900
  }
@@ -2925,11 +4907,11 @@ function memberSignature(
2925
4907
  function emitFunction(
2926
4908
  prepared: PreparedFunction,
2927
4909
  name: string,
2928
- mapType: (t: string) => string,
4910
+ mapType: DefoldTypeMapper,
2929
4911
  resolver: TableDocResolver,
2930
4912
  constantTokens: ConstantSlotTokens,
2931
4913
  urlParameters: UrlParameterTable,
2932
- slotSink?: Record<string, string>,
4914
+ slotSink?: Record<string, SlotType>,
2933
4915
  ): string {
2934
4916
  return `function ${memberSignature(prepared, name, mapType, resolver, constantTokens, urlParameters, slotSink)}`;
2935
4917
  }
@@ -2939,11 +4921,11 @@ function emitFunction(
2939
4921
  // member carries no `self` parameter.
2940
4922
  function emitMethod(
2941
4923
  prepared: PreparedFunction,
2942
- mapType: (t: string) => string,
4924
+ mapType: DefoldTypeMapper,
2943
4925
  resolver: TableDocResolver,
2944
4926
  constantTokens: ConstantSlotTokens,
2945
4927
  urlParameters: UrlParameterTable,
2946
- slotSink?: Record<string, string>,
4928
+ slotSink?: Record<string, SlotType>,
2947
4929
  ): string {
2948
4930
  return memberSignature(
2949
4931
  prepared,
@@ -2962,57 +4944,103 @@ function emitMethod(
2962
4944
  // fallback applies to non-identifier names and a vararg drops its dots, matching
2963
4945
  // `emitParameter` and `emitRestParameter`) so the tag resolves on hover; a
2964
4946
  // parameter folded into a rest element union keeps its own tag, so the prose
2965
- // documenting it survives. A single documented return becomes `@returns`. Returns `[]`
2966
- // for a fully-undocumented function, leaving its emission byte-identical.
4947
+ // documenting it survives. A single documented return becomes `@returns`; a
4948
+ // multi-return function gains one only when a slot carries a classified index
4949
+ // (see `multiReturnDoc`). A return authored in `RETURN_TYPE_OVERRIDES` has no
4950
+ // ref-doc slot, so it gains `@returns` only for the base notes of its classified
4951
+ // fields (see `overrideReturnDoc`). Returns `[]` for a fully-undocumented
4952
+ // function, leaving its emission byte-identical.
2967
4953
  function functionDocLines(
2968
4954
  fn: ApiFunction,
2969
4955
  translations: TranslationStore,
4956
+ baseNotes: IndexBaseNoteResolver,
2970
4957
  indent: string = INDENT,
2971
4958
  ): string[] {
2972
4959
  const params = fn.parameters.map((p, index) => ({
2973
4960
  name: emittedParamName(p, index),
2974
- doc: htmlToDocText(p.doc),
4961
+ doc: appendNotes(
4962
+ translateProseFences(htmlToDocText(p.doc), fn.name, translations),
4963
+ baseNotes(fn.name, "param", p.name),
4964
+ ),
2975
4965
  }));
2976
4966
  const onlyReturn = fn.returnValues.length === 1 ? fn.returnValues[0] : undefined;
4967
+ const notedReturns =
4968
+ multiReturnDoc(fn, translations, baseNotes) ?? overrideReturnDoc(fn, baseNotes);
2977
4969
  const lua = htmlToCodeText(fn.examples ?? "");
2978
- // A blob carrying several examples documents them as several, but only when
2979
- // every segment has an authored body: a partial resolve would drop the rest.
2980
- // Otherwise the whole-blob ladder stands — a hand-authored TS translation
2981
- // pinned to this exact Lua flips the fence to ```ts; any hash mismatch (or
2982
- // absent translation) keeps the Lua fallback.
4970
+ // Each example resolves by its own segment hash, a single-example element's
4971
+ // one segment being the whole blob with no prose. The blob documents its
4972
+ // examples as TypeScript only when every segment has an authored body: a
4973
+ // partial resolve would drop the rest, so any miss keeps the Lua fallback.
2983
4974
  const segments = splitExampleSources(fn.examples ?? "");
2984
- const perSegment =
2985
- segments.length > 1
2986
- ? lookupExampleTranslations(
2987
- translations,
2988
- fn.name,
2989
- segments.map((segment) => hashExampleSource(segment.code)),
2990
- )
2991
- : null;
2992
- const ts = lua === "" ? null : lookupTranslation(translations, fn.name, hashExampleSource(lua));
4975
+ const resolved = lookupExampleSegments(translations, fn.name, segments);
2993
4976
  const exampleParts: Pick<DocCommentParts, "examples"> =
2994
- perSegment !== null
4977
+ resolved !== null
2995
4978
  ? {
2996
- examples: perSegment.map((text, index) => ({
4979
+ examples: resolved.map(({ ts: text, prose }) => ({
2997
4980
  text,
2998
4981
  lang: "ts" as const,
2999
- ...(segments[index]?.prose ? { prose: segments[index]?.prose } : {}),
4982
+ ...(prose ? { prose } : {}),
3000
4983
  })),
3001
4984
  }
3002
- : ts !== null
3003
- ? { examples: [{ text: ts, lang: "ts" }] }
3004
- : lua !== ""
3005
- ? { examples: [{ text: lua, lang: "lua" }] }
3006
- : {};
4985
+ : lua !== ""
4986
+ ? { examples: [{ text: lua, lang: "lua" }] }
4987
+ : {};
3007
4988
  const parts: DocCommentParts = {
3008
- summary: htmlToDocText(summaryFor(fn.brief, fn.description)),
4989
+ summary: translateProseFences(
4990
+ htmlToDocText(summaryFor(fn.brief, fn.description)),
4991
+ fn.name,
4992
+ translations,
4993
+ ),
3009
4994
  params,
3010
- ...(onlyReturn ? { returns: htmlToDocText(onlyReturn.doc) } : {}),
4995
+ ...(onlyReturn
4996
+ ? {
4997
+ returns: appendNotes(
4998
+ translateProseFences(htmlToDocText(onlyReturn.doc), fn.name, translations),
4999
+ baseNotes(fn.name, "return", onlyReturn.name),
5000
+ ),
5001
+ }
5002
+ : notedReturns !== undefined
5003
+ ? { returns: notedReturns }
5004
+ : {}),
3011
5005
  ...exampleParts,
3012
5006
  };
3013
5007
  return indentDocLines(parts, indent);
3014
5008
  }
3015
5009
 
5010
+ // A multi-return function documents its tuple slot by slot, in tuple order, so
5011
+ // the hover can name each classified index's base. Every other multi-return
5012
+ // function keeps no `@returns`, leaving its emission byte-identical.
5013
+ function multiReturnDoc(
5014
+ fn: ApiFunction,
5015
+ translations: TranslationStore,
5016
+ baseNotes: IndexBaseNoteResolver,
5017
+ ): string | undefined {
5018
+ if (fn.returnValues.length < 2) return undefined;
5019
+ const classified = fn.returnValues.some(
5020
+ (slot) => baseNotes(fn.name, "return", slot.name).length > 0,
5021
+ );
5022
+ if (!classified) return undefined;
5023
+ const bullets = fn.returnValues.map((slot) => {
5024
+ const doc = appendNotes(
5025
+ translateProseFences(htmlToDocText(slot.doc), fn.name, translations),
5026
+ baseNotes(fn.name, "return", slot.name),
5027
+ );
5028
+ const [first = "", ...rest] = doc.split("\n");
5029
+ return [
5030
+ `- \`${slot.name}\` — ${first}`,
5031
+ ...rest.map((line) => (line === "" ? "" : ` ${line}`)),
5032
+ ];
5033
+ });
5034
+ const names = fn.returnValues.map((slot) => slot.name).join(", ");
5035
+ return [`\`[${names}]\`:`, ...bullets.flat()].join("\n");
5036
+ }
5037
+
5038
+ function overrideReturnDoc(fn: ApiFunction, baseNotes: IndexBaseNoteResolver): string | undefined {
5039
+ if (fn.returnValues.length > 0 || !RETURN_TYPE_OVERRIDES.has(fn.name)) return undefined;
5040
+ const notes = baseNotes(fn.name, "return", OVERRIDE_RETURN_SLOT);
5041
+ return notes.length > 0 ? notes.join(" ") : undefined;
5042
+ }
5043
+
3016
5044
  // Summary-only doc lines for a member that carries no params or returns
3017
5045
  // (constants, variables, `properties` members). Reuses the same renderer and
3018
5046
  // summary precedence as functions; returns `[]` for an undocumented member so
@@ -3022,11 +5050,11 @@ function summaryDocLines(brief: string, description: string, indent: string): st
3022
5050
  return indentDocLines({ summary: htmlToDocText(summaryFor(brief, description)) }, indent);
3023
5051
  }
3024
5052
 
3025
- function namespaceDocLines(module: ApiModule): string[] {
5053
+ function namespaceDocLines(module: ApiModule, translations: TranslationStore): string[] {
3026
5054
  const official = summaryFor(module.brief, module.description).trim();
3027
5055
  const summary =
3028
5056
  official.length > 0
3029
- ? htmlToDocText(official)
5057
+ ? translateProseFences(htmlToDocText(official), module.namespace, translations)
3030
5058
  : `(synthesized)\nDefold \`${module.namespace}\` API namespace.`;
3031
5059
  return indentDocLines({ summary }, "");
3032
5060
  }
@@ -3035,13 +5063,6 @@ function indentDocLines(parts: DocCommentParts, indent: string): string[] {
3035
5063
  return renderDocComment(parts).map((line) => `${indent}${line}`);
3036
5064
  }
3037
5065
 
3038
- // Prefer the full `description`; fall back to the one-line `brief` when prose is
3039
- // absent. Shared by every documented member kind so the summary source is
3040
- // consistent across functions, constants, variables, and properties.
3041
- export function summaryFor(brief: string, description: string): string {
3042
- return description.trim() !== "" ? description : brief;
3043
- }
3044
-
3045
5066
  export function isMarkedOptional(p: ApiParameter): boolean {
3046
5067
  return p.isOptional || p.types.includes("nil");
3047
5068
  }
@@ -3055,6 +5076,7 @@ export function isMarkedOptional(p: ApiParameter): boolean {
3055
5076
  // second copy of the rule. The fidelity audit reads it too, to tell which slots
3056
5077
  // the emitted surface still makes required.
3057
5078
  export function isDocOptional(p: ApiParameter, elementName: string): boolean {
5079
+ if (requiredSlotCorrection(p, elementName)) return false;
3058
5080
  if (isMarkedOptional(p)) return true;
3059
5081
  return OPTIONAL_SLOT_CORRECTIONS.has(tableSlotKey(elementName, "param", p.name));
3060
5082
  }
@@ -3082,7 +5104,7 @@ const SCENE_ADDRESS_ALIASES: Readonly<Record<string, string>> = {
3082
5104
  // becomes the matching scene-derived alias. Wrapping `mapType` rather than
3083
5105
  // rewriting the finished union leaves `mapSlotUnion`'s member ordering and
3084
5106
  // de-duplication in charge, so only the one token moves.
3085
- function addressMapType(mapType: (t: string) => string, alias: string): (t: string) => string {
5107
+ function addressMapType(mapType: DefoldTypeMapper, alias: string): (t: string) => string {
3086
5108
  return (token) => (token === "string" ? alias : mapType(token));
3087
5109
  }
3088
5110
 
@@ -3091,30 +5113,84 @@ function addressMapType(mapType: (t: string) => string, alias: string): (t: stri
3091
5113
  // type alone.
3092
5114
  function parameterType(
3093
5115
  p: ApiParameter,
3094
- mapType: (t: string) => string,
5116
+ mapType: DefoldTypeMapper,
3095
5117
  resolver: TableDocResolver,
3096
5118
  constantTokens: ConstantSlotTokens,
3097
5119
  elementName: string,
3098
5120
  urlParameters: UrlParameterTable,
3099
5121
  ): string {
3100
5122
  const concrete = p.types.filter((t) => t !== "nil");
5123
+ // The caller supplies a parameter, so a string-keyed table in it also takes an
5124
+ // object literal.
5125
+ const inputMapType = (token: string): string => mapType(token, "input");
3101
5126
  // The table is keyed by the *raw* ref-doc parameter name, not the emitted
3102
5127
  // `safeParamName` fallback.
3103
5128
  const alias = SCENE_ADDRESS_ALIASES[classifyUrlParameter(urlParameters, elementName, p.name)];
3104
- const slotMapType = alias === undefined ? mapType : addressMapType(mapType, alias);
3105
- return concrete.length > 0
3106
- ? mapSlotUnion(
3107
- concrete,
3108
- p.doc,
3109
- slotMapType,
3110
- true,
3111
- resolver,
3112
- constantTokens,
3113
- elementName,
3114
- "param",
3115
- p.name,
3116
- )
3117
- : "unknown";
5129
+ const slotMapType = alias === undefined ? inputMapType : addressMapType(inputMapType, alias);
5130
+ const mapped =
5131
+ concrete.length > 0
5132
+ ? mapSlotUnion(
5133
+ concrete,
5134
+ p.doc,
5135
+ slotMapType,
5136
+ true,
5137
+ resolver,
5138
+ constantTokens,
5139
+ elementName,
5140
+ "param",
5141
+ p.name,
5142
+ )
5143
+ : "unknown";
5144
+ const correction = PARAM_TYPE_CORRECTIONS.get(tableSlotKey(elementName, "param", p.name));
5145
+ if (correction === undefined || !pinMatches(p.types, correctionPins(correction))) return mapped;
5146
+ const kept =
5147
+ correction.removes === undefined ? mapped : withoutUnionMembers(mapped, correction.removes);
5148
+ if (correction.adds === undefined) return kept;
5149
+ if (kept === "") return correction.adds;
5150
+ const present = new Set(unionMembers(kept));
5151
+ const added = unionMembers(correction.adds).filter((member) => !present.has(member));
5152
+ return added.length === 0 ? kept : `${kept} | ${added.join(" | ")}`;
5153
+ }
5154
+
5155
+ function isFunctionTypeText(ts: string): boolean {
5156
+ let depth = 0;
5157
+ for (let i = 0; i < ts.length - 1; i++) {
5158
+ const c = ts[i];
5159
+ if (c === "(" || c === "[" || c === "{" || c === "<") depth++;
5160
+ else if (c === ")" || c === "]" || c === "}" || (c === ">" && ts[i - 1] !== "=")) depth--;
5161
+ else if (c === "=" && ts[i + 1] === ">" && depth === 0) return true;
5162
+ }
5163
+ return false;
5164
+ }
5165
+
5166
+ // The top-level members of a rendered union, leaving nested unions inside
5167
+ // brackets, braces or generics whole.
5168
+ function unionMembers(union: string): string[] {
5169
+ const members: string[] = [];
5170
+ let depth = 0;
5171
+ let start = 0;
5172
+ for (let i = 0; i < union.length; i++) {
5173
+ const c = union[i];
5174
+ if (c === "(" || c === "[" || c === "{" || c === "<") depth++;
5175
+ else if (c === ")" || c === "]" || c === "}" || (c === ">" && union[i - 1] !== "=")) depth--;
5176
+ else if (c === "|" && depth === 0) {
5177
+ members.push(union.slice(start, i).trim());
5178
+ start = i + 1;
5179
+ }
5180
+ }
5181
+ members.push(union.slice(start).trim());
5182
+ return members;
5183
+ }
5184
+
5185
+ // Drops whole top-level members from a rendered union. Called only once the
5186
+ // slot's pin matched, so a removal that finds nothing is a real defect.
5187
+ function withoutUnionMembers(union: string, removed: readonly string[]): string {
5188
+ const members = unionMembers(union);
5189
+ const kept = members.filter((member) => !removed.includes(member));
5190
+ if (kept.length === members.length) {
5191
+ throw new Error(`withoutUnionMembers: none of ${removed.join(", ")} is a member of ${union}`);
5192
+ }
5193
+ return kept.join(" | ");
3118
5194
  }
3119
5195
 
3120
5196
  const VARARG_PREFIX = "...";
@@ -3143,7 +5219,7 @@ function emittedParamName(p: ApiParameter, index: number): string {
3143
5219
  function emitRestParameter(
3144
5220
  params: readonly ApiParameter[],
3145
5221
  varargIndex: number,
3146
- mapType: (t: string) => string,
5222
+ mapType: DefoldTypeMapper,
3147
5223
  resolver: TableDocResolver,
3148
5224
  constantTokens: ConstantSlotTokens,
3149
5225
  elementName: string,
@@ -3177,25 +5253,29 @@ function emitParameter(
3177
5253
  p: ApiParameter,
3178
5254
  index: number,
3179
5255
  optional: boolean,
3180
- mapType: (t: string) => string,
5256
+ mapType: DefoldTypeMapper,
3181
5257
  resolver: TableDocResolver,
3182
5258
  constantTokens: ConstantSlotTokens,
3183
5259
  elementName: string,
3184
5260
  urlParameters: UrlParameterTable,
3185
- ): { text: string; ts: string } {
5261
+ ): { text: string; type: string; optional: boolean } {
3186
5262
  const name = safeParamName(p.name, index);
3187
5263
  const ts = parameterType(p, mapType, resolver, constantTokens, elementName, urlParameters);
3188
5264
  // An interior doc-optional param (a required param follows, so the trailing-`?`
3189
5265
  // projection cannot mark it) keeps its optionality as `| undefined` — TSTL
3190
5266
  // lowers `undefined` to `nil`, the faithful call. Trailing optionals keep the
3191
5267
  // `?` form; required params are untouched.
3192
- const interiorOptional = !optional && isDocOptional(p, elementName) ? " | undefined" : "";
3193
- return { text: `${name}${optional ? "?" : ""}: ${ts}${interiorOptional}`, ts };
5268
+ const nilRequired = requiredSlotCorrection(p, elementName)?.nil === true;
5269
+ const interiorOptional = !optional && (isDocOptional(p, elementName) || nilRequired);
5270
+ // `(a) => void | undefined` reads as a function returning `void | undefined`.
5271
+ const operand = interiorOptional && isFunctionTypeText(ts) ? `(${ts})` : ts;
5272
+ const type = `${operand}${interiorOptional ? " | undefined" : ""}`;
5273
+ return { text: `${name}${optional ? "?" : ""}: ${type}`, type, optional };
3194
5274
  }
3195
5275
 
3196
5276
  function emitReturn(
3197
5277
  returnValues: ApiParameter[],
3198
- mapType: (t: string) => string,
5278
+ mapType: DefoldTypeMapper,
3199
5279
  resolver: TableDocResolver,
3200
5280
  constantTokens: ConstantSlotTokens,
3201
5281
  elementName: string,
@@ -3217,19 +5297,23 @@ function emitReturn(
3217
5297
  position: index,
3218
5298
  name: rv.name,
3219
5299
  ts:
3220
- rv.types.length > 0
3221
- ? mapSlotUnion(
3222
- rv.types,
3223
- rv.doc,
3224
- mapType,
3225
- false,
3226
- resolver,
3227
- constantTokens,
3228
- elementName,
3229
- "return",
3230
- rv.name,
3231
- )
3232
- : "unknown",
5300
+ correction?.slot !== undefined &&
5301
+ correction.slot === rv.name &&
5302
+ pinMatches(rv.types, correctionPins(correction))
5303
+ ? correction.ts
5304
+ : rv.types.length > 0
5305
+ ? mapSlotUnion(
5306
+ rv.types,
5307
+ rv.doc,
5308
+ mapType,
5309
+ false,
5310
+ resolver,
5311
+ constantTokens,
5312
+ elementName,
5313
+ "return",
5314
+ rv.name,
5315
+ )
5316
+ : "unknown",
3233
5317
  }));
3234
5318
  return {
3235
5319
  type: `LuaMultiReturn<[${emitted.map((slot) => slot.ts).join(", ")}]>`,
@@ -3239,7 +5323,11 @@ function emitReturn(
3239
5323
  }
3240
5324
  const first = returnValues[0];
3241
5325
  if (!first) return { type: "void", trailing: "", slots: [] };
3242
- if (correction !== undefined) {
5326
+ if (
5327
+ correction !== undefined &&
5328
+ correction.slot === undefined &&
5329
+ pinMatches(first.types, correctionPins(correction))
5330
+ ) {
3243
5331
  return {
3244
5332
  type: correction.ts,
3245
5333
  trailing: "",
@@ -3260,23 +5348,41 @@ function emitReturn(
3260
5348
  first.name,
3261
5349
  )
3262
5350
  : "unknown";
5351
+ // A LuaLS vararg return (`...`) is every value the call returns, however many.
5352
+ if (first.name === "...") {
5353
+ const type = `LuaMultiReturn<${needsArrayParens(ts) ? `(${ts})` : ts}[]>`;
5354
+ return { type, trailing: "", slots: [{ position: 0, name: first.name, ts }] };
5355
+ }
3263
5356
  return { type: ts, trailing: "", slots: [{ position: 0, name: first.name, ts }] };
3264
5357
  }
3265
5358
 
3266
- function emitPropertyMember(
5359
+ // The mark upstream opens a property's description with when the engine refuses
5360
+ // to write it: `go.set` raises "Unable to set the property ... since it is read
5361
+ // only".
5362
+ const READ_ONLY_MARK = /^\s*<span class="mark">READ ONLY<\/span>/;
5363
+
5364
+ function emitPropertyMembers(
3267
5365
  p: ApiProperty,
3268
- mapType: (t: string) => string,
5366
+ mapType: DefoldTypeMapper,
3269
5367
  namespace: string,
3270
- ): string {
3271
- const key = TS_IDENTIFIER.test(p.name) ? p.name : JSON.stringify(p.name);
5368
+ ): readonly string[] {
5369
+ const keyRange = PROPERTY_KEY_RANGES.get(`${namespace}.${p.name}`);
5370
+ const keys: string[] = [];
5371
+ if (keyRange !== undefined) {
5372
+ for (let unit = keyRange.first; unit <= keyRange.last; unit++)
5373
+ keys.push(`${keyRange.prefix}${unit}`);
5374
+ } else {
5375
+ keys.push(TS_IDENTIFIER.test(p.name) ? p.name : JSON.stringify(p.name));
5376
+ }
3272
5377
  const correction = PROPERTY_TYPE_CORRECTIONS.get(`${namespace}.${p.name}`);
3273
5378
  const ts =
3274
- correction !== undefined
5379
+ correction !== undefined && pinMatches(p.types, propertyCorrectionPins(correction))
3275
5380
  ? correction.ts
3276
5381
  : p.types.length > 0
3277
5382
  ? unionFromTokens(p.types, mapType)
3278
5383
  : "unknown";
3279
- return `${key}: ${ts};`;
5384
+ const modifier = READ_ONLY_MARK.test(p.description) ? "readonly " : "";
5385
+ return keys.map((key) => `${modifier}${key}: ${ts};`);
3280
5386
  }
3281
5387
 
3282
5388
  // Like `unionFromTokens`, but a `table` token whose slot doc carries a parseable
@@ -3289,7 +5395,7 @@ function emitPropertyMember(
3289
5395
  function mapSlotUnion(
3290
5396
  types: readonly string[],
3291
5397
  doc: string,
3292
- mapType: (t: string) => string,
5398
+ mapType: DefoldTypeMapper,
3293
5399
  optionalFields: boolean,
3294
5400
  resolver: TableDocResolver,
3295
5401
  constantTokens: ConstantSlotTokens,
@@ -3313,11 +5419,12 @@ function mapSlotUnion(
3313
5419
  }
3314
5420
  }
3315
5421
  let ts: string;
3316
- if (token === "table") {
3317
- const curation =
3318
- slotKind !== undefined && slotName !== undefined
3319
- ? TABLE_SLOT_CURATIONS.get(tableSlotKey(elementName, slotKind, slotName))
3320
- : undefined;
5422
+ const slotCuration =
5423
+ slotKind !== undefined && slotName !== undefined
5424
+ ? TABLE_SLOT_CURATIONS.get(tableSlotKey(elementName, slotKind, slotName))
5425
+ : undefined;
5426
+ if (token === "table" || slotCuration?.retypedUpstream?.includes(token) === true) {
5427
+ const curation = slotCuration;
3321
5428
  const mapping =
3322
5429
  curation?.kind === "mapping" ? curation : MAPPING_TABLE_SLOTS.get(elementName);
3323
5430
  const element =
@@ -3341,15 +5448,33 @@ function mapSlotUnion(
3341
5448
  // A mapping key may be a `A | B` union token too (render.clear's three
3342
5449
  // documented graphics.BUFFER_TYPE_* constants); split it the same way
3343
5450
  // the value above is split. A single-token key (no `|`) is unaffected.
3344
- const key = unionFromTokens(
3345
- mapping.key.split("|").map((token) => token.trim()),
3346
- mapType,
3347
- );
3348
- ts = `LuaMap<${key}, ${value}>`;
5451
+ const keyTokens = mapping.key.split("|").map((token) => token.trim());
5452
+ if (slotKind === "param" && keyTokens.includes("string")) {
5453
+ // A caller builds a string-keyed parameter table as an object literal,
5454
+ // which `LuaMap` rejects; the `Record` arm admits it and TSTL lowers
5455
+ // both to the same Lua table. The `Record` arm carries the string keys,
5456
+ // so `LuaMap` keeps only the other key tokens: `LuaMap`'s key is
5457
+ // invariant, and a `LuaMap<Hash | string, V>` would reject the
5458
+ // `LuaMap<Hash, V>` a caller builds. A string-only key keeps its
5459
+ // `LuaMap` arm so an existing `LuaMap` caller stays valid.
5460
+ const otherKeys = keyTokens.filter((token) => token !== "string");
5461
+ const key = unionFromTokens(otherKeys.length > 0 ? otherKeys : keyTokens, mapType);
5462
+ ts = `LuaMap<${key}, ${value}> | Record<string, ${value}>`;
5463
+ } else {
5464
+ ts = `LuaMap<${unionFromTokens(keyTokens, mapType)}, ${value}>`;
5465
+ }
3349
5466
  } else if (element !== undefined) {
3350
5467
  ts = arrayTypeFromTokens(element, mapType);
5468
+ } else if (curation?.kind === "verbatim") {
5469
+ ts = curation.ts;
3351
5470
  } else if (curation?.kind === "object" || curation?.kind === "array-object") {
3352
- const object = inlineTableType(curation.fields, mapType, optionalFields);
5471
+ const fields = applyRequiredFieldCorrections(
5472
+ elementName,
5473
+ slotKind,
5474
+ slotName,
5475
+ curation.fields,
5476
+ );
5477
+ const object = inlineTableType(fields, mapType, optionalFields);
3353
5478
  ts = curation.kind === "array-object" ? `${object}[]` : object;
3354
5479
  } else if (curation?.kind === "keyed-object") {
3355
5480
  // The doc's `<dl>` describes the value shape; parse it and re-key by an
@@ -3375,7 +5500,13 @@ function mapSlotUnion(
3375
5500
  const added = applyFieldAdditions(elementName, slotKind, slotName, parsed);
3376
5501
  const nested = applyNestedFieldCurations(elementName, slotKind, slotName, added);
3377
5502
  const typed = applyFieldTypeOverrides(elementName, slotKind, slotName, nested);
3378
- const fields = applyFieldOptionalityCorrections(elementName, slotKind, slotName, typed);
5503
+ const required = applyRequiredFieldCorrections(
5504
+ elementName,
5505
+ slotKind,
5506
+ slotName,
5507
+ applyFieldOptionalityCorrections(elementName, slotKind, slotName, typed),
5508
+ );
5509
+ const fields = applyListFieldCorrections(elementName, slotKind, slotName, required);
3379
5510
  const object = inlineTableType(fields, mapType, optionalFields);
3380
5511
  ts = isSlotLevelList(doc) ? `${object}[]` : object;
3381
5512
  } else {
@@ -3430,18 +5561,21 @@ export function applyNestedFieldCurations(
3430
5561
  ): TableField[] {
3431
5562
  if (slotKind === undefined || slotName === undefined) return [...fields];
3432
5563
  return fields.map((field) => {
3433
- if (field.fields !== undefined) return field;
3434
5564
  const curated = NESTED_FIELD_CURATIONS.get(
3435
5565
  nestedFieldKey(elementName, slotKind, slotName, field.name),
3436
5566
  );
3437
5567
  if (curated === undefined) return field;
3438
- return { ...field, fields: [...curated], isList: true };
5568
+ // A parsed member list keeps its members; the curation supplies the ones
5569
+ // upstream flattened to the slot's top level.
5570
+ const parsed = field.fields ?? [];
5571
+ const missing = curated.filter((member) => !parsed.some((p) => p.name === member.name));
5572
+ return { ...field, fields: [...parsed, ...missing], isList: true };
3439
5573
  });
3440
5574
  }
3441
5575
 
3442
5576
  function arrayTypeFromTokens(
3443
5577
  element: string | readonly string[],
3444
- mapType: (t: string) => string,
5578
+ mapType: DefoldTypeMapper,
3445
5579
  ): string {
3446
5580
  const tokens = typeof element === "string" ? [element] : element;
3447
5581
  return tokens.length > 1
@@ -3451,7 +5585,7 @@ function arrayTypeFromTokens(
3451
5585
 
3452
5586
  export function inlineTableType(
3453
5587
  fields: readonly TableField[],
3454
- mapType: (t: string) => string,
5588
+ mapType: DefoldTypeMapper,
3455
5589
  optionalFields: boolean,
3456
5590
  ): string {
3457
5591
  const members = fields.map((field) => {
@@ -3469,7 +5603,7 @@ export function inlineTableType(
3469
5603
  field.tsType !== undefined
3470
5604
  ? field.tsType
3471
5605
  : field.fields !== undefined
3472
- ? `${inlineTableType(field.fields, mapType, optionalFields)}${field.isList ? "[]" : ""}`
5606
+ ? nestedFieldType(field, inlineTableType(field.fields, mapType, optionalFields))
3473
5607
  : field.numberList === true
3474
5608
  ? "number[]"
3475
5609
  : field.types.length > 0
@@ -3478,12 +5612,21 @@ export function inlineTableType(
3478
5612
  // A field is optional when the whole slot is param-side (optionalFields) or
3479
5613
  // when the curation marks that individual field optional (a return-side
3480
5614
  // variant field present only for some shape kinds, e.g. a b2d shape table).
3481
- return `${key}${optionalFields || field.optional === true ? "?" : ""}: ${ts}`;
5615
+ // A param-side field the engine requires stays required.
5616
+ const optional = (optionalFields && field.required !== true) || field.optional === true;
5617
+ return `${key}${optional ? "?" : ""}: ${ts}`;
3482
5618
  });
3483
5619
  return `{ ${members.join("; ")} }`;
3484
5620
  }
3485
5621
 
3486
- function unionFromTokens(tokens: readonly string[], mapType: (t: string) => string): string {
5622
+ function nestedFieldType(field: TableField, object: string): string {
5623
+ const element = field.alternatives === undefined ? object : `${object} & (${field.alternatives})`;
5624
+ if (field.isList !== true) return element;
5625
+ const rest = field.alternatives === undefined ? element : `(${element})`;
5626
+ return field.nonEmpty === true ? `[${element}, ...${rest}[]]` : `${rest}[]`;
5627
+ }
5628
+
5629
+ function unionFromTokens(tokens: readonly string[], mapType: DefoldTypeMapper): string {
3487
5630
  const mapped: string[] = [];
3488
5631
  const seen = new Set<string>();
3489
5632
  for (const token of tokens) {
@@ -3495,7 +5638,7 @@ function unionFromTokens(tokens: readonly string[], mapType: (t: string) => stri
3495
5638
  return joinUnionArms(mapped);
3496
5639
  }
3497
5640
 
3498
- const BRAND_ARM = /^number & \{ readonly __brand: "([^"]+)" \}$/;
5641
+ const BRAND_ARM = /^(?:number|string) & \{ readonly __brand: "([^"]+)" \}$/;
3499
5642
 
3500
5643
  // Joins already-mapped arms, collapsing the branded ones to their alias when
3501
5644
  // they are exactly a `CONSTANT_UNION_ALIASES` member set. The alias takes the
@@ -3524,20 +5667,70 @@ function joinUnionArms(mapped: readonly string[]): string {
3524
5667
  return out.join(" | ");
3525
5668
  }
3526
5669
 
3527
- export function isKnownDefoldTypeToken(token: string): boolean {
3528
- return (
3529
- token === "nil" ||
3530
- Object.hasOwn(DEFOLD_TYPE_MAP, token) ||
3531
- recoverCallbackSignature(token) !== null
3532
- );
5670
+ const BUILTIN_MESSAGE_NAMES: ReadonlySet<string> = new Set(
5671
+ messagesDoc.messages.map((message) => message.name),
5672
+ );
5673
+ const MESSAGE_REFERENCE = /^message\.[a-z_]+\.([a-z_]+)$/;
5674
+
5675
+ // The leaf vocabulary the LuaLS grammar resolves an engine type expression
5676
+ // against. `message.<ns>.<name>` names a documented message payload, typed only
5677
+ // when `<name>` is one `BuiltinMessages` declares.
5678
+ function resolveDefoldLeaf(leaf: string): string | undefined {
5679
+ if (leaf === "nil") return "undefined";
5680
+ if (Object.hasOwn(DEFOLD_TYPE_MAP, leaf)) return DEFOLD_TYPE_MAP[leaf];
5681
+ const message = MESSAGE_REFERENCE.exec(leaf);
5682
+ const name = message?.[1];
5683
+ if (name !== undefined && BUILTIN_MESSAGE_NAMES.has(name)) return `BuiltinMessages["${name}"]`;
5684
+ return undefined;
3533
5685
  }
3534
5686
 
3535
- export function defaultMapType(token: string): string {
5687
+ // Whole-token lookup and callback recovery run first, so the grammar sees only
5688
+ // what they miss; every pre-1.13.2 token resolves before it is reached.
5689
+ function wholeTokenMapping(token: string): string | undefined {
3536
5690
  if (Object.hasOwn(DEFOLD_TYPE_MAP, token)) {
3537
5691
  const mapped = DEFOLD_TYPE_MAP[token];
3538
5692
  if (typeof mapped === "string") return mapped;
3539
5693
  }
3540
- const callback = recoverCallbackSignature(token);
3541
- if (callback !== null) return callback;
3542
- return "unknown";
5694
+ return recoverCallbackSignature(token) ?? undefined;
5695
+ }
5696
+
5697
+ /** The leaves of `token` that resolve to nothing; empty when the whole token maps. */
5698
+ export function unknownDefoldTypeLeaves(token: string): string[] {
5699
+ if (token === "nil" || wholeTokenMapping(token) !== undefined) return [];
5700
+ if (token.trim() === "") return [token];
5701
+ return mapLualsExpression(token, resolveDefoldLeaf).unknowns;
5702
+ }
5703
+
5704
+ export function isKnownDefoldTypeToken(token: string): boolean {
5705
+ return unknownDefoldTypeLeaves(token).length === 0;
5706
+ }
5707
+
5708
+ export function defaultMapType(token: string, position?: LualsPosition): string {
5709
+ return wholeTokenMapping(token) ?? mapLualsExpression(token, resolveDefoldLeaf, position).ts;
5710
+ }
5711
+
5712
+ /**
5713
+ * The engine mapper extended with `leaves`, resolved ahead of the engine
5714
+ * vocabulary, and `declared` (Lua name -> TS path), consulted after it. A whole
5715
+ * token checks `leaves`, the engine's whole-token mapping, then `declared`; a
5716
+ * leaf inside a composite checks `leaves`, the engine leaves, then `declared`.
5717
+ * So a surface's handle brand (`editor.message`) outranks any declared path.
5718
+ */
5719
+ export function mapTypeWithVocabulary(vocabulary: {
5720
+ leaves?: Readonly<Record<string, string>> | undefined;
5721
+ declared?: ReadonlyMap<string, string> | undefined;
5722
+ }): DefoldTypeMapper {
5723
+ const leaves = vocabulary.leaves ?? {};
5724
+ const declared = vocabulary.declared ?? new Map<string, string>();
5725
+ if (Object.keys(leaves).length === 0 && declared.size === 0) return defaultMapType;
5726
+ const resolveLeaf = (leaf: string): string | undefined =>
5727
+ Object.hasOwn(leaves, leaf) ? leaves[leaf] : (resolveDefoldLeaf(leaf) ?? declared.get(leaf));
5728
+ return (token, position) => {
5729
+ if (Object.hasOwn(leaves, token)) return leaves[token] as string;
5730
+ return (
5731
+ wholeTokenMapping(token) ??
5732
+ declared.get(token) ??
5733
+ mapLualsExpression(token, resolveLeaf, position).ts
5734
+ );
5735
+ };
3543
5736
  }