modwright 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +123 -15
  2. package/bridges/bg3-se/README.md +15 -0
  3. package/bridges/cp2077-cet/ModWrightBridge/probes.lua +113 -0
  4. package/bridges/cp2077-cet/README.md +17 -1
  5. package/bridges/cp2077-cet/bridge.json +1 -1
  6. package/dist/core/bridge/pending.js +8 -1
  7. package/dist/core/bridge/stage.js +2 -2
  8. package/dist/core/build/run.js +14 -0
  9. package/dist/core/build/stage.js +3 -2
  10. package/dist/core/build/steps/core.js +4 -4
  11. package/dist/core/deploy/plan.js +68 -1
  12. package/dist/core/deploy/reload.js +5 -3
  13. package/dist/core/ini.js +26 -0
  14. package/dist/core/knowledge/facts.js +0 -12
  15. package/dist/core/ledger/hooks.js +22 -5
  16. package/dist/core/logs/registry.js +19 -0
  17. package/dist/core/logs/scan.js +65 -10
  18. package/dist/core/logs/triage.js +79 -2
  19. package/dist/core/managers/mo2.js +121 -0
  20. package/dist/core/managers/vortex.js +52 -0
  21. package/dist/core/ownership/index.js +364 -0
  22. package/dist/core/project/index.js +2 -1
  23. package/dist/core/project/load.js +7 -25
  24. package/dist/core/project/outside-root.js +36 -0
  25. package/dist/core/project/schema.js +38 -1
  26. package/dist/core/safety/backup.js +2 -2
  27. package/dist/core/safety/execute.js +12 -4
  28. package/dist/core/safety/restore.js +62 -0
  29. package/dist/core/testplan/registry.js +51 -2
  30. package/dist/core/text/converters/index.js +1 -1
  31. package/dist/core/text/toolchain.js +2 -2
  32. package/dist/core/toolchain/locate.js +13 -1
  33. package/dist/core/toolchain/run.js +6 -4
  34. package/dist/core/toolchain/types.js +1 -1
  35. package/dist/core/trust.js +105 -0
  36. package/dist/core/writes/index.js +206 -0
  37. package/dist/index.js +33 -9
  38. package/dist/server.js +252 -34
  39. package/dist/surfaces/baldursgate3/surface.js +3 -0
  40. package/dist/surfaces/baldursgate3/validators/story.js +3 -3
  41. package/dist/surfaces/baldursgate3/writes.js +152 -0
  42. package/dist/surfaces/cyberpunk2077/compat.js +8 -4
  43. package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +2 -2
  44. package/dist/surfaces/cyberpunk2077/logs.js +86 -6
  45. package/dist/surfaces/cyberpunk2077/surface.js +13 -1
  46. package/dist/surfaces/cyberpunk2077/validators/cr2w.js +9 -3
  47. package/dist/surfaces/cyberpunk2077/writes.js +67 -0
  48. package/dist/surfaces/eldenring/loaders.js +7 -1
  49. package/dist/surfaces/skyrimse/loadorder.js +2 -26
  50. package/dist/surfaces/skyrimse/surface.js +1 -0
  51. package/dist/surfaces/stardewvalley/logs.js +41 -0
  52. package/dist/surfaces/stardewvalley/surface.js +2 -0
  53. package/dist/surfaces/stardewvalley/writes.js +136 -0
  54. package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +29 -0
  55. package/knowledge/baldursgate3/osiris.tags.yaml +78 -0
  56. package/knowledge/cyberpunk2077/cet.rtti-binding.yaml +227 -0
  57. package/knowledge/cyberpunk2077/cet.sandbox.yaml +22 -0
  58. package/knowledge/cyberpunk2077/codeware.overview.yaml +63 -16
  59. package/knowledge/cyberpunk2077/codeware.systems.yaml +152 -33
  60. package/knowledge/cyberpunk2077/entities.queries.yaml +275 -0
  61. package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +30 -4
  62. package/knowledge/cyberpunk2077/rtti.game-systems.yaml +224 -0
  63. package/package.json +1 -1
  64. package/scaffolds/baldursgate3/new-project.yaml +4 -1
  65. package/scaffolds/cyberpunk2077/new-project.yaml +4 -1
package/README.md CHANGED
@@ -15,8 +15,9 @@ The core is generic. Each game is a **surface**: one module that declares
15
15
  where things live and what the rules are, so every tool works for every
16
16
  supported game.
17
17
 
18
- **Status:** 0.x. The tools are exercised against real installs and real mod
19
- projects, but the API is not stable yet.
18
+ **Status:** 0.x, and the API is not stable yet. How far each game is tested
19
+ differs a lot; "Supported games" says which surfaces have been used against
20
+ a real install and which are built from documentation alone.
20
21
 
21
22
  ## Why
22
23
 
@@ -63,18 +64,65 @@ without tcli." Most features need nothing. ModWright can install some
63
64
  external tools itself, and any other tool or data folder only has to be
64
65
  pointed at once.
65
66
 
