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