modwright 0.1.2 → 0.1.4

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 (45) hide show
  1. package/README.md +43 -4
  2. package/bridges/bg3-se/README.md +3 -2
  3. package/bridges/bg3-se/bridge.json +1 -1
  4. package/bridges/cp2077-cet/ModWrightBridge/probes.lua +228 -1
  5. package/bridges/cp2077-cet/README.md +6 -3
  6. package/bridges/cp2077-cet/bridge.json +2 -2
  7. package/dist/core/bridge/remove.js +48 -0
  8. package/dist/core/build/stage.js +3 -2
  9. package/dist/core/ini.js +26 -0
  10. package/dist/core/knowledge/facts.js +0 -12
  11. package/dist/core/logs/registry.js +19 -0
  12. package/dist/core/logs/scan.js +44 -5
  13. package/dist/core/logs/triage.js +79 -2
  14. package/dist/core/managers/mo2.js +121 -0
  15. package/dist/core/managers/vortex.js +52 -0
  16. package/dist/core/ownership/index.js +364 -0
  17. package/dist/core/project/index.js +1 -1
  18. package/dist/core/project/load.js +5 -0
  19. package/dist/core/testplan/registry.js +84 -2
  20. package/dist/core/writes/index.js +206 -0
  21. package/dist/index.js +0 -2
  22. package/dist/server.js +180 -15
  23. package/dist/surfaces/baldursgate3/surface.js +3 -0
  24. package/dist/surfaces/baldursgate3/validators/story.js +3 -3
  25. package/dist/surfaces/baldursgate3/writes.js +152 -0
  26. package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +2 -2
  27. package/dist/surfaces/cyberpunk2077/logs.js +82 -3
  28. package/dist/surfaces/cyberpunk2077/surface.js +11 -0
  29. package/dist/surfaces/cyberpunk2077/writes.js +67 -0
  30. package/dist/surfaces/skyrimse/loadorder.js +2 -26
  31. package/dist/surfaces/skyrimse/surface.js +1 -0
  32. package/dist/surfaces/stardewvalley/logs.js +41 -0
  33. package/dist/surfaces/stardewvalley/surface.js +2 -0
  34. package/dist/surfaces/stardewvalley/writes.js +136 -0
  35. package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +29 -0
  36. package/knowledge/baldursgate3/osiris.tags.yaml +78 -0
  37. package/knowledge/cyberpunk2077/cet.natives.yaml +48 -0
  38. package/knowledge/cyberpunk2077/cet.rtti-binding.yaml +227 -0
  39. package/knowledge/cyberpunk2077/cet.sandbox.yaml +22 -0
  40. package/knowledge/cyberpunk2077/codeware.overview.yaml +63 -16
  41. package/knowledge/cyberpunk2077/codeware.systems.yaml +152 -33
  42. package/knowledge/cyberpunk2077/entities.queries.yaml +275 -0
  43. package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +30 -4
  44. package/knowledge/cyberpunk2077/rtti.game-systems.yaml +224 -0
  45. package/package.json +1 -1
package/README.md CHANGED
@@ -64,6 +64,43 @@ without tcli." Most features need nothing. ModWright can install some
64
64
  external tools itself, and any other tool or data folder only has to be
65
65
  pointed at once.
66
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
+
67
104
  ## Supported games
68
105
 
69
106
  | Surface | Game | Tested | Mod systems modeled |
@@ -99,11 +136,13 @@ Epic paths where they apply. Override with `installPath` on any tool, or set
99
136
  | `detect_install` | Locate one game, and report every mod root and log source, marking which exist. |
100
137
  | `list_mods` | Enumerate installed mods across all roots, with sizes, timestamps and parsed manifests. |
101
138
  | `inspect_mod` | Full detail on one mod: files owned, manifest, warnings. |
102
- | `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. |
103
142
  | `load_order` | Read the game's explicit load order and cross-check it against what is installed. |
104
143
  | `load_order_diff` | Snapshot the load order before a launch, then report what the game dropped, added or reordered (BG3 `modsettings.lsx`). |
105
144
  | `read_log` | Tail a mod-related log (redscript, RED4ext, CET, SKSE, BG3SE, BepInEx, SMAPI and more). |
106
- | `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. |
107
146
  | `check_compat` | Detect the game build, loaders, frameworks and tools, and check them against cited compatibility floors. |
108
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. |
109
148
  | `project_info` | Load a `modwright.json` mod project: sources, build, deploy targets, dependencies and publish targets, with paths resolved and structural warnings. |