67
+ ## Which mod is doing this?
68
+
69
+ With hundreds of mods installed, the usual question is not "is something
70
+ broken" but "which mod did it". Three tools answer it from different sides,
71
+ without launching the game:
72
+
73
+ - **`owner_of`**: which mod a file comes from. Give it a DLL named in a
74
+ crash log, a path from `triage_logs`, or a bare file name. It answers
75
+ from the game's own mod folders, Vortex's deployment records and Mod
76
+ Organizer 2's profiles (Skyrim), and says how it decided. For example,
77
+ `owner_of game=cyberpunk2077 paths=["TweakXL.dll"]` names the Vortex mod
78
+ that deployed it.
79
+ - **`who_touches`**: which mods write the same record or entry: Cyberpunk
80
+ 2077 TweakDB records and flats, Baldur's Gate 3 stats entries, Stardew
81
+ Valley Content Patcher assets and entries. With no `key` it lists the
82
+ keys mods actually disagree on, for example
83
+ `who_touches game=cyberpunk2077`. These don't count as a disagreement:
84
+ - identical writes;
85
+ - list edits on different items;
86
+ - a mod the game does not load (a BG3 pak missing from
87
+ `modsettings.lsx`);
88
+ - a second copy of the same mod.
89
+
90
+ Copies of one mod are reported separately, under `duplicateCopies`.
91
+ `who_touches key=<record>` shows every writer of one key, and `mod=<name>`
92
+ shows what one mod writes.
93
+ - **`triage_logs`** with `attributionFor`: what the frameworks' own logs say
94
+ happened. For example, TweakXL's log gives the order it read tweak files
95
+ in, and a later file wins the same flat.
96
+
97
+ Together they catch the quiet problems, for example a tweak file installed
98
+ twice and loaded twice, or a stale copy of a mod that the game binds instead
99
+ of the current one. `who_touches` reads files, not the running game, so
100
+ scripts that change things at runtime are not seen. Which of two
101
+ disagreeing mods wins depends on load order, which these tools report but
102
+ do not decide.
103
+
66
104
  ## Supported games
67
105
 
68
- | Surface | Game | Mod systems modeled |
69
- |---|---|---|
70
- | `cyberpunk2077` | Cyberpunk 2077 | legacy archives, REDmod, redscript, TweakXL, ArchiveXL, RED4ext, CET; the TweakDB vanilla index; garment, vehicle, tweak and packaging validators; the CET bridge |
71
- | `baldursgate3` | Baldur's Gate 3 | `.pak` mods, loose overrides, Script Extender, the native mod loader; the stats, GUID and handle vanilla index; stats, story, progression and visual-bank validators; the Script Extender bridge |
72
- | `skyrimse` | Skyrim Special Edition | plugins, BSA archives, SKSE plugins, loose files |
73
- | `eldenring` | Elden Ring | me3 profiles, Mod Engine 2 overlay and DLLs, UXM loose files; paramdef-typed param exports, the `.me3` profile, package-layout and DCX rules |
74
- | `valheim` | Valheim | BepInEx plugins, patchers, config, MonoMod, the Doorstop loader; Thunderstore packaging, plugin-source and config rules; the vanilla index from Jötunn's dumps |
75
- | `stardewvalley` | Stardew Valley | SMAPI mods and Content Patcher packs, the load-order override lists, SMAPI's log; the manifest and `content.json` rules; the wiki schema index, built from the wiki's data pages, which `check_toolchain` fetches for you |
76
- | `subnautica2` | Subnautica 2 | `~mods` paks, LogicMods, UE4SS mods, DLC-style plugins; triplet, mount-point, TOC and UE4SS-shape rules |
77
- | `nivalisnights` | Nivalis Nights | BepInEx 6 IL2CPP plugins, patchers, config, generated interop, MelonLoader mods; the vanilla index (items, recipes, vendors, venues, NPCs, by GUID) built from your install with Cpp2IL and UnityPy |
106
+ | Surface | Game | Tested | Mod systems modeled |
107
+ |---|---|---|---|
108
+ | `cyberpunk2077` | Cyberpunk 2077 | field-tested | legacy archives, REDmod, redscript, TweakXL, ArchiveXL, RED4ext, CET; the TweakDB vanilla index; garment, vehicle, tweak and packaging validators; the CET bridge |
109
+ | `baldursgate3` | Baldur's Gate 3 | field-tested | `.pak` mods, loose overrides, Script Extender, the native mod loader; the stats, GUID and handle vanilla index; stats, story, progression and visual-bank validators; the Script Extender bridge |
110
+ | `skyrimse` | Skyrim Special Edition | research only | plugins, BSA archives, SKSE plugins, loose files |
111
+ | `eldenring` | Elden Ring | research only | me3 profiles, Mod Engine 2 overlay and DLLs, UXM loose files; paramdef-typed param exports, the `.me3` profile, package-layout and DCX rules |
112
+ | `valheim` | Valheim | research only | BepInEx plugins, patchers, config, MonoMod, the Doorstop loader; Thunderstore packaging, plugin-source and config rules; the vanilla index from Jötunn's dumps |
113
+ | `stardewvalley` | Stardew Valley | research only | SMAPI mods and Content Patcher packs, the load-order override lists, SMAPI's log; the manifest and `content.json` rules; the wiki schema index, built from the wiki's data pages, which `check_toolchain` fetches for you |
114
+ | `subnautica2` | Subnautica 2 | research only | `~mods` paks, LogicMods, UE4SS mods, DLC-style plugins; triplet, mount-point, TOC and UE4SS-shape rules |
115
+ | `nivalisnights` | Nivalis Nights | checked on an install | BepInEx 6 IL2CPP plugins, patchers, config, generated interop, MelonLoader mods; the vanilla index (items, recipes, vendors, venues, NPCs, by GUID) built from your install with Cpp2IL and UnityPy |
116
+
117
+ - **Field-tested:** used against real installs, real mod projects and the
118
+ running game, through the in-game bridge, over many field runs.
119
+ - **Checked on an install:** detection, logs, compatibility and the vanilla
120
+ index were checked against an installed copy of the game. No mod project
121
+ has been built or deployed through it yet, and it has no bridge.
122
+ - **Research only:** written from the game's modding documentation, its
123
+ community tools' sources and their published file formats, and tested
124
+ against fixtures. No install was behind it. Expect gaps, and please report
125
+ them.
78
126
 
79
127
  Installs are auto-detected from Steam (every library folder), plus GOG and
80
128
  Epic paths where they apply. Override with `installPath` on any tool, or set
@@ -88,13 +136,15 @@ Epic paths where they apply. Override with `installPath` on any tool, or set
88
136
  | `detect_install` | Locate one game, and report every mod root and log source, marking which exist. |
89
137
  | `list_mods` | Enumerate installed mods across all roots, with sizes, timestamps and parsed manifests. |
