modwright 0.1.2 → 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.
- package/README.md +41 -2
- package/bridges/cp2077-cet/ModWrightBridge/probes.lua +113 -0
- package/bridges/cp2077-cet/README.md +3 -1
- package/bridges/cp2077-cet/bridge.json +1 -1
- 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 +51 -2
- package/dist/core/writes/index.js +206 -0
- package/dist/index.js +0 -2
- package/dist/server.js +142 -13
- 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.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. |
|
|
@@ -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.
|
|
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` |
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
45
|
"path": "ModWrightBridge/probes.lua",
|
|
46
|
-
"sha256": "
|
|
46
|
+
"sha256": "169a31b158edcefda6721c3b90656c59fd9f7d7bf928a96d03ef3645a5eb1991"
|
|
47
47
|
},
|
|
48
48
|
{
|
|
49
49
|
"path": "ModWrightBridge/protocol.lua",
|
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) {
|
package/dist/core/logs/scan.js
CHANGED
|
@@ -37,14 +37,14 @@ function parseScanKey(key) {
|
|
|
37
37
|
}
|
|
38
38
|
export async function scanSource(source, signatures, scope, ctx) {
|
|
39
39
|
if (!source.exists) {
|
|
40
|
-
return { status: { source, linesScanned: 0, findingCount: 0 }, findings: [] };
|
|
40
|
+
return { status: { source, linesScanned: 0, findingCount: 0 }, findings: [], attributions: [] };
|
|
41
41
|
}
|
|
42
42
|
let stat;
|
|
43
43
|
try {
|
|
44
44
|
stat = await fs.stat(source.path);
|
|
45
45
|
}
|
|
46
46
|
catch {
|
|
47
|
-
return { status: { source, linesScanned: 0, findingCount: 0 }, findings: [] };
|
|
47
|
+
return { status: { source, linesScanned: 0, findingCount: 0 }, findings: [], attributions: [] };
|
|
48
48
|
}
|
|
49
49
|
const sizeBytes = stat.size;
|
|
50
50
|
const modifiedAt = stat.mtime;
|
|
@@ -63,7 +63,7 @@ export async function scanSource(source, signatures, scope, ctx) {
|
|
|
63
63
|
};
|
|
64
64
|
if (stale !== undefined)
|
|
65
65
|
status.stale = stale;
|
|
66
|
-
return { status, findings: [] };
|
|
66
|
+
return { status, findings: [], attributions: [] };
|
|
67
67
|
}
|
|
68
68
|
const birthtimeMs = Math.round(stat.birthtimeMs);
|
|
69
69
|
const mtimeMs = Math.round(stat.mtimeMs);
|
|
@@ -125,7 +125,7 @@ export async function scanSource(source, signatures, scope, ctx) {
|
|
|
125
125
|
};
|
|
126
126
|
if (stale !== undefined)
|
|
127
127
|
status.stale = stale;
|
|
128
|
-
return { status, findings: [] };
|
|
128
|
+
return { status, findings: [], attributions: [] };
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
if (startOffset === 0 && text.charCodeAt(0) === 0xfeff)
|
|
@@ -133,14 +133,18 @@ export async function scanSource(source, signatures, scope, ctx) {
|
|
|
133
133
|
let lines = splitLines(text);
|
|
134
134
|
if (clipped && lines.length > 0)
|
|
135
135
|
lines.shift();
|
|
136
|
+
let headTrimmed = clipped;
|
|
136
137
|
if ((!hadOffset || rotated) && lines.length > maxLines) {
|
|
137
138
|
lines = lines.slice(lines.length - maxLines);
|
|
139
|
+
headTrimmed = true;
|
|
138
140
|
}
|
|
139
141
|
const sinceDate = scope.since ? new Date(scope.since) : undefined;
|
|
140
142
|
let anyTimestampSeen = false;
|
|
141
143
|
let lastTimestamp;
|
|
142
144
|
const findingsBySignature = new Map();
|
|
143
145
|
const findings = [];
|
|
146
|
+
const attributions = [];
|
|
147
|
+
const context = {};
|
|
144
148
|
for (let i = 0; i < lines.length; i++) {
|
|
145
149
|
const line = lines[i];
|
|
146
150
|
if (sinceDate) {
|
|
@@ -157,6 +161,39 @@ export async function scanSource(source, signatures, scope, ctx) {
|
|
|
157
161
|
const match = sig.pattern.exec(line);
|
|
158
162
|
if (!match)
|
|
159
163
|
continue;
|
|
164
|
+
const spec = sig.attribution;
|
|
165
|
+
if (spec) {
|
|
166
|
+
const group = (name) => name === undefined ? undefined : match.groups?.[name];
|
|
167
|
+
if (spec.setsContext) {
|
|
168
|
+
const subject = group(spec.subject);
|
|
169
|
+
const actor = group(spec.actor);
|
|
170
|
+
if (subject !== undefined)
|
|
171
|
+
context.subject = subject;
|
|
172
|
+
if (actor !== undefined)
|
|
173
|
+
context.actor = actor;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const subject = group(spec.subject) ?? group(spec.subjectFallback) ?? spec.subjectLabel ?? context.subject;
|
|
177
|
+
if (subject !== undefined) {
|
|
178
|
+
const record = {
|
|
179
|
+
sourceId: source.id,
|
|
180
|
+
signatureId: sig.id,
|
|
181
|
+
kind: spec.kind,
|
|
182
|
+
subject,
|
|
183
|
+
actor: group(spec.actor) ?? context.actor ?? "?",
|
|
184
|
+
lineNumber: i + 1,
|
|
185
|
+
};
|
|
186
|
+
const via = group(spec.via);
|
|
187
|
+
if (via !== undefined)
|
|
188
|
+
record.via = via;
|
|
189
|
+
const detail = group(spec.detail);
|
|
190
|
+
if (detail !== undefined)
|
|
191
|
+
record.detail = detail;
|
|
192
|
+
attributions.push(record);
|
|
193
|
+
}
|
|
194
|
+
if (!spec.alsoFinding)
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
160
197
|
const existing = findingsBySignature.get(sig.id);
|
|
161
198
|
if (existing) {
|
|
162
199
|
existing.repeats = (existing.repeats ?? 0) + 1;
|
|
@@ -207,5 +244,7 @@ export async function scanSource(source, signatures, scope, ctx) {
|
|
|
207
244
|
status.rewritten = true;
|
|
208
245
|
if (sinceDate && !anyTimestampSeen)
|
|
209
246
|
status.timestamps = false;
|
|
210
|
-
|
|
247
|
+
if (headTrimmed)
|
|
248
|
+
status.headTrimmed = true;
|
|
249
|
+
return { status, findings, attributions };
|
|
211
250
|
}
|
package/dist/core/logs/triage.js
CHANGED
|
@@ -3,7 +3,9 @@ import { findRunningProcesses } from "../process.js";
|
|
|
3
3
|
import { resolveLogs } from "../surfaceutil.js";
|
|
4
4
|
import { scanSource } from "./scan.js";
|
|
5
5
|
const FINDINGS_CAP = 200;
|
|
6
|
-
|
|
6
|
+
const ATTRIBUTION_SUBJECT_CAP = 150;
|
|
7
|
+
const ATTRIBUTION_ACTOR_CAP = 100;
|
|
8
|
+
export async function triageLogs(surface, install, registry, scope = {}, options = {}) {
|
|
7
9
|
const sources = await resolveLogs(surface, install);
|
|
8
10
|
const processResult = await findRunningProcesses(surface.processNames ?? []);
|
|
9
11
|
const gameRunning = processResult.checked ? processResult.running.length > 0 : undefined;
|
|
@@ -34,15 +36,17 @@ export async function triageLogs(surface, install, registry, scope = {}) {
|
|
|
34
36
|
}
|
|
35
37
|
const statuses = [];
|
|
36
38
|
const allFindings = [];
|
|
39
|
+
const allAttributions = [];
|
|
37
40
|
const offsets = {};
|
|
38
41
|
const offsetKeys = {};
|
|
39
42
|
for (const source of sources) {
|
|
40
43
|
const signatures = registry.forSource(surface.id, source.id);
|
|
41
|
-
const { status, findings } = await scanSource(source, signatures, scope, { lastLaunch });
|
|
44
|
+
const { status, findings, attributions } = await scanSource(source, signatures, scope, { lastLaunch });
|
|
42
45
|
if (!source.exists && missingReasons[source.id])
|
|
43
46
|
status.missingReason = missingReasons[source.id];
|
|
44
47
|
statuses.push(status);
|
|
45
48
|
allFindings.push(...findings);
|
|
49
|
+
allAttributions.push(...attributions);
|
|
46
50
|
if (status.endOffset !== undefined)
|
|
47
51
|
offsets[source.id] = status.endOffset;
|
|
48
52
|
if (status.key !== undefined)
|
|
@@ -104,8 +108,81 @@ export async function triageLogs(surface, install, registry, scope = {}) {
|
|
|
104
108
|
verdict.truncated = true;
|
|
105
109
|
if (Object.keys(systemNotes).length > 0)
|
|
106
110
|
verdict.systemNotes = systemNotes;
|
|
111
|
+
if (allAttributions.length > 0) {
|
|
112
|
+
verdict.attribution = buildAttributionReport(allAttributions, statuses, options.attributionFor);
|
|
113
|
+
}
|
|
107
114
|
return verdict;
|
|
108
115
|
}
|
|
116
|
+
export function buildAttributionReport(records, statuses, filter) {
|
|
117
|
+
const needle = filter?.trim().toLowerCase();
|
|
118
|
+
const kept = needle
|
|
119
|
+
? records.filter((r) => [r.subject, r.actor, r.via].some((v) => v !== undefined && v.toLowerCase().includes(needle)))
|
|
120
|
+
: records;
|
|
121
|
+
const bySubject = new Map();
|
|
122
|
+
for (const record of kept) {
|
|
123
|
+
let entry = bySubject.get(record.subject);
|
|
124
|
+
if (!entry) {
|
|
125
|
+
entry = { subject: record.subject, kinds: [], actors: [], order: bySubject.size };
|
|
126
|
+
bySubject.set(record.subject, entry);
|
|
127
|
+
}
|
|
128
|
+
if (!entry.kinds.includes(record.kind))
|
|
129
|
+
entry.kinds.push(record.kind);
|
|
130
|
+
const same = entry.actors.find((a) => a.actor === record.actor && a.via === record.via && a.kind === record.kind && a.detail === record.detail);
|
|
131
|
+
if (same) {
|
|
132
|
+
same.count++;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const actor = {
|
|
136
|
+
actor: record.actor,
|
|
137
|
+
kind: record.kind,
|
|
138
|
+
sourceId: record.sourceId,
|
|
139
|
+
firstLine: record.lineNumber,
|
|
140
|
+
count: 1,
|
|
141
|
+
};
|
|
142
|
+
if (record.via !== undefined)
|
|
143
|
+
actor.via = record.via;
|
|
144
|
+
if (record.detail !== undefined)
|
|
145
|
+
actor.detail = record.detail;
|
|
146
|
+
entry.actors.push(actor);
|
|
147
|
+
}
|
|
148
|
+
const distinctActors = (subject) => new Set(subject.actors.map((a) => `${a.actor}\u0000${a.via ?? ""}`)).size;
|
|
149
|
+
const isContested = (subject) => !subject.kinds.every((k) => k === "read-order") && distinctActors(subject) > 1;
|
|
150
|
+
const all = [...bySubject.values()];
|
|
151
|
+
const contested = all.filter(isContested).length;
|
|
152
|
+
all.sort((a, b) => Number(isContested(b)) - Number(isContested(a)) || a.order - b.order);
|
|
153
|
+
const subjects = all.slice(0, ATTRIBUTION_SUBJECT_CAP).map(({ order: _order, ...subject }) => {
|
|
154
|
+
if (subject.actors.length <= ATTRIBUTION_ACTOR_CAP)
|
|
155
|
+
return subject;
|
|
156
|
+
return {
|
|
157
|
+
...subject,
|
|
158
|
+
actors: subject.actors.slice(0, ATTRIBUTION_ACTOR_CAP),
|
|
159
|
+
omittedActors: subject.actors.length - ATTRIBUTION_ACTOR_CAP,
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
const report = {
|
|
163
|
+
records: records.length,
|
|
164
|
+
contested,
|
|
165
|
+
subjects,
|
|
166
|
+
note: "Straight from the logs: each actor is who the log line itself names, listed in the order " +
|
|
167
|
+
"the log shows them acting, so for edits and reads the later actor applied last. Only what " +
|
|
168
|
+
"the frameworks chose to log is here; a mod that changes something without the loader " +
|
|
169
|
+
"logging it will not appear.",
|
|
170
|
+
};
|
|
171
|
+
if (needle)
|
|
172
|
+
report.filter = filter.trim();
|
|
173
|
+
if (all.length > ATTRIBUTION_SUBJECT_CAP)
|
|
174
|
+
report.omittedSubjects = all.length - ATTRIBUTION_SUBJECT_CAP;
|
|
175
|
+
const partial = statuses
|
|
176
|
+
.filter((st) => st.headTrimmed && records.some((r) => r.sourceId === st.source.id))
|
|
177
|
+
.map((st) => st.source.id);
|
|
178
|
+
if (partial.length > 0) {
|
|
179
|
+
report.partialSources = partial;
|
|
180
|
+
report.note +=
|
|
181
|
+
` The start of ${partial.join(", ")} was not read (tail cap), so earlier attribution lines ` +
|
|
182
|
+
"are missing; pass a larger maxLines (up to 20000) for the whole run.";
|
|
183
|
+
}
|
|
184
|
+
return report;
|
|
185
|
+
}
|
|
109
186
|
function buildHeadline(sources, statuses, findings, systems) {
|
|
110
187
|
if (sources.length === 0) {
|
|
111
188
|
return "No log sources are declared for this game; triage has nothing to read.";
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { localAppData, pathExists } from "../fsutil.js";
|
|
4
|
+
import { parseIni } from "../ini.js";
|
|
5
|
+
function iniPath(value) {
|
|
6
|
+
return value.replace(/\\\\/g, "\\");
|
|
7
|
+
}
|
|
8
|
+
function samePath(a, b) {
|
|
9
|
+
const norm = (p) => path.resolve(iniPath(p)).replace(/[\\/]+$/, "").toLowerCase();
|
|
10
|
+
return norm(a) === norm(b);
|
|
11
|
+
}
|
|
12
|
+
export async function readMo2Instance(iniFile, portable) {
|
|
13
|
+
let ini;
|
|
14
|
+
try {
|
|
15
|
+
ini = parseIni(await fs.readFile(iniFile, "utf8"));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const instanceDir = path.dirname(iniFile);
|
|
21
|
+
const general = ini.get("general");
|
|
22
|
+
const settings = ini.get("settings");
|
|
23
|
+
const base = iniPath(settings?.get("base_directory") ?? instanceDir);
|
|
24
|
+
const configured = (key, fallback) => path.resolve(instanceDir, iniPath(settings?.get(key) ?? `%BASE_DIR%/${fallback}`).replace(/%BASE_DIR%/gi, base));
|
|
25
|
+
const profileName = general?.get("selected_profile") ?? "Default";
|
|
26
|
+
const profilesDir = configured("profiles_directory", "profiles");
|
|
27
|
+
const gamePath = general?.get("gamepath");
|
|
28
|
+
const instance = {
|
|
29
|
+
instanceDir,
|
|
30
|
+
iniPath: iniFile,
|
|
31
|
+
portable,
|
|
32
|
+
profileName,
|
|
33
|
+
profileDir: path.join(profilesDir, profileName),
|
|
34
|
+
modsDir: configured("mod_directory", "mods"),
|
|
35
|
+
overwriteDir: configured("overwrite_directory", "overwrite"),
|
|
36
|
+
downloadsDir: configured("download_directory", "downloads"),
|
|
37
|
+
};
|
|
38
|
+
if (gamePath)
|
|
39
|
+
instance.gamePath = iniPath(gamePath);
|
|
40
|
+
return instance;
|
|
41
|
+
}
|
|
42
|
+
export async function findMo2Instance(install, options = {}) {
|
|
43
|
+
if (options.instancePath) {
|
|
44
|
+
const iniFile = options.instancePath.toLowerCase().endsWith(".ini")
|
|
45
|
+
? options.instancePath
|
|
46
|
+
: path.join(options.instancePath, "ModOrganizer.ini");
|
|
47
|
+
if (await pathExists(iniFile))
|
|
48
|
+
return readMo2Instance(iniFile, true);
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const root = options.instancesRoot ?? localAppData("ModOrganizer");
|
|
52
|
+
let names;
|
|
53
|
+
try {
|
|
54
|
+
names = await fs.readdir(root);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
for (const name of names) {
|
|
60
|
+
const iniFile = path.join(root, name, "ModOrganizer.ini");
|
|
61
|
+
if (!(await pathExists(iniFile)))
|
|
62
|
+
continue;
|
|
63
|
+
const instance = await readMo2Instance(iniFile, false);
|
|
64
|
+
if (instance?.gamePath && samePath(instance.gamePath, install.root))
|
|
65
|
+
return instance;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
export function parseMo2Modlist(text) {
|
|
70
|
+
const lines = [];
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
73
|
+
const line = raw.replace(/^/, "");
|
|
74
|
+
if (!line.trim() || line.startsWith("#"))
|
|
75
|
+
continue;
|
|
76
|
+
const marker = line[0];
|
|
77
|
+
const hasMarker = marker === "+" || marker === "-" || marker === "*";
|
|
78
|
+
const name = (hasMarker ? line.slice(1) : line).trim();
|
|
79
|
+
if (!name || seen.has(name))
|
|
80
|
+
continue;
|
|
81
|
+
seen.add(name);
|
|
82
|
+
lines.push({ name, enabled: marker !== "-", foreign: marker === "*" });
|
|
83
|
+
}
|
|
84
|
+
return lines.map((entry, index) => ({
|
|
85
|
+
...entry,
|
|
86
|
+
separator: entry.name.endsWith("_separator"),
|
|
87
|
+
priority: lines.length - index,
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
export async function readMo2Modlist(profileDir) {
|
|
91
|
+
try {
|
|
92
|
+
return parseMo2Modlist(await fs.readFile(path.join(profileDir, "modlist.txt"), "utf8"));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export async function readMo2ModMeta(modDir) {
|
|
99
|
+
let ini;
|
|
100
|
+
try {
|
|
101
|
+
ini = parseIni(await fs.readFile(path.join(modDir, "meta.ini"), "utf8"));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
const general = ini.get("general");
|
|
107
|
+
const meta = {};
|
|
108
|
+
const modId = Number(general?.get("modid"));
|
|
109
|
+
if (Number.isInteger(modId) && modId > 0)
|
|
110
|
+
meta.nexusModId = modId;
|
|
111
|
+
const version = general?.get("version");
|
|
112
|
+
if (version)
|
|
113
|
+
meta.version = version;
|
|
114
|
+
const installationFile = general?.get("installationfile");
|
|
115
|
+
if (installationFile)
|
|
116
|
+
meta.installationFile = installationFile;
|
|
117
|
+
const gameName = general?.get("gamename");
|
|
118
|
+
if (gameName)
|
|
119
|
+
meta.gameName = gameName;
|
|
120
|
+
return meta;
|
|
121
|
+
}
|