@@ -114,7 +153,7 @@ Epic paths where they apply. Override with `installPath` on any tool, or set
114
153
  | `deploy` | Copy a built mod into the game's mod roots as one write plan. It refuses an artifact the build did not produce or one older than its sources, prunes what a previous build left behind, and defers a file the running game holds locked. Every overwrite and delete is backed up first. |
115
154
  | `rollback` | List the deploy backups under a project's `.modwright/backups/`, or restore one, re-checking every file's hash first. |
116
155
  | `reload_plan` | Classify deployed changes into hot / hot-degraded / save-reload / restart / unknown, with the caveat for each and the single most conservative recommendation. |
117
- | `bridge` | The in-game bridge (BG3 Script Extender, Cyberpunk 2077 CET): `stage` the bridge into a project, check its heartbeat with `status`, `arm` a probe request and `poll` for its result. |
156
+ | `bridge` | The in-game bridge (BG3 Script Extender, Cyberpunk 2077 CET): `stage` the bridge into a project, check its heartbeat with `status`, `arm` a probe request and `poll` for its result, and `remove` it from the game when you are done. |
118
157
  | `run_tests` | Run a mod's in-game test plan (`<mod>/tests/*.yaml`) through the bridge. Probe rows are armed and polled; manual rows become a checklist. Results are written back into the plan. |
119
158
  | `ledger` | Track claims and decisions across a fix (`<mod>/ledger/`). A claim moves `authored → checker-clean → built → verified \| contradicted`, and nothing is verified without evidence. |
120
159
  | `convert` | Run one registered conversion: BG3 lsx↔lsf and xml↔loca through divine, Cyberpunk cr2w↔json through WolvenKit CLI. Each one checks its own round trip. |
@@ -266,7 +305,7 @@ What it runs, and where it writes:
266
305
  - **The in-game bridges.** While a verification bridge is deployed and the
267
306
  game runs, anything that can write the bridge's request folder can run Lua
268
307
  in the game. There is no token. Deploy a bridge only while you verify,
269
- remove it afterwards, and never ship it (the release validators block a
308
+ remove it afterwards (`bridge action=remove`), and never ship it (the release validators block a
270
309
  build that contains it). The bridge READMEs under `bridges/` describe each
271
310
  one's trust model.
272
311
 
@@ -93,8 +93,9 @@ from running again; they do not say who wrote it. So:
93
93
 
94
94
  - deploy the bridge only to a machine and a profile you control, and only while you are
95
95
  verifying a mod;
96
- - remove `Mods/ModWrightBridge` from the profile when you are done, before playing normally
97
- or sharing the profile;
96
+ - remove it when you are done, before playing normally or sharing the profile:
97
+ `bridge action=remove path=<project> mode=apply` deletes every folder the project's
98
+ `withBridge` targets deployed, with a backup (a dry run first shows which);
98
99
  - never ship it. `bg3.history.bridge-never-ships` blocks a release build that contains it.
99
100
 
100
101
  ## What this bridge never does
@@ -17,7 +17,7 @@
17
17
  "prune": [
18
18
  "ModWrightBridge/**"
19
19
  ],
20
- "note": "The deploy target a shipped SE mod uses, verbatim: `bridge stage` adds it to the project's deploy targets when no target already names this rootId, into and variant, so a fresh project deploys the bridge without a hand edit. A deploy WITHOUT `--variant withBridge` prunes the folder back out, with backup."
20
+ "note": "The deploy target a shipped SE mod uses, verbatim: `bridge stage` adds it to the project's deploy targets when no target already names this rootId, into and variant, so a fresh project deploys the bridge without a hand edit. A deploy without `--variant withBridge` leaves an installed bridge where it is: a variant's prune runs only in a deploy that writes that variant. Take it out with `bridge action=remove path=<project> mode=apply` when you are done verifying (dry-run first; backed up)."
21
21
  },
22
22
  "sha256": {
23
23
  "algorithm": "sha256",
@@ -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")))
@@ -935,6 +1048,114 @@ Probes.handlers = {
935
1048
  }
936
1049
  end,
937
1050
  },