90
138
  | `inspect_mod` | Full detail on one mod: files owned, manifest, warnings. |
91
- | `find_conflicts` | Paths claimed by more than one mod, restricted to roots where that is actually possible. |
139
+ | `find_conflicts` | Paths claimed by more than one mod, restricted to roots where that is actually possible. Where Mod Organizer 2 manages the game, it also reports the files one MO2 mod overrides in another. |
140
+ | `owner_of` | Which installed mod a file comes from, and which copy the game sees when several provide it: for a DLL named in a crash log, a path from `triage_logs`, or a bare file name. It reads the game's own mod folders, Mod Organizer 2's profiles (Skyrim) and Vortex's deployment records, and says how each answer was decided. |
141
+ | `who_touches` | Which installed mods write a given record or entry, at the level the game merges on: Cyberpunk 2077 TweakDB records and flats, Baldur's Gate 3 stats entries (read out of paks), Stardew Valley Content Patcher assets and entries. With no key it lists the contested ones. A mod the game does not load is listed but never contests, and copies of one mod count once. Static: it does not decide load order, and runtime scripts are not seen. |
92
142
  | `load_order` | Read the game's explicit load order and cross-check it against what is installed. |
93
143
  | `load_order_diff` | Snapshot the load order before a launch, then report what the game dropped, added or reordered (BG3 `modsettings.lsx`). |
94
144
  | `read_log` | Tail a mod-related log (redscript, RED4ext, CET, SKSE, BG3SE, BepInEx, SMAPI and more). |
95
- | `triage_logs` | Read every mod log for the current run and classify lines against known failure signatures. It returns one verdict: which systems loaded, what failed first, and the usual fix. |
145
+ | `triage_logs` | Read every mod log for the current run and classify lines against known failure signatures. It returns one verdict: which systems loaded, what failed first, and the usual fix. Where a framework's log names who acted (TweakXL's read order, ArchiveXL's merges), it also says which mod did what; `attributionFor` narrows that to one asset, record or mod. |
96
146
  | `check_compat` | Detect the game build, loaders, frameworks and tools, and check them against cited compatibility floors. |
97
- | `check_toolchain` | Locate the external tools (LSLib divine, WolvenKit CLI, Blender, Cpp2IL and others), report each one's path and version (never guessed), and say what each feature needs. It also remembers a tool or data folder you point it at, and installs the tools ModWright can install. |
147
+ | `check_toolchain` | Locate the external tools (LSLib divine, WolvenKit CLI, Blender, Cpp2IL and others), report each one's path and version (never guessed), and say what each feature needs. It also remembers a tool or data folder you point it at, installs the tools ModWright can install, and trusts a project to run its own build commands. |
98
148
  | `project_info` | Load a `modwright.json` mod project: sources, build, deploy targets, dependencies and publish targets, with paths resolved and structural warnings. |
99
149
  | `lookup` | Search the knowledge base of game and toolchain facts (each with a status and a source), the validator catalogue, or the game's vanilla index you built locally. |
100
150
  | `build_index` | Extract and index the game's own data into a local SQLite file in ModWright's cache. Never redistributed. |
@@ -114,6 +164,23 @@ Epic paths where they apply. Override with `installPath` on any tool, or set
114
164
  Every tool that writes is **dry-run by default**: it describes the plan and
115
165
  writes nothing until you apply it.
116
166
 
167
+ ## How much to trust an answer
168
+
169
+ Every fact `lookup` returns carries a status and a source:
170
+
171
+ | Status | Means |
172
+ |---|---|
173
+ | `verified` | Someone observed it in the game; the fact says when. |
174
+ | `community` | A wiki, README, issue or forum post states it, cited by URL, and it has not been re-checked here. |
175
+ | `inferred` | Derived from reading code or from other facts. |
176
+ | `unverified` | Written from documentation or reasoning, and never observed. |
177
+ | `contradicted` | Once believed and later shown wrong. It is kept, pointing at what replaced it, so the mistake is not made again. |
178
+
179
+ A validator that cannot run is reported as **skipped**, with the reason,
180
+ never as passed. That happens when the vanilla index is not built, a tool is
181
+ missing, or the project has nothing of the kind it checks. An in-game test
182
+ whose probe is `unverified` can pass, but its result stays below `verified`.
183
+
117
184
  ## Workflow prompts
118
185
 
119
186
  Seven workflow prompts walk the authoring loop in any MCP client
@@ -173,6 +240,12 @@ project:
173
240
  `check_toolchain action=install tool=cpp2il` shows exactly what it would
174
241
  fetch (URL, size, sha256, destination); `mode=apply` installs it. Downloads
175
242
  are pinned and checked before anything is written.
243
+ - **Trusted projects** are listed in `trusted-projects.json`, beside that
244
+ config. A project's `exec` build steps and `toolchain` overrides run only
245
+ after you trust it once: `check_toolchain action=trust projectPath=<mod>`
246
+ lists what it would allow, and `mode=apply` records it. Until then an
247
+ applied build, convert, template extract or index build that would run
248
+ them is refused. `MODWRIGHT_TRUST_ALL=1` trusts every project, for CI.
176
249
  - **Overrides:** `MODWRIGHT_CACHE_DIR` moves the cache; `MODWRIGHT_HOME`
177
250
  moves everything.
178
251
  - **Project state** stays in the project's own `.modwright/`.
@@ -205,6 +278,41 @@ produce confident nonsense:
205
278
  - **Redistributing game data.** Indexes and templates are built from your
206
279
  own install, on your machine, and never shipped.
207
280
 
