modwright 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (455) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +212 -0
  3. package/assets/claude/agents/mw-explore.md +46 -0
  4. package/assets/claude/agents/mw-fixture.md +33 -0
  5. package/assets/claude/agents/mw-rule-author.md +51 -0
  6. package/assets/claude/agents/mw-scaffold-author.md +48 -0
  7. package/assets/claude/skills/mw-author.md +59 -0
  8. package/assets/claude/skills/mw-new-item.md +51 -0
  9. package/assets/claude/skills/mw-new-spell.md +50 -0
  10. package/assets/claude/skills/mw-scaffold.md +59 -0
  11. package/assets/claude/skills/mw-ship.md +57 -0
  12. package/assets/claude/skills/mw-validate.md +51 -0
  13. package/assets/claude/skills/mw-verify.md +59 -0
  14. package/assets/nivalisnights/extract_resources.py +177 -0
  15. package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Config.json +5 -0
  16. package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/BootstrapClient.lua +55 -0
  17. package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/BootstrapServer.lua +81 -0
  18. package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/ModWrightBridge/Log.lua +96 -0
  19. package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/ModWrightBridge/Probes.lua +1038 -0
  20. package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/ModWrightBridge/ProbesClient.lua +74 -0
  21. package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/ModWrightBridge/Protocol.lua +985 -0
  22. package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/ModWrightBridge/Watch.lua +175 -0
  23. package/bridges/bg3-se/Mods/ModWrightBridge/meta.lsx +45 -0
  24. package/bridges/bg3-se/README.md +99 -0
  25. package/bridges/bg3-se/bridge.json +68 -0
  26. package/bridges/cp2077-cet/ModWrightBridge/init.lua +375 -0
  27. package/bridges/cp2077-cet/ModWrightBridge/probes.lua +1104 -0
  28. package/bridges/cp2077-cet/ModWrightBridge/protocol.lua +1306 -0
  29. package/bridges/cp2077-cet/README.md +165 -0
  30. package/bridges/cp2077-cet/bridge.json +53 -0
  31. package/dist/core/bridge/arm.js +175 -0
  32. package/dist/core/bridge/cet-console.js +26 -0
  33. package/dist/core/bridge/cet-log.js +362 -0
  34. package/dist/core/bridge/index.js +11 -0
  35. package/dist/core/bridge/locate.js +35 -0
  36. package/dist/core/bridge/paths.js +34 -0
  37. package/dist/core/bridge/pending.js +89 -0
  38. package/dist/core/bridge/poll.js +155 -0
  39. package/dist/core/bridge/stage.js +100 -0
  40. package/dist/core/bridge/status.js +97 -0
  41. package/dist/core/bridge/types.js +145 -0
  42. package/dist/core/bridge/validate.js +107 -0
  43. package/dist/core/build/index.js +6 -0
  44. package/dist/core/build/manifest.js +47 -0
  45. package/dist/core/build/paths.js +32 -0
  46. package/dist/core/build/run.js +342 -0
  47. package/dist/core/build/stage.js +265 -0
  48. package/dist/core/build/steps/bg3.js +129 -0
  49. package/dist/core/build/steps/core.js +195 -0
  50. package/dist/core/build/steps/cyberpunk.js +95 -0
  51. package/dist/core/build/steps/listing.js +35 -0
  52. package/dist/core/build/types.js +13 -0
  53. package/dist/core/build/zip.js +148 -0
  54. package/dist/core/compat/check.js +113 -0
  55. package/dist/core/compat/pe.js +220 -0
  56. package/dist/core/compat/types.js +1 -0
  57. package/dist/core/compat/version.js +70 -0
  58. package/dist/core/deploy/guards.js +292 -0
  59. package/dist/core/deploy/index.js +5 -0
  60. package/dist/core/deploy/plan.js +696 -0
  61. package/dist/core/deploy/reload-apply.js +361 -0
  62. package/dist/core/deploy/reload.js +358 -0
  63. package/dist/core/deploy/sources.js +169 -0
  64. package/dist/core/deploy/types.js +1 -0
  65. package/dist/core/epic.js +54 -0
  66. package/dist/core/fsutil.js +191 -0
  67. package/dist/core/home.js +70 -0
  68. package/dist/core/knowledge/facts.js +229 -0
  69. package/dist/core/knowledge/index.js +4 -0
  70. package/dist/core/knowledge/search.js +112 -0
  71. package/dist/core/knowledge/store.js +123 -0
  72. package/dist/core/knowledge/types.js +1 -0
  73. package/dist/core/ledger/check.js +42 -0
  74. package/dist/core/ledger/hooks.js +80 -0
  75. package/dist/core/ledger/index.js +7 -0
  76. package/dist/core/ledger/query.js +17 -0
  77. package/dist/core/ledger/record.js +84 -0
  78. package/dist/core/ledger/status.js +53 -0
  79. package/dist/core/ledger/store.js +98 -0
  80. package/dist/core/ledger/types.js +108 -0
  81. package/dist/core/logs/index.js +4 -0
  82. package/dist/core/logs/registry.js +50 -0
  83. package/dist/core/logs/scan.js +195 -0
  84. package/dist/core/logs/triage.js +160 -0
  85. package/dist/core/logs/types.js +1 -0
  86. package/dist/core/lsx.js +10 -0
  87. package/dist/core/lz4.js +66 -0
  88. package/dist/core/mods.js +229 -0
  89. package/dist/core/process.js +49 -0
  90. package/dist/core/project/build-steps.js +83 -0
  91. package/dist/core/project/describe.js +74 -0
  92. package/dist/core/project/index.js +5 -0
  93. package/dist/core/project/load.js +146 -0
  94. package/dist/core/project/schema.js +264 -0
  95. package/dist/core/project/types.js +3 -0
  96. package/dist/core/prompts.js +72 -0
  97. package/dist/core/registry.js +35 -0
  98. package/dist/core/safety/backup.js +206 -0
  99. package/dist/core/safety/execute.js +188 -0
  100. package/dist/core/safety/index.js +5 -0
  101. package/dist/core/safety/plan.js +122 -0
  102. package/dist/core/safety/restore.js +172 -0
  103. package/dist/core/safety/types.js +1 -0
  104. package/dist/core/scaffold/bootstrap.js +7 -0
  105. package/dist/core/scaffold/catalog.js +28 -0
  106. package/dist/core/scaffold/claude-assets.js +271 -0
  107. package/dist/core/scaffold/collisions.js +64 -0
  108. package/dist/core/scaffold/emit.js +356 -0
  109. package/dist/core/scaffold/engine.js +680 -0
  110. package/dist/core/scaffold/ids.js +78 -0
  111. package/dist/core/scaffold/index.js +10 -0
  112. package/dist/core/scaffold/jsonpath.js +121 -0
  113. package/dist/core/scaffold/load.js +81 -0
  114. package/dist/core/scaffold/render.js +85 -0
  115. package/dist/core/scaffold/sequence.js +396 -0
  116. package/dist/core/scaffold/types.js +309 -0
  117. package/dist/core/steam.js +63 -0
  118. package/dist/core/surfaceutil.js +36 -0
  119. package/dist/core/testplan/check.js +67 -0
  120. package/dist/core/testplan/index.js +5 -0
  121. package/dist/core/testplan/load.js +100 -0
  122. package/dist/core/testplan/registry.js +317 -0
  123. package/dist/core/testplan/run.js +389 -0
  124. package/dist/core/testplan/types.js +82 -0
  125. package/dist/core/text/converters/bg3.js +50 -0
  126. package/dist/core/text/converters/cyberpunk.js +36 -0
  127. package/dist/core/text/converters/index.js +261 -0
  128. package/dist/core/text/diff/cr2w.js +180 -0
  129. package/dist/core/text/diff/index.js +16 -0
  130. package/dist/core/text/diff/loca.js +43 -0
  131. package/dist/core/text/diff/lsx.js +126 -0
  132. package/dist/core/text/handles.js +86 -0
  133. package/dist/core/text/index.js +6 -0
  134. package/dist/core/text/json.js +46 -0
  135. package/dist/core/text/templates/cache.js +71 -0
  136. package/dist/core/text/templates/index.js +3 -0
  137. package/dist/core/text/templates/library.js +245 -0
  138. package/dist/core/text/templates/types.js +51 -0
  139. package/dist/core/text/toolchain.js +16 -0
  140. package/dist/core/text/types.js +1 -0
  141. package/dist/core/text/validate.js +149 -0
  142. package/dist/core/text/xml.js +58 -0
  143. package/dist/core/toolchain/datafetch.js +151 -0
  144. package/dist/core/toolchain/env.js +63 -0
  145. package/dist/core/toolchain/features.js +170 -0
  146. package/dist/core/toolchain/index.js +8 -0
  147. package/dist/core/toolchain/install.js +191 -0
  148. package/dist/core/toolchain/locate.js +275 -0
  149. package/dist/core/toolchain/remember.js +84 -0
  150. package/dist/core/toolchain/run.js +318 -0
  151. package/dist/core/toolchain/specs.js +351 -0
  152. package/dist/core/toolchain/types.js +1 -0
  153. package/dist/core/types.js +1 -0
  154. package/dist/core/userconfig.js +66 -0
  155. package/dist/core/validate/context.js +139 -0
  156. package/dist/core/validate/index.js +5 -0
  157. package/dist/core/validate/registry.js +58 -0
  158. package/dist/core/validate/run.js +131 -0
  159. package/dist/core/validate/suppressions.js +91 -0
  160. package/dist/core/validate/types.js +2 -0
  161. package/dist/core/yamledit.js +51 -0
  162. package/dist/index.js +389 -0
  163. package/dist/server.js +1914 -0
  164. package/dist/surfaces/baldursgate3/compat.js +164 -0
  165. package/dist/surfaces/baldursgate3/identity.js +110 -0
  166. package/dist/surfaces/baldursgate3/index/build.js +331 -0
  167. package/dist/surfaces/baldursgate3/index/index.js +17 -0
  168. package/dist/surfaces/baldursgate3/index/info.js +40 -0
  169. package/dist/surfaces/baldursgate3/index/parsers/loca.js +41 -0
  170. package/dist/surfaces/baldursgate3/index/parsers/lsx.js +137 -0
  171. package/dist/surfaces/baldursgate3/index/parsers/stats.js +45 -0
  172. package/dist/surfaces/baldursgate3/index/parsers/treasuretable.js +57 -0
  173. package/dist/surfaces/baldursgate3/index/parsers/xmlutil.js +142 -0
  174. package/dist/surfaces/baldursgate3/index/queries.js +327 -0
  175. package/dist/surfaces/baldursgate3/index/schema.js +62 -0
  176. package/dist/surfaces/baldursgate3/index.js +3 -0
  177. package/dist/surfaces/baldursgate3/logs.js +278 -0
  178. package/dist/surfaces/baldursgate3/modsettings.js +115 -0
  179. package/dist/surfaces/baldursgate3/pak.js +153 -0
  180. package/dist/surfaces/baldursgate3/settings.js +49 -0
  181. package/dist/surfaces/baldursgate3/surface.js +232 -0
  182. package/dist/surfaces/baldursgate3/validators/bridge.js +7 -0
  183. package/dist/surfaces/baldursgate3/validators/files.js +476 -0
  184. package/dist/surfaces/baldursgate3/validators/items.js +1001 -0
  185. package/dist/surfaces/baldursgate3/validators/progression.js +734 -0
  186. package/dist/surfaces/baldursgate3/validators/stats.js +1076 -0
  187. package/dist/surfaces/baldursgate3/validators/story.js +790 -0
  188. package/dist/surfaces/baldursgate3/validators/templates.js +7 -0
  189. package/dist/surfaces/cyberpunk2077/compat.js +223 -0
  190. package/dist/surfaces/cyberpunk2077/index/build.js +231 -0
  191. package/dist/surfaces/cyberpunk2077/index/index.js +17 -0
  192. package/dist/surfaces/cyberpunk2077/index/info.js +51 -0
  193. package/dist/surfaces/cyberpunk2077/index/model.js +52 -0
  194. package/dist/surfaces/cyberpunk2077/index/parsers/tweak.js +483 -0
  195. package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +183 -0
  196. package/dist/surfaces/cyberpunk2077/index/queries.js +504 -0
  197. package/dist/surfaces/cyberpunk2077/index/resource.js +135 -0
  198. package/dist/surfaces/cyberpunk2077/index/schema.js +90 -0
  199. package/dist/surfaces/cyberpunk2077/index.js +3 -0
  200. package/dist/surfaces/cyberpunk2077/logs.js +742 -0
  201. package/dist/surfaces/cyberpunk2077/paths.js +12 -0
  202. package/dist/surfaces/cyberpunk2077/surface.js +218 -0
  203. package/dist/surfaces/cyberpunk2077/validators/bridge.js +7 -0
  204. package/dist/surfaces/cyberpunk2077/validators/cr2w.js +349 -0
  205. package/dist/surfaces/cyberpunk2077/validators/garments.js +1181 -0
  206. package/dist/surfaces/cyberpunk2077/validators/mesh.js +338 -0
  207. package/dist/surfaces/cyberpunk2077/validators/modsettings.js +120 -0
  208. package/dist/surfaces/cyberpunk2077/validators/packaging.js +463 -0
  209. package/dist/surfaces/cyberpunk2077/validators/red4ext.js +318 -0
  210. package/dist/surfaces/cyberpunk2077/validators/redscript.js +193 -0
  211. package/dist/surfaces/cyberpunk2077/validators/templates.js +7 -0
  212. package/dist/surfaces/cyberpunk2077/validators/tweaks.js +768 -0
  213. package/dist/surfaces/cyberpunk2077/validators/vehicles.js +531 -0
  214. package/dist/surfaces/cyberpunk2077/validators/xl.js +308 -0
  215. package/dist/surfaces/eldenring/binders.js +111 -0
  216. package/dist/surfaces/eldenring/compat.js +166 -0
  217. package/dist/surfaces/eldenring/dcx.js +49 -0
  218. package/dist/surfaces/eldenring/index.js +13 -0
  219. package/dist/surfaces/eldenring/loaders.js +132 -0
  220. package/dist/surfaces/eldenring/logs.js +67 -0
  221. package/dist/surfaces/eldenring/paramdef.js +256 -0
  222. package/dist/surfaces/eldenring/params.js +129 -0
  223. package/dist/surfaces/eldenring/surface.js +121 -0
  224. package/dist/surfaces/eldenring/tools.js +52 -0
  225. package/dist/surfaces/eldenring/validators/binders.js +193 -0
  226. package/dist/surfaces/eldenring/validators/packages.js +159 -0
  227. package/dist/surfaces/eldenring/validators/params.js +334 -0
  228. package/dist/surfaces/eldenring/validators/profiles.js +287 -0
  229. package/dist/surfaces/index.js +34 -0
  230. package/dist/surfaces/nivalisnights/compat.js +229 -0
  231. package/dist/surfaces/nivalisnights/index/build.js +310 -0
  232. package/dist/surfaces/nivalisnights/index/index.js +5 -0
  233. package/dist/surfaces/nivalisnights/index/queries.js +231 -0
  234. package/dist/surfaces/nivalisnights/index.js +4 -0
  235. package/dist/surfaces/nivalisnights/logs.js +473 -0
  236. package/dist/surfaces/nivalisnights/surface.js +195 -0
  237. package/dist/surfaces/skyrimse/archives.js +229 -0
  238. package/dist/surfaces/skyrimse/compat.js +163 -0
  239. package/dist/surfaces/skyrimse/index.js +12 -0
  240. package/dist/surfaces/skyrimse/loadorder.js +174 -0
  241. package/dist/surfaces/skyrimse/logs.js +241 -0
  242. package/dist/surfaces/skyrimse/plugins.js +149 -0
  243. package/dist/surfaces/skyrimse/records.js +94 -0
  244. package/dist/surfaces/skyrimse/surface.js +240 -0
  245. package/dist/surfaces/skyrimse/validators/archive-records.js +165 -0
  246. package/dist/surfaces/skyrimse/validators/archives.js +110 -0
  247. package/dist/surfaces/skyrimse/validators/plugins.js +374 -0
  248. package/dist/surfaces/skyrimse/validators/records.js +117 -0
  249. package/dist/surfaces/stardewvalley/compat.js +190 -0
  250. package/dist/surfaces/stardewvalley/config.js +42 -0
  251. package/dist/surfaces/stardewvalley/contentpatcher.js +203 -0
  252. package/dist/surfaces/stardewvalley/index/index.js +229 -0
  253. package/dist/surfaces/stardewvalley/index/wiki.js +190 -0
  254. package/dist/surfaces/stardewvalley/index.js +12 -0
  255. package/dist/surfaces/stardewvalley/logs.js +307 -0
  256. package/dist/surfaces/stardewvalley/mods.js +182 -0
  257. package/dist/surfaces/stardewvalley/surface.js +244 -0
  258. package/dist/surfaces/stardewvalley/validators/content.js +321 -0
  259. package/dist/surfaces/stardewvalley/validators/fields.js +147 -0
  260. package/dist/surfaces/stardewvalley/validators/loadorder.js +69 -0
  261. package/dist/surfaces/stardewvalley/validators/manifest.js +221 -0
  262. package/dist/surfaces/subnautica2/compat.js +138 -0
  263. package/dist/surfaces/subnautica2/index.js +5 -0
  264. package/dist/surfaces/subnautica2/logs.js +129 -0
  265. package/dist/surfaces/subnautica2/paks.js +173 -0
  266. package/dist/surfaces/subnautica2/surface.js +151 -0
  267. package/dist/surfaces/subnautica2/validators/packaging.js +298 -0
  268. package/dist/surfaces/valheim/compat.js +171 -0
  269. package/dist/surfaces/valheim/index/index.js +301 -0
  270. package/dist/surfaces/valheim/index/tables.js +103 -0
  271. package/dist/surfaces/valheim/index.js +11 -0
  272. package/dist/surfaces/valheim/logs.js +197 -0
  273. package/dist/surfaces/valheim/packaging.js +131 -0
  274. package/dist/surfaces/valheim/plugins.js +270 -0
  275. package/dist/surfaces/valheim/references.js +121 -0
  276. package/dist/surfaces/valheim/surface.js +223 -0
  277. package/dist/surfaces/valheim/validators/packaging.js +218 -0
  278. package/dist/surfaces/valheim/validators/plugins.js +287 -0
  279. package/dist/surfaces/valheim/validators/references.js +169 -0
  280. package/knowledge/baldursgate3/animations.gr2.yaml +166 -0
  281. package/knowledge/baldursgate3/audio.voice.yaml +182 -0
  282. package/knowledge/baldursgate3/audio.wwise.yaml +104 -0
  283. package/knowledge/baldursgate3/charactercreation.heads-hair.yaml +193 -0
  284. package/knowledge/baldursgate3/communitylibrary.shared-content.yaml +129 -0
  285. package/knowledge/baldursgate3/compatibilityframework.api.yaml +176 -0
  286. package/knowledge/baldursgate3/crash.causes.yaml +90 -0
  287. package/knowledge/baldursgate3/dialogue.dialogbank.yaml +120 -0
  288. package/knowledge/baldursgate3/dialogue.files.yaml +236 -0
  289. package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +386 -0
  290. package/knowledge/baldursgate3/dialogue.yaml +122 -0
  291. package/knowledge/baldursgate3/feats.classes.yaml +164 -0
  292. package/knowledge/baldursgate3/items.cloth-physics.yaml +167 -0
  293. package/knowledge/baldursgate3/items.roottemplates.yaml +320 -0
  294. package/knowledge/baldursgate3/items.visualbank.yaml +628 -0
  295. package/knowledge/baldursgate3/journal.quests.yaml +214 -0
  296. package/knowledge/baldursgate3/loadorder.modsettings.yaml +128 -0
  297. package/knowledge/baldursgate3/localization.handles.yaml +92 -0
  298. package/knowledge/baldursgate3/misc.encoding-and-practice.yaml +111 -0
  299. package/knowledge/baldursgate3/origins.companions.yaml +144 -0
  300. package/knowledge/baldursgate3/osiris.signatures.yaml +211 -0
  301. package/knowledge/baldursgate3/packaging.meta.yaml +155 -0
  302. package/knowledge/baldursgate3/packaging.paks.yaml +79 -0
  303. package/knowledge/baldursgate3/process.launch.yaml +34 -0
  304. package/knowledge/baldursgate3/progressions.yaml +92 -0
  305. package/knowledge/baldursgate3/publishing.modio-nexus.yaml +170 -0
  306. package/knowledge/baldursgate3/publishing.toolkit-modio.yaml +135 -0
  307. package/knowledge/baldursgate3/races.progression.yaml +228 -0
  308. package/knowledge/baldursgate3/scriptextender.console.yaml +257 -0
  309. package/knowledge/baldursgate3/scriptextender.yaml +252 -0
  310. package/knowledge/baldursgate3/stats.boosts.yaml +293 -0
  311. package/knowledge/baldursgate3/stats.equipment-kits.yaml +69 -0
  312. package/knowledge/baldursgate3/stats.interrupts.yaml +45 -0
  313. package/knowledge/baldursgate3/stats.itemcombos.yaml +65 -0
  314. package/knowledge/baldursgate3/stats.spells.yaml +293 -0
  315. package/knowledge/baldursgate3/stats.weapons.yaml +210 -0
  316. package/knowledge/baldursgate3/toolchain.divine.yaml +179 -0
  317. package/knowledge/baldursgate3/toolchain.lslib-formats.yaml +242 -0
  318. package/knowledge/baldursgate3/toolkit.levels.yaml +177 -0
  319. package/knowledge/baldursgate3/ui.icons-atlas.yaml +78 -0
  320. package/knowledge/baldursgate3/ui.icons.yaml +412 -0
  321. package/knowledge/baldursgate3/ui.mcm.yaml +161 -0
  322. package/knowledge/baldursgate3/vfx.materials.yaml +174 -0
  323. package/knowledge/baldursgate3/visuals.characterfix.yaml +73 -0
  324. package/knowledge/cyberpunk2077/animations.anims.yaml +118 -0
  325. package/knowledge/cyberpunk2077/anims.archivexl.yaml +163 -0
  326. package/knowledge/cyberpunk2077/anims.authoring.yaml +579 -0
  327. package/knowledge/cyberpunk2077/anims.encoder.yaml +156 -0
  328. package/knowledge/cyberpunk2077/anims.graph.yaml +629 -0
  329. package/knowledge/cyberpunk2077/anims.method.yaml +360 -0
  330. package/knowledge/cyberpunk2077/anims.pipeline.yaml +352 -0
  331. package/knowledge/cyberpunk2077/anims.roundtrip.yaml +222 -0
  332. package/knowledge/cyberpunk2077/anims.sets.yaml +176 -0
  333. package/knowledge/cyberpunk2077/archivexl.bodytypes.yaml +149 -0
  334. package/knowledge/cyberpunk2077/archivexl.dynamic-appearances.yaml +218 -0
  335. package/knowledge/cyberpunk2077/archivexl.manifest.yaml +214 -0
  336. package/knowledge/cyberpunk2077/audio.events.yaml +168 -0
  337. package/knowledge/cyberpunk2077/audio.sounds.yaml +164 -0
  338. package/knowledge/cyberpunk2077/audio.voicesets.yaml +146 -0
  339. package/knowledge/cyberpunk2077/cet.sandbox.yaml +219 -0
  340. package/knowledge/cyberpunk2077/clothing.garments.yaml +283 -0
  341. package/knowledge/cyberpunk2077/clothing.refits.yaml +215 -0
  342. package/knowledge/cyberpunk2077/codeware.overview.yaml +111 -0
  343. package/knowledge/cyberpunk2077/codeware.systems.yaml +192 -0
  344. package/knowledge/cyberpunk2077/drones.combat.yaml +416 -0
  345. package/knowledge/cyberpunk2077/drones.control.yaml +552 -0
  346. package/knowledge/cyberpunk2077/entities.appearance.yaml +166 -0
  347. package/knowledge/cyberpunk2077/equipmentex.slots.yaml +151 -0
  348. package/knowledge/cyberpunk2077/fx.effects.yaml +246 -0
  349. package/knowledge/cyberpunk2077/fx.lights.yaml +171 -0
  350. package/knowledge/cyberpunk2077/hair.modding.yaml +145 -0
  351. package/knowledge/cyberpunk2077/items.wiring.yaml +246 -0
  352. package/knowledge/cyberpunk2077/loadorder.archives.yaml +71 -0
  353. package/knowledge/cyberpunk2077/mesh.blender.yaml +209 -0
  354. package/knowledge/cyberpunk2077/nativedb.dump.yaml +98 -0
  355. package/knowledge/cyberpunk2077/npc.appearance.yaml +275 -0
  356. package/knowledge/cyberpunk2077/npc.behaviour.yaml +128 -0
  357. package/knowledge/cyberpunk2077/npc.locomotion.yaml +490 -0
  358. package/knowledge/cyberpunk2077/packaging.deploy.yaml +175 -0
  359. package/knowledge/cyberpunk2077/packaging.variants.yaml +176 -0
  360. package/knowledge/cyberpunk2077/player.control.yaml +918 -0
  361. package/knowledge/cyberpunk2077/process.assetbuild.yaml +154 -0
  362. package/knowledge/cyberpunk2077/process.lessons.yaml +276 -0
  363. package/knowledge/cyberpunk2077/quests.minor.yaml +46 -0
  364. package/knowledge/cyberpunk2077/quests.scenes.yaml +282 -0
  365. package/knowledge/cyberpunk2077/quickhacks.tiers.yaml +41 -0
  366. package/knowledge/cyberpunk2077/red4ext.api.yaml +645 -0
  367. package/knowledge/cyberpunk2077/red4ext.plugins.yaml +124 -0
  368. package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +245 -0
  369. package/knowledge/cyberpunk2077/redscript.compile-log.yaml +76 -0
  370. package/knowledge/cyberpunk2077/redscript.modules.yaml +118 -0
  371. package/knowledge/cyberpunk2077/toolchain.wolvenkit.yaml +163 -0
  372. package/knowledge/cyberpunk2077/tweakdb.loot-recipes.yaml +184 -0
  373. package/knowledge/cyberpunk2077/tweakdb.melee.yaml +352 -0
  374. package/knowledge/cyberpunk2077/tweakdb.weapons.yaml +325 -0
  375. package/knowledge/cyberpunk2077/tweakxl.hotreload.yaml +151 -0
  376. package/knowledge/cyberpunk2077/tweakxl.yaml-syntax.yaml +208 -0
  377. package/knowledge/cyberpunk2077/ui.icons.yaml +259 -0
  378. package/knowledge/cyberpunk2077/ui.inkatlas.yaml +81 -0
  379. package/knowledge/cyberpunk2077/ui.modsettings.yaml +204 -0
  380. package/knowledge/cyberpunk2077/vehicles.control.yaml +475 -0
  381. package/knowledge/cyberpunk2077/vehicles.dashboard-ui.yaml +203 -0
  382. package/knowledge/cyberpunk2077/vehicles.entities.yaml +195 -0
  383. package/knowledge/cyberpunk2077/vehicles.wiring.yaml +137 -0
  384. package/knowledge/cyberpunk2077/wolvenkit.cli.yaml +167 -0
  385. package/knowledge/cyberpunk2077/workspot.system.yaml +55 -0
  386. package/knowledge/cyberpunk2077/world.interactions.yaml +41 -0
  387. package/knowledge/cyberpunk2077/world.loot-container.yaml +167 -0
  388. package/knowledge/cyberpunk2077/world.sectors.yaml +233 -0
  389. package/knowledge/eldenring/formats.binders.yaml +237 -0
  390. package/knowledge/eldenring/launcher.me3.yaml +230 -0
  391. package/knowledge/eldenring/launcher.modengine2.yaml +119 -0
  392. package/knowledge/eldenring/params.paramdef.yaml +149 -0
  393. package/knowledge/eldenring/params.regulation.yaml +142 -0
  394. package/knowledge/eldenring/saves.online.yaml +118 -0
  395. package/knowledge/eldenring/toolchain.cli.yaml +256 -0
  396. package/knowledge/nivalisnights/modding.runtime.yaml +157 -0
  397. package/knowledge/skyrimse/archives.bsa.yaml +212 -0
  398. package/knowledge/skyrimse/install.detection.yaml +41 -0
  399. package/knowledge/skyrimse/loadorder.files.yaml +242 -0
  400. package/knowledge/skyrimse/plugins.esl.yaml +189 -0
  401. package/knowledge/skyrimse/plugins.format.yaml +224 -0
  402. package/knowledge/skyrimse/skse.runtime.yaml +283 -0
  403. package/knowledge/skyrimse/toolchain.xedit-spriggit.yaml +336 -0
  404. package/knowledge/stardewvalley/contentpatcher.format.yaml +338 -0
  405. package/knowledge/stardewvalley/distribution.channels.yaml +236 -0
  406. package/knowledge/stardewvalley/game.versions.yaml +202 -0
  407. package/knowledge/stardewvalley/install.layout.yaml +277 -0
  408. package/knowledge/stardewvalley/smapi.loader.yaml +284 -0
  409. package/knowledge/stardewvalley/smapi.logs.yaml +329 -0
  410. package/knowledge/stardewvalley/smapi.manifest.yaml +293 -0
  411. package/knowledge/stardewvalley/toolchain.build.yaml +261 -0
  412. package/knowledge/stardewvalley/wiki.dataformat.yaml +130 -0
  413. package/knowledge/subnautica2/game.build.yaml +331 -0
  414. package/knowledge/subnautica2/modding.state.yaml +210 -0
  415. package/knowledge/subnautica2/ue5.containers.yaml +72 -0
  416. package/knowledge/subnautica2/ue5.paks.yaml +203 -0
  417. package/knowledge/subnautica2/ue5.tooling.yaml +175 -0
  418. package/knowledge/subnautica2/ue5.ue4ss.yaml +203 -0
  419. package/knowledge/valheim/bepinex.loader.yaml +247 -0
  420. package/knowledge/valheim/bepinex.plugins.yaml +251 -0
  421. package/knowledge/valheim/game.versions.yaml +321 -0
  422. package/knowledge/valheim/install.layout.yaml +216 -0
  423. package/knowledge/valheim/jotunn.library.yaml +334 -0
  424. package/knowledge/valheim/thunderstore.packaging.yaml +217 -0
  425. package/knowledge/valheim/toolchain.build.yaml +111 -0
  426. package/package.json +29 -0
  427. package/scaffolds/baldursgate3/new-class-injection.yaml +148 -0
  428. package/scaffolds/baldursgate3/new-item.yaml +264 -0
  429. package/scaffolds/baldursgate3/new-mod.yaml +174 -0
  430. package/scaffolds/baldursgate3/new-passive.yaml +100 -0
  431. package/scaffolds/baldursgate3/new-project.yaml +118 -0
  432. package/scaffolds/baldursgate3/new-quest-stub.yaml +159 -0
  433. package/scaffolds/baldursgate3/new-spell.yaml +102 -0
  434. package/scaffolds/baldursgate3/new-status.yaml +119 -0
  435. package/scaffolds/baldursgate3/sequences/subclass-kit.yaml +50 -0
  436. package/scaffolds/cyberpunk2077/edit-tweak.yaml +70 -0
  437. package/scaffolds/cyberpunk2077/mesh-export-preset.yaml +170 -0
  438. package/scaffolds/cyberpunk2077/new-entity-patch.yaml +255 -0
  439. package/scaffolds/cyberpunk2077/new-garment-refit.yaml +279 -0
  440. package/scaffolds/cyberpunk2077/new-item-chain.yaml +548 -0
  441. package/scaffolds/cyberpunk2077/new-localization.yaml +130 -0
  442. package/scaffolds/cyberpunk2077/new-mod-settings.yaml +133 -0
  443. package/scaffolds/cyberpunk2077/new-player-replacer.yaml +1003 -0
  444. package/scaffolds/cyberpunk2077/new-project.yaml +157 -0
  445. package/scaffolds/cyberpunk2077/new-sector-node.yaml +156 -0
  446. package/scaffolds/cyberpunk2077/new-tweak.yaml +121 -0
  447. package/scaffolds/cyberpunk2077/new-vehicle-livery.yaml +148 -0
  448. package/scaffolds/cyberpunk2077/new-workspot-entity.yaml +177 -0
  449. package/scaffolds/cyberpunk2077/sequences/iconic-weapon.yaml +72 -0
  450. package/scaffolds/cyberpunk2077/sequences/quickhack-takeover.yaml +213 -0
  451. package/scaffolds/stardewvalley/new-cp-patch.yaml +106 -0
  452. package/scaffolds/stardewvalley/new-csharp-mod.yaml +195 -0
  453. package/scaffolds/stardewvalley/new-project.yaml +184 -0
  454. package/scaffolds/stardewvalley/sequences/content-pack.yaml +31 -0
  455. package/scripts/mw-tool.mjs +184 -0
