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.
Files changed (41) hide show
  1. package/README.md +41 -2
  2. package/bridges/cp2077-cet/ModWrightBridge/probes.lua +113 -0
  3. package/bridges/cp2077-cet/README.md +3 -1
  4. package/bridges/cp2077-cet/bridge.json +1 -1
  5. package/dist/core/build/stage.js +3 -2
  6. package/dist/core/ini.js +26 -0
  7. package/dist/core/knowledge/facts.js +0 -12
  8. package/dist/core/logs/registry.js +19 -0
  9. package/dist/core/logs/scan.js +44 -5
  10. package/dist/core/logs/triage.js +79 -2
  11. package/dist/core/managers/mo2.js +121 -0
  12. package/dist/core/managers/vortex.js +52 -0
  13. package/dist/core/ownership/index.js +364 -0
  14. package/dist/core/project/index.js +1 -1
  15. package/dist/core/project/load.js +5 -0
  16. package/dist/core/testplan/registry.js +51 -2
  17. package/dist/core/writes/index.js +206 -0
  18. package/dist/index.js +0 -2
  19. package/dist/server.js +142 -13
  20. package/dist/surfaces/baldursgate3/surface.js +3 -0
  21. package/dist/surfaces/baldursgate3/validators/story.js +3 -3
  22. package/dist/surfaces/baldursgate3/writes.js +152 -0
  23. package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +2 -2
  24. package/dist/surfaces/cyberpunk2077/logs.js +82 -3
  25. package/dist/surfaces/cyberpunk2077/surface.js +11 -0
  26. package/dist/surfaces/cyberpunk2077/writes.js +67 -0
  27. package/dist/surfaces/skyrimse/loadorder.js +2 -26
  28. package/dist/surfaces/skyrimse/surface.js +1 -0
  29. package/dist/surfaces/stardewvalley/logs.js +41 -0
  30. package/dist/surfaces/stardewvalley/surface.js +2 -0
  31. package/dist/surfaces/stardewvalley/writes.js +136 -0
  32. package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +29 -0
  33. package/knowledge/baldursgate3/osiris.tags.yaml +78 -0
  34. package/knowledge/cyberpunk2077/cet.rtti-binding.yaml +227 -0
  35. package/knowledge/cyberpunk2077/cet.sandbox.yaml +22 -0
  36. package/knowledge/cyberpunk2077/codeware.overview.yaml +63 -16
  37. package/knowledge/cyberpunk2077/codeware.systems.yaml +152 -33
  38. package/knowledge/cyberpunk2077/entities.queries.yaml +275 -0
  39. package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +30 -4
  40. package/knowledge/cyberpunk2077/rtti.game-systems.yaml +224 -0
  41. 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. 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` |
@@ -43,7 +43,7 @@
43
43
  },
44
44
  {
45
45
  "path": "ModWrightBridge/probes.lua",
46
- "sha256": "a9dc28d3fd949f353f17404366bd2bb329813b9ca639e06a9b51aa34c104fb7d"
46
+ "sha256": "169a31b158edcefda6721c3b90656c59fd9f7d7bf928a96d03ef3645a5eb1991"
47
47
  },
48
48
  {
49
49
  "path": "ModWrightBridge/protocol.lua",
@@ -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) {
@@ -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
- return { status, findings };
247
+ if (headTrimmed)
248
+ status.headTrimmed = true;
249
+ return { status, findings, attributions };
211
250
  }
@@ -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
- export async function triageLogs(surface, install, registry, scope = {}) {
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
+ }