281
+ ## Security
282
+
283
+ ModWright runs programs on your machine and writes into your game folders.
284
+ What it runs, and where it writes:
285
+
286
+ - **Tools it finds.** It runs the external tools it locates (LSLib divine,
287
+ WolvenKit CLI, Blender and the others `check_toolchain` lists). They come
288
+ from a `MODWRIGHT_TOOL_*` variable, your per-user config, its own managed
289
+ installs, PATH or common install locations. Managed installs are pinned
290
+ and their sha256 is checked before anything is written.
291
+ - **A project's own commands.** A mod project's `exec` build steps and its
292
+ `toolchain` overrides are programs the project's author chose. They run
293
+ only after you trust that project on this machine:
294
+ `check_toolchain action=trust projectPath=<mod>` lists what it would allow,
295
+ and `mode=apply` records it. Read the project's `modwright.json` first,
296
+ above all for a mod you cloned from someone else. Trust belongs to the
297
+ folder, not to the file's content, so a trusted project that later gains
298
+ a step (after a `git pull`) is not asked again.
299
+ - **Where it writes.** Every writer is dry-run until you apply it, and it
300
+ backs up what it replaces. A build writes under the project's
301
+ `build.outDir`, and a deploy writes under the game's mod roots. A project
302
+ path that is absolute or climbs out with `..` is refused unless that entry
303
+ sets `"allowOutsideRoot": true`. `rollback` restores only under the
304
+ project and the game's mod roots, unless you pass `allowOutsideRoots`.
305
+ - **The in-game bridges.** While a verification bridge is deployed and the
306
+ game runs, anything that can write the bridge's request folder can run Lua
307
+ in the game. There is no token. Deploy a bridge only while you verify,
308
+ remove it afterwards, and never ship it (the release validators block a
309
+ build that contains it). The bridge READMEs under `bridges/` describe each
310
+ one's trust model.
311
+
312
+ To report a security problem, use **Report a vulnerability** on
313
+ [modwright-community's Security tab](https://github.com/jtrachtenberg/modwright-community/security),
314
+ not a public issue.
315
+
208
316
  ## Reporting a problem
209
317
 
210
318
  Bug reports, game and feature requests and questions go to
@@ -82,6 +82,21 @@ No Lua parser is vendored. Syntax is checked with `luac -p` under Lua 5.1 and
82
82
  dispatch-table keys equal the catalogue's probe ids, that the `PROTOCOL_VERSION` literals
83
83
  agree, that `bridge.json` lists every shipped file, and that every `.lua` carries the banner.
84
84
 
85
+ ## Trust model
86
+
87
+ While this bridge is deployed and the game is running, anything that can write a file into
88
+ its request folder (`%LOCALAPPDATA%\Larian Studios\Baldur's Gate 3\Script Extender`) can run
89
+ Lua inside the game. A request names its own probes and its own grants, and `bg3.lua.eval`
90
+ runs whatever code a request carries once the request grants the `session` tier. There is no
91
+ token. The nonce and the request's `armedUntil` expiry only stop a consumed or stale request
92
+ from running again; they do not say who wrote it. So:
93
+
94
+ - deploy the bridge only to a machine and a profile you control, and only while you are
95
+ verifying a mod;
96
+ - remove `Mods/ModWrightBridge` from the profile when you are done, before playing normally
97
+ or sharing the profile;
98
+ - never ship it. `bg3.history.bridge-never-ships` blocks a release build that contains it.
99
+
85
100
  ## What this bridge never does
86
101
 
87
102
  - No Osiris **action**, ever, and no forced cast: `Osi.UseSpell` was observed to re-cast,
@@ -39,6 +39,9 @@
39
39
  -- (verified, 2.31, observed in game 2026-08-16)
40
40
  -- TweakDB:GetRecord / TweakDB:GetFlat observed in the CET console on
41
41
  -- 2026-09-08 (see the rows below).
42
+ -- obj:GetNPCsAroundObject(radius) the game's RTTI dump (rayshader/cp2077-nativedb,
43
+ -- obj:GetEntitiesAroundObject(radius, filter) gameObject methods); no CET mod or wiki page calls
44
+ -- TSF_Or / TSF_Any / Enum.new them — `unverified`; CET's source pins the Lua side
42
45
  --
43
46
  -- MUTATION. Two rows have effect `mutate`: `cp2077.inventory.add`, and `cp2077.lua.eval`
44
47
  -- (added 2026-09-11). They are the two handlers in this
@@ -816,6 +819,116 @@ Probes.handlers = {
816
819
  end,
817
820
  },
818
821
 
