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,1306 @@
1
+ -- MODWRIGHT-BRIDGE-DO-NOT-SHIP
2
+ --
3
+ -- ModWrightBridge / protocol.lua — write-root discovery, the log-only fallback, JSON
4
+ -- framing, nonce arming, the heartbeat and the request state machine. This is the CET half
5
+ -- of the bridge 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
+ -- WHAT IS ATTESTED HERE, AND WHAT IS NOT
10
+ -- ============================================================================
11
+ -- The ONLY sources that may attest a CET call are the CyberEngineTweaks wiki pages, shipped
12
+ -- CET mods, and knowledge/cyberpunk2077/*.yaml facts whose status is `verified`. Each
13
+ -- call's first use below carries its source as a comment.
14
+ --
15
+ -- ATTESTED and used freely:
16
+ -- `print(...)` used by shipped CET mods
17
+ -- `string.format(...)` used by shipped CET mods
18
+ -- `table.concat/table.insert` used by shipped CET mods
19
+ -- `pcall(fn)` used by shipped CET mods
20
+ -- `ipairs`, `tostring` used by shipped CET mods
21
+ -- `registerForEvent(name, fn)` used by shipped CET mods
22
+ -- a returned table for GetMod() shipped CET mods; knowledge fact
23
+ -- cp2077.process.cet-mods-sandboxed
24
+ -- (status verified, 2.31, observed in game 2026-08-16)
25
+ -- dt accumulation in onUpdate shipped CET mods (the ONLY attested timer)
26
+ --
27
+ -- NOT ATTESTED BY ANY SOURCE — every one of these is feature-detected at load,
28
+ -- reported in the heartbeat's capability line, and can never crash the mod:
29
+ -- `io` (io.open/read/write/close) nothing attests CET's `io` for a mod at all; it was
30
+ -- observed nil only in the console (2026-09-08).
31
+ -- `json` (a built-in decoder) used only if `json.decode` is actually a function.
32
+ -- `os` (os.time/os.date/os.clock) no wall clock is attested; see "TIME" below.
33
+ -- `math` not used by either shipped mod; guarded all the same.
34
+ -- `Cron`, `Observe`, `ObserveAfter` CET extras that no source here mentions. Never called;
35
+ -- their presence is only reported.
36
+ -- `TweakDB:GetRecord/GetFlat` reported, and used only by the two probes the catalogue
37
+ -- marks `unverified` (see probes.lua).
38
+ -- `require` / `dofile` the module loader ladder lives in init.lua, not here.
39
+ --
40
+ -- ============================================================================
41
+ -- TIME
42
+ -- ============================================================================
43
+ -- Every timestamp this bridge writes is read on the Node side by `Date.parse`
44
+ -- (`iso8601Schema`, src/core/bridge/types.ts), so it must be a parseable ISO 8601 UTC
45
+ -- string. CET attests no clock at all. Two clocks are therefore used:
46
+ -- * MONOTONIC: `dt` accumulated in onUpdate (the common CET pattern). Always available,
47
+ -- and the only thing the poll/heartbeat/settle intervals are ever measured with.
48
+ -- * WALL: `os.time()` when the sandbox has it. When it does not, the bridge ADOPTS the
49
+ -- `issuedAt` of the first request it sees as an anchor and counts monotonic seconds from
50
+ -- there. Until a request arrives its timestamps are Unix-epoch offsets, which ModWright
51
+ -- will classify as a stale heartbeat — honest, and named in the capability line rather
52
+ -- than papered over with an invented date.
53
+ -- Arming without a wall clock is handled in `Protocol.pollOnce` (see ARMING WITHOUT A
54
+ -- CLOCK there); it is the one place where the missing clock changes a decision instead of
55
+ -- just a displayed value.
56
+ --
57
+ -- ============================================================================
58
+ -- WHY THIS FILE CARRIES ITS OWN JSON ENCODER
59
+ -- ============================================================================
60
+ -- Same reason as the BG3 bridge's Protocol.lua, and the reason survives the change of
61
+ -- runtime: an empty Lua table is both an array and an object, and nothing on disk says which
62
+ -- one CET's `json` global (if it even exists) would emit. The Node schemas are `.strict()`
63
+ -- and typed — `errors` and `probes` are `z.array(...)`, `evidence` is `z.record(...)` — and
64
+ -- poll.ts's `tryReadComplete` SILENTLY DISCARDS a result that fails to parse, so an empty
65
+ -- `errors` emitted as `{}` reads back as a bridge that never answered. `Protocol.array(t)`
66
+ -- tags a list: a tagged empty table is always `[]`, an untagged empty table always `{}`.
67
+ -- Decoding is the opposite case — the request is written by ModWright and its shape is
68
+ -- known — so `json.decode` is used there when it exists, and a small in-house decoder
69
+ -- otherwise. The heartbeat says which.
70
+
71
+ local Protocol = {}
72
+
73
+ --- An integer duplicated in the Lua and in the Node constant
74
+ --- (`PROTOCOL_VERSION` in src/core/bridge/types.ts), checked at arm time.
75
+ Protocol.PROTOCOL_VERSION = 1
76
+
77
+ --- Mirrors bridge.json's `version`. Reported in every heartbeat and result.
78
+ Protocol.BRIDGE_NAME = "ModWrightBridge"
79
+ Protocol.BRIDGE_VERSION = "0.1.0"
80
+
81
+ --- The Cyberpunk file names. These basenames must equal `CP2077_FILES` in
82
+ --- src/core/bridge/paths.ts exactly.
83
+ Protocol.FILES = {
84
+ heartbeat = "modwright_hb.json",
85
+ request = "modwright_req.json",
86
+ requestMarker = "modwright_req.marker.json",
87
+ }
88
+
89
+ function Protocol.resultFile(id) return "modwright_res_" .. tostring(id) .. ".json" end
90
+ function Protocol.doneFile(id) return "modwright_res_" .. tostring(id) .. ".done.json" end
91
+
92
+ --- The write-root candidate list, in order, as relative path PREFIXES. The first
93
+ --- one whose `io.open(prefix .. "modwright_hb.json", "w")` succeeds wins. The absolute path
94
+ --- is unknowable from Lua, which is exactly why the heartbeat reports the candidate that
95
+ --- worked and ModWright polls the matching absolute directories under the install
96
+ --- (`bridgeCandidateDirs`, src/core/bridge/paths.ts).
97
+ ---
98
+ --- The empty prefix is first ON PURPOSE and is expected to be the one that wins: "All pathing
99
+ --- is now relative to mod (breaking change, includes all io functions, dofile() and such
100
+ --- included)" (CET Nexus changelog, version 1.10). Under that rule "" IS this mod's own
101
+ --- folder, which is the directory ModWright polls first. The other two are kept below it for
102
+ --- a CET that does not behave that way; under 1.10 they would resolve to subdirectories of
103
+ --- the mod folder that do not exist, and simply fail.
104
+ Protocol.WRITE_ROOT_CANDIDATES = {
105
+ "",
106
+ "mods/ModWrightBridge/",
107
+ "plugins/cyber_engine_tweaks/mods/ModWrightBridge/",
108
+ }
109
+
110
+ --- A 1.0 s accumulator, the common CET pattern. The
111
+ --- heartbeat is 2 s. Both are measured in accumulated `dt`, the only timer CET
112
+ --- attests.
113
+ Protocol.POLL_SECONDS = 1.0
114
+ Protocol.HEARTBEAT_SECONDS = 2.0
115
+ --- Log-only mode has no file for ModWright to poll, so the heartbeat is PRINTED instead —
116
+ --- far less often, because the CET log is read by a human and by `triage_logs`, not by a
117
+ --- 500 ms poller.
118
+ Protocol.LOG_HEARTBEAT_SECONDS = 15.0
119
+ --- A run that never finishes must not park the bridge in `running` forever, where poll.ts
120
+ --- would only ever return its "hung" verdict.
121
+ Protocol.WATCHDOG_SLACK_SECONDS = 30.0
122
+
123
+ --- Machine-readable log tags.
124
+ Protocol.LOG_PREFIX = "[ModWrightBridge]"
125
+ Protocol.RESULT_TAG = "[MODWRIGHT-RESULT]"
126
+ Protocol.DONE_TAG = "[MODWRIGHT-DONE]"
127
+ Protocol.HEARTBEAT_TAG = "[MODWRIGHT-HEARTBEAT]"
128
+
129
+ -- ---------------------------------------------------------------------------
130
+ -- Runtime state (all in memory; a CET mod reload clears it, which is why arming carries an
131
+ -- `armedUntil`)
132
+ -- ---------------------------------------------------------------------------
133
+ Protocol.context = "cet"
134
+ Protocol.state = "idle"
135
+ Protocol.consumed = {}
136
+ Protocol.lastRequestId = nil
137
+ Protocol.lastResultId = nil
138
+ Protocol.uptime = 0.0
139
+ Protocol.pollAcc = 0.0
140
+ Protocol.hbAcc = 0.0
141
+ Protocol.logHbAcc = 0.0
142
+ Protocol.run = nil
143
+ Protocol.dispatcher = nil
144
+ Protocol.firstPollDone = false
145
+ Protocol.preexistingNonce = nil
146
+ Protocol.pendingMismatch = nil
147
+ Protocol.tornReads = 0
148
+ Protocol.loaderUsed = "?"
149
+ --- Set on the first `tick`, when RTTI-dependent globals can honestly be asked about (CET 1.14).
150
+ Protocol.rttiRechecked = false
151
+
152
+ --- Transport, decided by `Protocol.discoverWriteRoot()`: "file" or "log".
153
+ Protocol.transport = "log"
154
+ --- The candidate that worked, as the relative path of the heartbeat file, or "log-only".
155
+ Protocol.writeRoot = "log-only"
156
+ --- The relative prefix that won ("" for the game's own working directory).
157
+ Protocol.writeRootPrefix = nil
158
+ --- One string per failed candidate, plus the capability line. Rides in the heartbeat's
159
+ --- `writeRootErrors` (see `Protocol.writeHeartbeat`).
160
+ Protocol.writeRootErrors = {}
161
+
162
+ --- Wall-clock anchor: `epoch` seconds observed at monotonic `uptime`.
163
+ Protocol.clockAnchorEpoch = nil
164
+ Protocol.clockAnchorUptime = 0.0
165
+ Protocol.clockSource = "none"
166
+
167
+ -- ---------------------------------------------------------------------------
168
+ -- Capability detection
169
+ -- ---------------------------------------------------------------------------
170
+ -- Nothing below calls anything: it only asks whether a global is there. Every capability is
171
+ -- consulted through `Protocol.caps` at the point of use, and every use is additionally
172
+ -- pcall-guarded, because "the global exists" and "the call works" are different claims and
173
+ -- only the first one is testable from here.
174
+
175
+ -- Globals are referenced by NAME inside a closure, never through `_G`. CET builds each mod's
176
+ -- environment as a fresh table that falls back to a whitelisted copy of the globals
177
+ -- (CyberEngineTweaks/src/scripting/Sandbox.cpp:9 `sol::environment(state, sol::create,
178
+ -- aBaseEnvironment)`; the whitelist is s_cGlobalObjectsWhitelist in LuaSandbox.cpp), and
179
+ -- `_G` is not on that whitelist. So `_G[name]` indexes nil, the pcall fails, and every
180
+ -- capability reads as absent — which is exactly what was observed in game (2026-09-08): the bridge printed
181
+ -- heartbeats and wrote no file although the sandbox does provide `io` (LuaSandbox.cpp
182
+ -- InitializeIOForSandbox). A free name in this chunk resolves through the sandbox
183
+ -- environment, which is the only lookup that answers the question being asked.
184
+ local function globalIs(get, wanted)
185
+ local ok, v = pcall(get)
186
+ if not ok then return false end
187
+ return type(v) == wanted
188
+ end
189
+
190
+ Protocol.caps = {}
191
+
192
+ function Protocol.detectCapabilities()
193
+ local c = {}
194
+ -- The plain Lua half. `io` is expected to be here: CET 1.10 "stripped ImGui, json, io and
195
+ -- dofile and such from console sandbox" — from the CONSOLE sandbox, while mods keep them
196
+ -- with all pathing relative to the mod's own folder (CET Nexus changelog, version 1.10).
197
+ -- The `io.open` check of 2026-09-08 was typed in the console, so it tested the half that was
198
+ -- deliberately stripped and says nothing about this one.
199
+ c.io = globalIs(function() return io end, "table")
200
+ c.ioOpen = c.io and (type(io.open) == "function")
201
+ -- Added for mods in CET 1.9.2 (CET Nexus changelog), so this is expected to be true and
202
+ -- the in-house decoder below is the fallback, not the plan. Only `decode` matters here;
203
+ -- encoding stays ours, because nothing attests how CET's `json.encode` orders keys or
204
+ -- distinguishes an empty array from an empty object, and the result's `bytes` framing
205
+ -- depends on the exact string.
206
+ c.json = globalIs(function() return json end, "table") and (type(json.decode) == "function")
207
+ c.os = globalIs(function() return os end, "table")
208
+ c.osTime = c.os and (type(os.time) == "function")
209
+ -- "os.remove and os.rename were not working properly" was fixed in CET 1.12.0 (CET Nexus
210
+ -- changelog), so a result can be committed by rename instead of being read half-written.
211
+ c.osRename = c.os and (type(os.rename) == "function")
212
+ c.math = globalIs(function() return math end, "table")
213
+ Protocol.caps = c
214
+ Protocol.detectRttiCapabilities()
215
+ return Protocol.caps
216
+ end
217
+
218
+ --- The RTTI-dependent half, re-run on the first tick.
219
+ ---
220
+ --- CET 1.14: `GetMod()` and RTTI-dependent types are only available AFTER onInit (CET Nexus
221
+ --- changelog, version 1.14). Nothing here CALLS any of them — `globalIs` reads a global by name and
222
+ --- a `type()` test, never an invocation — but a global that is not bound yet reads as absent,
223
+ --- so onInit's answer would be a lie by omission. `Protocol.tick` runs this once more on its
224
+ --- first frame, and the capability line the heartbeat carries is the corrected one.
225
+ function Protocol.detectRttiCapabilities()
226
+ local c = Protocol.caps
227
+ c.TweakDB = globalIs(function() return TweakDB end, "table") or globalIs(function() return TweakDB end, "userdata")
228
+ c.Cron = globalIs(function() return Cron end, "table")
229
+ c.Observe = globalIs(function() return Observe end, "function")
230
+ c.GetMod = globalIs(function() return GetMod end, "function")
231
+ c.Game = globalIs(function() return Game end, "table") or globalIs(function() return Game end, "userdata")
232
+ return c
233
+ end
234
+
235
+ --- `math` is not used by either shipped mod, so it is not attested either. These two locals
236
+ --- are the whole dependency, and both have a pure-Lua fallback.
237
+ local function floorNum(n)
238
+ if type(math) == "table" and type(math.floor) == "function" then return math.floor(n) end
239
+ if n >= 0 then return n - (n % 1) end
240
+ local t = n - (n % 1)
241
+ if t > n then t = t - 1 end
242
+ return t
243
+ end
244
+
245
+ local function absNum(n)
246
+ if n < 0 then return -n end
247
+ return n
248
+ end
249
+
250
+ -- ---------------------------------------------------------------------------
251
+ -- Logging (`print("[ModWright] …")` breadcrumbs; the prefix used here is the
252
+ -- `[ModWrightBridge]` one the mod folder is named for, so a log grep finds only this mod)
253
+ -- ---------------------------------------------------------------------------
254
+ -- `print(...)` is the console output used by shipped CET mods.
255
+
256
+ -- `print` reaches the console; observed in game, it never reached the mod's own log file
257
+ -- (`ModWrightBridge.log` stayed empty across three launches). CET gives each mod an
258
+ -- `spdlog` table bound to that file (CET changelog 1.9.1: "you can also use spdlog to print
259
+ -- in your log"; LuaSandbox.cpp InitializeLoggerForSandbox), so the same line goes there too
260
+ -- when the global exists, which is what makes the banner recoverable from disk.
261
+ function Protocol.log(msg)
262
+ local line = Protocol.LOG_PREFIX .. " " .. tostring(msg)
263
+ pcall(function() print(line) end)
264
+ pcall(function()
265
+ if type(spdlog) == "table" and type(spdlog.info) == "function" then spdlog.info(line) end
266
+ end)
267
+ return line
268
+ end
269
+
270
+ function Protocol.err(where, msg)
271
+ return Protocol.log("ERROR [" .. tostring(where) .. "] " .. tostring(msg))
272
+ end
273
+
274
+ --- Wrap a callback so a raw error can never escape into a CET event handler. Shipped
275
+ --- mods wrap every engine call this way.
276
+ function Protocol.guarded(where, fn)
277
+ return function(...)
278
+ local ok, e = pcall(fn, ...)
279
+ if not ok then Protocol.err(where, e) end
280
+ return ok
281
+ end
282
+ end
283
+
284
+ -- ---------------------------------------------------------------------------
285
+ -- JSON encoding (see the header note for why this is not the `json` global)
286
+ -- ---------------------------------------------------------------------------
287
+
288
+ local ARRAY_MT = { __modwright_array = true }
289
+
290
+ --- Tag a Lua table so the encoder always emits a JSON array, even when it is empty.
291
+ function Protocol.array(t)
292
+ return setmetatable(t or {}, ARRAY_MT)
293
+ end
294
+
295
+ local ESCAPES = {
296
+ ['"'] = '\\"',
297
+ ['\\'] = '\\\\',
298
+ ['\b'] = '\\b',
299
+ ['\f'] = '\\f',
300
+ ['\n'] = '\\n',
301
+ ['\r'] = '\\r',
302
+ ['\t'] = '\\t',
303
+ }
304
+
305
+ local function escapeChar(c)
306
+ local e = ESCAPES[c]
307
+ if e then return e end
308
+ return string.format("\\u%04x", string.byte(c))
309
+ end
310
+
311
+ local function encodeString(s)
312
+ -- `%c` rather than `%z`: `%z` is gone in Lua 5.2+ and the lint step runs
313
+ -- `luac -p` under both 5.1 and 5.4 over this tree.
314
+ local body = tostring(s):gsub('[%c"\\]', escapeChar)
315
+ return '"' .. body .. '"'
316
+ end
317
+
318
+ local function encodeNumber(n)
319
+ if n ~= n then return "null" end -- NaN
320
+ if n == 1 / 0 or n == -1 / 0 then return "null" end -- +/-inf without math.huge
321
+ if n % 1 == 0 and n >= -9007199254740992 and n <= 9007199254740992 then
322
+ return string.format("%d", n)
323
+ end
324
+ return string.format("%.14g", n)
325
+ end
326
+
327
+ local function isArrayLike(t)
328
+ if getmetatable(t) == ARRAY_MT then return true end
329
+ local n = 0
330
+ for _ in pairs(t) do n = n + 1 end
331
+ if n == 0 then return false end -- untagged empty table -> {}
332
+ if #t ~= n then return false end
333
+ for i = 1, n do
334
+ if t[i] == nil then return false end
335
+ end
336
+ return true
337
+ end
338
+
339
+ local encodeValue
340
+
341
+ local function encodeTable(t, depth, seen, maxDepth)
342
+ if depth > maxDepth then return '"<max-depth>"' end
343
+ if seen[t] then return '"<recursion>"' end
344
+ seen[t] = true
345
+
346
+ local parts = {}
347
+ local out
348
+ if isArrayLike(t) then
349
+ for i = 1, #t do
350
+ parts[#parts + 1] = encodeValue(t[i], depth + 1, seen, maxDepth)
351
+ end
352
+ out = "[" .. table.concat(parts, ",") .. "]"
353
+ else
354
+ -- Keys sorted so two runs over the same data produce byte-identical output; these
355
+ -- files are diffed, and in log-only mode read by eye.
356
+ local keys = {}
357
+ for k in pairs(t) do
358
+ local kt = type(k)
359
+ if kt == "string" or kt == "number" then keys[#keys + 1] = k end
360
+ end
361
+ table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
362
+ for _, k in ipairs(keys) do
363
+ local encoded = encodeValue(t[k], depth + 1, seen, maxDepth)
364
+ if encoded ~= nil then
365
+ parts[#parts + 1] = encodeString(tostring(k)) .. ":" .. encoded
366
+ end
367
+ end
368
+ out = "{" .. table.concat(parts, ",") .. "}"
369
+ end
370
+
371
+ seen[t] = nil
372
+ return out
373
+ end
374
+
375
+ encodeValue = function(v, depth, seen, maxDepth)
376
+ local t = type(v)
377
+ if v == nil then return "null" end
378
+ if t == "boolean" then return v and "true" or "false" end
379
+ if t == "number" then return encodeNumber(v) end
380
+ if t == "string" then return encodeString(v) end
381
+ if t == "table" then return encodeTable(v, depth, seen, maxDepth) end
382
+ -- userdata/function/thread: engine handles reach this encoder only after probes.lua has
383
+ -- coerced them. Anything else is REPORTED, never guessed at — an engine object silently
384
+ -- encoded as "{}" would read as a successful probe with no evidence.
385
+ return encodeString("<unencodable:" .. t .. ">")
386
+ end
387
+
388
+ --- Encode a value as compact JSON. Never throws; always returns a string. The output has no
389
+ --- newline in it anywhere, which is what lets `io.open(path, "w")` (text mode on Windows)
390
+ --- write exactly `#json` bytes — see `Protocol.writeFile`.
391
+ function Protocol.encode(value, maxDepth)
392
+ local ok, out = pcall(function()
393
+ return encodeValue(value, 1, {}, maxDepth or 12)
394
+ end)
395
+ if ok and type(out) == "string" then return out end
396
+ return '{"encodeError":' .. encodeString(tostring(out)) .. "}"
397
+ end
398
+
399
+ -- ---------------------------------------------------------------------------
400
+ -- JSON decoding: the `json` global when it exists, otherwise the decoder below
401
+ -- ---------------------------------------------------------------------------
402
+ -- The in-house decoder handles exactly what the Request and Marker schemas
403
+ -- contain: objects, arrays, strings (with the six short escapes and \uXXXX), numbers,
404
+ -- true/false/null. It is deliberately strict — a trailing comma or an unquoted key is an
405
+ -- error, not a guess — because the alternative to a parse failure here is executing a
406
+ -- request nobody wrote.
407
+
408
+ local decodeValueAt
409
+
410
+ local function skipWs(s, i)
411
+ while true do
412
+ local c = s:sub(i, i)
413
+ if c == " " or c == "\t" or c == "\r" or c == "\n" then i = i + 1 else return i end
414
+ end
415
+ end
416
+
417
+ local function utf8FromCodepoint(cp)
418
+ if cp < 0x80 then return string.char(cp) end
419
+ if cp < 0x800 then
420
+ return string.char(0xC0 + floorNum(cp / 0x40), 0x80 + (cp % 0x40))
421
+ end
422
+ return string.char(
423
+ 0xE0 + floorNum(cp / 0x1000),
424
+ 0x80 + (floorNum(cp / 0x40) % 0x40),
425
+ 0x80 + (cp % 0x40))
426
+ end
427
+
428
+ local SHORT_ESCAPES = {
429
+ ['"'] = '"', ["\\"] = "\\", ["/"] = "/",
430
+ b = "\b", f = "\f", n = "\n", r = "\r", t = "\t",
431
+ }
432
+
433
+ local function decodeStringAt(s, i)
434
+ -- i points at the opening quote.
435
+ local out = {}
436
+ i = i + 1
437
+ while true do
438
+ local c = s:sub(i, i)
439
+ if c == "" then return nil, i, "unterminated string" end
440
+ if c == '"' then return table.concat(out), i + 1, nil end
441
+ if c == "\\" then
442
+ local e = s:sub(i + 1, i + 1)
443
+ local short = SHORT_ESCAPES[e]
444
+ if short then
445
+ out[#out + 1] = short
446
+ i = i + 2
447
+ elseif e == "u" then
448
+ local hex = s:sub(i + 2, i + 5)
449
+ if not hex:match("^%x%x%x%x$") then return nil, i, "bad \\u escape" end
450
+ local cp = tonumber(hex, 16)
451
+ -- Surrogate halves are passed through as U+FFFD rather than pair-decoded:
452
+ -- nothing in a request id, nonce, probe kind or TweakDB path is outside the
453
+ -- BMP, and inventing a pair decoder would be untested code on a path that
454
+ -- never runs.
455
+ if cp >= 0xD800 and cp <= 0xDFFF then cp = 0xFFFD end
456
+ out[#out + 1] = utf8FromCodepoint(cp)
457
+ i = i + 6
458
+ else
459
+ return nil, i, "unknown escape \\" .. tostring(e)
460
+ end
461
+ else
462
+ out[#out + 1] = c
463
+ i = i + 1
464
+ end
465
+ end
466
+ end
467
+
468
+ local function decodeNumberAt(s, i)
469
+ local body = s:match("^%-?%d+%.?%d*[eE]?[%+%-]?%d*", i)
470
+ if body == nil or body == "" then return nil, i, "bad number" end
471
+ local n = tonumber(body)
472
+ if n == nil then return nil, i, "bad number: " .. body end
473
+ return n, i + #body, nil
474
+ end
475
+
476
+ decodeValueAt = function(s, i, depth)
477
+ if depth > 32 then return nil, i, "nesting too deep" end
478
+ i = skipWs(s, i)
479
+ local c = s:sub(i, i)
480
+ if c == "" then return nil, i, "unexpected end of input" end
481
+
482
+ if c == "{" then
483
+ local obj = {}
484
+ i = skipWs(s, i + 1)
485
+ if s:sub(i, i) == "}" then return obj, i + 1, nil end
486
+ while true do
487
+ i = skipWs(s, i)
488
+ if s:sub(i, i) ~= '"' then return nil, i, "object key must be a string" end
489
+ local key, ni, e = decodeStringAt(s, i)
490
+ if e then return nil, ni, e end
491
+ i = skipWs(s, ni)
492
+ if s:sub(i, i) ~= ":" then return nil, i, "expected ':' after object key" end
493
+ local val
494
+ val, i, e = decodeValueAt(s, i + 1, depth + 1)
495
+ if e then return nil, i, e end
496
+ obj[key] = val
497
+ i = skipWs(s, i)
498
+ local d = s:sub(i, i)
499
+ if d == "," then i = i + 1
500
+ elseif d == "}" then return obj, i + 1, nil
501
+ else return nil, i, "expected ',' or '}' in object" end
502
+ end
503
+ end
504
+
505
+ if c == "[" then
506
+ local arr = {}
507
+ i = skipWs(s, i + 1)
508
+ if s:sub(i, i) == "]" then return Protocol.array(arr), i + 1, nil end
509
+ while true do
510
+ local val, e
511
+ val, i, e = decodeValueAt(s, i, depth + 1)
512
+ if e then return nil, i, e end
513
+ arr[#arr + 1] = val
514
+ i = skipWs(s, i)
515
+ local d = s:sub(i, i)
516
+ if d == "," then i = i + 1
517
+ elseif d == "]" then return Protocol.array(arr), i + 1, nil
518
+ else return nil, i, "expected ',' or ']' in array" end
519
+ end
520
+ end
521
+
522
+ if c == '"' then return decodeStringAt(s, i) end
523
+ if s:sub(i, i + 3) == "true" then return true, i + 4, nil end
524
+ if s:sub(i, i + 4) == "false" then return false, i + 5, nil end
525
+ if s:sub(i, i + 3) == "null" then return nil, i + 4, nil end
526
+ if c == "-" or c:match("%d") then return decodeNumberAt(s, i) end
527
+ return nil, i, "unexpected character " .. string.format("%q", c)
528
+ end
529
+
530
+ --- Which decoder this bridge is using, reported in the heartbeat's capability line.
531
+ Protocol.decoderName = "in-house"
532
+
533
+ --- Decode a JSON document. Returns nil on any failure — a request that does not parse is
534
+ --- never half-applied.
535
+ function Protocol.decode(raw)
536
+ if type(raw) ~= "string" or raw == "" then return nil end
537
+ if Protocol.caps.json then
538
+ local ok, v = pcall(function() return json.decode(raw) end)
539
+ if ok and type(v) == "table" then return v end
540
+ -- The global exists but did not answer: fall through to the in-house decoder rather
541
+ -- than reporting the request as unreadable.
542
+ end
543
+ local ok, value, _, e = pcall(function()
544
+ local v, i, err = decodeValueAt(raw, 1, 1)
545
+ return v, i, err
546
+ end)
547
+ if not ok then return nil end
548
+ if e ~= nil then return nil end
549
+ if type(value) ~= "table" then return nil end
550
+ return value
551
+ end
552
+
553
+ --- Capability tiers. A request carries the tiers it was granted in
554
+ --- `request.grant`; a mutating probe kind needs the `session` tier. `allowMutate: true` is
555
+ --- accepted for one release as the retired alias for grant:["session"], so a request written
556
+ --- by an older ModWright still gates correctly.
557
+ function Protocol.grantsTier(request, tier)
558
+ if type(request) ~= "table" then return false end
559
+ if type(request.grant) == "table" then
560
+ for _, g in ipairs(request.grant) do
561
+ if tostring(g) == tier then return true end
562
+ end
563
+ end
564
+ if tier == "session" and request.allowMutate == true then return true end
565
+ return false
566
+ end
567
+
568
+ -- ---------------------------------------------------------------------------
569
+ -- Files: every `io` call in this bridge is here, and every one is pcall-guarded
570
+ -- ---------------------------------------------------------------------------
571
+ -- UNVERIFIED (nothing attests CET's `io` for a mod). If `io` is absent or
572
+ -- `io.open` refuses every candidate, `Protocol.transport` becomes "log" and nothing below is
573
+ -- ever called again.
574
+
575
+ --- Open a file, or return nil plus the error string `io.open` gave. Never throws.
576
+ local function tryOpen(path, mode)
577
+ if not Protocol.caps.ioOpen then return nil, "io.open is not available in this CET sandbox" end
578
+ local ok, f, e = pcall(function() return io.open(path, mode) end)
579
+ if not ok then return nil, "io.open threw: " .. tostring(f) end
580
+ if f == nil then return nil, tostring(e) end
581
+ return f, nil
582
+ end
583
+
584
+ --- Read a whole file as bytes. "rb" so a Windows text-mode read can never drop a byte the
585
+ --- marker's `bytes` counted: ModWright writes the request pretty-printed (arm.ts:74), so it
586
+ --- DOES contain newlines and the framing check is only exact in binary mode.
587
+ function Protocol.readFile(relPath)
588
+ local f, e = tryOpen(relPath, "rb")
589
+ if f == nil then return nil, e end
590
+ local raw
591
+ local ok = pcall(function() raw = f:read("*a") end)
592
+ pcall(function() f:close() end)
593
+ if not ok or type(raw) ~= "string" then return nil, "read failed" end
594
+ return raw, nil
595
+ end
596
+
597
+ --- Write a whole file. Mode "w" and not "wb" on purpose: it is the mode the
598
+ --- original console check used, so what the bridge does is exactly what that console line
599
+ --- proved. Safe because `Protocol.encode` never emits a newline, so text-mode translation
600
+ --- has nothing to translate and `#json` stays the true byte count.
601
+ function Protocol.writeFile(relPath, content)
602
+ local f, e = tryOpen(relPath, "w")
603
+ if f == nil then return false, e end
604
+ local ok = pcall(function() f:write(content) end)
605
+ pcall(function() f:close() end)
606
+ if not ok then return false, "write failed" end
607
+ return true, nil
608
+ end
609
+
610
+ --- Write, then commit by rename — for the result file only.
611
+ ---
612
+ --- "os.remove and os.rename were not working properly" was FIXED in CET 1.12.0 (CET Nexus
613
+ --- changelog), so a result can be written to a `.tmp` sibling and moved into place, which
614
+ --- closes the window where ModWright could read a result file the bridge is still writing.
615
+ --- This does NOT replace the marker: the completeness test is the `done` file's
616
+ --- `bytes` against the result's real byte length, that is what the Node side checks, and it
617
+ --- stays exactly as it was. This only makes the torn window smaller on installs where the
618
+ --- rename works. When `os.rename` is missing or refuses, the plain write is used and the
619
+ --- marker catches what it always caught.
620
+ function Protocol.writeFileCommitted(relPath, content)
621
+ if not Protocol.caps.osRename then return Protocol.writeFile(relPath, content) end
622
+ local tmpPath = relPath .. ".tmp"
623
+ local okWrite, e = Protocol.writeFile(tmpPath, content)
624
+ if not okWrite then return false, e end
625
+ -- `os.rename` returns true on success, or nil plus a message; whether CET resolves ITS
626
+ -- paths relative to the mod folder the way it does `io.open`'s is not stated anywhere, so
627
+ -- this is attempted, not assumed.
628
+ local okCall, renamed = pcall(function() return os.rename(tmpPath, relPath) end)
629
+ if okCall and renamed then return true, nil end
630
+ -- The rename did not take: fall back to writing in place, and clean up the temp file so a
631
+ -- half-written `.tmp` is not left beside a good result to confuse a human reading the
632
+ -- folder.
633
+ local okDirect, directErr = Protocol.writeFile(relPath, content)
634
+ pcall(function() os.remove(tmpPath) end)
635
+ if not okDirect then return false, directErr end
636
+ return true, nil
637
+ end
638
+
639
+ --- Absolute-path-free: everything is relative to whatever directory CET resolves `io.open`
640
+ --- against, which is the whole point of discovery.
641
+ function Protocol.path(name)
642
+ return (Protocol.writeRootPrefix or "") .. name
643
+ end
644
+
645
+ -- ---------------------------------------------------------------------------
646
+ -- Write-root discovery
647
+ -- ---------------------------------------------------------------------------
648
+
649
+ --- Try each candidate prefix with `io.open(candidate, "w")`, keep the first that succeeds,
650
+ --- and record every failure string. On total failure the transport becomes log-only and each
651
+ --- result is printed as `[MODWRIGHT-RESULT] <json>` for `triage_logs` to recover
652
+ --- (src/surfaces/cyberpunk2077/surface.ts's `cet` log source).
653
+ function Protocol.discoverWriteRoot()
654
+ Protocol.writeRootErrors = {}
655
+ Protocol.writeRootPrefix = nil
656
+ Protocol.transport = "log"
657
+ Protocol.writeRoot = "log-only"
658
+
659
+ if not Protocol.caps.ioOpen then
660
+ Protocol.writeRootErrors[#Protocol.writeRootErrors + 1] =
661
+ "io.open is not available in this CET sandbox (type(io) = " .. type(io) .. "); no candidate was tried"
662
+ return false
663
+ end
664
+
665
+ for _, prefix in ipairs(Protocol.WRITE_ROOT_CANDIDATES) do
666
+ local candidate = prefix .. Protocol.FILES.heartbeat
667
+ local f, e = tryOpen(candidate, "w")
668
+ if f ~= nil then
669
+ -- Prove it is writable, not merely openable: an opened-but-unwritable handle
670
+ -- would strand every result with no error anywhere.
671
+ local wrote = pcall(function() f:write("") end)
672
+ pcall(function() f:close() end)
673
+ if wrote then
674
+ Protocol.writeRootPrefix = prefix
675
+ Protocol.writeRoot = candidate
676
+ Protocol.transport = "file"
677
+ return true
678
+ end
679
+ Protocol.writeRootErrors[#Protocol.writeRootErrors + 1] =
680
+ 'io.open("' .. candidate .. '", "w") opened but f:write failed'
681
+ else
682
+ Protocol.writeRootErrors[#Protocol.writeRootErrors + 1] =
683
+ 'io.open("' .. candidate .. '", "w") failed: ' .. tostring(e)
684
+ end
685
+ end
686
+ return false
687
+ end
688
+
689
+ -- ---------------------------------------------------------------------------
690
+ -- Time
691
+ -- ---------------------------------------------------------------------------
692
+ -- Howard Hinnant's civil-calendar algorithms in pure Lua, so the ISO format never depends on
693
+ -- an `os.date` the CET sandbox may not have.
694
+
695
+ local function daysFromCivil(y, m, d)
696
+ if m <= 2 then y = y - 1 end
697
+ local era = floorNum(y / 400)
698
+ local yoe = y - era * 400
699
+ local mp = m + ((m > 2) and -3 or 9)
700
+ local doy = floorNum((153 * mp + 2) / 5) + d - 1
701
+ local doe = yoe * 365 + floorNum(yoe / 4) - floorNum(yoe / 100) + doy
702
+ return era * 146097 + doe - 719468
703
+ end
704
+
705
+ local function civilFromDays(z)
706
+ z = z + 719468
707
+ local era = floorNum(z / 146097)
708
+ local doe = z - era * 146097
709
+ local yoe = floorNum((doe - floorNum(doe / 1460) + floorNum(doe / 36524) - floorNum(doe / 146096)) / 365)
710
+ local y = yoe + era * 400
711
+ local doy = doe - (365 * yoe + floorNum(yoe / 4) - floorNum(yoe / 100))
712
+ local mp = floorNum((5 * doy + 2) / 153)
713
+ local d = doy - floorNum((153 * mp + 2) / 5) + 1
714
+ local m = mp + ((mp < 10) and 3 or -9)
715
+ if m <= 2 then y = y + 1 end
716
+ return y, m, d
717
+ end
718
+
719
+ function Protocol.isoFromEpoch(seconds)
720
+ if type(seconds) ~= "number" then return nil end
721
+ seconds = floorNum(seconds)
722
+ local days = floorNum(seconds / 86400)
723
+ local rem = seconds - days * 86400
724
+ local y, m, d = civilFromDays(days)
725
+ local h = floorNum(rem / 3600)
726
+ local mi = floorNum((rem % 3600) / 60)
727
+ local s = rem % 60
728
+ return string.format("%04d-%02d-%02dT%02d:%02d:%02dZ", y, m, d, h, mi, floorNum(s))
729
+ end
730
+
731
+ --- Parse an ISO 8601 UTC timestamp to epoch seconds. ModWright writes
732
+ --- `Date#toISOString()`, always `YYYY-MM-DDTHH:MM:SS.mmmZ`; fractional seconds and the
733
+ --- trailing `Z` are ignored, and a value carrying a numeric UTC offset is REJECTED rather
734
+ --- than silently misread as UTC.
735
+ function Protocol.parseIso(s)
736
+ if type(s) ~= "string" then return nil end
737
+ local y, mo, d, h, mi, se = s:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)")
738
+ if not y then return nil end
739
+ local tail = s:sub(20)
740
+ if tail:match("[%+%-]%d%d:?%d%d$") then return nil end
741
+ local days = daysFromCivil(tonumber(y), tonumber(mo), tonumber(d))
742
+ return days * 86400 + tonumber(h) * 3600 + tonumber(mi) * 60 + tonumber(se)
743
+ end
744
+
745
+ --- Epoch seconds now, or nil when the bridge has no clock at all.
746
+ --- `os.time()` is UNVERIFIED in the CET sandbox — nothing on disk uses `os` — so it is
747
+ --- feature-detected and pcall-guarded, and its absence is a reported capability, not a crash.
748
+ function Protocol.epochSeconds()
749
+ if Protocol.caps.osTime then
750
+ local ok, t = pcall(function() return os.time() end)
751
+ if ok then
752
+ local n = tonumber(t)
753
+ if n and n > 1000000000 then
754
+ Protocol.clockSource = "os.time"
755
+ return floorNum(n)
756
+ end
757
+ end
758
+ end
759
+ if Protocol.clockAnchorEpoch ~= nil then
760
+ return floorNum(Protocol.clockAnchorEpoch + (Protocol.uptime - Protocol.clockAnchorUptime))
761
+ end
762
+ return nil
763
+ end
764
+
765
+ --- Adopt a request's `issuedAt` as the wall-clock anchor when the sandbox has no clock of
766
+ --- its own. Only ever called for a request the bridge WATCHED ARRIVE (see pollOnce), so the
767
+ --- anchor comes from a file ModWright wrote seconds ago rather than from an unknown past.
768
+ function Protocol.adoptClock(issuedAt)
769
+ if Protocol.caps.osTime then return false end
770
+ if Protocol.clockAnchorEpoch ~= nil then return false end
771
+ local secs = Protocol.parseIso(issuedAt)
772
+ if secs == nil then return false end
773
+ Protocol.clockAnchorEpoch = secs
774
+ Protocol.clockAnchorUptime = Protocol.uptime
775
+ Protocol.clockSource = "adopted-from-request-issuedAt"
776
+ Protocol.log("no wall clock in this sandbox; adopted the request's issuedAt (" ..
777
+ tostring(issuedAt) .. ") as the clock anchor")
778
+ return true
779
+ end
780
+
781
+ --- Current time as an ISO 8601 UTC string. Falls back to a Unix-epoch offset — a real,
782
+ --- parseable timestamp that reads as 1970, which is the honest signal that this bridge does
783
+ --- not know what time it is.
784
+ function Protocol.isoNow(run)
785
+ local secs = Protocol.epochSeconds()
786
+ if secs then
787
+ local iso = Protocol.isoFromEpoch(secs)
788
+ if iso then return iso end
789
+ end
790
+ if run and run.errors then
791
+ run.errors[#run.errors + 1] =
792
+ "no wall clock available (os.time is absent from this CET sandbox and no request had a parseable issuedAt); " ..
793
+ "every timestamp in this result is Unix epoch + bridge uptime"
794
+ end
795
+ return Protocol.isoFromEpoch(floorNum(Protocol.uptime)) or "1970-01-01T00:00:00Z"
796
+ end
797
+
798
+ -- ---------------------------------------------------------------------------
799
+ -- Heartbeat
800
+ -- ---------------------------------------------------------------------------
801
+
802
+ --- The `bridge` block shared by heartbeats and results (`bridgeInfoSchema`, .strict()).
803
+ function Protocol.info()
804
+ return {
805
+ name = Protocol.BRIDGE_NAME,
806
+ version = Protocol.BRIDGE_VERSION,
807
+ protocolVersion = Protocol.PROTOCOL_VERSION,
808
+ context = Protocol.context,
809
+ }
810
+ end
811
+
812
+ --- The capability block, as one line of `writeRootErrors`.
813
+ ---
814
+ --- WHY IT RIDES THERE AND NOT IN A KEY OF ITS OWN: `bridgeHeartbeatSchema`
815
+ --- (src/core/bridge/types.ts) is `.strict()`, and poll.ts/status.ts DISCARD a heartbeat that
816
+ --- fails to parse. A top-level `capabilities` key would therefore turn every heartbeat this
817
+ --- bridge writes into "no heartbeat found" on the ModWright side — the exact failure the
818
+ --- capability block exists to diagnose. `writeRootErrors` is `z.array(z.string()).optional()`
819
+ --- and is already the field that explains why the write root is what it is, so the line goes
820
+ --- there, machine-readably: `capabilities: {json object of booleans}`. The same block is
821
+ --- returned as real booleans in `cp2077.bridge.io`'s evidence, where `evidence` is
822
+ --- `z.record(z.unknown())` and nothing is lost.
823
+ function Protocol.capabilityLine()
824
+ local c = Protocol.caps
825
+ return "capabilities: " .. Protocol.encode({
826
+ io = c.ioOpen == true,
827
+ json = c.json == true,
828
+ TweakDB = c.TweakDB == true,
829
+ Cron = c.Cron == true,
830
+ Observe = c.Observe == true,
831
+ os = c.osTime == true,
832
+ math = c.math == true,
833
+ GetMod = c.GetMod == true,
834
+ requestDecoder = Protocol.decoderName,
835
+ clockSource = Protocol.clockSource,
836
+ transport = Protocol.transport,
837
+ moduleLoader = Protocol.loaderUsed,
838
+ })
839
+ end
840
+
841
+ --- Build the heartbeat table (`bridgeHeartbeatSchema`, .strict() — every key below is in it,
842
+ --- and no key that is not).
843
+ function Protocol.heartbeat()
844
+ local hb = {
845
+ protocolVersion = Protocol.PROTOCOL_VERSION,
846
+ bridge = Protocol.info(),
847
+ at = Protocol.isoNow(),
848
+ uptimeMs = floorNum(Protocol.uptime * 1000),
849
+ state = Protocol.state,
850
+ writeRoot = Protocol.writeRoot,
851
+ }
852
+ local errs = Protocol.array({ Protocol.capabilityLine() })
853
+ for _, e in ipairs(Protocol.writeRootErrors) do errs[#errs + 1] = e end
854
+ hb.writeRootErrors = errs
855
+ if Protocol.lastRequestId then hb.lastRequestId = Protocol.lastRequestId end
856
+ if Protocol.lastResultId then hb.lastResultId = Protocol.lastResultId end
857
+ return hb
858
+ end
859
+
860
+ --- Write (or, in log-only mode, print) one heartbeat.
861
+ function Protocol.writeHeartbeat()
862
+ local json_ = Protocol.encode(Protocol.heartbeat())
863
+ if Protocol.transport == "file" then
864
+ local ok, e = Protocol.writeFile(Protocol.path(Protocol.FILES.heartbeat), json_)
865
+ if not ok then
866
+ -- The root worked at load and does not now. Say so once per occurrence and keep
867
+ -- the bridge alive; the next discovery happens only on reload, by design (a
868
+ -- write root that changes under a running game is not a case anything attests).
869
+ Protocol.err("heartbeat", "write to " .. Protocol.path(Protocol.FILES.heartbeat) ..
870
+ " failed: " .. tostring(e))
871
+ end
872
+ return
873
+ end
874
+ pcall(function() print(Protocol.HEARTBEAT_TAG .. " " .. json_) end)
875
+ end
876
+
877
+ -- ---------------------------------------------------------------------------
878
+ -- Results: result first, `done` marker second
879
+ -- ---------------------------------------------------------------------------
880
+
881
+ --- `bytes` is `#json` — Lua's byte length of the exact string handed to `f:write`, which is
882
+ --- what poll.ts compares against `Buffer.byteLength(resultRaw, "utf8")`. Nothing may be
883
+ --- appended to `json` after this point (no trailing newline).
884
+ ---
885
+ --- LOG-ONLY MODE: the same two payloads are printed instead, on one line each, tagged
886
+ --- `[MODWRIGHT-RESULT]` and `[MODWRIGHT-DONE]`. `bytes` still counts the result JSON, so a
887
+ --- line recovered from the CET log can be checked for truncation exactly the way the file
888
+ --- transport checks it (CET's log is known to cut the previous run's last line mid-write —
889
+ --- src/surfaces/cyberpunk2077/surface.ts's `cet` log source notes).
890
+ function Protocol.writeResult(result)
891
+ local json_ = Protocol.encode(result)
892
+ local probeCount = 0
893
+ if type(result.probes) == "table" then probeCount = #result.probes end
894
+ local done = {
895
+ protocolVersion = Protocol.PROTOCOL_VERSION,
896
+ id = result.id,
897
+ nonce = result.nonce,
898
+ bytes = #json_,
899
+ probeCount = probeCount,
900
+ }
901
+ local doneJson = Protocol.encode(done)
902
+
903
+ if Protocol.transport == "file" then
904
+ -- Committed by rename where CET 1.12.0's fixed `os.rename` allows it; the `done`
905
+ -- marker below is still the protocol's completeness proof.
906
+ local okResult, e = Protocol.writeFileCommitted(Protocol.path(Protocol.resultFile(result.id)), json_)
907
+ if not okResult then
908
+ Protocol.err("writeResult", "result file write failed for " .. tostring(result.id) ..
909
+ " (" .. tostring(e) .. "); the done marker is deliberately NOT written, so ModWright " ..
910
+ "retries rather than reading a partial file. The result follows on one line:")
911
+ pcall(function() print(Protocol.RESULT_TAG .. " " .. json_) end)
912
+ pcall(function() print(Protocol.DONE_TAG .. " " .. doneJson) end)
913
+ return false
914
+ end
915
+ local okDone = Protocol.writeFile(Protocol.path(Protocol.doneFile(result.id)), doneJson)
916
+ Protocol.log(string.format("result %s written (%d bytes, %d probes, done marker %s)",
917
+ tostring(result.id), #json_, probeCount, okDone and "ok" or "FAILED"))
918
+ return okDone
919
+ end
920
+
921
+ pcall(function() print(Protocol.RESULT_TAG .. " " .. json_) end)
922
+ pcall(function() print(Protocol.DONE_TAG .. " " .. doneJson) end)
923
+ Protocol.log(string.format("result %s printed to the log (%d bytes, %d probes) — log-only mode, " ..
924
+ "recover it with triage_logs", tostring(result.id), #json_, probeCount))
925
+ return true
926
+ end
927
+
928
+ --- Build a Result envelope (`bridgeResultSchema`, .strict()).
929
+ function Protocol.buildResult(request, startedAt, probeResults, errors, disarm)
930
+ local result = {
931
+ protocolVersion = Protocol.PROTOCOL_VERSION,
932
+ id = tostring(request.id),
933
+ nonce = tostring(request.nonce),
934
+ startedAt = startedAt,
935
+ finishedAt = Protocol.isoNow(),
936
+ complete = true,
937
+ bridge = Protocol.info(),
938
+ probes = Protocol.array(probeResults or {}),
939
+ errors = Protocol.array(errors or {}),
940
+ }
941
+ if disarm then result.disarm = disarm end
942
+ return result
943
+ end
944
+
945
+ --- One `skipped` ProbeResult, used for every probe of a refused request (a refused request writes
946
+ --- a Result with the matching `disarm.reason` and every probe `skipped`).
947
+ function Protocol.skippedProbe(probe, why)
948
+ return {
949
+ id = tostring((probe and probe.id) or "?"),
950
+ kind = tostring((probe and probe.kind) or "?"),
951
+ status = "skipped",
952
+ observed = nil,
953
+ evidence = { reason = why },
954
+ error = why,
955
+ ranAt = Protocol.isoNow(),
956
+ }
957
+ end
958
+
959
+ --- Write a refusal and consume the nonce so it is not rewritten on every poll.
960
+ function Protocol.refuse(request, reason, detail)
961
+ local probes = {}
962
+ if type(request.probes) == "table" then
963
+ for i, p in ipairs(request.probes) do
964
+ probes[i] = Protocol.skippedProbe(p, "request disarmed: " .. reason)
965
+ end
966
+ end
967
+ local startedAt = Protocol.isoNow()
968
+ local result = Protocol.buildResult(request, startedAt, probes, {}, { reason = reason, detail = detail })
969
+ Protocol.state = "disarmed"
970
+ Protocol.lastRequestId = tostring(request.id)
971
+ if request.nonce ~= nil then Protocol.consumed[tostring(request.nonce)] = true end
972
+ Protocol.writeResult(result)
973
+ Protocol.lastResultId = tostring(request.id)
974
+ Protocol.log("REFUSED request " .. tostring(request.id) .. " (" .. reason .. "): " .. tostring(detail))
975
+ Protocol.writeHeartbeat()
976
+ end
977
+
978
+ -- ---------------------------------------------------------------------------
979
+ -- Running a request
980
+ -- ---------------------------------------------------------------------------
981
+ -- CET attests exactly one timer: `dt` accumulated in onUpdate (the common CET pattern).
982
+ -- There is no WaitForRealtime here and no `Cron` (unverified), so `settleSeconds` — the
983
+ -- console hazard, "the weapon drops into the holstered pose while you type", 2.0 s by
984
+ -- default for pose/anim/equipment reads — is served by carrying the run across onUpdate
985
+ -- frames in this little state machine.
986
+
987
+ --- Begin an accepted request. Probes are not sampled yet: the settle delay runs first.
988
+ function Protocol.beginRun(request)
989
+ local settle = 0
990
+ local windowSeconds = 0
991
+ if type(request.window) == "table" then windowSeconds = tonumber(request.window.seconds) or 0 end
992
+
993
+ local run = {
994
+ request = request,
995
+ results = {},
996
+ errors = {},
997
+ startedAt = nil,
998
+ settleRemaining = 0,
999
+ elapsed = 0,
1000
+ windowSeconds = windowSeconds,
1001
+ }
1002
+ run.startedAt = Protocol.isoNow(run)
1003
+
1004
+ for _, probe in ipairs(request.probes) do
1005
+ local s = tonumber(probe.settleSeconds) or 0
1006
+ if s > settle then settle = s end
1007
+ end
1008
+ run.settleRemaining = settle
1009
+ run.budget = settle + Protocol.WATCHDOG_SLACK_SECONDS
1010
+
1011
+ if windowSeconds > 0 then
1012
+ run.errors[#run.errors + 1] =
1013
+ "window.seconds = " .. tostring(windowSeconds) .. " was ignored: no CET listener API is attested by any " ..
1014
+ "source on disk, so this bridge cannot observe. Every probe was sampled once, after the settle delay."
1015
+ end
1016
+
1017
+ Protocol.run = run
1018
+ Protocol.state = "running"
1019
+ Protocol.lastRequestId = tostring(request.id)
1020
+ Protocol.consumed[tostring(request.nonce)] = true
1021
+ Protocol.writeHeartbeat()
1022
+
1023
+ Protocol.log(string.format("ARMED request %s: %d probe(s), settle %ss, allowMutate=%s",
1024
+ tostring(request.id), #request.probes, tostring(settle), tostring(request.allowMutate == true)))
1025
+ if settle > 0 then
1026
+ Protocol.log("settling " .. tostring(settle) .. "s before the first sample — THE CONSOLE MUST BE CLOSED " ..
1027
+ "for any pose, animation or equipment read")
1028
+ end
1029
+ end
1030
+
1031
+ --- Sample every probe and write the result. Called from `tick` once the settle delay is up.
1032
+ function Protocol.finishRun(run)
1033
+ local request = run.request
1034
+ for i, probe in ipairs(request.probes) do
1035
+ local res
1036
+ if run.results[i] ~= nil then
1037
+ res = run.results[i]
1038
+ else
1039
+ local ok, out = pcall(function() return Protocol.dispatcher.run(probe, request, run) end)
1040
+ if ok and type(out) == "table" then
1041
+ res = out
1042
+ else
1043
+ Protocol.err("probe", tostring(out))
1044
+ res = {
1045
+ id = tostring(probe.id or ("probe-" .. i)),
1046
+ kind = tostring(probe.kind or "?"),
1047
+ status = "error",
1048
+ observed = nil,
1049
+ evidence = {},
1050
+ error = "dispatch failed: " .. tostring(out),
1051
+ ranAt = Protocol.isoNow(),
1052
+ }
1053
+ end
1054
+ end
1055
+ run.results[i] = res
1056
+ end
1057
+
1058
+ local result = Protocol.buildResult(request, run.startedAt, run.results, run.errors, nil)
1059
+ Protocol.run = nil
1060
+ Protocol.state = "idle"
1061
+ Protocol.writeResult(result)
1062
+ Protocol.lastResultId = tostring(request.id)
1063
+ Protocol.writeHeartbeat()
1064
+ return result
1065
+ end
1066
+
1067
+ -- ---------------------------------------------------------------------------
1068
+ -- Polling and arming
1069
+ -- ---------------------------------------------------------------------------
1070
+
1071
+ --- One poll. Reads the marker and the request, applies every arming check in the
1072
+ --- protocol's order, and either refuses IN WRITING or starts the run.
1073
+ ---
1074
+ --- ARMING WITHOUT A CLOCK. The protocol makes `armedUntil` the guarantee that an armed bridge
1075
+ --- never fires in normal play (hazard 2). With `os.time` absent that check cannot be
1076
+ --- evaluated directly, so a second, weaker but attested guarantee stands in for it: the
1077
+ --- bridge remembers whether a request file was ALREADY THERE at its first poll after load. A
1078
+ --- request that was already on disk is refused `expired` — it could be weeks old. A request
1079
+ --- that appeared while this session was running was written by ModWright seconds ago, so its
1080
+ --- `issuedAt` is adopted as the clock anchor and `armedUntil` is evaluated from there. That
1081
+ --- is the whole difference the missing clock makes.
1082
+ function Protocol.pollOnce()
1083
+ if Protocol.run ~= nil then return end
1084
+ if Protocol.transport ~= "file" then return end
1085
+
1086
+ local markerRaw = Protocol.readFile(Protocol.path(Protocol.FILES.requestMarker))
1087
+ local requestRaw = Protocol.readFile(Protocol.path(Protocol.FILES.request))
1088
+
1089
+ if not Protocol.firstPollDone then
1090
+ Protocol.firstPollDone = true
1091
+ if requestRaw ~= nil and requestRaw ~= "" then
1092
+ local pre = Protocol.decode(requestRaw)
1093
+ if type(pre) == "table" and pre.nonce ~= nil then
1094
+ Protocol.preexistingNonce = tostring(pre.nonce)
1095
+ Protocol.log("a request file was already present at the first poll (nonce " ..
1096
+ Protocol.preexistingNonce .. "); it is treated as possibly stale")
1097
+ end
1098
+ end
1099
+ end
1100
+
1101
+ if markerRaw == nil or markerRaw == "" then return end
1102
+ if requestRaw == nil or requestRaw == "" then return end
1103
+ local marker = Protocol.decode(markerRaw)
1104
+ if type(marker) ~= "table" then return end
1105
+ local request = Protocol.decode(requestRaw)
1106
+ if type(request) ~= "table" then return end
1107
+
1108
+ local reqNonce = tostring(request.nonce or "")
1109
+ local mkNonce = tostring(marker.nonce or "")
1110
+ local reqId = tostring(request.id or "")
1111
+ local mkId = tostring(marker.id or "")
1112
+
1113
+ -- Already handled (executed or refused). Silence: the protocol consumes the nonce
1114
+ -- precisely so a refusal is not rewritten on every poll.
1115
+ if Protocol.consumed[reqNonce] then return end
1116
+
1117
+ -- Identity mismatch. ModWright writes the request FIRST and the marker SECOND
1118
+ -- (arm.ts `planArmRequest`), so a poll landing between the two writes legitimately sees a
1119
+ -- new request beside the previous marker. Refusing that in writing would consume a nonce
1120
+ -- about to become valid, so a mismatch must be STABLE across two polls (>= 1 s apart)
1121
+ -- before it is called a real mismatch rather than a torn read.
1122
+ if reqNonce ~= mkNonce or reqId ~= mkId then
1123
+ local key = reqId .. "/" .. reqNonce .. "|" .. mkId .. "/" .. mkNonce
1124
+ if Protocol.pendingMismatch and Protocol.pendingMismatch == key then
1125
+ Protocol.pendingMismatch = nil
1126
+ Protocol.consumed[mkNonce] = true
1127
+ Protocol.refuse(request, "nonce-mismatch",
1128
+ string.format("marker names id=%s nonce=%s; request names id=%s nonce=%s (stable across two polls, so not a torn write)",
1129
+ mkId, mkNonce, reqId, reqNonce))
1130
+ else
1131
+ Protocol.pendingMismatch = key
1132
+ end
1133
+ return
1134
+ end
1135
+ Protocol.pendingMismatch = nil
1136
+
1137
+ -- Byte length. A disagreement is the half-written case, which has no `disarm.reason` in
1138
+ -- the protocol's enum on purpose: the right answer is to wait, not to refuse.
1139
+ local markerBytes = tonumber(marker.bytes)
1140
+ if markerBytes == nil or #requestRaw ~= markerBytes then
1141
+ Protocol.tornReads = Protocol.tornReads + 1
1142
+ if Protocol.tornReads % 20 == 0 then
1143
+ Protocol.log(string.format("request %s still incomplete after %d polls: marker says %s bytes, file is %d",
1144
+ reqId, Protocol.tornReads, tostring(marker.bytes), #requestRaw))
1145
+ end
1146
+ return
1147
+ end
1148
+ Protocol.tornReads = 0
1149
+
1150
+ -- protocolVersion. A bridge reading a different version writes
1151
+ -- `disarm.reason = "protocol-mismatch"` rather than guessing.
1152
+ local reqVersion = tonumber(request.protocolVersion)
1153
+ local mkVersion = tonumber(marker.protocolVersion)
1154
+ if reqVersion ~= Protocol.PROTOCOL_VERSION or mkVersion ~= Protocol.PROTOCOL_VERSION then
1155
+ Protocol.refuse(request, "protocol-mismatch",
1156
+ string.format("this bridge speaks protocolVersion %d; the request says %s and its marker says %s. Re-run `bridge stage`, then `deploy --variant withBridge`.",
1157
+ Protocol.PROTOCOL_VERSION, tostring(request.protocolVersion), tostring(marker.protocolVersion)))
1158
+ return
1159
+ end
1160
+
1161
+ if tostring(request.game) ~= "cyberpunk2077" then
1162
+ Protocol.refuse(request, "wrong-game",
1163
+ "this is the Cyberpunk 2077 CET bridge; the request names game=" .. tostring(request.game))
1164
+ return
1165
+ end
1166
+
1167
+ if type(request.probes) ~= "table" or #request.probes == 0 then
1168
+ Protocol.refuse(request, "protocol-mismatch", "the request carries no probes")
1169
+ return
1170
+ end
1171
+
1172
+ -- armedUntil, and the no-clock substitute described above.
1173
+ local wasPreexisting = (Protocol.preexistingNonce ~= nil and Protocol.preexistingNonce == reqNonce)
1174
+ if not Protocol.caps.osTime and Protocol.clockAnchorEpoch == nil then
1175
+ if wasPreexisting then
1176
+ Protocol.refuse(request, "expired",
1177
+ "this CET sandbox has no wall clock (os.time is absent) and this request file was already on disk when " ..
1178
+ "the bridge loaded, so its armedUntil cannot be shown to be in the future. Re-arm with `bridge arm` " ..
1179
+ "while the game is running, and it will be honoured.")
1180
+ return
1181
+ end
1182
+ Protocol.adoptClock(tostring(request.issuedAt or ""))
1183
+ end
1184
+
1185
+ local untilSecs = Protocol.parseIso(tostring(request.armedUntil or ""))
1186
+ if untilSecs == nil then
1187
+ Protocol.refuse(request, "expired",
1188
+ "armedUntil is not a parseable ISO 8601 UTC timestamp: " .. tostring(request.armedUntil))
1189
+ return
1190
+ end
1191
+ local nowSecs = Protocol.epochSeconds()
1192
+ if nowSecs == nil then
1193
+ -- No clock and no anchor: honouring an expiry that cannot be evaluated would break
1194
+ -- the one guarantee arming exists to give.
1195
+ Protocol.refuse(request, "expired",
1196
+ "the bridge has no wall clock (os.time is absent) and the request's issuedAt did not parse, so it cannot " ..
1197
+ "prove the request is still armed")
1198
+ return
1199
+ end
1200
+ if nowSecs >= untilSecs then
1201
+ Protocol.refuse(request, "expired",
1202
+ string.format("armedUntil %s has passed (bridge clock %s, source %s)",
1203
+ tostring(request.armedUntil), tostring(Protocol.isoFromEpoch(nowSecs)), Protocol.clockSource))
1204
+ return
1205
+ end
1206
+
1207
+ Protocol.beginRun(request)
1208
+ end
1209
+
1210
+ --- Arm from a JSON string typed at the console. The one way to drive the bridge in log-only
1211
+ --- mode, where there is no request file to read. Every arming check above still applies
1212
+ --- except the two that need a file (marker framing and byte length).
1213
+ function Protocol.armFromString(raw)
1214
+ if Protocol.run ~= nil then return nil, "a run is already in progress" end
1215
+ local request = Protocol.decode(raw)
1216
+ if type(request) ~= "table" then return nil, "that is not a JSON object this bridge can decode" end
1217
+ if tonumber(request.protocolVersion) ~= Protocol.PROTOCOL_VERSION then
1218
+ return nil, "protocolVersion must be " .. Protocol.PROTOCOL_VERSION
1219
+ end
1220
+ if tostring(request.game) ~= "cyberpunk2077" then return nil, "game must be cyberpunk2077" end
1221
+ if type(request.probes) ~= "table" or #request.probes == 0 then return nil, "the request carries no probes" end
1222
+ if request.id == nil or request.nonce == nil then return nil, "id and nonce are required" end
1223
+ if Protocol.consumed[tostring(request.nonce)] then return nil, "that nonce has already been consumed" end
1224
+ Protocol.adoptClock(tostring(request.issuedAt or ""))
1225
+ Protocol.beginRun(request)
1226
+ return true, nil
1227
+ end
1228
+
1229
+ -- ---------------------------------------------------------------------------
1230
+ -- The loop (a 1.0 s accumulator in onUpdate, the common CET pattern)
1231
+ -- ---------------------------------------------------------------------------
1232
+
1233
+ --- Called from init.lua's onUpdate handler with the frame delta. Never throws.
1234
+ function Protocol.tick(dt)
1235
+ dt = tonumber(dt) or 0
1236
+ if dt < 0 then dt = 0 end
1237
+ Protocol.uptime = Protocol.uptime + dt
1238
+
1239
+ -- CET 1.14: `GetMod()` and RTTI-dependent types are only available after onInit (CET
1240
+ -- Nexus changelog, version 1.14). The first frame is the earliest point at which asking
1241
+ -- about them gives a true answer, so the capability line is corrected here — once.
1242
+ if not Protocol.rttiRechecked then
1243
+ Protocol.rttiRechecked = true
1244
+ pcall(Protocol.detectRttiCapabilities)
1245
+ end
1246
+
1247
+ local run = Protocol.run
1248
+ if run ~= nil then
1249
+ run.elapsed = run.elapsed + dt
1250
+ if run.settleRemaining > 0 then
1251
+ run.settleRemaining = run.settleRemaining - dt
1252
+ end
1253
+ if run.settleRemaining <= 0 then
1254
+ Protocol.finishRun(run)
1255
+ elseif run.elapsed > run.budget then
1256
+ run.errors[#run.errors + 1] = string.format(
1257
+ "the bridge watchdog fired after %.1fs (settle %.1fs + %.1fs slack); probes were sampled at that point",
1258
+ run.elapsed, run.budget - Protocol.WATCHDOG_SLACK_SECONDS, Protocol.WATCHDOG_SLACK_SECONDS)
1259
+ Protocol.finishRun(run)
1260
+ end
1261
+ end
1262
+
1263
+ Protocol.pollAcc = Protocol.pollAcc + dt
1264
+ if Protocol.pollAcc >= Protocol.POLL_SECONDS then
1265
+ Protocol.pollAcc = 0
1266
+ Protocol.guarded("poll", Protocol.pollOnce)()
1267
+ end
1268
+
1269
+ if Protocol.transport == "file" then
1270
+ Protocol.hbAcc = Protocol.hbAcc + dt
1271
+ if Protocol.hbAcc >= Protocol.HEARTBEAT_SECONDS then
1272
+ Protocol.hbAcc = 0
1273
+ Protocol.guarded("heartbeat", Protocol.writeHeartbeat)()
1274
+ end
1275
+ else
1276
+ Protocol.logHbAcc = Protocol.logHbAcc + dt
1277
+ if Protocol.logHbAcc >= Protocol.LOG_HEARTBEAT_SECONDS then
1278
+ Protocol.logHbAcc = 0
1279
+ Protocol.guarded("heartbeat", Protocol.writeHeartbeat)()
1280
+ end
1281
+ end
1282
+ end
1283
+
1284
+ --- Feature-detect, discover the write root, and announce both. Called once from onInit.
1285
+ function Protocol.init(opts)
1286
+ Protocol.dispatcher = opts.dispatcher
1287
+ Protocol.loaderUsed = opts.loaderUsed or "?"
1288
+ Protocol.detectCapabilities()
1289
+ Protocol.decoderName = Protocol.caps.json and "json.decode" or "in-house"
1290
+ if Protocol.caps.osTime then Protocol.clockSource = "os.time" end
1291
+
1292
+ local ok = Protocol.discoverWriteRoot()
1293
+ if ok then
1294
+ Protocol.log("write root: " .. Protocol.writeRoot .. " (io.open succeeded on candidate " ..
1295
+ tostring(Protocol.writeRootPrefix == "" and "1" or Protocol.writeRoot) .. ")")
1296
+ else
1297
+ Protocol.log("NO WRITABLE CANDIDATE — log-only mode. Every result is printed as one " ..
1298
+ Protocol.RESULT_TAG .. " line; recover it with triage_logs.")
1299
+ for _, e in ipairs(Protocol.writeRootErrors) do Protocol.log(" " .. e) end
1300
+ end
1301
+ Protocol.log(Protocol.capabilityLine())
1302
+ Protocol.writeHeartbeat()
1303
+ return Protocol
1304
+ end
1305
+
1306
+ return Protocol