@@ -0,0 +1,985 @@
1
+ -- MODWRIGHT-BRIDGE-DO-NOT-SHIP
2
+ --
3
+ -- ModWrightBridge / Protocol.lua — file transport, marker framing, nonce arming, the
4
+ -- heartbeat, and the request orchestration. This is the Lua half of the ModWright
5
+ -- request/result protocol, and its output is parsed byte-for-byte by
6
+ -- src/core/bridge/{types,paths,poll}.ts. Change nothing here without re-reading both.
7
+ --
8
+ -- ============================================================================
9
+ -- Ext API used in this file (heading + signature quoted above each first use)
10
+ -- ============================================================================
11
+ -- API.md §"I/O - `Ext.IO`" / "Methods":
12
+ -- `Ext.IO.LoadFile(path, [context]): string?` -- Reads file contents. Returns `nil` if the file cannot be read.
13
+ -- `Ext.IO.SaveFile(path, content): boolean` -- Writes content to a file. Creates missing parent directories.
14
+ -- (Note: that list is the WHOLE of Ext.IO. There is no delete and no rename, which is why
15
+ -- a consumed request is retired by an in-memory nonce and an `armedUntil` expiry, not by
16
+ -- removing the file.)
17
+ -- API.md §"JSON support - `Ext.Json`":
18
+ -- `Ext.Json.Parse` and `Ext.Json.Stringify`; `Stringify(value, [options])` with
19
+ -- `IterateUserdata`, `AvoidRecursion`, `StringifyInternalTypes`, `MaxDepth`.
20
+ -- API.md §"Timers - `Ext.Timer`" / "Delayed execution":
21
+ -- `Ext.Timer.WaitForRealtime(ms, callback)` -- Uses OS clock.
22
+ -- API.md §"Timers - `Ext.Timer`" / "Clock helpers":
23
+ -- `Ext.Timer.ClockEpoch(): int64`
24
+ -- `Ext.Timer.MonotonicTime(): int64`
25
+ -- API.md §"Utils - `Ext.Utils`" / "Common methods":
26
+ -- `Ext.Utils.Version(): int32` - Script Extender API version.
27
+ -- `Ext.Utils.GameVersion(): string?` - game version string.
28
+ -- `Ext.Utils.GetGameState()` - current game state enum.
29
+ -- `Ext.Utils.GetCommandLineParams(): string[]` - CLI arguments used to launch the game
30
+ -- API.md §"Helper/aliased functions":
31
+ -- `_P()`: Equivalent to `Ext.Utils.Print()` (used via Log.lua)
32
+ --
33
+ -- ============================================================================
34
+ -- Why this file carries its own JSON encoder
35
+ -- ============================================================================
36
+ -- `Ext.Json.Stringify` is used here for exactly one job — flattening ENGINE OBJECTS into
37
+ -- plain Lua tables (`Protocol.dumpComponent`), which is what API.md §"JSON support" documents
38
+ -- `IterateUserdata` for and what a hand-run console dump of a component uses.
39
+ --
40
+ -- It is NOT used to serialise the protocol envelope. API.md's Lua/JS type table says
41
+ -- "`table` (sequential keys)" becomes an array and "`table` (non-sequential)" an object, and
42
+ -- says nothing about an EMPTY table, which is both and neither. The Node schemas are
43
+ -- `.strict()` and typed: `errors` and `probes` are `z.array(...)`, `evidence` is
44
+ -- `z.record(...)`. An empty `errors` list emitted as `{}` — or an empty `evidence` map emitted
45
+ -- as `[]` — fails `bridgeResultSchema.safeParse`, and `poll.ts`'s `tryReadComplete` silently
46
+ -- discards a result that fails to parse, so the whole run reads back as a bridge that never
47
+ -- answered. That is a relaunch cycle spent on a coin flip nothing on disk settles. The
48
+ -- encoder below removes the coin flip: `Protocol.array(t)` tags a list, and a tagged empty
49
+ -- list is always `[]` while an untagged empty table is always `{}`.
50
+ -- ============================================================================
51
+
52
+ local Log = Ext.Require("ModWrightBridge/Log.lua")
53
+
54
+ local Protocol = {}
55
+
56
+ --- An integer duplicated in the Lua and in the Node constant
57
+ --- (`PROTOCOL_VERSION` in src/core/bridge/types.ts), checked at arm time.
58
+ Protocol.PROTOCOL_VERSION = 1
59
+
60
+ --- Mirrors bridge.json's `version`. Reported in every heartbeat and result.
61
+ Protocol.BRIDGE_NAME = "ModWrightBridge"
62
+ Protocol.BRIDGE_VERSION = "0.1.0"
63
+
64
+ --- The BG3 file names, flat names in the SE storage dir. These strings must equal
65
+ --- `BG3_FILES` in src/core/bridge/paths.ts exactly.
66
+ Protocol.FILES = {
67
+ heartbeatServer = "ModWright_hb.json",
68
+ heartbeatClient = "ModWright_hb_client.json",
69
+ request = "ModWright_req.json",
70
+ requestMarker = "ModWright_req.marker.json",
71
+ }
72
+
73
+ function Protocol.resultFile(id) return "ModWright_res_" .. tostring(id) .. ".json" end
74
+ function Protocol.doneFile(id) return "ModWright_res_" .. tostring(id) .. ".done.json" end
75
+
76
+ --- Timer periods: 1000 ms for the request marker, 2000 ms for the heartbeat.
77
+ Protocol.POLL_MS = 1000
78
+ Protocol.HEARTBEAT_MS = 2000
79
+
80
+ --- Hard stop for one request inside the bridge, on top of its own settle+window. Keeps a
81
+ --- lost harvest timer from parking the bridge in `running` forever, where ModWright would
82
+ --- only ever see poll.ts's "hung" verdict and never a result.
83
+ Protocol.WATCHDOG_SLACK_MS = 30000
84
+
85
+ --- Runtime state (all in-memory: a console `reset` reloads the Lua state and clears it,
86
+ --- which is exactly why arming carries an `armedUntil`).
87
+ Protocol.context = "server"
88
+ Protocol.heartbeatFile = Protocol.FILES.heartbeatServer
89
+ Protocol.state = "idle"
90
+ Protocol.consumed = {}
91
+ --- Raw bytes of the last marker seen with an already-consumed nonce (pollOnce's fast path).
92
+ Protocol.lastConsumedMarkerRaw = nil
93
+ Protocol.lastRequestId = nil
94
+ Protocol.lastResultId = nil
95
+ Protocol.osirisDegraded = false
96
+ Protocol.osirisDegradedReason = nil
97
+ Protocol.loadedAtMs = Log.monotonic()
98
+ Protocol.busy = false
99
+ Protocol.currentRun = nil
100
+ Protocol.dispatcher = nil
101
+ Protocol.pollingStarted = false
102
+ Protocol.heartbeatStarted = false
103
+ Protocol.pendingMismatch = nil
104
+ Protocol.tornReads = 0
105
+
106
+ -- ---------------------------------------------------------------------------
107
+ -- JSON encoding (see the header note for why this is not Ext.Json.Stringify)
108
+ -- ---------------------------------------------------------------------------
109
+
110
+ local ARRAY_MT = { __modwright_array = true }
111
+
112
+ --- Tag a Lua table so the encoder always emits a JSON array, even when it is empty.
113
+ function Protocol.array(t)
114
+ return setmetatable(t or {}, ARRAY_MT)
115
+ end
116
+
117
+ local ESCAPES = {
118
+ ['"'] = '\\"',
119
+ ['\\'] = '\\\\',
120
+ ['\b'] = '\\b',
121
+ ['\f'] = '\\f',
122
+ ['\n'] = '\\n',
123
+ ['\r'] = '\\r',
124
+ ['\t'] = '\\t',
125
+ }
126
+
127
+ local function escapeChar(c)
128
+ local e = ESCAPES[c]
129
+ if e then return e end
130
+ return string.format("\\u%04x", string.byte(c))
131
+ end
132
+
133
+ local function encodeString(s)
134
+ -- `%c` (control characters) rather than `%z`: `%z` is gone in Lua 5.2+ and the bridge
135
+ -- lint job runs `luac -p` under both 5.1 and 5.4 over this tree.
136
+ local body = tostring(s):gsub('[%c"\\]', escapeChar)
137
+ return '"' .. body .. '"'
138
+ end
139
+
140
+ local function encodeNumber(n)
141
+ if n ~= n then return "null" end -- NaN
142
+ if n == math.huge or n == -math.huge then return "null" end
143
+ if n % 1 == 0 and n >= -9007199254740992 and n <= 9007199254740992 then
144
+ return string.format("%d", n)
145
+ end
146
+ return string.format("%.14g", n)
147
+ end
148
+
149
+ local function isArrayLike(t)
150
+ if getmetatable(t) == ARRAY_MT then return true end
151
+ local n = 0
152
+ for _ in pairs(t) do n = n + 1 end
153
+ if n == 0 then return false end -- untagged empty table -> {}
154
+ if #t ~= n then return false end
155
+ for i = 1, n do
156
+ if t[i] == nil then return false end
157
+ end
158
+ return true
159
+ end
160
+
161
+ local encodeValue
162
+
163
+ local function encodeTable(t, depth, seen, maxDepth)
164
+ if depth > maxDepth then return '"<max-depth>"' end
165
+ if seen[t] then return '"<recursion>"' end
166
+ seen[t] = true
167
+
168
+ local parts = {}
169
+ local out
170
+ if isArrayLike(t) then
171
+ for i = 1, #t do
172
+ parts[#parts + 1] = encodeValue(t[i], depth + 1, seen, maxDepth)
173
+ end
174
+ out = "[" .. table.concat(parts, ",") .. "]"
175
+ else
176
+ -- Keys sorted so two runs over the same data produce byte-identical output; the
177
+ -- these files can be diffed by hand.
178
+ local keys = {}
179
+ for k in pairs(t) do
180
+ local kt = type(k)
181
+ if kt == "string" or kt == "number" then keys[#keys + 1] = k end
182
+ end
183
+ table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
184
+ for _, k in ipairs(keys) do
185
+ local encoded = encodeValue(t[k], depth + 1, seen, maxDepth)
186
+ if encoded ~= nil then
187
+ parts[#parts + 1] = encodeString(tostring(k)) .. ":" .. encoded
188
+ end
189
+ end
190
+ out = "{" .. table.concat(parts, ",") .. "}"
191
+ end
192
+
193
+ seen[t] = nil
194
+ return out
195
+ end
196
+
197
+ encodeValue = function(v, depth, seen, maxDepth)
198
+ local t = type(v)
199
+ if v == nil then return "null" end
200
+ if t == "boolean" then return v and "true" or "false" end
201
+ if t == "number" then return encodeNumber(v) end
202
+ if t == "string" then return encodeString(v) end
203
+ if t == "table" then return encodeTable(v, depth, seen, maxDepth) end
204
+ -- userdata / function / thread: engine objects reach this encoder only as already-flattened
205
+ -- tables (Protocol.dumpComponent). Anything else is reported, never guessed at.
206
+ return encodeString("<unencodable:" .. t .. ">")
207
+ end
208
+
209
+ --- Encode a value as compact JSON. Never throws; returns a string.
210
+ function Protocol.encode(value, maxDepth)
211
+ local ok, out = pcall(function()
212
+ return encodeValue(value, 1, {}, maxDepth or 16)
213
+ end)
214
+ if ok and type(out) == "string" then return out end
215
+ return '{"encodeError":' .. encodeString(tostring(out)) .. "}"
216
+ end
217
+
218
+ --- Parse JSON with the attested API.md call. Returns nil on any failure.
219
+ function Protocol.decode(raw)
220
+ if type(raw) ~= "string" or raw == "" then return nil end
221
+ local ok, value = pcall(function() return Ext.Json.Parse(raw) end)
222
+ if ok then return value end
223
+ return nil
224
+ end
225
+
226
+ --- Capability tiers. A request carries the tiers it was granted in
227
+ --- `request.grant`; a mutating probe kind needs the `session` tier. `allowMutate: true` is
228
+ --- accepted for one release as the retired alias for grant:["session"], so a request written
229
+ --- by an older ModWright still gates correctly.
230
+ function Protocol.grantsTier(request, tier)
231
+ if type(request) ~= "table" then return false end
232
+ if type(request.grant) == "table" then
233
+ for _, g in ipairs(request.grant) do
234
+ if tostring(g) == tier then return true end
235
+ end
236
+ end
237
+ if tier == "session" and request.allowMutate == true then return true end
238
+ return false
239
+ end
240
+
241
+ -- ---------------------------------------------------------------------------
242
+ -- Files
243
+ -- ---------------------------------------------------------------------------
244
+
245
+ --- `Ext.IO.LoadFile(path, [context]): string?` — nil when the file cannot be read.
246
+ function Protocol.load(name)
247
+ local ok, raw = pcall(function() return Ext.IO.LoadFile(name) end)
248
+ if ok and type(raw) == "string" then return raw end
249
+ return nil
250
+ end
251
+
252
+ --- `Ext.IO.SaveFile(path, content): boolean`.
253
+ function Protocol.save(name, content)
254
+ local ok, res = pcall(function() return Ext.IO.SaveFile(name, content) end)
255
+ if not ok then
256
+ Log.err("save", tostring(name) .. ": " .. tostring(res))
257
+ return false
258
+ end
259
+ return res ~= false
260
+ end
261
+
262
+ -- ---------------------------------------------------------------------------
263
+ -- Time
264
+ -- ---------------------------------------------------------------------------
265
+ -- Every timestamp this bridge writes is read on the Node side by `Date.parse`
266
+ -- (`iso8601Schema` in src/core/bridge/types.ts), so it must be an ISO 8601 UTC string.
267
+ -- The conversions below are Howard Hinnant's civil-calendar algorithms in pure Lua so the
268
+ -- format never depends on an `os.date` that may or may not exist in the SE sandbox.
269
+
270
+ local function daysFromCivil(y, m, d)
271
+ if m <= 2 then y = y - 1 end
272
+ local era = math.floor(y / 400)
273
+ local yoe = y - era * 400
274
+ local mp = m + ((m > 2) and -3 or 9)
275
+ local doy = math.floor((153 * mp + 2) / 5) + d - 1
276
+ local doe = yoe * 365 + math.floor(yoe / 4) - math.floor(yoe / 100) + doy
277
+ return era * 146097 + doe - 719468
278
+ end
279
+
280
+ local function civilFromDays(z)
281
+ z = z + 719468
282
+ local era = math.floor(z / 146097)
283
+ local doe = z - era * 146097
284
+ local yoe = math.floor((doe - math.floor(doe / 1460) + math.floor(doe / 36524) - math.floor(doe / 146096)) / 365)
285
+ local y = yoe + era * 400
286
+ local doy = doe - (365 * yoe + math.floor(yoe / 4) - math.floor(yoe / 100))
287
+ local mp = math.floor((5 * doy + 2) / 153)
288
+ local d = doy - math.floor((153 * mp + 2) / 5) + 1
289
+ local m = mp + ((mp < 10) and 3 or -9)
290
+ if m <= 2 then y = y + 1 end
291
+ return y, m, d
292
+ end
293
+
294
+ --- Seconds since the Unix epoch, UTC. Total.
295
+ --- API.md §"Timers"/"Clock helpers" gives `Ext.Timer.ClockEpoch(): int64` but does NOT state
296
+ --- its unit, so the value is normalised here rather than assumed: anything past 1e12 is
297
+ --- treated as milliseconds. `os.time()` is the fallback (a shipped SE mod shows that the `os`
298
+ --- library is reachable from SE Lua via `os.clock`).
299
+ function Protocol.epochSeconds()
300
+ local ok, v = pcall(function() return Ext.Timer.ClockEpoch() end)
301
+ if ok then
302
+ local n = tonumber(v)
303
+ if n and n > 1000000000 then
304
+ if n > 1000000000000 then return math.floor(n / 1000) end
305
+ return math.floor(n)
306
+ end
307
+ end
308
+ local ok2, t = pcall(function() return os.time() end)
309
+ if ok2 then
310
+ local n2 = tonumber(t)
311
+ if n2 and n2 > 1000000000 then return math.floor(n2) end
312
+ end
313
+ return nil
314
+ end
315
+
316
+ function Protocol.isoFromEpoch(seconds)
317
+ if type(seconds) ~= "number" then return nil end
318
+ local days = math.floor(seconds / 86400)
319
+ local rem = seconds - days * 86400
320
+ local y, m, d = civilFromDays(days)
321
+ local h = math.floor(rem / 3600)
322
+ local mi = math.floor((rem % 3600) / 60)
323
+ local s = math.floor(rem % 60)
324
+ return string.format("%04d-%02d-%02dT%02d:%02d:%02dZ", y, m, d, h, mi, s)
325
+ end
326
+
327
+ --- Current time as an ISO 8601 UTC string. Falls back to the epoch itself (still a valid,
328
+ --- parseable timestamp) and records the loss of accuracy in the run's `errors` instead of
329
+ --- inventing a plausible-looking time.
330
+ function Protocol.isoNow(run)
331
+ local secs = Protocol.epochSeconds()
332
+ if secs then
333
+ local iso = Protocol.isoFromEpoch(secs)
334
+ if iso then return iso end
335
+ end
336
+ if run and run.errors then
337
+ run.errors[#run.errors + 1] =
338
+ "no wall clock available (Ext.Timer.ClockEpoch and os.time both unavailable); timestamps are the Unix epoch"
339
+ end
340
+ return "1970-01-01T00:00:00Z"
341
+ end
342
+
343
+ --- Parse an ISO 8601 UTC timestamp to epoch seconds. Node writes `Date#toISOString()`, i.e.
344
+ --- always `YYYY-MM-DDTHH:MM:SS.mmmZ`; fractional seconds and the trailing `Z` are ignored,
345
+ --- and a value carrying a numeric UTC offset is rejected (nil) rather than silently misread.
346
+ function Protocol.parseIso(s)
347
+ if type(s) ~= "string" then return nil end
348
+ local y, mo, d, h, mi, se = s:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)")
349
+ if not y then return nil end
350
+ local tail = s:sub(20)
351
+ if tail:match("[%+%-]%d%d:?%d%d$") then return nil end
352
+ local days = daysFromCivil(tonumber(y), tonumber(mo), tonumber(d))
353
+ return days * 86400 + tonumber(h) * 3600 + tonumber(mi) * 60 + tonumber(se)
354
+ end
355
+
356
+ -- ---------------------------------------------------------------------------
357
+ -- Small shared helpers used by the probe dispatch tables
358
+ -- ---------------------------------------------------------------------------
359
+
360
+ --- GUID form mismatch: Osiris hands the same entity as a bare UUID in some events and
361
+ --- prefixed `Name_UUID` in others (observed in game). Every compare in this bridge goes
362
+ --- through this function.
363
+ function Protocol.normGuid(g)
364
+ if type(g) ~= "string" then return g end
365
+ local uuid = g:match("(%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x)$")
366
+ return uuid or g
367
+ end
368
+
369
+ --- Flatten an engine object (component, event, userdata) into a plain Lua table plus the raw
370
+ --- JSON text. Returns (table|nil, json|nil).
371
+ ---
372
+ --- API.md §"JSON support - `Ext.Json`": `Stringify(value, [options])` with `IterateUserdata`
373
+ --- ("Dump engine objects similarly to tables instead of throwing an error"), `AvoidRecursion`,
374
+ --- `StringifyInternalTypes` and `MaxDepth`; and "parsing a JSON with userdata objects will
375
+ --- return them as normal tables" — which is precisely the round trip used here. The same
376
+ --- call can be run by hand at the console:
377
+ --- Ext.IO.SaveFile("ModWright_probe_resources.json",
378
+ --- Ext.Json.Stringify(Ext.Entity.Get(...).ActionResources, {IterateUserdata=true, MaxDepth=4}))
379
+ ---
380
+ --- Deliberately NOT `Ext.Types.Serialize`: that call appears nowhere in API.md, so it is
381
+ --- attested(engine) only, and this route does not need it.
382
+ function Protocol.dumpComponent(component, maxDepth)
383
+ if component == nil then return nil, nil end
384
+ local json
385
+ local ok = pcall(function()
386
+ json = Ext.Json.Stringify(component, {
387
+ IterateUserdata = true,
388
+ AvoidRecursion = true,
389
+ StringifyInternalTypes = true,
390
+ MaxDepth = maxDepth or 4,
391
+ })
392
+ end)
393
+ if not ok or type(json) ~= "string" then return nil, nil end
394
+ local tbl
395
+ pcall(function() tbl = Ext.Json.Parse(json) end)
396
+ return tbl, json
397
+ end
398
+
399
+ --- Deep, order-insensitive value search used by the `contains` operator. Strings are matched
400
+ --- as plain substrings; tables are searched over their encoded form, which is what makes
401
+ --- `expect: { op: contains, value: "<subclass uuid>" }` work against class rows
402
+ --- without pinning a field path the docs never gave.
403
+ local function containsValue(observed, value)
404
+ if observed == nil then return false end
405
+ local needle = tostring(value)
406
+ if type(observed) == "string" then
407
+ return observed:find(needle, 1, true) ~= nil
408
+ end
409
+ if type(observed) == "table" then
410
+ for _, v in pairs(observed) do
411
+ if type(v) == "table" then
412
+ if containsValue(v, value) then return true end
413
+ elseif tostring(v) == needle then
414
+ return true
415
+ end
416
+ end
417
+ local blob = Protocol.encode(observed)
418
+ return blob:find(needle, 1, true) ~= nil
419
+ end
420
+ return tostring(observed) == needle
421
+ end
422
+
423
+ local function deepEqual(a, b)
424
+ if type(a) ~= "table" or type(b) ~= "table" then return a == b end
425
+ return Protocol.encode(a) == Protocol.encode(b)
426
+ end
427
+
428
+ --- Apply a probe's `expect` block
429
+ --- (`{ op: "eq"|"ne"|"gte"|"lte"|"contains"|"absent", value: unknown }`).
430
+ --- Returns "pass" | "fail" | "error" plus a short explanation for `evidence.expect`.
431
+ function Protocol.evaluateExpect(observed, expect)
432
+ if type(expect) ~= "table" or expect.op == nil then return nil, nil end
433
+ local op = tostring(expect.op)
434
+ local value = expect.value
435
+
436
+ if op == "eq" then
437
+ return deepEqual(observed, value) and "pass" or "fail", "eq"
438
+ elseif op == "ne" then
439
+ return (not deepEqual(observed, value)) and "pass" or "fail", "ne"
440
+ elseif op == "gte" or op == "lte" then
441
+ local a, b = tonumber(observed), tonumber(value)
442
+ if a == nil or b == nil then
443
+ return "error", op .. " needs two numbers; observed=" .. tostring(observed)
444
+ end
445
+ if op == "gte" then return (a >= b) and "pass" or "fail", "gte" end
446
+ return (a <= b) and "pass" or "fail", "lte"
447
+ elseif op == "contains" then
448
+ return containsValue(observed, value) and "pass" or "fail", "contains"
449
+ elseif op == "absent" then
450
+ -- "absent" is true for nil, false, an empty string and an empty table: the four shapes
451
+ -- a probe uses to say "there is nothing here".
452
+ local empty = (observed == nil) or (observed == false) or (observed == "")
453
+ if not empty and type(observed) == "table" then
454
+ empty = (next(observed) == nil)
455
+ end
456
+ return empty and "pass" or "fail", "absent"
457
+ end
458
+ return "error", "unknown expect op: " .. op
459
+ end
460
+
461
+ -- ---------------------------------------------------------------------------
462
+ -- Heartbeat
463
+ -- ---------------------------------------------------------------------------
464
+
465
+ --- The `bridge` block shared by heartbeats and results (`bridgeInfoSchema`, .strict()).
466
+ function Protocol.info()
467
+ if Protocol.cachedInfo then return Protocol.cachedInfo end
468
+ local info = {
469
+ name = Protocol.BRIDGE_NAME,
470
+ version = Protocol.BRIDGE_VERSION,
471
+ protocolVersion = Protocol.PROTOCOL_VERSION,
472
+ context = Protocol.context,
473
+ }
474
+ -- `Ext.Utils.Version(): int32` — an integer; the schema wants a string.
475
+ local okV, v = pcall(function() return Ext.Utils.Version() end)
476
+ if okV and v ~= nil then info.runtimeVersion = tostring(v) end
477
+ -- `Ext.Utils.GameVersion(): string?`
478
+ local okG, g = pcall(function() return Ext.Utils.GameVersion() end)
479
+ if okG and g ~= nil then info.gameVersion = tostring(g) end
480
+ -- Static for the life of the Lua state; cached once both versions resolved, so the
481
+ -- 2 s heartbeat stops re-asking the extender (item 6 above). `context` is set by
482
+ -- Protocol.init before the first heartbeat.
483
+ if info.runtimeVersion and info.gameVersion then Protocol.cachedInfo = info end
484
+ return info
485
+ end
486
+
487
+ --- `Ext.Utils.GetGameState()` - current game state enum. Stringified: the schema wants a
488
+ --- string and the enum's Lua representation is not pinned by API.md.
489
+ function Protocol.gameState()
490
+ local ok, s = pcall(function() return Ext.Utils.GetGameState() end)
491
+ if ok and s ~= nil then return tostring(s) end
492
+ return nil
493
+ end
494
+
495
+ --- `Ext.Utils.GetCommandLineParams(): string[]` — evidence that the game
496
+ --- was launched the confirmed way (`--skip-launcher` as a Steam launch option). Attested by
497
+ --- API.md, which also prints an example containing "--skip-launcher"; still pcall-guarded and
498
+ --- omitted rather than faked when an older extender does not have it (the mod's
499
+ --- RequiredVersion is pinned at 21, below API.md's own example of 29).
500
+ function Protocol.commandLine()
501
+ if Protocol.cachedCommandLine then return Protocol.cachedCommandLine end
502
+ local ok, params = pcall(function() return Ext.Utils.GetCommandLineParams() end)
503
+ if not ok or type(params) ~= "table" then return nil end
504
+ local out = Protocol.array({})
505
+ for _, p in ipairs(params) do out[#out + 1] = tostring(p) end
506
+ Protocol.cachedCommandLine = out -- the process's own argv does not change
507
+ return out
508
+ end
509
+
510
+ --- Write the heartbeat (`bridgeHeartbeatSchema`, .strict() — every key below is in it, and
511
+ --- no key that is not). `writeRoot` is the fixed "<SE>" label paths.ts documents for BG3.
512
+ function Protocol.writeHeartbeat()
513
+ local t0 = Log.monotonic()
514
+ local hb = {
515
+ protocolVersion = Protocol.PROTOCOL_VERSION,
516
+ bridge = Protocol.info(),
517
+ at = Protocol.isoNow(),
518
+ uptimeMs = math.max(0, Log.monotonic() - Protocol.loadedAtMs),
519
+ state = Protocol.state,
520
+ writeRoot = "<SE>",
521
+ osirisDegraded = Protocol.osirisDegraded,
522
+ }
523
+ if Protocol.lastRequestId then hb.lastRequestId = Protocol.lastRequestId end
524
+ if Protocol.lastResultId then hb.lastResultId = Protocol.lastResultId end
525
+ local gs = Protocol.gameState()
526
+ if gs then hb.gameState = gs end
527
+ local cl = Protocol.commandLine()
528
+ if cl then hb.commandLine = cl end
529
+ local t1 = Log.monotonic()
530
+ local json = Protocol.encode(hb)
531
+ local t2 = Log.monotonic()
532
+ Protocol.save(Protocol.heartbeatFile, json)
533
+ local t3 = Log.monotonic()
534
+ -- One breadcrumb, on the third heartbeat (the first two pay one-off costs), so the
535
+ -- next field run can say which half of the tick the extender's slow-callback warning
536
+ -- is measuring: the extender API reads, the JSON encode, or the file write.
537
+ Protocol.heartbeatCount = (Protocol.heartbeatCount or 0) + 1
538
+ if Protocol.heartbeatCount == 3 then
539
+ Log.info(("heartbeat cost: fields %.2f ms, encode %.2f ms, save %.2f ms")
540
+ :format(t1 - t0, t2 - t1, t3 - t2))
541
+ end
542
+ end
543
+
544
+ -- ---------------------------------------------------------------------------
545
+ -- Result writing: result first, `done` marker second (the atomicity rule on the game side)
546
+ -- ---------------------------------------------------------------------------
547
+
548
+ --- `bytes` is `#json` — Lua's byte length of the exact string handed to SaveFile, which is
549
+ --- what poll.ts compares against `Buffer.byteLength(resultRaw, "utf8")`. Nothing may be
550
+ --- appended to `json` after this point (no trailing newline).
551
+ function Protocol.writeResult(result)
552
+ local json = Protocol.encode(result)
553
+ local probeCount = 0
554
+ if type(result.probes) == "table" then probeCount = #result.probes end
555
+
556
+ local okResult = Protocol.save(Protocol.resultFile(result.id), json)
557
+ if not okResult then
558
+ Log.err("writeResult", "result file write failed for " .. tostring(result.id) ..
559
+ "; the done marker is deliberately NOT written, so ModWright retries rather than reading a partial file")
560
+ return false
561
+ end
562
+
563
+ local done = {
564
+ protocolVersion = Protocol.PROTOCOL_VERSION,
565
+ id = result.id,
566
+ nonce = result.nonce,
567
+ bytes = #json,
568
+ probeCount = probeCount,
569
+ }
570
+ local okDone = Protocol.save(Protocol.doneFile(result.id), Protocol.encode(done))
571
+ Log.info(("result %s written (%d bytes, %d probes, done marker %s)")
572
+ :format(tostring(result.id), #json, probeCount, okDone and "ok" or "FAILED"))
573
+ return okDone
574
+ end
575
+
576
+ --- Build a Result envelope (`bridgeResultSchema`, .strict()).
577
+ function Protocol.buildResult(request, startedAt, probeResults, errors, disarm)
578
+ local result = {
579
+ protocolVersion = Protocol.PROTOCOL_VERSION,
580
+ id = tostring(request.id),
581
+ nonce = tostring(request.nonce),
582
+ startedAt = startedAt,
583
+ finishedAt = Protocol.isoNow(),
584
+ complete = true,
585
+ bridge = Protocol.info(),
586
+ probes = Protocol.array(probeResults or {}),
587
+ errors = Protocol.array(errors or {}),
588
+ }
589
+ if Protocol.osirisDegraded then
590
+ result.degraded = {
591
+ osiris = true,
592
+ reason = Protocol.osirisDegradedReason or
593
+ "Ext.Events.ResetCompleted fired in this Lua state; a mid-session `reset` degrades Osiris",
594
+ }
595
+ end
596
+ if disarm then result.disarm = disarm end
597
+ return result
598
+ end
599
+
600
+ --- One `skipped` ProbeResult, used for every probe in a refused request (the bridge
601
+ --- writes a Result with the matching `disarm.reason` and every probe `skipped`).
602
+ function Protocol.skippedProbe(probe, why)
603
+ return {
604
+ id = tostring((probe and probe.id) or "?"),
605
+ kind = tostring((probe and probe.kind) or "?"),
606
+ status = "skipped",
607
+ observed = nil,
608
+ evidence = { reason = why },
609
+ error = why,
610
+ ranAt = Protocol.isoNow(),
611
+ }
612
+ end
613
+
614
+ --- Write a refusal and consume the nonce so it is not rewritten on every poll.
615
+ function Protocol.refuse(request, reason, detail)
616
+ local probes = {}
617
+ if type(request.probes) == "table" then
618
+ for i, p in ipairs(request.probes) do
619
+ probes[i] = Protocol.skippedProbe(p, "request disarmed: " .. reason)
620
+ end
621
+ end
622
+ local startedAt = Protocol.isoNow()
623
+ local result = Protocol.buildResult(request, startedAt, probes, {}, { reason = reason, detail = detail })
624
+ Protocol.state = "disarmed"
625
+ Protocol.lastRequestId = tostring(request.id)
626
+ if request.nonce ~= nil then Protocol.consumed[tostring(request.nonce)] = true end
627
+ Protocol.writeResult(result)
628
+ Protocol.lastResultId = tostring(request.id)
629
+ Log.info("REFUSED request " .. tostring(request.id) .. " (" .. reason .. "): " .. tostring(detail))
630
+ Protocol.writeHeartbeat()
631
+ end
632
+
633
+ -- ---------------------------------------------------------------------------
634
+ -- Running a request
635
+ -- ---------------------------------------------------------------------------
636
+
637
+ local function finish(run)
638
+ if run.finished then return end
639
+ run.finished = true
640
+
641
+ local probes = {}
642
+ for i = 1, run.probeCount do
643
+ local r = run.results[i]
644
+ if r == nil then
645
+ local p = run.request.probes[i]
646
+ r = {
647
+ id = tostring((p and p.id) or ("probe-" .. i)),
648
+ kind = tostring((p and p.kind) or "?"),
649
+ status = "error",
650
+ observed = nil,
651
+ evidence = { reason = "no result recorded" },
652
+ error = "the bridge finished the batch before this probe produced a result (watchdog or harvest failure)",
653
+ ranAt = Protocol.isoNow(),
654
+ }
655
+ end
656
+ probes[i] = r
657
+ end
658
+
659
+ local result = Protocol.buildResult(run.request, run.startedAt, probes, run.errors, nil)
660
+ Protocol.state = "idle"
661
+ Protocol.writeResult(result)
662
+ Protocol.lastResultId = tostring(run.request.id)
663
+ Protocol.busy = false
664
+ Protocol.currentRun = nil
665
+ Protocol.writeHeartbeat()
666
+ end
667
+
668
+ --- Harvest every armed observe probe, then finish.
669
+ local function harvest(run)
670
+ for _, entry in ipairs(run.observe) do
671
+ local ok, res = pcall(function()
672
+ return Protocol.dispatcher.harvest(entry.probe, run, entry.armed)
673
+ end)
674
+ if ok and type(res) == "table" then
675
+ run.results[entry.index] = res
676
+ else
677
+ run.results[entry.index] = {
678
+ id = tostring(entry.probe.id),
679
+ kind = tostring(entry.probe.kind),
680
+ status = "error",
681
+ observed = nil,
682
+ evidence = {},
683
+ error = "harvest failed: " .. tostring(res),
684
+ ranAt = Protocol.isoNow(),
685
+ }
686
+ end
687
+ end
688
+ finish(run)
689
+ end
690
+
691
+ --- Run every read/mutate probe, then either harvest immediately or after `window.seconds`.
692
+ local function runReadPhase(run)
693
+ for i, probe in ipairs(run.request.probes) do
694
+ if run.results[i] == nil and not run.observeIndex[i] then
695
+ local ok, res = pcall(function() return Protocol.dispatcher.run(probe, run) end)
696
+ if ok and type(res) == "table" then
697
+ run.results[i] = res
698
+ else
699
+ run.results[i] = {
700
+ id = tostring(probe.id),
701
+ kind = tostring(probe.kind),
702
+ status = "error",
703
+ observed = nil,
704
+ evidence = {},
705
+ error = "dispatch failed: " .. tostring(res),
706
+ ranAt = Protocol.isoNow(),
707
+ }
708
+ end
709
+ end
710
+ end
711
+
712
+ if #run.observe > 0 and run.windowSeconds > 0 then
713
+ Ext.Timer.WaitForRealtime(math.floor(run.windowSeconds * 1000), Log.guarded("harvest", function()
714
+ harvest(run)
715
+ end))
716
+ else
717
+ if #run.observe > 0 then
718
+ run.errors[#run.errors + 1] =
719
+ "the request carries observe probes but no window.seconds, so they were harvested immediately"
720
+ end
721
+ harvest(run)
722
+ end
723
+ end
724
+
725
+ --- Execute an accepted request. Never throws into the timer callback.
726
+ function Protocol.execute(request)
727
+ local run = {
728
+ request = request,
729
+ probeCount = #request.probes,
730
+ results = {},
731
+ observe = {},
732
+ observeIndex = {},
733
+ errors = {},
734
+ finished = false,
735
+ startedAt = nil,
736
+ }
737
+ run.startedAt = Protocol.isoNow(run)
738
+ run.windowSeconds = tonumber((type(request.window) == "table" and request.window.seconds) or 0) or 0
739
+
740
+ Protocol.busy = true
741
+ Protocol.state = "running"
742
+ Protocol.lastRequestId = tostring(request.id)
743
+ Protocol.consumed[tostring(request.nonce)] = true
744
+ Protocol.currentRun = run
745
+ Protocol.writeHeartbeat()
746
+
747
+ Log.info(("ARMED request %s: %d probe(s), window %ss, allowMutate=%s")
748
+ :format(tostring(request.id), run.probeCount, tostring(run.windowSeconds), tostring(request.allowMutate == true)))
749
+
750
+ -- Arm phase. `mutate` is gated here on capability tiers (they replace the older
751
+ -- "mutate runs only under allowMutate" boolean): a mutate probe runs only when
752
+ -- the request was granted the `session` tier, which Protocol.grantsTier resolves — honouring
753
+ -- the retired `allowMutate: true` alias for one release. `observe` registers its listener
754
+ -- now, before the settle delay, so the window really is the window the request asked for.
755
+ local settleSeconds = 0
756
+ for i, probe in ipairs(request.probes) do
757
+ local effect = tostring(probe.effect or "read")
758
+ local s = tonumber(probe.settleSeconds) or 0
759
+ if s > settleSeconds then settleSeconds = s end
760
+
761
+ if effect == "mutate" and not Protocol.grantsTier(request, "session") then
762
+ run.results[i] = Protocol.skippedProbe(probe,
763
+ "effect is \"mutate\" (session tier) and the request was not granted the \"session\" tier; " ..
764
+ "grant it via bridge.policy mode: workbench, grant: [\"session\"], or the retired allowMutate: true alias")
765
+ elseif effect == "observe" then
766
+ local ok, armed = pcall(function() return Protocol.dispatcher.arm(probe, run) end)
767
+ if not ok then
768
+ run.results[i] = {
769
+ id = tostring(probe.id),
770
+ kind = tostring(probe.kind),
771
+ status = "error",
772
+ observed = nil,
773
+ evidence = {},
774
+ error = "arm failed: " .. tostring(armed),
775
+ ranAt = Protocol.isoNow(),
776
+ }
777
+ else
778
+ run.observeIndex[i] = true
779
+ run.observe[#run.observe + 1] = { index = i, probe = probe, armed = armed }
780
+ end
781
+ end
782
+ end
783
+
784
+ -- Watchdog: a lost harvest timer must not park the bridge in `running` forever.
785
+ local budgetMs = math.floor((settleSeconds + run.windowSeconds) * 1000) + Protocol.WATCHDOG_SLACK_MS
786
+ Ext.Timer.WaitForRealtime(budgetMs, Log.guarded("watchdog", function()
787
+ if not run.finished then
788
+ run.errors[#run.errors + 1] =
789
+ ("the bridge watchdog fired after %d ms; probes without a result are reported as errors"):format(budgetMs)
790
+ Log.err("watchdog", "request " .. tostring(request.id) .. " did not finish in " .. tostring(budgetMs) .. " ms")
791
+ finish(run)
792
+ end
793
+ end))
794
+
795
+ if settleSeconds > 0 then
796
+ Ext.Timer.WaitForRealtime(math.floor(settleSeconds * 1000), Log.guarded("settle", function()
797
+ runReadPhase(run)
798
+ end))
799
+ else
800
+ runReadPhase(run)
801
+ end
802
+ end
803
+
804
+ -- ---------------------------------------------------------------------------
805
+ -- Polling and arming
806
+ -- ---------------------------------------------------------------------------
807
+
808
+ --- One poll. Reads the marker and the request, applies every arming check in
809
+ --- order, and either refuses in writing or executes.
810
+ function Protocol.pollOnce()
811
+ if Protocol.busy then return end
812
+
813
+ local markerRaw = Protocol.load(Protocol.FILES.requestMarker)
814
+ if markerRaw == nil or markerRaw == "" then return end
815
+ -- Observed in game: ModWright leaves the marker and
816
+ -- request files in place after a request completes, so every poll after that was
817
+ -- loading and decoding both files only to find the nonce consumed below. The extender
818
+ -- flagged the guarded tick as a slow callback (5 to 25 ms) on the console.
819
+ -- An unchanged marker whose nonce is already consumed needs no decode and no second read.
820
+ if markerRaw == Protocol.lastConsumedMarkerRaw then return end
821
+ local marker = Protocol.decode(markerRaw)
822
+ if type(marker) ~= "table" then return end
823
+
824
+ local requestRaw = Protocol.load(Protocol.FILES.request)
825
+ if requestRaw == nil or requestRaw == "" then return end
826
+ local request = Protocol.decode(requestRaw)
827
+ if type(request) ~= "table" then return end
828
+
829
+ local reqNonce = tostring(request.nonce or "")
830
+ local mkNonce = tostring(marker.nonce or "")
831
+ local reqId = tostring(request.id or "")
832
+ local mkId = tostring(marker.id or "")
833
+
834
+ -- Already handled (executed or refused). Silence: the nonce is consumed
835
+ -- precisely so a refusal is not rewritten on every poll.
836
+ if Protocol.consumed[reqNonce] then
837
+ Protocol.lastConsumedMarkerRaw = markerRaw
838
+ return
839
+ end
840
+
841
+ -- Identity mismatch. ModWright writes the request FIRST and the marker SECOND
842
+ -- (arm.ts `planArmRequest`), so a poll landing between the two writes legitimately sees a
843
+ -- new request beside the previous marker. Refusing that in writing would consume a nonce
844
+ -- that was about to become valid, so a mismatch must be STABLE across two polls (>= 1 s
845
+ -- apart) before it is treated as a real nonce mismatch rather than a torn read.
846
+ if reqNonce ~= mkNonce or reqId ~= mkId then
847
+ local key = reqId .. "/" .. reqNonce .. "|" .. mkId .. "/" .. mkNonce
848
+ if Protocol.pendingMismatch and Protocol.pendingMismatch.key == key then
849
+ Protocol.pendingMismatch = nil
850
+ Protocol.consumed[mkNonce] = true
851
+ Protocol.refuse(request, "nonce-mismatch",
852
+ ("marker names id=%s nonce=%s; request names id=%s nonce=%s (stable across two polls, so not a torn write)")
853
+ :format(mkId, mkNonce, reqId, reqNonce))
854
+ else
855
+ Protocol.pendingMismatch = { key = key }
856
+ end
857
+ return
858
+ end
859
+ Protocol.pendingMismatch = nil
860
+
861
+ -- Byte length. A disagreement is the half-written case, which has no `disarm.reason`
862
+ -- on purpose: the right answer is to wait, not to refuse.
863
+ local markerBytes = tonumber(marker.bytes)
864
+ if markerBytes == nil or #requestRaw ~= markerBytes then
865
+ Protocol.tornReads = Protocol.tornReads + 1
866
+ if Protocol.tornReads % 20 == 0 then
867
+ Log.info(("request %s still incomplete after %d polls: marker says %s bytes, file is %d")
868
+ :format(reqId, Protocol.tornReads, tostring(marker.bytes), #requestRaw))
869
+ end
870
+ return
871
+ end
872
+ Protocol.tornReads = 0
873
+
874
+ -- protocolVersion: a bridge reading a different version writes
875
+ -- `disarm.reason = "protocol-mismatch"` rather than guessing.
876
+ local reqVersion = tonumber(request.protocolVersion)
877
+ local mkVersion = tonumber(marker.protocolVersion)
878
+ if reqVersion ~= Protocol.PROTOCOL_VERSION or mkVersion ~= Protocol.PROTOCOL_VERSION then
879
+ Protocol.refuse(request, "protocol-mismatch",
880
+ ("this bridge speaks protocolVersion %d; the request says %s and its marker says %s. Re-run `bridge stage`, then `deploy --variant withBridge`.")
881
+ :format(Protocol.PROTOCOL_VERSION, tostring(request.protocolVersion), tostring(marker.protocolVersion)))
882
+ return
883
+ end
884
+
885
+ -- game
886
+ if tostring(request.game) ~= "baldursgate3" then
887
+ Protocol.refuse(request, "wrong-game",
888
+ ("this is the Baldur's Gate 3 bridge; the request names game=%s"):format(tostring(request.game)))
889
+ return
890
+ end
891
+
892
+ -- probes present
893
+ if type(request.probes) ~= "table" or #request.probes == 0 then
894
+ Protocol.refuse(request, "protocol-mismatch", "the request carries no probes")
895
+ return
896
+ end
897
+
898
+ -- armedUntil
899
+ local nowSecs = Protocol.epochSeconds()
900
+ local untilSecs = Protocol.parseIso(tostring(request.armedUntil or ""))
901
+ if untilSecs == nil then
902
+ Protocol.refuse(request, "expired",
903
+ ("armedUntil is not a parseable ISO 8601 UTC timestamp: %s"):format(tostring(request.armedUntil)))
904
+ return
905
+ end
906
+ if nowSecs == nil then
907
+ -- No clock at all: honouring an expiry we cannot evaluate would break the one
908
+ -- guarantee arming exists to give (never fire in normal play).
909
+ Protocol.refuse(request, "expired",
910
+ "the bridge has no wall clock (Ext.Timer.ClockEpoch and os.time both unavailable), so it cannot prove the request is still armed")
911
+ return
912
+ end
913
+ if nowSecs >= untilSecs then
914
+ Protocol.refuse(request, "expired",
915
+ ("armedUntil %s has passed (bridge clock %s)"):format(tostring(request.armedUntil), Protocol.isoFromEpoch(nowSecs)))
916
+ return
917
+ end
918
+
919
+ Protocol.execute(request)
920
+ end
921
+
922
+ -- ---------------------------------------------------------------------------
923
+ -- Timers (Ext.Timer.WaitForRealtime, re-armed inside the callback)
924
+ -- ---------------------------------------------------------------------------
925
+ -- Realtime rather than WaitFor so a paused or loading game still answers; not
926
+ -- Ext.Events.Tick, which API.md §"Engine and SE Events" puts at "roughly every 33ms", where
927
+ -- a per-tick Lua error floods the log for latency this bridge does not need.
928
+
929
+ local function armHeartbeatTimer()
930
+ Ext.Timer.WaitForRealtime(Protocol.HEARTBEAT_MS, Log.guarded("heartbeat", function()
931
+ Protocol.writeHeartbeat()
932
+ armHeartbeatTimer()
933
+ end))
934
+ end
935
+
936
+ local function armPollTimer()
937
+ Ext.Timer.WaitForRealtime(Protocol.POLL_MS, Log.guarded("poll", function()
938
+ Protocol.pollOnce()
939
+ armPollTimer()
940
+ end))
941
+ end
942
+
943
+ --- Start the heartbeat. Idempotent; called at chunk scope so a heartbeat exists from the
944
+ --- moment the bridge loads (it is rewritten every 2 s while the bridge is loaded),
945
+ --- including at the main menu where `bridge status` needs it.
946
+ function Protocol.startHeartbeat()
947
+ if Protocol.heartbeatStarted then return end
948
+ Protocol.heartbeatStarted = true
949
+ Protocol.writeHeartbeat()
950
+ armHeartbeatTimer()
951
+ Log.info("heartbeat started (" .. Protocol.heartbeatFile .. ", every " .. Protocol.HEARTBEAT_MS .. " ms)")
952
+ end
953
+
954
+ --- Start request polling. Idempotent; called from SessionLoaded and again from
955
+ --- ResetCompleted, because a console `reset` reloads the Lua
956
+ --- state WITHOUT re-running the session, so SessionLoaded may never fire again.
957
+ function Protocol.startPolling()
958
+ if Protocol.pollingStarted then return end
959
+ Protocol.pollingStarted = true
960
+ armPollTimer()
961
+ Log.info("request polling started (" .. Protocol.FILES.request .. ", every " .. Protocol.POLL_MS .. " ms)")
962
+ end
963
+
964
+ --- Called from Ext.Events.ResetCompleted. API.md §"Engine and SE Events": `ResetCompleted` —
965
+ --- "Thrown when `Ext.Debug.Reset()` or `reset` console command completes on the client or
966
+ --- server. Indicates that the Lua state was reloaded." Observed consequence
967
+ --- this flag records: a mid-session `reset` degrades Osiris; Osi queries return junk and Osi
968
+ --- calls silently no-op afterward.
969
+ function Protocol.markOsirisDegraded()
970
+ Protocol.osirisDegraded = true
971
+ Protocol.osirisDegradedReason =
972
+ "Ext.Events.ResetCompleted fired: the Lua state was reloaded mid-session, which degrades Osiris; a save reload (F8) is the only clean fix."
973
+ Log.info("ResetCompleted: osirisDegraded = true; probe bg3.osiris.health measures it")
974
+ end
975
+
976
+ --- Wire the dispatch table and the per-context file names, then start the heartbeat.
977
+ function Protocol.init(opts)
978
+ Protocol.context = opts.context or "server"
979
+ Protocol.dispatcher = opts.dispatcher
980
+ Protocol.heartbeatFile = opts.heartbeatFile or Protocol.FILES.heartbeatServer
981
+ Protocol.loadedAtMs = Log.monotonic()
982
+ return Protocol
983
+ end
984
+
985
+ return Protocol