822
+ -- World-placed object verification — **unverified**, written 2026-09-10 with no game on
823
+ -- hand and never run in game. `gameObject` (parent `entGameEntity`) carries
824
+ -- `GetNPCsAroundObject(range: Float) -> array<ref<NPCPuppet>>` and
825
+ -- `GetEntitiesAroundObject(range: Float, searchFilter: gameTargetSearchFilter) -> array<ref<entEntity>>`
826
+ -- in the shipped game's own RTTI (rayshader/cp2077-nativedb at b5d29af, game 2.31), so both
827
+ -- exist — stronger evidence than "nothing on disk", which is why this row exists instead of
828
+ -- sitting out like `cp2077.loot.roll`. Neither a shipped CET mod nor a wiki page calls either
829
+ -- one from Lua, but CET's own source (yamashi/CyberEngineTweaks 9a8522f; knowledge facts
830
+ -- cp2077.cet.*) settles the calling convention:
831
+ -- * the TSF_* filter constructors are exposed as bare globals, `Game.TSF_X` and
832
+ -- `Game["TSF_X;"]`; a returned struct is a ClassReference copy that passes straight back
833
+ -- as a struct parameter (no layout is ever guessed);
834
+ -- * an enum parameter accepts a number, a member name or `Enum.new(type, name)`, but an
835
+ -- UNDECLARED value is coerced to 0 silently — so no ORed masks; declared members only,
836
+ -- combined with TSF_Or, and `Enum.new` with the full type name so a mistake is loud;
837
+ -- * an array return is a 1-based table whose null handles are nil holes — count with
838
+ -- pairs, never `#`.
839
+ -- Two shapes, chosen by `params.filter`:
840
+ -- "npc" (default): `GetNPCsAroundObject(radius)`, one scalar argument, the same risk class
841
+ -- as `ent:GetWorldPosition()` and `GetTaggedIDs(tag)` above.
842
+ -- "objects": `GetEntitiesAroundObject(radius, TSF_Or(TSF_Any(Obj_Device), TSF_Any(Obj_Other),
843
+ -- TSF_Any(Obj_Puppet), TSF_Any(Obj_Sensor)))` — the call that could verify a placed static
844
+ -- prop (a loot-container template carrying a gameTargetingComponent is plausibly in the
845
+ -- targeting set). TSF_Any/TSF_Or semantics and whether the prop is in the set are exactly
846
+ -- what a live run observes; every step is pcall-wrapped and reported, never swallowed.
847
+ -- The anchor defaults to the player; `params.tag`/`params.entityRef` pick another, exactly as
848
+ -- `resolveEntity` does for the rows above.
849
+ ["cp2077.entity.nearby"] = {
850
+ attestation = "unverified (RTTI dump confirms gameObject::GetNPCsAroundObject/GetEntitiesAroundObject exist; CET source pins the Lua calling convention; no live call observed yet)",
851
+ citation = "rayshader/cp2077-nativedb classes.json at b5d29af (gameObject::GetNPCsAroundObject;Float, GetEntitiesAroundObject;FloatTargetSearchFilter); " ..
852
+ "yamashi/CyberEngineTweaks 9a8522f (RTTI binding)",
853
+ effect = "read",
854
+ run = function(probe)
855
+ local p = params(probe)
856
+ local radius = tonumber(p.radius)
857
+ if radius == nil or radius <= 0 then return errored("params.radius is required and must be a positive number") end
858
+ local filter = p.filter == nil and "npc" or tostring(p.filter)
859
+ if filter ~= "npc" and filter ~= "objects" then
860
+ return errored("params.filter must be \"npc\" (default) or \"objects\", got " .. tostring(p.filter))
861
+ end
862
+
863
+ local anchorParams = p
864
+ if p.tag == nil and p.entityRef == nil then anchorParams = { entityRef = "$player" } end
865
+ local ent, describe, e = resolveEntity(anchorParams)
866
+ if ent == nil then return errored(e or "could not resolve an anchor entity", { resolve = describe }) end
867
+
868
+ local evidence = { resolve = describe, radius = radius, filter = filter }
869
+
870
+ local list
871
+ if filter == "npc" then
872
+ evidence.attemptedCall = "GetNPCsAroundObject(radius) — NPCs only; a static prop like a placed item does not " ..
873
+ "show up here even when it does exist"
874
+ local okCall, callErr = pcall(function() list = ent:GetNPCsAroundObject(radius) end)
875
+ if not okCall then
876
+ return errored("obj:GetNPCsAroundObject(" .. tostring(radius) .. ") raised an error (recorded rather " ..
877
+ "than swallowed, since this row is unverified): " .. tostring(callErr), evidence)
878
+ end
879
+ else
880
+ evidence.attemptedCall = "GetEntitiesAroundObject(radius, TSF_Or(TSF_Any(Obj_Device), TSF_Any(Obj_Other), " ..
881
+ "TSF_Any(Obj_Puppet), TSF_Any(Obj_Sensor))) — each member via Enum.new so a wrong name is an error, " ..
882
+ "never a silent 0"
883
+ local searchFilter
884
+ local okFilter, filterErr = pcall(function()
885
+ local function member(name) return Enum.new("gametargetingSystemSearchFilterMaskValue", name) end
886
+ searchFilter = TSF_Or(TSF_Any(member("Obj_Device")), TSF_Any(member("Obj_Other")),
887
+ TSF_Any(member("Obj_Puppet")), TSF_Any(member("Obj_Sensor")))
888
+ end)
889
+ if not okFilter or searchFilter == nil then
890
+ return errored("building the gameTargetSearchFilter raised an error or returned nil (TSF_*/Enum.new " ..
891
+ "exposure is what this settles): " .. tostring(filterErr), evidence)
892
+ end
893
+ local okCall, callErr = pcall(function() list = ent:GetEntitiesAroundObject(radius, searchFilter) end)
894
+ if not okCall then
895
+ return errored("obj:GetEntitiesAroundObject(" .. tostring(radius) .. ", filter) raised an error (recorded " ..
896
+ "rather than swallowed, since this row is unverified): " .. tostring(callErr), evidence)
897
+ end
898
+ end
899
+ if list == nil then
900
+ return errored(evidence.attemptedCall:match("^[%w]+") .. " returned nil — the RTTI method exists but this " ..
901
+ "call did not resolve here; a live run is needed to say why", evidence)
902
+ end
903
+ if type(list) ~= "table" then
904
+ return errored("expected a table from the array return, got " .. type(list), evidence)
905
+ end
906
+
907
+ -- CET returns an array as a 1-based table, but a null handle inside it becomes a nil
908
+ -- hole, so `#` and ipairs can stop early: walk with pairs and count what is there.
909
+ local count, holes = 0, 0
910
+ local out = Protocol.array({})
911
+ local slots = 0
912
+ for k, _ in pairs(list) do if type(k) == "number" and k > slots then slots = k end end
913
+ for i = 1, slots do
914
+ local other = list[i]
915
+ if other == nil then
916
+ holes = holes + 1
917
+ else
918
+ count = count + 1
919
+ local okName, name = pcall(function() return other:GetClassName() end)
920
+ local entry = { className = (okName and name ~= nil) and tostring(name) or nil }
921
+ local okApp, app = pcall(function() return other:GetCurrentAppearanceName() end)
922
+ if okApp and app ~= nil then entry.appearance = tostring(app) end
923
+ out[#out + 1] = entry
924
+ end
925
+ end
926
+ evidence.entities = out
927
+ evidence.nilHoles = holes
928
+ return ok(count, evidence)
929
+ end,
930
+ },
931
+
819
932
  -- Row 10. `TweakDB:GetRecord(id)`, not attested by any page or mod on disk, but observed
820
933
  -- to work from the CET console (2026-09-08):
821
934
  -- print(tostring(TweakDB:GetRecord("Items.Preset_Example")))
@@ -111,7 +111,7 @@ be diagnosed.
111
111
 
112
112
  Fenced with `-- MODWRIGHT-PROBE-TABLE-BEGIN` / `-- MODWRIGHT-PROBE-TABLE-END` in
113
113
  `probes.lua`, so the parity test reads only the table and never an id that appears in a
114
- comment. Thirteen ids, one per catalogue row, each carrying that row's own status word:
114
+ comment. Fifteen ids, one per catalogue row, each carrying that row's own status word:
115
115
 
116
116
  | id | Status | If its call is missing |
117
117
  |---|---|---|
@@ -120,10 +120,12 @@ comment. Thirteen ids, one per catalogue row, each carrying that row's own statu
120
120
  | `cp2077.player.present` | attested(engine) | `error` if `Game` is absent |
121
121
  | `cp2077.item.equipped` | attested(engine) | — |
122
122
  | `cp2077.inventory.add` | attested(doc), **mutate** | `error`; skipped without `allowMutate` |
123
+ | `cp2077.lua.eval` | **mutate**; runs a request's Lua chunk | skipped without the `session` tier |
123
124
  | `cp2077.entity.tagged` | attested(engine) | — |
124
125
  | `cp2077.entity.components` | attested(engine) for the call; names unverified | `error` without `params.names` |
125
126
  | `cp2077.entity.transform` | attested(engine) for item objects; unverified for a sector prop | says which, in `evidence.attestationForThisCall` |
126
127
  | `cp2077.entity.appearance` | unverified (composite) | `skipped` without a baseline |
128
+ | `cp2077.entity.nearby` | unverified — the RTTI methods exist; never run in game | `error` naming the call that raised or returned nil |
127
129
  | `cp2077.tweakdb.record` | unverified | `error` |
128
130
  | `cp2077.tweakdb.flat` | unverified | `error` |
129
131
  | `cp2077.interaction.present` | unverified (the component *name*) | `error` without `params.names` |
@@ -155,6 +157,20 @@ The Lua is kept inside the 5.1-compatible subset shipped CET mods use, and check
155
157
  against both dialects — CET embeds a 5.4-era sol2, but nothing on disk pins which dialect
156
158
  features are available, so the intersection is the safe target.
157
159
 
160
+ ## Trust model
161
+
162
+ While this bridge is deployed and the game is running, anything that can write a file into
163
+ its write root (the folders listed under "The `io` write-root discovery") can run Lua inside
164
+ the game, and so can anything that can type into the CET console. A request names its own
165
+ probes and its own grants, and `cp2077.lua.eval` runs whatever code a request carries once
166
+ the request grants the `session` tier. There is no token. The nonce and the request's expiry
167
+ only stop a consumed or stale request from running again; they do not say who wrote it. So:
168
+
169
+ - deploy the bridge only to an install you control, and only while you are verifying a mod;
170
+ - remove `bin/x64/plugins/cyber_engine_tweaks/mods/ModWrightBridge` when you are done,
171
+ before playing normally;
172
+ - never ship it. `cp2077.history.bridge-never-ships` blocks a release build that contains it.
173
+
158
174
  ## What this bridge never does
159
175
 
160
176
  * It never observes: no CET listener API is attested, so `effect: "observe"` is `unsupported`
@@ -43,7 +43,7 @@
43
43
  },
