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.
- package/README.md +43 -4
- package/bridges/bg3-se/README.md +3 -2
- package/bridges/bg3-se/bridge.json +1 -1
- package/bridges/cp2077-cet/ModWrightBridge/probes.lua +228 -1
- package/bridges/cp2077-cet/README.md +6 -3
- package/bridges/cp2077-cet/bridge.json +2 -2
- package/dist/core/bridge/remove.js +48 -0
- package/dist/core/build/stage.js +3 -2
- package/dist/core/ini.js +26 -0
- package/dist/core/knowledge/facts.js +0 -12
- package/dist/core/logs/registry.js +19 -0
- package/dist/core/logs/scan.js +44 -5
- package/dist/core/logs/triage.js +79 -2
- package/dist/core/managers/mo2.js +121 -0
- package/dist/core/managers/vortex.js +52 -0
- package/dist/core/ownership/index.js +364 -0
- package/dist/core/project/index.js +1 -1
- package/dist/core/project/load.js +5 -0
- package/dist/core/testplan/registry.js +84 -2
- package/dist/core/writes/index.js +206 -0
- package/dist/index.js +0 -2
- package/dist/server.js +180 -15
- package/dist/surfaces/baldursgate3/surface.js +3 -0
- package/dist/surfaces/baldursgate3/validators/story.js +3 -3
- package/dist/surfaces/baldursgate3/writes.js +152 -0
- package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +2 -2
- package/dist/surfaces/cyberpunk2077/logs.js +82 -3
- package/dist/surfaces/cyberpunk2077/surface.js +11 -0
- package/dist/surfaces/cyberpunk2077/writes.js +67 -0
- package/dist/surfaces/skyrimse/loadorder.js +2 -26
- package/dist/surfaces/skyrimse/surface.js +1 -0
- package/dist/surfaces/stardewvalley/logs.js +41 -0
- package/dist/surfaces/stardewvalley/surface.js +2 -0
- package/dist/surfaces/stardewvalley/writes.js +136 -0
- package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +29 -0
- package/knowledge/baldursgate3/osiris.tags.yaml +78 -0
- package/knowledge/cyberpunk2077/cet.natives.yaml +48 -0
- package/knowledge/cyberpunk2077/cet.rtti-binding.yaml +227 -0
- package/knowledge/cyberpunk2077/cet.sandbox.yaml +22 -0
- package/knowledge/cyberpunk2077/codeware.overview.yaml +63 -16
- package/knowledge/cyberpunk2077/codeware.systems.yaml +152 -33
- package/knowledge/cyberpunk2077/entities.queries.yaml +275 -0
- package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +30 -4
- package/knowledge/cyberpunk2077/rtti.game-systems.yaml +224 -0
- 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
|
|
package/bridges/bg3-se/README.md
CHANGED
|
@@ -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
|
|
97
|
-
|
|
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
|
|
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 = {
|
|
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.
|
|
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
|
|
169
|
-
|
|
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
|
|
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": "
|
|
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
|
+
}
|
package/dist/core/build/stage.js
CHANGED
|
@@ -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
|
|
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(
|
|
105
|
+
claimed.set(key, origin);
|
|
105
106
|
files.push({ abs, relDest, origin });
|
|
106
107
|
};
|
|
107
108
|
if (entry.output !== undefined) {
|
package/dist/core/ini.js
ADDED
|
@@ -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) {
|