1051
+ -- Workbench probes: session-tier mutations that put the world into a testable state, so
1052
+ -- a common setup is a catalogue row rather than hand-written Lua each time. Listed in
1053
+ -- Probes.MUTATING_KINDS below and gated on the `session` tier like the other mutating rows.
1054
+
1055
+ -- attested 2026-10-04: this row ran live and the entity appeared. The call:
1056
+ -- exEntitySpawner.Spawn('base\characters\entities\gang\gang__wraith_ma.ent',
1057
+ -- Game.GetPlayer():GetWorldTransform(), '')
1058
+ -- returned a userdata handle, and Game.FindEntityByID(handle) resolved the entity.
1059
+ -- Despawn takes the RESOLVED object, not the handle, and the handle does not persist across
1060
+ -- bridge calls, so a despawn belongs in the same cp2077.lua.eval as its spawn.
1061
+ ["cp2077.world.spawn"] = {
1062
+ attestation = "attested 2026-10-04 (ran live; the entity appeared)",
1063
+ citation = "exEntitySpawner.Spawn(entPath, GetPlayer():GetWorldTransform(), appearance), a CET-added native (RTTIExtender.cpp)",
1064
+ effect = "mutate",
1065
+ mutates = true,
1066
+ run = function(probe)
1067
+ local p = params(probe)
1068
+ if p.entPath == nil then
1069
+ return errored("params.entPath is required, e.g. \"base\\characters\\entities\\gang\\gang__wraith_ma.ent\"")
1070
+ end
1071
+ local entPath = tostring(p.entPath)
1072
+ local appearance = p.appearance ~= nil and tostring(p.appearance) or ""
1073
+ if not have("Game") then return absent("the `Game` global", "design §8 step 4") end
1074
+ local pl = player()
1075
+ if pl == nil then return failed("Game.GetPlayer() returned nil — load a save first", nil, { entPath = entPath }) end
1076
+
1077
+ local spawner
1078
+ local okG = pcall(function() spawner = exEntitySpawner end)
1079
+ if not okG or spawner == nil then
1080
+ return missingAttested("`exEntitySpawner`", "exEntitySpawner.Spawn", { entPath = entPath })
1081
+ end
1082
+
1083
+ local transform
1084
+ local okT, e = pcall(function() transform = pl:GetWorldTransform() end)
1085
+ if not okT then return errored("GetPlayer():GetWorldTransform() raised: " .. tostring(e), { entPath = entPath }) end
1086
+
1087
+ local handle
1088
+ local okS, e2 = pcall(function() handle = exEntitySpawner.Spawn(entPath, transform, appearance) end)
1089
+ local evidence = {
1090
+ entPath = entPath,
1091
+ appearance = appearance,
1092
+ spawnedAtPlayerTransform = true,
1093
+ note = "spawned at the player's own transform: the player is inside the entity until they separate. " ..
1094
+ "The entity arrives after this call returns, so `resolved` is normally false here. " ..
1095
+ "The id handle does not persist across bridge calls; despawn within one eval (cp2077.lua.eval) or re-find the entity.",
1096
+ }
1097
+ if not okS then return errored("exEntitySpawner.Spawn raised: " .. tostring(e2), evidence) end
1098
+ evidence.handle = handle ~= nil and tostring(handle) or nil
1099
+ if handle == nil then return failed("exEntitySpawner.Spawn returned nil for \"" .. entPath .. "\"", nil, evidence) end
1100
+ local ent
1101
+ pcall(function() ent = Game.FindEntityByID(handle) end)
1102
+ evidence.resolved = ent ~= nil
1103
+ return ok(tostring(handle), evidence)
1104
+ end,
1105
+ },
1106
+
1107
+ -- attested 2026-10-04: a teleport to x+4 moved the player by exactly that.
1108
+ -- Game.GetTeleportationFacility():Teleport(GetPlayer(), Vector4, EulerAngles)
1109
+ ["cp2077.player.teleport"] = {
1110
+ attestation = "attested 2026-10-04 (ran live, read back)",
1111
+ citation = "Game.GetTeleportationFacility():Teleport(player, Vector4, EulerAngles), from the CET wiki",
1112
+ effect = "mutate",
1113
+ mutates = true,
1114
+ run = function(probe)
1115
+ local p = params(probe)
1116
+ local x, y, z = tonumber(p.x), tonumber(p.y), tonumber(p.z)
1117
+ if x == nil or y == nil or z == nil then
1118
+ return errored("params.x, params.y and params.z (numbers) are required")
1119
+ end
1120
+ local yaw = tonumber(p.yaw) or 0.0
1121
+ if not have("Game") then return absent("the `Game` global", "design §8 step 4") end
1122
+ local pl = player()
1123
+ if pl == nil then return failed("Game.GetPlayer() returned nil — load a save first") end
1124
+ local evidence = {
1125
+ x = x, y = y, z = z, yaw = yaw,
1126
+ call = "Game.GetTeleportationFacility():Teleport(player, Vector4.new(x,y,z,1), EulerAngles.new(0,0,yaw))",
1127
+ note = "the call and the Vector4/EulerAngles construction from the CET wiki; confirmed live 2026-10-04.",
1128
+ }
1129
+ local okTp, e = pcall(function()
1130
+ Game.GetTeleportationFacility():Teleport(pl, Vector4.new(x, y, z, 1.0), EulerAngles.new(0.0, 0.0, yaw))
1131
+ end)
1132
+ if not okTp then return errored("Teleport raised: " .. tostring(e), evidence) end
1133
+ return ok(true, evidence)
1134
+ end,
1135
+ },
1136
+
1137
+ -- attested 2026-10-04: the level followed 15 -> 16 -> 15, read back each time.
1138
+ -- Game.SetLevel(kind, n, 1) (kind defaults to "Level")
1139
+ ["cp2077.player.level"] = {
1140
+ attestation = "attested 2026-10-04 (ran live, read back)",
1141
+ citation = "Game.SetLevel(kind, level, 1), from the CET wiki",
1142
+ effect = "mutate",
1143
+ mutates = true,
1144
+ run = function(probe)
1145
+ local p = params(probe)
1146
+ local lvl = tonumber(p.level)
1147
+ if lvl == nil then return errored("params.level (a number) is required") end
1148
+ local kind = p.kind ~= nil and tostring(p.kind) or "Level"
1149
+ if not have("Game") then return absent("the `Game` global", "design §8 step 4") end
1150
+ local evidence = {
1151
+ level = lvl, kind = kind, call = "Game.SetLevel(kind, level, 1)",
1152
+ note = "from the CET wiki; confirmed live 2026-10-04 (level read back).",
1153
+ }
1154
+ local okL, e = pcall(function() Game.SetLevel(kind, lvl, 1) end)
1155
+ if not okL then return errored("Game.SetLevel raised: " .. tostring(e), evidence) end
1156
+ return ok(true, evidence)
1157
+ end,
1158
+ },
938
1159
  }