44
44
  {
45
45
  "path": "ModWrightBridge/probes.lua",
46
- "sha256": "a9dc28d3fd949f353f17404366bd2bb329813b9ca639e06a9b51aa34c104fb7d"
46
+ "sha256": "169a31b158edcefda6721c3b90656c59fd9f7d7bf928a96d03ef3645a5eb1991"
47
47
  },
48
48
  {
49
49
  "path": "ModWrightBridge/protocol.lua",
@@ -51,7 +51,14 @@ export async function readPendingArm(projectRoot, id) {
51
51
  catch {
52
52
  return undefined;
53
53
  }
54
- const parsed = pendingArmSchema.safeParse(JSON.parse(raw));
54
+ let json;
55
+ try {
56
+ json = JSON.parse(raw);
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ const parsed = pendingArmSchema.safeParse(json);
55
62
  return parsed.success ? parsed.data : undefined;
56
63
  }
57
64
  export async function listPendingArms(projectRoot, game, opts) {
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import { isDirectory, pathExists } from "../fsutil.js";
5
5
  import { parseProject } from "../project/schema.js";
6
6
  import { PROJECT_FILE } from "../project/types.js";
7
- import { executePlan, planCopy, planFile, planMkdir } from "../safety/index.js";
7
+ import { executePlan, planCopy, planDelete, planFile, planMkdir } from "../safety/index.js";
8
8
  const BRIDGE_DIR_BY_GAME = {
9
9
  baldursgate3: "bg3-se",
10
10
  cyberpunk2077: "cp2077-cet",
@@ -77,7 +77,7 @@ export async function planStageBridge(projectRoot, game) {
77
77
  }
78
78
  const to = stagedBridgeDir(projectRoot, game);
79
79
  const registration = await planDeployTargetRegistration(projectRoot, game);
80
- const operations = [planMkdir(path.dirname(to)), planCopy(from, to)];
80
+ const operations = [planMkdir(path.dirname(to)), ...(await pathExists(to) ? [planDelete(to)] : []), planCopy(from, to)];
81
81
  if (registration.op)
82
82
  operations.push(registration.op);
83
83
  return {
@@ -2,7 +2,9 @@ import { promises as fs } from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { pathExists, walk } from "../fsutil.js";
4
4
  import { normalizeBuildSteps } from "../project/build-steps.js";
5
+ import { outsideRootPaths } from "../project/outside-root.js";
5
6
  import { locateTool as coreLocateTool, TOOL_SPECS } from "../toolchain/index.js";
7
+ import { describeNeeds, isProjectTrusted, trustRefusal } from "../trust.js";
6
8
  import { buildContext, runValidators } from "../validate/index.js";
7
9
  import { runAssertAbsentStep, runAssertPresentStep, runExecStep, runZipStep } from "./steps/core.js";
8
10
  import { runBlenderExportStep, runVerifyArchiveListingStep, runWolvenkitImportStep, runWolvenkitPackStep, } from "./steps/cyberpunk.js";
@@ -168,6 +170,18 @@ export async function runBuild(project, surface, options) {
168
170
  throw new Error(`--only named no matching step id(s): ${options.only.join(", ")}. Known ids: ${active.map(({ step }) => step.id).filter(Boolean).join(", ") || "(none declared)"}`);
169
171
  }
170
172
  const skippedVariant = gatedOut.map(stepName);
173
+ const outside = outsideRootPaths(project.project, selected.map(({ step }) => step)).filter((e) => e.kind === "build-step" && !e.allowed);
174
+ if (outside.length > 0) {
175
+ throw new Error(`${project.project.name}: ${outside.map((e) => e.message).join("; ")}. A build writes only under build.outDir and runs only inside the project; ` +
176
+ `set "allowOutsideRoot": true on the step if this is intended.`);
177
+ }
178
+ if (options.mode === "apply") {
179
+ const exec = selected.flatMap(({ step, stepIndex }) => (step.step === "exec" ? [`"${stepName({ step, stepIndex })}" (${step.command})`] : []));
180
+ const what = describeNeeds({ toolchain: Object.keys(project.project.toolchain ?? {}), exec });
181
+ if (what && !(await (options.isTrusted ?? ((root) => isProjectTrusted(root)))(project.root))) {
182
+ throw new Error(trustRefusal(project.root, what));
183
+ }
184
+ }
171
185
  const version = options.version ?? project.project.version ?? "0.0.0";
172
186
  const stagingDir = project.build.staging
173
187
  ? path.resolve(project.root, project.build.staging)
@@ -96,12 +96,13 @@ async function planFiles(step, ctx) {
96
96
  };
97
97
  const isIncluded = (relForMatch) => includeRes.length === 0 || includeRes.some((re) => re.test(relForMatch));
98
98
  const add = (abs, relDest) => {
99
- const existing = claimed.get(relDest);
99
+ const key = relDest.toLowerCase();
100
+ const existing = claimed.get(key);
100
101
  if (existing !== undefined && existing !== origin) {
101
102
  throw new BuildStepError(`stage: ${existing} and ${origin} both write "${relDest}" — one would silently overlay the other. ` +
102
103
  `Give one of them its own "into" prefix.`, step.id, step.step);
103
104
  }
104
- claimed.set(relDest, origin);
105
+ claimed.set(key, origin);
105
106
  files.push({ abs, relDest, origin });
106
107
  };
107
108
  if (entry.output !== undefined) {
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import { isDirectory, walk } from "../../fsutil.js";
4
4
  import { globToRegExp } from "../../validate/context.js";
5
5
  import { runTool } from "../../toolchain/run.js";
6
+ import { DEFAULT_TOOL_TIMEOUT_MS } from "../../toolchain/types.js";
6
7
  import { TOOL_SPECS } from "../../toolchain/specs.js";
7
8
  import { directoryEntry, pathEntries } from "../manifest.js";
8
9
  import { resolveOutDirPath, resolveSourceOrStagePath, resolveStageId } from "../paths.js";
@@ -86,9 +87,8 @@ export async function runZipStep(step, ctx) {
86
87
  .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
87
88
  await fs.mkdir(path.dirname(out), { recursive: true });
88
89
  await writeZipFile(out, entries.map((entry) => ({ name: entry.name, read: () => fs.readFile(entry.abs) })));
89
- const stat = await fs.stat(out);
90
- if (stat.size === 0)
91
- throw new BuildStepError(`zip: wrote an empty archive: ${out}`, step.id, step.step);
90
+ if (entries.length === 0)
91
+ throw new BuildStepError(`zip: nothing to archive under ${from}`, step.id, step.step);
92
92
  const outputs = await pathEntries([out]);
93
93
  return {
94
94
  inputs: [await directoryEntry(from)],
@@ -128,7 +128,7 @@ async function resolveCommandLocation(step, ctx) {
128
128
  }
129
129
  return { id: "exec", label, found: true, path: resolveSourceOrStagePath(step.command, ctx), usedBy: [] };
130
130
  }
131
- export const EXEC_DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
131
+ export const EXEC_DEFAULT_TIMEOUT_MS = DEFAULT_TOOL_TIMEOUT_MS;
132
132
  export async function runExecStep(step, ctx) {
133
133
  const location = await resolveCommandLocation(step, ctx);
134
134
  const command = location.path;
@@ -1,7 +1,8 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import * as path from "node:path";
3
- import { pathExists, walk } from "../fsutil.js";
3
+ import { isDirectory, pathExists, walk } from "../fsutil.js";
4
4
  import { globToRegExp, isExcludedByGlobs } from "../validate/index.js";
5
+ import { outsideRootPaths } from "../project/outside-root.js";
5
6
  import { findRunningProcesses } from "../process.js";
6
7
  import { executePlan, isIdenticalFile, planCopy, planDelete, planJunction, planMkdir, planUnlink, sha256File } from "../safety/index.js";
7
8
  import { classifyReload } from "./reload.js";
@@ -158,6 +159,60 @@ export async function planDeploy(inputs) {
158
159
  backupRoot,
159
160
  };
160
161
  }
162
+ if (inputs.variant !== undefined) {
163
+ const named = new Set([
164
+ ...deploy.targets.flatMap((t) => (t.variant ? [t.variant] : [])),
165
+ ...Object.keys(project.project.build?.variants ?? {}),
166
+ ]);
167
+ if (!named.has(inputs.variant)) {
168
+ const known = [...named].sort();
169
+ return {
170
+ blocked: [
171
+ {
172
+ code: "unknown-variant",
173
+ message: `${project.project.name}: variant "${inputs.variant}" is named by no deploy target and no build.variants entry` +
174
+ (known.length > 0 ? ` (known: ${known.join(", ")})` : " (this project declares none)") +
175
+ "; nothing was deployed rather than silently deploying only the ungated targets.",
176
+ detail: { variant: inputs.variant, known },
177
+ },
178
+ ],
179
+ confidence: { level: "high", assumptions: [], verifiedBy: ["modwright.json"] },
180
+ targets: [],
181
+ skipped: [],
182
+ deferred: [],
183
+ junctions: [],
184
+ pruned: [],
185
+ skippedDestDirs: [],
186
+ excluded: [],
187
+ warnings,
188
+ writtenPaths: [],
189
+ processCheck: emptyProcessCheck,
190
+ backupRoot,
191
+ };
192
+ }
193
+ }
194
+ const outside = outsideRootPaths(project.project).filter((e) => (e.kind === "deploy-target" || e.kind === "junction") && !e.allowed);
195
+ if (outside.length > 0) {
196
+ return {
197
+ blocked: outside.map((e) => ({
198
+ code: "outside-root",
199
+ message: `${e.message}; a deploy writes only under the game's mod roots. Set "allowOutsideRoot": true on that entry if this is intended.`,
200
+ detail: { kind: e.kind, value: e.value },
201
+ })),
202
+ confidence: { level: "high", assumptions: [], verifiedBy: ["modwright.json"] },
203
+ targets: [],
204
+ skipped: [],
205
+ deferred: [],
206
+ junctions: [],
207
+ pruned: [],
208
+ skippedDestDirs: [],
209
+ excluded: [],
210
+ warnings,
211
+ writtenPaths: [],
212
+ processCheck: emptyProcessCheck,
213
+ backupRoot,
214
+ };
215
+ }
161
216
  const roots = inputs.roots ?? (inputs.install ? surface.modRoots(inputs.install) : undefined);
162
217
  if (!roots) {
163
218
  return {
@@ -202,6 +257,7 @@ export async function planDeploy(inputs) {
202
257
  }
203
258
  if (target.mode && target.mode !== deployMode) {
204
259
  skipped.push({ rootId: target.rootId, from: target.from, reason: `target is mode "${target.mode}", this deploy is mode "${deployMode}"` });
260
+ variantExcludedTargets.add(target);
205
261
  const skippedRoot = roots.find((r) => r.id === target.rootId);
206
262
  if (skippedRoot && (target.locked ?? defaultLocked(target.rootId))) {
207
263
  notDeployedLocked.push({ target, rootPath: skippedRoot.path });
@@ -365,7 +421,18 @@ export async function planDeploy(inputs) {
365
421
  }
366
422
  }
367
423
  else {
424
+ const fold = (p) => (process.platform === "win32" ? path.resolve(p).toLowerCase() : path.resolve(p));
368
425
  for (const j of junctions) {
426
+ if (!(await isDirectory(j.target))) {
427
+ blocked.push({
428
+ code: "junction-target-missing",
429
+ message: `deploy.junctions: ${j.linkPath} would point at ${j.target}, which is not a directory. Build or create it first, or fix the junction's target.`,
430
+ detail: { linkPath: j.linkPath, target: j.target },
431
+ });
432
+ continue;
433
+ }
434
+ if (j.before === "link" && j.linkTargetBefore !== undefined && fold(j.linkTargetBefore) === fold(j.target))
435
+ continue;
369
436
  if (j.before === "directory" || j.before === "file") {
370
437
  warnings.push(`${j.linkPath} is a real ${j.before}, not a link. It will be backed up and replaced by a link to ${j.target}.`);
371
438
  }
@@ -331,9 +331,11 @@ export function classifyReload(surfaceId, project, writtenPaths, options = {}) {
331
331
  .map((c) => RANK[c.action]);
332
332
  if (unclassified.length > 0)
333
333
  ranked.push(RANK.restart);
334
- const overall = ranked.length === 0
335
- ? "unknown"
336
- : Object.keys(RANK).find((a) => RANK[a] === Math.max(...ranked));
334
+ const overall = writtenPaths.length === 0
335
+ ? "none"
336
+ : ranked.length === 0
337
+ ? "unknown"
338
+ : Object.keys(RANK).find((a) => RANK[a] === Math.max(...ranked));
337
339
  const notes = [];
338
340
  if (writtenPaths.length === 0) {
339
341
  notes.push("nothing was written, so there is nothing to reload");
@@ -0,0 +1,26 @@
1
+ export function parseIni(text) {
2
+ const out = new Map();
3
+ let section = "";
4
+ for (const raw of text.split(/\r?\n/)) {
5
+ const line = raw.trim();
6
+ if (!line || line.startsWith(";") || line.startsWith("#"))
7
+ continue;
8
+ const sec = /^\[(.+)\]$/.exec(line);
9
+ if (sec) {
10
+ section = sec[1].trim().toLowerCase();
11
+ continue;
12
+ }
13
+ const eq = line.indexOf("=");
14
+ if (eq < 0)
15
+ continue;
16
+ const key = line.slice(0, eq).trim().toLowerCase();
17
+ let value = line.slice(eq + 1).trim();
18
+ const wrapped = /^@ByteArray\((.*)\)$/.exec(value);
19
+ if (wrapped)
20
+ value = wrapped[1];
21
+ if (!out.has(section))
22
+ out.set(section, new Map());
23
+ out.get(section).set(key, value);
24
+ }
25
+ return out;
26
+ }