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,1038 @@
1
+ -- MODWRIGHT-BRIDGE-DO-NOT-SHIP
2
+ --
3
+ -- ModWrightBridge / Probes.lua — the SERVER-context dispatch table. One entry per probe
4
+ -- row of the bridge's probe catalogue, keyed by the row's `id`, and nothing else: probes are
5
+ -- data rather than new Lua precisely because an SE `reset` degrades Osiris, so the probe
6
+ -- list can never be shipped as fresh script.
7
+ --
8
+ -- THE RULE THAT SHAPES EVERY HANDLER HERE (the headline engine finding, observed in game):
9
+ -- in the tested SE build, every Osiris QUERY function returns wrong/false for the entities
10
+ -- probed: Osi.HasPassive, Osi.HasActiveStatus, Osi.IsEnemy, Osi.GetDistanceTo, Osi.IsDead,
11
+ -- Osi.IsPlayer and others are not to be trusted. So every read below is
12
+ -- an ENTITY COMPONENT read, no probe forces a cast, and no Osiris ACTION is ever called.
13
+ --
14
+ -- There are exactly TWO Osiris calls in this file, both deliberate and both named here so a
15
+ -- reviewer never has to hunt for them:
16
+ -- 1. `Osi.GetMaxHitpoints(host)` inside `bg3.osiris.health` — where the query's
17
+ -- unreliability IS the measurement. The degradation detector is
18
+ -- `Osi.GetMaxHitpoints(host)` disagreeing with `Ext.Entity.Get(host).Health.MaxHp`.
19
+ -- 2. `Osi.GetHostCharacter()` to resolve the `"$host"` parameter token used by the
20
+ -- converted test rows. It returns an identity, not a truth value; API.md's own helper
21
+ -- `_C()` is defined as `Ext.Entity.Get(Osi.GetHostCharacter())`, and it is not among
22
+ -- the queries observed to return junk. Every probe that uses it records
23
+ -- `hostResolvedVia` in its evidence so the provenance travels with the answer.
24
+ --
25
+ -- ============================================================================
26
+ -- Ext API used in this file (heading + signature above each first use)
27
+ -- ============================================================================
28
+ -- API.md §"Stats - `Ext.Stats`" / "Ext.Stats.GetStats(type: string): string[]"
29
+ -- API.md §"Stats Objects" / "Ext.Stats.Get(stat, [level], [warnOnError]): StatEntry"
30
+ -- API.md §"Reading stat attributes"
31
+ -- API.md §"Mod info - `Ext.Mod`" / "IsModLoaded(modGuid)", "GetLoadOrder()", "GetMod(modGuid)"
32
+ -- API.md §"Entity class - `Ext.Entity`" / "Entity:GetAllComponentNames() : string[]"
33
+ -- API.md §"Saving the console output to a file" (the attested `Ext.Entity.Get("<uuid>")` call)
34
+ -- API.md §"Utils - `Ext.Utils`" / "Common methods"
35
+ -- (Ext.IO / Ext.Json / Ext.Timer citations live in Protocol.lua, Ext.Osiris in Watch.lua.)
36
+
37
+ local Log = Ext.Require("ModWrightBridge/Log.lua")
38
+ local Protocol = Ext.Require("ModWrightBridge/Protocol.lua")
39
+ local Watch = Ext.Require("ModWrightBridge/Watch.lua")
40
+
41
+ local Probes = {}
42
+
43
+ --- One-shot dump bookkeeping, so the serialized container is dumped on first miss
44
+ --- literally on first miss and not on every poll of a long test plan.
45
+ Probes.dumped = {}
46
+
47
+ --- API.md §"Ext.Stats.GetStats(type: string): string[]" lists thirteen supported types, but the
48
+ --- installed extender (runtime 32, observed in game 2026-09-09) answers four of them
49
+ --- with `bg3se::lua::stats::FetchStatEntries(): Unknown stats entry type: <type>` on the console
50
+ --- (SpellSet, EquipmentSet, ItemGroup, NameGroup) and returns nothing for them, so they are left
51
+ --- out here rather than raising a warning on every stat probe. A stat of one of those types
52
+ --- is not reachable through this probe; say so in the row instead of listing the type.
53
+ local STAT_TYPES = {
54
+ "StatusData", "SpellData", "PassiveData", "Armor", "Weapon", "Character", "Object",
55
+ "TreasureTable", "TreasureCategory",
56
+ }
57
+
58
+ local HOST_TOKEN = "$host"
59
+
60
+ -- ---------------------------------------------------------------------------
61
+ -- Shared helpers
62
+ -- ---------------------------------------------------------------------------
63
+
64
+ local function params(probe)
65
+ if type(probe.params) == "table" then return probe.params end
66
+ return {}
67
+ end
68
+
69
+ local function fail(msg, evidence)
70
+ return { status = "fail", observed = nil, evidence = evidence or {}, error = msg }
71
+ end
72
+
73
+ local function errored(msg, evidence)
74
+ return { status = "error", observed = nil, evidence = evidence or {}, error = msg }
75
+ end
76
+
77
+ --- Turn any engine value into something the encoder can carry.
78
+ local function coerce(v)
79
+ local t = type(v)
80
+ if t == "nil" then return nil end
81
+ if t == "string" or t == "number" or t == "boolean" then return v end
82
+ local tbl = Protocol.dumpComponent(v, 4)
83
+ if tbl ~= nil then return tbl end
84
+ return tostring(v)
85
+ end
86
+
87
+ --- `table.pack` is 5.2+, and both bridge trees are kept inside the 5.1-compatible subset that
88
+ --- the `luac -p` pass (5.1 and 5.4) and bridges/*/README.md describe. So pack by
89
+ --- hand: `select("#", ...)` counts the values including nils, which `#{...}` cannot.
90
+ --- Returns (count, table).
91
+ local function packResults(...)
92
+ return select("#", ...), { ... }
93
+ end
94
+
95
+ --- Resolve a guid parameter, expanding the `"$host"` token. Returns (guid, err, viaOsiris).
96
+ local function resolveGuid(raw)
97
+ if raw == nil then return nil, "params.guid is required" end
98
+ local s = tostring(raw)
99
+ if s ~= HOST_TOKEN then return s, nil, false end
100
+ local ok, host = pcall(function() return Osi.GetHostCharacter() end)
101
+ if ok and host ~= nil and tostring(host) ~= "" then
102
+ return tostring(host), nil, true
103
+ end
104
+ return nil, "could not resolve \"$host\": Osi.GetHostCharacter() returned " .. tostring(host), true
105
+ end
106
+
107
+ --- Try the normalised UUID first, then the raw string, because Osiris hands the same
108
+ --- entity in two different forms (a pattern proven in a shipped mod).
109
+ local function getEntity(guid)
110
+ local e
111
+ pcall(function() e = Ext.Entity.Get(Protocol.normGuid(guid)) end)
112
+ if e == nil then pcall(function() e = Ext.Entity.Get(guid) end) end
113
+ return e
114
+ end
115
+
116
+ --- Read one named component off an entity and flatten it. Returns (table, blob, err).
117
+ local function readComponent(guid, componentName)
118
+ local e = getEntity(guid)
119
+ if e == nil then
120
+ return nil, nil, "Ext.Entity.Get returned nil for " .. tostring(guid) .. " (entity not in the current session?)"
121
+ end
122
+ local raw
123
+ local ok = pcall(function() raw = e[componentName] end)
124
+ if not ok or raw == nil then
125
+ return nil, nil, "entity " .. tostring(guid) .. " has no readable ." .. componentName .. " component"
126
+ end
127
+ local tbl, blob = Protocol.dumpComponent(raw, 6)
128
+ if tbl == nil and blob == nil then
129
+ return nil, nil, "." .. componentName .. " could not be flattened by Ext.Json.Stringify{IterateUserdata=true}"
130
+ end
131
+ return tbl, blob, nil
132
+ end
133
+
134
+ --- Best-effort structural search used by the two rows whose field shape is
135
+ --- NOT pinned. Collects (a) any value stored under a key equal to `needle` and (b) any
136
+ --- table that carries `needle` as one of its own scalar values. Capped, depth-limited, and
137
+ --- always reported alongside the whole dump so the reader can see what was searched.
138
+ local function findNamed(node, needle, out, path, depth)
139
+ if depth > 8 or type(node) ~= "table" or #out >= 16 then return end
140
+ for k, v in pairs(node) do
141
+ local kp = path .. "/" .. tostring(k)
142
+ if tostring(k) == needle then
143
+ out[#out + 1] = { path = kp, value = coerce(v) }
144
+ end
145
+ if type(v) == "table" then
146
+ local names = false
147
+ for _, v2 in pairs(v) do
148
+ if type(v2) ~= "table" and tostring(v2) == needle then names = true end
149
+ end
150
+ if names then out[#out + 1] = { path = kp, value = coerce(v) } end
151
+ findNamed(v, needle, out, kp, depth + 1)
152
+ end
153
+ end
154
+ end
155
+
156
+ --- Sample the ActionResources component for one named resource. Returns
157
+ --- (matches|nil, dumpTable, err). `matches` is nil when the resource could not be located —
158
+ --- never a zero that would read as "the resource is empty".
159
+ local function sampleResource(guid, resourceName)
160
+ local tbl, _, err = readComponent(guid, "ActionResources")
161
+ if tbl == nil then return nil, nil, err end
162
+ if resourceName == nil then return nil, tbl, nil end
163
+ local out = {}
164
+ findNamed(tbl, tostring(resourceName), out, "", 1)
165
+ if #out == 0 then return nil, tbl, nil end
166
+ return Protocol.array(out), tbl, nil
167
+ end
168
+
169
+ --- Status presence via the StatusContainer blob search (search the serialized blob for the
170
+ --- id), the path that actually worked in game once Osi.HasActiveStatus was proven
171
+ --- false-for-present.
172
+ local function statusPresent(guid, statusId)
173
+ local tbl, blob, err = readComponent(guid, "StatusContainer")
174
+ if tbl == nil and blob == nil then return nil, nil, err end
175
+ local hit = false
176
+ if type(blob) == "string" and blob:find(tostring(statusId), 1, true) then hit = true end
177
+ return hit, tbl, nil
178
+ end
179
+
180
+ -- ---------------------------------------------------------------------------
181
+ -- The dispatch table. Keys are the probe ids, verbatim.
182
+ -- Each entry has `run` (read/mutate), and optionally `arm` + `harvest` (observe).
183
+ -- test/unit/bridge/bg3-lua-parity.test.ts reads the keys back out of this block.
184
+ -- ---------------------------------------------------------------------------
185
+ -- MODWRIGHT-PROBE-TABLE-BEGIN
186
+ Probes.handlers = {
187
+
188
+ -- attested(doc). API.md §"Ext.Stats.Get(stat, [level], [warnOnError]): StatEntry" —
189
+ -- "Returns the specified stats entry as an object for easier manipulation."
190
+ ["bg3.stat.exists"] = {
191
+ run = function(probe)
192
+ local p = params(probe)
193
+ if p.name == nil then return errored("params.name is required") end
194
+ local name = tostring(p.name)
195
+
196
+ local entry
197
+ -- warnOnError = false: a deliberate existence check must not spam the SE log.
198
+ pcall(function() entry = Ext.Stats.Get(name, nil, false) end)
199
+ local present = entry ~= nil
200
+
201
+ local searched = Protocol.array({})
202
+ if p.type ~= nil then
203
+ searched[1] = tostring(p.type)
204
+ else
205
+ for i, t in ipairs(STAT_TYPES) do searched[i] = t end
206
+ end
207
+
208
+ local foundIn = Protocol.array({})
209
+ for _, t in ipairs(searched) do
210
+ local names
211
+ pcall(function() names = Ext.Stats.GetStats(t) end)
212
+ if type(names) == "table" then
213
+ for _, n in pairs(names) do
214
+ if tostring(n) == name then
215
+ foundIn[#foundIn + 1] = t
216
+ break
217
+ end
218
+ end
219
+ end
220
+ end
221
+
222
+ return {
223
+ status = (present or #foundIn > 0) and "pass" or "fail",
224
+ observed = present,
225
+ evidence = { name = name, entryPresent = present, foundInTypes = foundIn, typesSearched = searched },
226
+ }
227
+ end,
228
+ },
229
+
230
+ -- attested(doc). API.md §"Reading stat attributes": "Stat attributes can be retrieved by
231
+ -- reading the appropriate property of the StatEntry object" — `local useCosts = spell.UseCosts`.
232
+ -- "If the stat entry doesn't have the specified attribute ... nil is returned."
233
+ ["bg3.stat.fields"] = {
234
+ run = function(probe)
235
+ local p = params(probe)
236
+ if p.name == nil then return errored("params.name is required") end
237
+ if type(p.fields) ~= "table" then return errored("params.fields must be an array of field names") end
238
+ local name = tostring(p.name)
239
+
240
+ local entry
241
+ pcall(function() entry = Ext.Stats.Get(name, nil, false) end)
242
+ if entry == nil then
243
+ return fail("no stats entry named \"" .. name .. "\"", { name = name })
244
+ end
245
+
246
+ local values = {}
247
+ local missing = Protocol.array({})
248
+ local unreadable = Protocol.array({})
249
+ for _, f in ipairs(p.fields) do
250
+ local key = tostring(f)
251
+ local v
252
+ local ok = pcall(function() v = entry[key] end)
253
+ if not ok then
254
+ unreadable[#unreadable + 1] = key
255
+ elseif v == nil then
256
+ missing[#missing + 1] = key
257
+ else
258
+ values[key] = coerce(v)
259
+ end
260
+ end
261
+
262
+ return {
263
+ status = (#missing == 0 and #unreadable == 0) and "pass" or "fail",
264
+ observed = values,
265
+ evidence = { name = name, values = values, missing = missing, unreadable = unreadable },
266
+ }
267
+ end,
268
+ },
269
+
270
+ -- unverified (testplan/registry.ts). Observed in game (2026-09-10):
271
+ -- Ext.Template.GetTemplate(mapKey) called live at the console returns nil for a loose
272
+ -- Public/<Mod>/RootTemplates/*.lsx, and the full template object (with readable Name/
273
+ -- TemplateType/FileName fields, per a console dump) once the identical
274
+ -- node was converted to a sibling .lsf. That console call is attested; this handler,
275
+ -- reached only through the bridge, has not itself run yet — see knowledge fact
276
+ -- bg3.items.roottemplates.loose-lsx-in-roottemplates-is-not-loaded-a-separate-lsf-is.
277
+ ["bg3.template.exists"] = {
278
+ run = function(probe)
279
+ local p = params(probe)
280
+ if p.mapKey == nil then return errored("params.mapKey is required") end
281
+ local mapKey = tostring(p.mapKey)
282
+
283
+ -- Same convention every other handler uses for a required Ext.* call: pcall it,
284
+ -- never let one bad probe take Osiris down with it. A wholly missing `Ext.Template`
285
+ -- global (not expected in a real SE build, but reported like any other missing
286
+ -- precondition rather than guessed past) short-circuits before the call.
287
+ if Ext.Template == nil then return errored("Ext.Template is not available in this build") end
288
+
289
+ -- The pcall's own result is checked here, unlike `bg3.stat.exists`'s, and
290
+ -- deliberately: a `fail` from THIS probe is read as "the template did not reach the
291
+ -- engine", the exact signal the RootTemplates `.lsx` defect produces. A call that
292
+ -- raised instead of returning nil is a different fact entirely, and reporting it as
293
+ -- `fail` would send a reader hunting a packaging bug that is not there. `error` says
294
+ -- "this probe could not answer"; `fail` says "the answer is no".
295
+ local template
296
+ local ok, err = pcall(function() template = Ext.Template.GetTemplate(mapKey) end)
297
+ if not ok then
298
+ return errored("Ext.Template.GetTemplate(\"" .. mapKey .. "\") raised: " .. tostring(err), { mapKey = mapKey })
299
+ end
300
+ if template == nil then
301
+ return { status = "fail", observed = false, evidence = { present = false, mapKey = mapKey } }
302
+ end
303
+
304
+ local name, templateType, fileName
305
+ pcall(function() name = coerce(template.Name) end)
306
+ pcall(function() templateType = coerce(template.TemplateType) end)
307
+ pcall(function() fileName = coerce(template.FileName) end)
308
+
309
+ return {
310
+ status = "pass",
311
+ observed = true,
312
+ evidence = { present = true, mapKey = mapKey, name = name, templateType = templateType, fileName = fileName },
313
+ }
314
+ end,
315
+ },
316
+
317
+ -- attested(doc). API.md §"Mod info - `Ext.Mod`" / "IsModLoaded(modGuid)": "Returns whether
318
+ -- the module with the specified GUID is loaded."; "GetMod(modGuid)" whose `Info: ModuleInfo`
319
+ -- "includes fields such as Name, Author, Description, Directory, ModuleUUID, and ModVersion";
320
+ -- "GetLoadOrder()": "Returns the list of loaded module UUIDs in the order they're loaded in."
321
+ ["bg3.mod.active"] = {
322
+ run = function(probe)
323
+ local p = params(probe)
324
+ if p.uuid == nil then return errored("params.uuid is required") end
325
+ local uuid = tostring(p.uuid)
326
+
327
+ local loaded
328
+ pcall(function() loaded = Ext.Mod.IsModLoaded(uuid) end)
329
+
330
+ local modName, modVersion
331
+ pcall(function()
332
+ local m = Ext.Mod.GetMod(uuid)
333
+ if m ~= nil and m.Info ~= nil then
334
+ modName = coerce(m.Info.Name)
335
+ modVersion = coerce(m.Info.ModVersion)
336
+ end
337
+ end)
338
+
339
+ local index
340
+ pcall(function()
341
+ local order = Ext.Mod.GetLoadOrder()
342
+ if type(order) == "table" then
343
+ local i = 0
344
+ for _, u in pairs(order) do
345
+ i = i + 1
346
+ if Protocol.normGuid(tostring(u)) == Protocol.normGuid(uuid) then index = i end
347
+ end
348
+ end
349
+ end)
350
+
351
+ return {
352
+ status = (loaded == true) and "pass" or "fail",
353
+ observed = loaded == true,
354
+ evidence = { uuid = uuid, loaded = loaded == true, modName = modName,
355
+ modVersion = modVersion, loadOrderIndex = index },
356
+ }
357
+ end,
358
+ },
359
+
360
+ -- attested(doc). API.md §"Entity:GetAllComponentNames() : string[]" — "Returns all engine
361
+ -- component types (native C++ class names) that the entity has." Its printed sample is a
362
+ -- name-keyed map, so both shapes are accepted below rather than assuming one.
363
+ ["bg3.entity.components"] = {
364
+ run = function(probe)
365
+ local p = params(probe)
366
+ local guid, err, viaOsiris = resolveGuid(p.guid)
367
+ if guid == nil then return errored(err) end
368
+
369
+ local e = getEntity(guid)
370
+ if e == nil then return fail("Ext.Entity.Get returned nil for " .. guid, { guid = guid }) end
371
+
372
+ local raw
373
+ local ok = pcall(function() raw = e:GetAllComponentNames() end)
374
+ if not ok or type(raw) ~= "table" then
375
+ return errored("Entity:GetAllComponentNames() failed or returned a non-table for " .. guid, { guid = guid })
376
+ end
377
+
378
+ local names = Protocol.array({})
379
+ for k, v in pairs(raw) do
380
+ if type(k) == "string" then names[#names + 1] = k else names[#names + 1] = tostring(v) end
381
+ end
382
+ table.sort(names)
383
+
384
+ return {
385
+ status = (#names > 0) and "pass" or "fail",
386
+ observed = names,
387
+ evidence = { guid = guid, componentCount = #names, components = names,
388
+ hostResolvedVia = viaOsiris and "Osi.GetHostCharacter" or nil },
389
+ }
390
+ end,
391
+ },
392
+
393
+ -- attested(engine). Active status -> e.StatusContainer (search the serialized blob for
394
+ -- the id); proven in a shipped mod.
395
+ -- Osi.HasActiveStatus is NOT used: it was observed to return false for a present status.
396
+ -- Precondition worth reading before believing a miss: console `AddBoosts`
397
+ -- do not survive save/load, so test on a fresh save.
398
+ ["bg3.entity.status"] = {
399
+ run = function(probe)
400
+ local p = params(probe)
401
+ if p.status == nil then return errored("params.status is required") end
402
+ local guid, err, viaOsiris = resolveGuid(p.guid)
403
+ if guid == nil then return errored(err) end
404
+ local statusId = tostring(p.status)
405
+
406
+ local hit, tbl, readErr = statusPresent(guid, statusId)
407
+ if hit == nil then return fail(readErr, { guid = guid, status = statusId }) end
408
+
409
+ local evidence = {
410
+ guid = guid, status = statusId, hit = hit,
411
+ readVia = "e.StatusContainer, searched as a serialized blob",
412
+ hostResolvedVia = viaOsiris and "Osi.GetHostCharacter" or nil,
413
+ }
414
+ -- The serialized container is attached on the first miss only.
415
+ local dumpKey = "status/" .. guid
416
+ if not hit and not Probes.dumped[dumpKey] then
417
+ Probes.dumped[dumpKey] = true
418
+ evidence.statusContainer = tbl
419
+ end
420
+
421
+ return { status = hit and "pass" or "fail", observed = hit, evidence = evidence }
422
+ end,
423
+ },
424
+
425
+ -- attested(engine). `e.PassiveContainer.Passives[].Passive.PassiveId`, a path walked by
426
+ -- a shipped mod.
427
+ ["bg3.entity.passive"] = {
428
+ run = function(probe)
429
+ local p = params(probe)
430
+ if p.passive == nil then return errored("params.passive is required") end
431
+ local guid, err, viaOsiris = resolveGuid(p.guid)
432
+ if guid == nil then return errored(err) end
433
+ local wanted = tostring(p.passive)
434
+
435
+ local tbl, blob, readErr = readComponent(guid, "PassiveContainer")
436
+ if tbl == nil and blob == nil then return fail(readErr, { guid = guid, passive = wanted }) end
437
+
438
+ local ids = Protocol.array({})
439
+ if type(tbl) == "table" and type(tbl.Passives) == "table" then
440
+ for _, row in ipairs(tbl.Passives) do
441
+ local id
442
+ if type(row) == "table" and type(row.Passive) == "table" then id = row.Passive.PassiveId end
443
+ if id ~= nil then ids[#ids + 1] = tostring(id) end
444
+ end
445
+ end
446
+
447
+ local hit = false
448
+ for _, id in ipairs(ids) do
449
+ if id == wanted then hit = true end
450
+ end
451
+ local viaBlob = false
452
+ if not hit and type(blob) == "string" and blob:find(wanted, 1, true) then
453
+ hit = true
454
+ viaBlob = true
455
+ end
456
+
457
+ return {
458
+ status = hit and "pass" or "fail",
459
+ observed = hit,
460
+ evidence = { guid = guid, passive = wanted, passives = ids, hit = hit,
461
+ matchedViaBlobSearch = viaBlob,
462
+ hostResolvedVia = viaOsiris and "Osi.GetHostCharacter" or nil },
463
+ }
464
+ end,
465
+ },
466
+
467
+ -- attested(engine). `e.Classes` -> `.Classes[].ClassUUID/.SubClassUUID/.Level`; an
468
+ -- entity probe in a shipped mod proved the shape and field names:
469
+ -- { Classes = { { ClassUUID=..., Level=12, SubClassUUID=... } } }.
470
+ ["bg3.entity.class"] = {
471
+ run = function(probe)
472
+ local p = params(probe)
473
+ local guid, err, viaOsiris = resolveGuid(p.guid)
474
+ if guid == nil then return errored(err) end
475
+
476
+ local tbl, _, readErr = readComponent(guid, "Classes")
477
+ if tbl == nil then return fail(readErr, { guid = guid }) end
478
+
479
+ local rows = Protocol.array({})
480
+ local src = (type(tbl) == "table" and type(tbl.Classes) == "table") and tbl.Classes or nil
481
+ if src ~= nil then
482
+ for _, c in ipairs(src) do
483
+ if type(c) == "table" then
484
+ rows[#rows + 1] = {
485
+ ClassUUID = c.ClassUUID ~= nil and tostring(c.ClassUUID) or nil,
486
+ SubClassUUID = c.SubClassUUID ~= nil and tostring(c.SubClassUUID) or nil,
487
+ Level = tonumber(c.Level),
488
+ }
489
+ end
490
+ end
491
+ end
492
+
493
+ return {
494
+ status = (#rows > 0) and "pass" or "fail",
495
+ observed = rows,
496
+ evidence = { guid = guid, classes = rows, component = tbl,
497
+ hostResolvedVia = viaOsiris and "Osi.GetHostCharacter" or nil },
498
+ }
499
+ end,
500
+ },
501
+
502
+ -- **unverified (shape)**. The component's existence is attested by API.md's
503
+ -- own Entity:GetAllComponentNames() sample output ("eoc::ActionResourcesComponent"), but
504
+ -- the FIELD SHAPE is not documented anywhere. So this probe dumps the whole component
505
+ -- and reports a best-effort structural match, and says so in its evidence. One console
506
+ -- dump of the component on a live character settles it and promotes this row (and
507
+ -- bg3.boost.freecast with it) to attested.
508
+ ["bg3.entity.resource"] = {
509
+ run = function(probe)
510
+ local p = params(probe)
511
+ local guid, err, viaOsiris = resolveGuid(p.guid)
512
+ if guid == nil then return errored(err) end
513
+
514
+ local matches, tbl, readErr = sampleResource(guid, p.resource)
515
+ if tbl == nil then return fail(readErr, { guid = guid, resource = p.resource and tostring(p.resource) or nil }) end
516
+
517
+ local evidence = {
518
+ guid = guid,
519
+ resource = p.resource ~= nil and tostring(p.resource) or nil,
520
+ shapeUnverified = true,
521
+ shapeNote = "bg3.entity.resource is unverified (shape); confirm with a console dump of ActionResources on a live character and pin the SpellSlot path",
522
+ hostResolvedVia = viaOsiris and "Osi.GetHostCharacter" or nil,
523
+ }
524
+ local dumpKey = "resource/" .. guid
525
+ if not Probes.dumped[dumpKey] then
526
+ Probes.dumped[dumpKey] = true
527
+ evidence.component = tbl
528
+ end
529
+
530
+ if p.resource == nil then
531
+ return { status = "pass", observed = tbl, evidence = evidence }
532
+ end
533
+ if matches == nil then
534
+ evidence.component = tbl
535
+ return errored("could not locate resource \"" .. tostring(p.resource) ..
536
+ "\" inside the ActionResources dump; the field shape is unverified; dump ActionResources at the console to pin it", evidence)
537
+ end
538
+ evidence.matches = matches
539
+ return { status = "pass", observed = matches, evidence = evidence }
540
+ end,
541
+ },
542
+
543
+ -- **unverified** (depends on bg3.entity.resource). The acceptance regression for a
544
+ -- free-cast boost: with the toggle ON, a leveled non-cantrip cast must consume no spell
545
+ -- slot. Sampled before/after ONE player cast inside the window and corroborated by the
546
+ -- toggle status (params.status); the cast is performed by the human (a forced
547
+ -- Osi.UseSpell was observed to re-cast, move the caster and self-kill via the toggle's
548
+ -- HP charge).
549
+ -- Precondition, observed in game: a toggled-off passive's OnCast functors never
550
+ -- fire, so a probe run with the toggle OFF reports `skipped`, never `pass`.
551
+ ["bg3.boost.freecast"] = {
552
+ run = function(probe)
553
+ return errored("bg3.boost.freecast is an observe probe: send it with effect \"observe\" and a window.seconds long enough for one cast")
554
+ end,
555
+ arm = function(probe)
556
+ local p = params(probe)
557
+ local guid, err, viaOsiris = resolveGuid(p.guid)
558
+ if guid == nil then return { error = err } end
559
+ local resource = p.resource ~= nil and tostring(p.resource) or "SpellSlot"
560
+ local statusId = p.status ~= nil and tostring(p.status) or "FREECAST_STATUS"
561
+
562
+ local before, dump, readErr = sampleResource(guid, resource)
563
+ local toggled, _, toggleErr = statusPresent(guid, statusId)
564
+ local watch, watchErr = Watch.osiris("CastedSpell", 5, { [1] = guid }, 32)
565
+
566
+ return {
567
+ guid = guid, resource = resource, statusId = statusId,
568
+ before = before, beforeDump = dump, readErr = readErr,
569
+ toggled = toggled, toggleErr = toggleErr, watch = watch, watchErr = watchErr,
570
+ hostResolvedVia = viaOsiris and "Osi.GetHostCharacter" or nil,
571
+ }
572
+ end,
573
+ harvest = function(probe, runCtx, armed)
574
+ if type(armed) ~= "table" or armed.error then
575
+ return errored(tostring(armed and armed.error or "arm state missing"))
576
+ end
577
+ local after, afterDump = sampleResource(armed.guid, armed.resource)
578
+ local casts = (armed.watch ~= nil) and armed.watch.rows or Protocol.array({})
579
+ Watch.stop(armed.watch)
580
+
581
+ local evidence = {
582
+ guid = armed.guid, resource = armed.resource, status = armed.statusId,
583
+ overclockToggledOn = armed.toggled,
584
+ overclockReadError = armed.toggleErr,
585
+ before = armed.before, after = after,
586
+ beforeComponent = armed.beforeDump, afterComponent = afterDump,
587
+ casts = casts, castCount = #casts,
588
+ shapeUnverified = true,
589
+ hostResolvedVia = armed.hostResolvedVia,
590
+ watchError = armed.watchErr,
591
+ }
592
+
593
+ if armed.watchErr ~= nil then
594
+ return errored("could not watch CastedSpell: " .. tostring(armed.watchErr), evidence)
595
+ end
596
+ if armed.toggled ~= true then
597
+ local why = "precondition unmet: " .. tostring(armed.statusId) ..
598
+ " is not on the character; a toggled-off passive's OnCast functors never fire."
599
+ if armed.toggleErr ~= nil then
600
+ why = "could not read the toggle status: " .. tostring(armed.toggleErr)
601
+ end
602
+ return { status = "skipped", observed = nil, evidence = evidence, error = why }
603
+ end
604
+ if #casts == 0 then
605
+ return { status = "skipped", observed = nil, evidence = evidence,
606
+ error = "no CastedSpell row for this caster inside the window; the human did not cast (the bridge never forces one)" }
607
+ end
608
+ if armed.before == nil or after == nil then
609
+ return errored("the SpellSlot path inside ActionResources could not be read before and/or after the cast; " ..
610
+ "the component shape is unverified; dump ActionResources at the console to pin it", evidence)
611
+ end
612
+
613
+ local unchanged = (Protocol.encode(armed.before) == Protocol.encode(after))
614
+ evidence.unchanged = unchanged
615
+ return {
616
+ status = unchanged and "pass" or "fail",
617
+ observed = unchanged,
618
+ evidence = evidence,
619
+ error = (not unchanged) and "the spell-slot resource changed across the cast: the free-cast boost did not apply" or nil,
620
+ }
621
+ end,
622
+ },
623
+
624
+ -- attested(doc). API.md §"Capturing Events/Calls":
625
+ -- `Ext.Osiris.RegisterListener(name, arity, event, handler)`. Arity must be exact or the
626
+ -- listener is silently dead (observed in game).
627
+ ["bg3.event.watch"] = {
628
+ run = function(probe)
629
+ return errored("bg3.event.watch is an observe probe: send it with effect \"observe\" and a window.seconds")
630
+ end,
631
+ arm = function(probe)
632
+ local p = params(probe)
633
+ local match = type(p.match) == "table" and p.match or nil
634
+ if type(match) == "table" then
635
+ -- Expand "$host" inside the match map so a plan row can watch the host without
636
+ -- knowing its GUID.
637
+ for k, v in pairs(match) do
638
+ if tostring(v) == HOST_TOKEN then
639
+ local g = resolveGuid(HOST_TOKEN)
640
+ if g ~= nil then match[k] = g end
641
+ end
642
+ end
643
+ end
644
+ local w, err = Watch.osiris(p.name, p.arity, match, tonumber(p.limit) or 64)
645
+ return { watch = w, error = err, name = p.name, arity = p.arity, match = match }
646
+ end,
647
+ harvest = function(probe, runCtx, armed)
648
+ if type(armed) ~= "table" then return errored("arm state missing") end
649
+ if armed.error ~= nil then return errored(tostring(armed.error), { name = armed.name, arity = armed.arity }) end
650
+ local rows = armed.watch.rows
651
+ Watch.stop(armed.watch)
652
+ return {
653
+ status = (#rows > 0) and "pass" or "fail",
654
+ observed = rows,
655
+ evidence = { name = tostring(armed.name), arity = tonumber(armed.arity),
656
+ match = armed.match, rowCount = #rows, rows = rows,
657
+ timebase = "Ext.Timer.MonotonicTime() milliseconds" },
658
+ }
659
+ end,
660
+ },
661
+
662
+ -- **unverified (event fields)**. `Ext.Events.EsvLuaBeforeDealDamage:Subscribe` is NOT in
663
+ -- API.md's event table — it is attested only by use in a shipped SE mod, and the field
664
+ -- paths inside the event are not pinned. Rows are therefore dumped WHOLE (the first
665
+ -- <=10 raw events) and any `filter` is applied as a substring test over the dump, labelled as such.
666
+ ["bg3.event.damage"] = {
667
+ run = function(probe)
668
+ return errored("bg3.event.damage is an observe probe: send it with effect \"observe\" and a window.seconds")
669
+ end,
670
+ arm = function(probe)
671
+ local p = params(probe)
672
+ local w, err = Watch.engineEvent("EsvLuaBeforeDealDamage", tonumber(p.limit) or 10)
673
+ return { watch = w, error = err, filter = type(p.filter) == "table" and p.filter or nil }
674
+ end,
675
+ harvest = function(probe, runCtx, armed)
676
+ if type(armed) ~= "table" then return errored("arm state missing") end
677
+ if armed.error ~= nil then return errored(tostring(armed.error)) end
678
+ local rows = armed.watch.rows
679
+ Watch.stop(armed.watch)
680
+
681
+ local kept = Protocol.array({})
682
+ if armed.filter == nil then
683
+ for i, r in ipairs(rows) do kept[i] = r end
684
+ else
685
+ for _, r in ipairs(rows) do
686
+ local blob = Protocol.encode(r.event)
687
+ local all = true
688
+ for _, want in pairs(armed.filter) do
689
+ if blob:find(tostring(want), 1, true) == nil then all = false end
690
+ end
691
+ if all then kept[#kept + 1] = r end
692
+ end
693
+ end
694
+
695
+ return {
696
+ status = (#kept > 0) and "pass" or "fail",
697
+ observed = kept,
698
+ evidence = {
699
+ event = "EsvLuaBeforeDealDamage",
700
+ fieldPathsUnverified = true,
701
+ fieldPathsNote = "the field paths inside this event are not pinned; rows are dumped whole",
702
+ filter = armed.filter,
703
+ filterMode = "plain substring over the whole dumped event, because no field path is pinned",
704
+ captured = #rows, matched = #kept, rows = kept,
705
+ },
706
+ }
707
+ end,
708
+ },
709
+
710
+ -- attested(doc/repo). The degradation detector is `Osi.GetMaxHitpoints(host)` vs
711
+ -- `Ext.Entity.Get(host).Health.MaxHp` disagreeing, after a mid-session `reset` degrades
712
+ -- Osiris (Osi queries return junk). This is the ONE probe
713
+ -- that calls an Osiris query on purpose: the query IS the thing being measured.
714
+ ["bg3.osiris.health"] = {
715
+ run = function(probe)
716
+ local p = params(probe)
717
+ local guid, err, viaOsiris = resolveGuid(p.guid ~= nil and p.guid or HOST_TOKEN)
718
+ if guid == nil then return errored(err) end
719
+
720
+ local osiRaw
721
+ pcall(function() osiRaw = Osi.GetMaxHitpoints(guid) end)
722
+
723
+ local ecsRaw
724
+ local e = getEntity(guid)
725
+ if e ~= nil then
726
+ pcall(function() ecsRaw = e.Health.MaxHp end)
727
+ end
728
+
729
+ local a, b = tonumber(osiRaw), tonumber(ecsRaw)
730
+ local agree = (a ~= nil and b ~= nil and a == b)
731
+
732
+ return {
733
+ status = agree and "pass" or "fail",
734
+ observed = agree,
735
+ evidence = {
736
+ guid = guid,
737
+ osiGetMaxHitpoints = (a ~= nil) and a or tostring(osiRaw),
738
+ entityHealthMaxHp = (b ~= nil) and b or tostring(ecsRaw),
739
+ agree = agree,
740
+ resetCompletedSeenThisState = Protocol.osirisDegraded,
741
+ detector = "Osi.GetMaxHitpoints(host) vs Ext.Entity.Get(host).Health.MaxHp disagreeing",
742
+ hostResolvedVia = viaOsiris and "Osi.GetHostCharacter" or nil,
743
+ },
744
+ error = (not agree) and
745
+ "Osiris disagrees with the ECS read: this Lua state is degraded (a mid-session `reset`). A save reload (F8) is the only clean fix; `reset` reloads Lua only." or nil,
746
+ }
747
+ end,
748
+ },
749
+
750
+ -- attested(doc). API.md §"Utils" / "Common methods": `Ext.Utils.GetGameState()`,
751
+ -- `Ext.Utils.GameVersion(): string?`, `Ext.Utils.Version(): int32`,
752
+ -- `Ext.Utils.GetCommandLineParams(): string[]` (whose documented sample output contains
753
+ -- "--skip-launcher"); the argv is after-the-fact evidence that the game was launched the
754
+ -- confirmed way, since ModWright never launches it. Reports the fixture, never assumes it.
755
+ -- bg3.lua.eval: console lines that used to be pasted by hand
756
+ -- (`_D(Ext.Stats.Get(...))`, `Osi.TemplateAddTo(...)`, `Ext.Mod.GetMod(...).Info.Directory`)
757
+ -- run through ModWright instead. One expression or chunk, compiled with the state's own
758
+ -- `loadstring`/`load` (`return <code>` first, the bare chunk as the fallback), run under
759
+ -- pcall, every return value coerced like any other probe's evidence, and the encoded result
760
+ -- capped at `maxBytes` (default 16 KB) so one careless `Ext.Entity.Get(...)` cannot flood
761
+ -- the result file. Registered with effect "mutate" and never anything weaker: the bridge
762
+ -- cannot know what a chunk does. Protocol's arm gate skips a `mutate` probe unless the
763
+ -- request set allowMutate — but that gate reads the effect word the REQUEST carries, so the
764
+ -- handler refuses outright when that word is anything but "mutate" (below).
765
+ -- Status unverified: nothing here has run in a game, so whether this state exposes a Lua
766
+ -- loader at all is the first thing a launch settles.
767
+ ["bg3.lua.eval"] = {
768
+ run = function(probe)
769
+ -- THE GATE, handler side. Protocol.lua's arm phase skips a probe when
770
+ -- `effect == "mutate" and request.allowMutate ~= true`; a request that labelled this
771
+ -- kind "read" (or "observe", which falls through to one sample at harvest time) would
772
+ -- therefore reach this handler with no allowMutate anywhere in the request. The
773
+ -- catalogue gives this kind effect `mutate`, so the handler enforces that rather than
774
+ -- trusting the label on the request file.
775
+ local effect = tostring(probe.effect or "read")
776
+ if effect ~= "mutate" then
777
+ return {
778
+ status = "skipped",
779
+ observed = nil,
780
+ evidence = { effect = effect, kind = "bg3.lua.eval" },
781
+ error = "bg3.lua.eval is a mutate-effect probe: send it with effect \"mutate\" and " ..
782
+ "allowMutate: true. It never runs under a weaker effect, because the bridge cannot " ..
783
+ "know what a chunk does.",
784
+ }
785
+ end
786
+ local p = params(probe)
787
+ if type(p.code) ~= "string" or p.code == "" then
788
+ return errored("params.code (a Lua expression or chunk) is required")
789
+ end
790
+ local code = p.code
791
+ -- The catalogue bounds maxBytes at 1..65536 (registry.ts); a request file is not the
792
+ -- catalogue, so an out-of-range value falls back to the default rather than
793
+ -- truncating the answer to nothing.
794
+ local maxBytes = tonumber(p.maxBytes) or 16384
795
+ if maxBytes < 1 or maxBytes > 65536 then maxBytes = 16384 end
796
+ -- `loadstring` first: wherever it exists the state is 5.1-era, and there `load`
797
+ -- takes a reader FUNCTION, not a string. 5.2+ drops `loadstring` and `load` takes
798
+ -- the string. The third argument ("t": text chunks only, never a binary chunk) is
799
+ -- honoured by 5.2+'s `load` and ignored by 5.1's `loadstring`.
800
+ local loader = loadstring or load
801
+ if loader == nil then
802
+ return errored("no Lua loader (loadstring/load) is exposed in this state", { code = code })
803
+ end
804
+ -- Observed in game (2026-09-11, BG3): the extender's loader rejected a chunk name as its second argument
805
+ -- ("bad argument #2 to 'loader' (table expected, got string)"), so the
806
+ -- code is the only argument passed; the loader's own defaults apply.
807
+ local fn, compileErr = loader("return " .. code)
808
+ local form = "expression"
809
+ if fn == nil then
810
+ fn, compileErr = loader(code)
811
+ form = "chunk"
812
+ end
813
+ if fn == nil then
814
+ return errored("does not compile: " .. tostring(compileErr), { code = code })
815
+ end
816
+ local n, results = packResults(pcall(fn))
817
+ if not results[1] then
818
+ return errored("raised: " .. tostring(results[2]), { code = code, form = form })
819
+ end
820
+ -- `coerce` maps nil to nil, so a nil among several returned values drops out of this
821
+ -- array instead of holding its place: `returnCount`, not `#observed`, is the
822
+ -- authority on how many values the chunk returned.
823
+ local values = {}
824
+ for i = 2, n do values[#values + 1] = coerce(results[i]) end
825
+ local returnCount = n - 1
826
+ local observed = values[1]
827
+ if returnCount > 1 then observed = values end
828
+ -- Truncation keeps a RAW PREFIX of the encoded JSON, as a plain Lua string. It is
829
+ -- deliberately not valid JSON on its own: the result writer re-encodes `observed`
830
+ -- with Protocol.encode, which escapes it into one JSON string literal, so the
831
+ -- result file stays parseable and the reader sees exactly how far the value got.
832
+ local encoded = Protocol.encode(observed)
833
+ local bytes = type(encoded) == "string" and #encoded or nil
834
+ local truncated = false
835
+ if bytes ~= nil and bytes > maxBytes then
836
+ observed = string.sub(encoded, 1, maxBytes)
837
+ truncated = true
838
+ end
839
+ return {
840
+ status = "pass",
841
+ observed = observed,
842
+ evidence = { code = code, form = form, returnCount = returnCount, bytes = bytes, truncated = truncated },
843
+ }
844
+ end,
845
+ },
846
+
847
+ ["bg3.precondition.report"] = {
848
+ run = function(probe, runCtx)
849
+ local report = {
850
+ gameState = Protocol.gameState(),
851
+ seVersion = nil,
852
+ gameVersion = nil,
853
+ commandLine = Protocol.commandLine(),
854
+ osirisDegraded = Protocol.osirisDegraded,
855
+ bridgeUptimeMs = math.max(0, Log.monotonic() - Protocol.loadedAtMs),
856
+ }
857
+ local okV, v = pcall(function() return Ext.Utils.Version() end)
858
+ if okV and v ~= nil then report.seVersion = tostring(v) end
859
+ local okG, g = pcall(function() return Ext.Utils.GameVersion() end)
860
+ if okG and g ~= nil then report.gameVersion = tostring(g) end
861
+
862
+ local host, hostErr = resolveGuid(HOST_TOKEN)
863
+ report.host = host
864
+ report.hostError = hostErr
865
+ report.hostResolvedVia = "Osi.GetHostCharacter"
866
+
867
+ if host ~= nil then
868
+ local classProbe = { id = "host-classes", kind = "bg3.entity.class", params = { guid = host } }
869
+ local ok, res = pcall(function() return Probes.handlers["bg3.entity.class"].run(classProbe) end)
870
+ if ok and type(res) == "table" then
871
+ report.hostClasses = res.observed
872
+ report.hostClassStatus = res.status
873
+ else
874
+ report.hostClassError = tostring(res)
875
+ end
876
+ end
877
+
878
+ return { status = "pass", observed = report, evidence = report }
879
+ end,
880
+ },
881
+
882
+ -- attested(engine), RELAYED. The client row; its citation is Ext.IO.SaveFile called from
883
+ -- BootstrapClient.lua, the client-state marker pattern of a shipped SE mod.
884
+ --
885
+ -- The two Lua states cannot see each other (API.md §"Client / Server States": the states
886
+ -- "are completely separated from each other and cannot access the internal state of each
887
+ -- other"), and the protocol has exactly ONE request file and ONE result file per id —
888
+ -- so having both states poll and write the same result would race. The client therefore
889
+ -- writes only its own heartbeat (ModWright_hb_client.json) and this server-side handler
890
+ -- relays it. Every field the row reports is carried there: `stage` is the client's
891
+ -- Ext.Utils.GetGameState(), `seVersion` its bridge.runtimeVersion, `gameVersion` its
892
+ -- bridge.gameVersion. One case this arrangement cannot cover: a client state that never loads
893
+ -- leaves no heartbeat to relay, which the handler reports as a failure.
894
+ ["bg3.client.loaded"] = {
895
+ run = function(probe)
896
+ local raw = Protocol.load(Protocol.FILES.heartbeatClient)
897
+ if raw == nil or raw == "" then
898
+ return fail("no " .. Protocol.FILES.heartbeatClient .. " in the Script Extender folder: the client bridge state never loaded",
899
+ { file = Protocol.FILES.heartbeatClient })
900
+ end
901
+ local hb = Protocol.decode(raw)
902
+ if type(hb) ~= "table" then
903
+ return errored("could not parse " .. Protocol.FILES.heartbeatClient, { file = Protocol.FILES.heartbeatClient })
904
+ end
905
+
906
+ local marker = {
907
+ loaded = true,
908
+ stage = hb.gameState,
909
+ seVersion = (type(hb.bridge) == "table") and hb.bridge.runtimeVersion or nil,
910
+ gameVersion = (type(hb.bridge) == "table") and hb.bridge.gameVersion or nil,
911
+ }
912
+
913
+ -- Freshness, using the same 15 s window as HEARTBEAT_FRESH_MS in
914
+ -- src/core/bridge/locate.ts, so "the client is loaded" never rests on a heartbeat
915
+ -- left behind by an earlier launch.
916
+ local ageSeconds
917
+ local nowSecs = Protocol.epochSeconds()
918
+ local atSecs = Protocol.parseIso(tostring(hb.at or ""))
919
+ if nowSecs ~= nil and atSecs ~= nil then ageSeconds = nowSecs - atSecs end
920
+
921
+ local evidence = {
922
+ file = Protocol.FILES.heartbeatClient,
923
+ marker = marker,
924
+ clientHeartbeatAt = hb.at,
925
+ clientState = hb.state,
926
+ ageSeconds = ageSeconds,
927
+ relayed = true,
928
+ relayNote = "read from the client heartbeat by the server state; the two Lua states are fully separated (API.md §\"Client / Server States\")",
929
+ }
930
+
931
+ if ageSeconds ~= nil and ageSeconds > 15 then
932
+ return { status = "fail", observed = marker, evidence = evidence,
933
+ error = ("the client heartbeat is %ds old (>15s): the client bridge state is not running now"):format(ageSeconds) }
934
+ end
935
+ return { status = "pass", observed = marker, evidence = evidence }
936
+ end,
937
+ },
938
+
939
+ }
940
+ -- MODWRIGHT-PROBE-TABLE-END
941
+
942
+ -- ---------------------------------------------------------------------------
943
+ -- Dispatch (Probes.run(probe) looks `kind` up in a fixed table; an unknown kind is
944
+ -- "unsupported"; every handler is pcall-wrapped following the guarded-callback pattern of a
945
+ -- shipped SE mod, whose reason — a bad error path leaves Osiris returning junk — applies
946
+ -- here exactly)
947
+ -- ---------------------------------------------------------------------------
948
+
949
+ --- Wrap a handler's return into a ProbeResult (`probeResultSchema`, .strict()) and apply the
950
+ --- probe's `expect` block.
951
+ function Probes.finalize(probe, out)
952
+ local status = out.status or "pass"
953
+ local evidence = out.evidence
954
+ if type(evidence) ~= "table" then evidence = {} end
955
+
956
+ if (status == "pass" or status == "fail") and type(probe.expect) == "table" then
957
+ local verdict, note = Protocol.evaluateExpect(out.observed, probe.expect)
958
+ if verdict ~= nil then
959
+ status = verdict
960
+ evidence.expect = { op = tostring(probe.expect.op), value = probe.expect.value, note = note }
961
+ end
962
+ end
963
+
964
+ local res = {
965
+ id = tostring(probe.id or "?"),
966
+ kind = tostring(probe.kind or "?"),
967
+ status = status,
968
+ observed = out.observed,
969
+ evidence = evidence,
970
+ ranAt = Protocol.isoNow(),
971
+ }
972
+ if out.error ~= nil then res.error = tostring(out.error) end
973
+ return res
974
+ end
975
+
976
+ --- Run one read/mutate probe. Never throws: one probe failing can never abort the batch.
977
+ function Probes.run(probe, runCtx)
978
+ local kind = tostring(probe.kind or "")
979
+ local h = Probes.handlers[kind]
980
+ if h == nil or h.run == nil then
981
+ return Probes.finalize(probe, {
982
+ status = "unsupported",
983
+ observed = nil,
984
+ evidence = { kind = kind, context = Protocol.context },
985
+ error = "no handler for probe kind \"" .. kind .. "\" in the " .. Protocol.context .. " dispatch table",
986
+ })
987
+ end
988
+ local ok, res = pcall(h.run, probe, runCtx)
989
+ if ok and type(res) == "table" then return Probes.finalize(probe, res) end
990
+ Log.err("probe:" .. kind, res)
991
+ return Probes.finalize(probe, { status = "error", observed = nil, evidence = { kind = kind }, error = tostring(res) })
992
+ end
993
+
994
+ --- Arm one observe probe. Returns the arm state handed back to `harvest`.
995
+ function Probes.arm(probe, runCtx)
996
+ local kind = tostring(probe.kind or "")
997
+ local h = Probes.handlers[kind]
998
+ if h == nil then
999
+ return { unsupported = true, kind = kind }
1000
+ end
1001
+ if h.arm == nil then
1002
+ return { noArm = true, kind = kind }
1003
+ end
1004
+ local ok, state = pcall(h.arm, probe, runCtx)
1005
+ if ok then return state end
1006
+ Log.err("probe-arm:" .. kind, state)
1007
+ return { error = tostring(state), kind = kind }
1008
+ end
1009
+
1010
+ --- Harvest one observe probe at the end of the window.
1011
+ function Probes.harvest(probe, runCtx, armed)
1012
+ local kind = tostring(probe.kind or "")
1013
+ local h = Probes.handlers[kind]
1014
+
1015
+ if type(armed) == "table" and armed.unsupported then
1016
+ return Probes.finalize(probe, {
1017
+ status = "unsupported", observed = nil, evidence = { kind = kind },
1018
+ error = "no handler for probe kind \"" .. kind .. "\" in the " .. Protocol.context .. " dispatch table",
1019
+ })
1020
+ end
1021
+
1022
+ if h ~= nil and h.harvest ~= nil then
1023
+ local ok, res = pcall(h.harvest, probe, runCtx, armed)
1024
+ if ok and type(res) == "table" then return Probes.finalize(probe, res) end
1025
+ Log.err("probe-harvest:" .. kind, res)
1026
+ return Probes.finalize(probe, { status = "error", observed = nil, evidence = { kind = kind }, error = tostring(res) })
1027
+ end
1028
+
1029
+ -- A read-only kind sent with effect "observe": sample it once, at the end of the window,
1030
+ -- and say so rather than pretending a listener watched anything.
1031
+ local res = Probes.run(probe, runCtx)
1032
+ if type(res.evidence) == "table" then
1033
+ res.evidence.observeNote = "this probe kind has no observe handler; it was sampled once at the end of the window"
1034
+ end
1035
+ return res
1036
+ end
1037
+
1038
+ return Probes