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