939
1160
  -- MODWRIGHT-PROBE-TABLE-END
940
1161
 
@@ -1025,7 +1246,13 @@ end
1025
1246
  --- The ids allowed to mutate anything: the inventory row it always had, and
1026
1247
  --- (since 2026-09-11) the eval row. Named
1027
1248
  --- as a constant so the gate below reads as the rule it enforces.
1028
- Probes.MUTATING_KINDS = { ["cp2077.inventory.add"] = true, ["cp2077.lua.eval"] = true }
1249
+ Probes.MUTATING_KINDS = {
1250
+ ["cp2077.inventory.add"] = true,
1251
+ ["cp2077.lua.eval"] = true,
1252
+ ["cp2077.world.spawn"] = true,
1253
+ ["cp2077.player.teleport"] = true,
1254
+ ["cp2077.player.level"] = true,
1255
+ }
1029
1256
 
1030
1257
  --- Run one probe. NEVER throws: one probe failing can never abort the batch, and a handler
1031
1258
  --- that raises is reported as `error` with the raised message.
@@ -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` |
@@ -165,8 +167,9 @@ the request grants the `session` tier. There is no token. The nonce and the requ
165
167
  only stop a consumed or stale request from running again; they do not say who wrote it. So:
166
168
 
167
169
  - deploy the bridge only to an install you control, and only while you are verifying a mod;
168
- - remove `bin/x64/plugins/cyber_engine_tweaks/mods/ModWrightBridge` when you are done,
169
- before playing normally;
170
+ - remove it when you are done, before playing normally:
171
+ `bridge action=remove path=<project> mode=apply` deletes every folder the project's
172
+ `withBridge` targets deployed, with a backup (a dry run first shows which);
170
173
  - never ship it. `cp2077.history.bridge-never-ships` blocks a release build that contains it.
171
174
 
172
175
  ## What this bridge never does
@@ -26,7 +26,7 @@
26
26
  "prune": [
27
27
  "ModWrightBridge/**"
28
28
  ],
29
- "note": "The deploy target `bridge stage` adds to the project's deploy targets when no target already names this rootId, into and variant, so a fresh project deploys the bridge without a hand edit; paste it in yourself only if you want it there before staging. A deploy WITHOUT `--variant withBridge` prunes the folder back out, with backup."
29
+ "note": "The deploy target `bridge stage` adds to the project's deploy targets when no target already names this rootId, into and variant, so a fresh project deploys the bridge without a hand edit; paste it in yourself only if you want it there before staging. A deploy without `--variant withBridge` leaves an installed bridge where it is: a variant's prune runs only in a deploy that writes that variant. Take it out with `bridge action=remove path=<project> mode=apply` when you are done verifying (dry-run first; backed up)."
30
30
  },
31
31
  "sha256": {
32
32
  "algorithm": "sha256",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  {
45
45
  "path": "ModWrightBridge/probes.lua",
46
- "sha256": "a9dc28d3fd949f353f17404366bd2bb329813b9ca639e06a9b51aa34c104fb7d"
46
+ "sha256": "759015ac7c1167faecbc5d29e91f567d7c45446164544ad7d72029c115a60df6"
47
47
  },
48
48
  {
49
49
  "path": "ModWrightBridge/protocol.lua",
@@ -0,0 +1,48 @@
1
+ import * as path from "node:path";
2
+ import { pathExists } from "../fsutil.js";
3
+ import { escapesRoot } from "../project/outside-root.js";
4
+ import { executePlan, planDelete } from "../safety/index.js";
5
+ export const BRIDGE_VARIANT = "withBridge";
6
+ export async function planRemoveBridge(project, roots) {
7
+ const removals = [];
8
+ const operations = [];
9
+ for (const target of project.project.deploy?.targets ?? []) {
10
+ if (target.variant !== BRIDGE_VARIANT || !target.into)
11
+ continue;
12
+ const base = { rootId: target.rootId, into: target.into };
13
+ if (escapesRoot(target.into)) {
14
+ removals.push({ ...base, action: "outside-root" });
15
+ continue;
16
+ }
17
+ const root = roots.find((r) => r.id === target.rootId);
18
+ if (!root) {
19
+ removals.push({ ...base, action: "unknown-root" });
20
+ continue;
21
+ }
22
+ const dest = path.join(root.path, target.into);
23
+ if (await pathExists(dest)) {
24
+ operations.push(planDelete(dest));
25
+ removals.push({ ...base, path: dest, action: "remove" });
26
+ }
27
+ else {
28
+ removals.push({ ...base, path: dest, action: "absent" });
29
+ }
30
+ }
31
+ return {
32
+ plan: {
33
+ purpose: `remove the deployed bridge of ${project.project.name}`,
34
+ operations,
35
+ backupRoot: path.join(project.root, ".modwright", "backups"),
36
+ },
37
+ removals,
38
+ };
39
+ }
40
+ export async function removeBridge(project, roots, options = {}) {
41
+ const { plan, removals } = await planRemoveBridge(project, roots);
42
+ const report = await executePlan(plan, {
43
+ mode: options.mode ?? "dry-run",
44
+ ...(options.now ? { now: options.now } : {}),
45
+ confidence: { level: "high", assumptions: [], verifiedBy: ["the project's withBridge deploy targets", "the game's mod roots"] },
46
+ });
47
+ return { ...report, removals };
48
+ }
@@ -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) {
@@ -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
+ }
@@ -20,18 +20,6 @@ export function idPrefixFor(game) {
20
20
  }
21
21
  const ID_PATTERN = /^[a-z0-9]+(\.[a-z0-9-]+)+$/;
22
22
  const TOPIC_PATTERN = /^[a-z0-9]+(\.[a-z0-9-]+)*$/;
23
- const KNOWN_FACT_KEYS = [
24
- "id",
25
- "claim",
26
- "status",
27
- "verified_on",
28
- "verified_by",
29
- "source",
30
- "tags",
31
- "detail",
32
- "supersedes",
33
- "related",
34
- ];
35
23
  const factStatusSchema = z.enum(FACT_STATUSES);
36
24
  const optionalString = () => z
37
25
  .string()
@@ -21,6 +21,25 @@ export class SignatureRegistry {
21
21
  }
22
22
  }
23
23
  }
24
+ if (sig.attribution) {
25
+ const spec = sig.attribution;
26
+ const groups = namedGroupsOf(sig.pattern);
27
+ for (const field of ["subject", "subjectFallback", "actor", "via", "detail"]) {
28
+ const name = spec[field];
29
+ if (name !== undefined && !groups.has(name)) {
30
+ throw new Error(`Signature "${sig.id}" attribution ${field} "${name}" has no named group "(?<${name}>...)" in its pattern`);
31
+ }
32
+ }
33
+ if (spec.setsContext && spec.subject === undefined && spec.actor === undefined) {
34
+ throw new Error(`Signature "${sig.id}" sets attribution context but captures neither subject nor actor`);
35
+ }
36
+ if (spec.setsContext && spec.alsoFinding) {
37
+ throw new Error(`Signature "${sig.id}" cannot both set attribution context and report a finding`);
38
+ }
39
+ if (spec.subject !== undefined && spec.subjectLabel !== undefined) {
40
+ throw new Error(`Signature "${sig.id}" attribution names both a subject group and a subjectLabel`);
41
+ }
42
+ }
24
43
  seenInBatch.add(sig.id);
25
44
  }
26
45
  for (const sig of signatures) {