dsh-plc-decode 0.0.0-stage → 0.1.2

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/index.js ADDED
@@ -0,0 +1,3435 @@
1
+ /**
2
+ * PLC 工程文件解码插件(DeepSeek Harness / Cordis)
3
+ *
4
+ * 一个入口承载多品牌 PLC 工程文件的解码与分析:
5
+ * - `.project`(PK 头)→ CoDeSys / LeadSysStudio:read_project.py(ST 源码 + 报告 + 静态校验)
6
+ * - `.dvp`(57 01 01 00 头)→ 台达 WPLSoft / ISPSoft:dvp_decode.py(结构 / 注释 / 指令表)
7
+ * - `.smart`(SH3 头)→ 西门子 S7-200 SMART 工程:内嵌解码器(块 / 符号表 / 指令 + 操作数)
8
+ * - `.smartlib`(DEM 头)→ 西门子 SMART 库:内嵌解码器(库变量 + 注释流)
9
+ * - 新旧工程对比(.project 或已解码目录)→ 逻辑变化报告(区分真实变化与保存噪音)
10
+ *
11
+ * 实现策略:
12
+ * - codesys / dvp 用插件自带的 Python 脚本(assets/codesys-tools/),只依赖标准库
13
+ * - smart 用内嵌 Node 解码器:块级指令、操作数流、用户 + 系统符号表
14
+ * - 工程版本对比用纯函数 compareDecodedDirs
15
+ *
16
+ * 只读分析,不改动工程文件;解码产物默认写入工作区 `解码归档/<工程文件名>/`。
17
+ */
18
+
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'
20
+ import { createHash } from 'node:crypto'
21
+ import { inflateSync } from 'node:zlib'
22
+ import { tmpdir } from 'node:os'
23
+ import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'
24
+ import { fileURLToPath } from 'node:url'
25
+ import z from '@deepseek-ai/schemastery'
26
+ import { writeSmartPack, writeSourcePack } from './assets/decode-pack.mjs'
27
+
28
+ export const name = 'dsh-plc-decode'
29
+ export const inject = ['tools', 'commands', 'shell', 'sandboxPolicy']
30
+
31
+ /** 插件配置(DSH 官方 Config 机制:cordis.patch.yml 里本插件行的 config 传进 apply)。
32
+ * 2026-11-05 加:让装插件的人不改代码就能换解码产物的存放位置。 */
33
+ export const Config = z.object({
34
+ archiveDir: z.string().default('解码归档')
35
+ .description('解码产物归档根目录。相对路径按当前会话工作区解析,绝对路径原样使用。默认「解码归档」。'),
36
+ })
37
+
38
+ /** 生效中的归档根目录;apply 时由 config.archiveDir 覆盖,未配置即默认值(与旧版行为一致)。 */
39
+ let ARCHIVE_ROOT = '解码归档'
40
+
41
+ /** 归档根目录的绝对路径:绝对路径原样用,相对路径拼会话工作区。 */
42
+ function archiveRootPath(base) {
43
+ return isAbsolute(ARCHIVE_ROOT) ? ARCHIVE_ROOT : join(base || process.cwd(), ARCHIVE_ROOT)
44
+ }
45
+
46
+ const PY = 'python'
47
+ const TIMEOUT_MS = 300_000
48
+ /** 本插件自带的资源目录(smart 连线/网表脚本等) */
49
+ const ASSETS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'assets')
50
+ /** CoDeSys/DVP 解码用 Python 脚本:插件自带,只依赖标准库,不引用外部工程目录。 */
51
+ const CODESYS_TOOLS_DIR = join(ASSETS_DIR, 'codesys-tools')
52
+
53
+ // ───────────────────────── 通用工具 ─────────────────────────
54
+
55
+ const globalDecoder = new TextDecoder('gb18030')
56
+ function gbk(buf) {
57
+ return globalDecoder.decode(buf)
58
+ }
59
+
60
+ function psq(value) {
61
+ return `'${String(value).replace(/'/g, "''")}'`
62
+ }
63
+
64
+ function timestamp() {
65
+ const d = new Date()
66
+ const p = (n) => String(n).padStart(2, '0')
67
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`
68
+ + `_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
69
+ }
70
+
71
+ /**
72
+ * 会话工作区根:优先 agent.session.header.cwd(本会话真正的工作区),取不到才回落进程 cwd。
73
+ */
74
+ function sessionBase(agent) {
75
+ const cwd = agent?.session?.header?.cwd
76
+ return typeof cwd === 'string' && cwd.trim() !== '' ? cwd : process.cwd()
77
+ }
78
+
79
+ /** 解码产物默认归档目录:<工作区>\解码归档\<项目文件夹名>\
80
+ * 用工程所在文件夹名建目录(不用工程文件名):不同文件夹里的同名工程文件(如各自的同名 .dvp)不会互相覆盖;
81
+ * 同一文件夹里有多个工程文件时用「文件夹名_文件名」区分。 */
82
+ function defaultArchiveDir(file, base) {
83
+ const dir = dirname(file)
84
+ const stem = basename(file, extname(file))
85
+ const folder = basename(dir)
86
+ let name = folder || stem
87
+ try {
88
+ const siblings = readdirSync(dir).filter((n) => /\.(smart|smartlib|dvp|project)$/i.test(n))
89
+ if (siblings.length > 1) name = `${folder}_${stem}`
90
+ } catch { /* 读不到目录就退回文件名 */ }
91
+ return join(archiveRootPath(base), name)
92
+ }
93
+
94
+ /** 把命令名后的原始输入按 shell 引号规则切成参数(支持带空格路径)。 */
95
+ function splitArgs(rawInput) {
96
+ const line = String(rawInput ?? '').trim()
97
+ const args = []
98
+ let cur = ''
99
+ let quote = null
100
+ for (const ch of line) {
101
+ if (quote !== null) {
102
+ if (ch === quote) quote = null
103
+ else cur += ch
104
+ } else if (ch === '"' || ch === "'") {
105
+ quote = ch
106
+ } else if (/\s/.test(ch)) {
107
+ if (cur.length > 0) { args.push(cur); cur = '' }
108
+ } else {
109
+ cur += ch
110
+ }
111
+ }
112
+ if (cur.length > 0) args.push(cur)
113
+ return args
114
+ }
115
+
116
+ /** 0.1.7 起 shell 是「先 execute 拿进程句柄、再 result() 取结果」两步,这里收成一步。 */
117
+ async function shellResult(ctx, spec) {
118
+ const handle = await ctx.shell.execute(spec)
119
+ return await handle.result()
120
+ }
121
+
122
+ /** 通过 ctx.shell 前台运行一个 Python 脚本(继承会话沙箱策略 + WinError 5 重试一次)。 */
123
+ async function runPy(ctx, script, args, workdir, signal, exec) {
124
+ const cmd = `$env:PYTHONIOENCODING='utf-8'; & ${PY} ${psq(join(CODESYS_TOOLS_DIR, script))} ${args.map(psq).join(' ')}`
125
+ const policy = ctx.sandboxPolicy && exec && exec.agent
126
+ ? ctx.sandboxPolicy.resolve({ session: exec.agent.session })
127
+ : undefined
128
+ const spec = ctx.shell.resolve({
129
+ command: cmd,
130
+ workdir,
131
+ timeoutMs: TIMEOUT_MS,
132
+ ...policy ? { sandboxPolicy: policy } : {},
133
+ ...signal ? { signal } : {},
134
+ })
135
+ let res = await shellResult(ctx, spec)
136
+ if (res.exitCode !== 0 && /PermissionError|WinError 5|OSError/.test(`${res.stderr.text} ${res.stdout.text}`)) {
137
+ res = await shellResult(ctx, spec)
138
+ }
139
+ const out = res.stdout.text.trim()
140
+ const err = res.stderr.text.trim()
141
+ if (res.exitCode !== 0) {
142
+ const reason = err || out || '(无输出)'
143
+ const detail = res.timedOut ? '(超时)' : res.aborted ? '(已中止)' : ''
144
+ return { ok: false, text: `python 退出码 ${res.exitCode}${detail}\n${reason}` }
145
+ }
146
+ return { ok: true, text: out }
147
+ }
148
+
149
+ /** 识别工程类型(按文件头 4 字节) */
150
+ function identify(file) {
151
+ const raw = readFileSync(file)
152
+ const head = raw.subarray(0, 4)
153
+ if (head.length === 4 && head[0] === 0x50 && head[1] === 0x4B && (head[2] === 0x03 || head[2] === 0x05) && head[3] === 0x04) {
154
+ return { kind: 'project', raw }
155
+ }
156
+ if (head.length === 4 && head[0] === 0x57 && head[1] === 0x01 && head[2] === 0x01 && head[3] === 0x00) {
157
+ return { kind: 'dvp', raw }
158
+ }
159
+ if (head.length === 4 && head[0] === 0x53 && head[1] === 0x48 && head[2] === 0x33 && head[3] === 0x00) {
160
+ return { kind: 'smart', raw }
161
+ }
162
+ if (head.length === 4 && head[0] === 0x44 && head[1] === 0x45 && head[2] === 0x4D && head[3] === 0x00) {
163
+ return { kind: 'smartlib', raw }
164
+ }
165
+ if (head.length === 4 && head[0] === 0x01 && head[1] === 0x0F && head[2] === 0x00 && head[3] === 0x00) {
166
+ return { kind: 'hmismart', raw }
167
+ }
168
+ throw new Error(
169
+ `不是可识别的 PLC 工程文件(前 4 字节: ${[...head].map(b => b.toString(16).padStart(2, '0')).join(' ')}): ${file}\n`
170
+ + '支持四种: .project(CoDeSys V3.5 ZIP 容器)/ .dvp(台达)/ .smart(西门子 SMART 工程)/ .smartlib(西门子库)。\n'
171
+ + '触摸屏工程(.hmismart / .fsprj)不在本插件范围——请用 HMI 解码工具。'
172
+ )
173
+ }
174
+
175
+ // ── HMI 触摸屏工程(.hmismart / .fsprj)不在本插件范围:本插件只处理 .project / .dvp / .smart / .smartlib。 ──
176
+
177
+ // ───────────────────────── CodeSys / 台达(Python 脚本) ─────────────────────────
178
+
179
+ /** 校验 .project/.dvp 工程文件(按文件头),返回 { abs, dir, kind }。 */
180
+ function requireProject(proj, usage) {
181
+ if (!proj) throw new Error(usage)
182
+ const abs = resolve(proj)
183
+ if (!existsSync(abs)) throw new Error(`文件不存在: ${proj}`)
184
+ const head = readFileSync(abs).subarray(0, 4)
185
+ const isZip = head.length === 4 && head[0] === 0x50 && head[1] === 0x4B
186
+ && (head[2] === 0x03 || head[2] === 0x05) && head[3] === 0x04
187
+ const isDvp = head.length === 4 && head[0] === 0x57 && head[1] === 0x01
188
+ && head[2] === 0x01 && head[3] === 0x00
189
+ if (!isZip && !isDvp) {
190
+ throw new Error(
191
+ `不是可识别的 PLC 工程文件(前 4 字节: ${[...head].map(b => b.toString(16).padStart(2, '0')).join(' ')}): ${proj}\n`
192
+ + '支持两种: .project(CoDeSys V3.5 ZIP 容器,雷赛 LeadSys Studio 保存产生);\n'
193
+ + ' .dvp(台达 WPLSoft/ISPSoft 工程,57 01 01 00 头)。\n'
194
+ + '提示: .i6x / .coppkg / .rar 等是 IDE 安装包或设备包,不是可解码的 PLC 工程。'
195
+ )
196
+ }
197
+ return { abs, dir: dirname(abs), kind: isDvp ? 'dvp' : 'project' }
198
+ }
199
+
200
+ /** 校验 .dvp 文件(台达专用,带针对性提示)。 */
201
+ function requireDvp(dvp, usage) {
202
+ if (!dvp) throw new Error(usage)
203
+ const abs = resolve(dvp)
204
+ if (!existsSync(abs)) throw new Error(`文件不存在: ${dvp}`)
205
+ const head = readFileSync(abs).subarray(0, 4)
206
+ const isDvp = head.length === 4 && head[0] === 0x57 && head[1] === 0x01
207
+ if (!isDvp) {
208
+ throw new Error(
209
+ `不是有效的台达 DVP 工程文件(缺少 WPLSoft 特征头 57 01,前 4 字节: ${[...head].map(b => b.toString(16).padStart(2, '0')).join(' ')}): ${dvp}\n`
210
+ + '提示: 台达触摸屏工程 (.dpa/.dcf) 与 PLC 工程 (.dvp) 不同;.dvp 由 WPLSoft/ISPSoft 保存 PLC 程序产生。'
211
+ )
212
+ }
213
+ return abs
214
+ }
215
+
216
+ /** 预建输出目录及其子目录(治 WinError 5:目录存在时 python makedirs exist_ok 不再报错)。
217
+ * 2026-11-05:dvp 产物只有「解码报告.md」,不再为它建 extracted/、pous/ 两个空目录。 */
218
+ function precreateOutDir(outd, kind = 'project') {
219
+ for (const sub of kind === 'dvp' ? [''] : ['', 'extracted', 'pous']) {
220
+ try { mkdirSync(join(outd, sub), { recursive: true }) } catch { /* 留给 python */ }
221
+ }
222
+ }
223
+
224
+ /** 解码 .project/.dvp 到统一归档 `<工作区>\解码归档\<工程文件名>\`(原默认写工程旁 `导出_<时间戳>`,与工作区归档规则冲突);
225
+ * 调用方给了 outDir 就用它。 */
226
+ async function decodeCodesysProject(ctx, projPath, signal, exec, outDir, base) {
227
+ const { abs, dir, kind } = requireProject(projPath, '需要一个 .project / .dvp 工程文件路径(带空格请加引号)')
228
+ const outd = outDir || defaultArchiveDir(abs, base)
229
+ precreateOutDir(outd, kind)
230
+ const result = kind === 'dvp'
231
+ ? await runPy(ctx, 'dvp_decode.py', [abs, '-o', outd], dir, signal, exec)
232
+ : await runPy(ctx, 'read_project.py', [abs, outd], dir, signal, exec)
233
+ // 加密 .project(不是 zip,整文件高熵):read_project.py 报 BadZipFile。
234
+ // 强硬解码通道:同目录常有 <工程>.precompilecache(zip),里面有全部标识符与中文名,
235
+ // 抽出来至少能回答「有哪些变量/功能点」,并明确告诉调用方逻辑读不到。
236
+ let degraded = false
237
+ let out = result
238
+ if (!out.ok && kind === 'project') {
239
+ const fb = await runPy(ctx, 'precompile_extract.py', [abs, outd], dir, signal, exec)
240
+ if (fb.ok) { out = fb; degraded = true }
241
+ else throw new Error(`${out.text}\n\n(已尝试加密工程降级通道,也失败)\n${fb.text}`)
242
+ }
243
+ if (!out.ok) throw new Error(out.text)
244
+ // 导航件:人看 ST(pous/)或指令表报告,AI 先读本页;失败不影响解码结果
245
+ let packNote = ''
246
+ if (!degraded) {
247
+ try {
248
+ const pk = writeSourcePack({ outd, kind, projName: basename(abs), projFile: abs })
249
+ packNote = `索引: ${join(outd, '00-索引.md')}${pk.pous ? `(POU/对象 ${pk.pous} 个)` : ''}\n`
250
+ } catch { /* 忽略 */ }
251
+ }
252
+ const tail = degraded
253
+ ? '\n⚠ 该工程是加密保存(不是 zip):只解出符号名与中文名,读不到逻辑与梯形图。要逻辑请让原作者在软件里「另存为非加密工程」或导出 ST。'
254
+ : ''
255
+ return { outd, kind: degraded ? 'project-加密降级' : kind, text: packNote + out.text + tail }
256
+ }
257
+
258
+ /** 解码 .dvp 为文本直出(stdout 报告,不落盘;delta-dvp-read 工具/命令形态)。 */
259
+ async function decodeDvpText(ctx, dvpPath, signal, exec) {
260
+ const abs = requireDvp(dvpPath, '需要一个 .dvp 工程文件路径(带空格请加引号)')
261
+ const dir = dirname(abs)
262
+ const result = await runPy(ctx, 'dvp_decode.py', [abs], dir, signal, exec)
263
+ if (!result.ok) throw new Error(result.text)
264
+ return { abs, dir, text: result.text }
265
+ }
266
+
267
+ // ───────────────────────── SMART(内嵌增强版解码器) ─────────────────────────
268
+
269
+ /** 常见块名白名单:补足短名与拉丁字母开头的块名(0x0D 名记录扫描时放宽用,可自行增补)。 */
270
+ const KNOWN_BLOCKS = ['主程序', '初始化', '手动', '自动', '报警', '输出', 'S_ITR']
271
+ // 库块/用户库块名(0D00 记录但无标准指令盒头,需强行解码)
272
+ const LIB_BLOCKS = ['MBUS_CTRL', 'MBUS_MSG', 'MBUSM1', 'MBUSM2', 'INT_0']
273
+
274
+ /** 解容器:SH3(工程)或 DEM(库)→ zlib 流 → 内部文档 Buffer */
275
+ function decodeContainer(raw) {
276
+ const magic = raw.subarray(0, 4).toString('ascii')
277
+ if (magic === 'SH3\x00' || raw[0] === 0x53) {
278
+ const want = raw.readUInt32LE(0x6C)
279
+ const body = inflateSync(raw.subarray(0x70))
280
+ return { kind: 'project', version: raw.subarray(4, 16).toString('ascii').replace(/\0/g, ''), want, data: body }
281
+ }
282
+ if (magic === 'DEM\x00' || raw[0] === 0x44) {
283
+ const want = raw.readUInt32LE(0x40)
284
+ const body = inflateSync(raw.subarray(0x44))
285
+ return { kind: 'library', version: raw.subarray(4, 16).toString('ascii').replace(/\0/g, ''), want, data: body }
286
+ }
287
+ throw new Error(`不是可识别的 SMART 文件(magic: ${magic}),支持 .smart(SH3 头)/ .smartlib(DEM 头)`)
288
+ }
289
+
290
+ /** 提取所有可读串(GBK 中文 + ASCII) */
291
+ function extractTexts(data) {
292
+ const out = []
293
+ const len = data.length
294
+ for (let i = 0; i < len - 1;) {
295
+ const b = data[i]
296
+ if (b >= 0x81 && b <= 0xFE && data[i + 1] >= 0x40 && data[i + 1] <= 0xFE) {
297
+ let j = i
298
+ while (j < len - 1) {
299
+ const a = data[j]
300
+ if (a >= 0x81 && a <= 0xFE && data[j + 1] >= 0x40 && data[j + 1] <= 0xFE) j += 2
301
+ else if (a >= 0x20 && a < 0x7F) j += 1
302
+ else break
303
+ }
304
+ if (j - i >= 3) out.push({ off: i, text: gbk(data.subarray(i, j)) })
305
+ i = j + 1
306
+ } else if (b >= 0x20 && b < 0x7F) {
307
+ let j = i
308
+ while (j < len && data[j] >= 0x20 && data[j] < 0x7F) j += 1
309
+ if (j - i >= 3) out.push({ off: i, text: data.subarray(i, j).toString('ascii') })
310
+ i = j
311
+ } else {
312
+ i += 1
313
+ }
314
+ }
315
+ return out
316
+ }
317
+
318
+ /** POU 块清单(tag 0x0D 名记录):项目树块 + 库块 + 任意中文名块 */
319
+ function findPous(data) {
320
+ const marks = []
321
+ for (let i = 0; i < data.length - 6;) {
322
+ if (data[i] === 0x0D && data[i + 1] === 0x00) {
323
+ const ln = data[i + 2] | (data[i + 3] << 8)
324
+ if (ln > 0 && ln < 40 && i + 4 + ln <= data.length) {
325
+ const nm = gbk(data.subarray(i + 4, i + 4 + ln)).trim()
326
+ if (KNOWN_BLOCKS.includes(nm) || LIB_BLOCKS.includes(nm) || /^[\u4e00-\u9fa5][\u4e00-\u9fa5A-Za-z0-9_]{1,39}$/.test(nm)) {
327
+ marks.push({ off: i, name: nm })
328
+ }
329
+ i += 4 + ln
330
+ continue
331
+ }
332
+ }
333
+ i += 1
334
+ }
335
+ return marks
336
+ }
337
+
338
+ const INST_RE = /^(MOV_W|MOV_B|MOV_DW|MUL_I|MUL_DI|INC_W|INC_DW|ADD_I|ADD_DI|SUB_I|SUB_DI|DIV_I|DIV_DI|WAND_DW|WOR_DW|WXOR_DW|SHL_DW|SHR_DW|BCD_I|I_BCD|S_ITR|INT_0|MBUS_CTRL|MBUS_MSG|READ_RTC|SET_RTC|HSC0_|HSC2_|B_I|I_B|NET_EXE|PID_EXE|TON|TOF|TONR|CTU|CTD|CTUD|ENI|DISI|HSC|BCD|LPS|LRD|LPP|SCRT|XMT|RCV|ATCH|DTCH|DIV|MOD|ABS|NEG|SWAP|FILL|ROTL|ROTR)$/
339
+
340
+ /** 指令盒头:01 01 00 00 00 <序号 00-0F ×1-2> 03 01
341
+ * 全文件验证:序号字节不限于 02/05/06(旧版只认 02/05/06 03 01,漏掉约 85% 盒头,
342
+ * 导致"一条记录带 30+ 操作数"的盒子流未切分)。
343
+ * 注意:子记录/元数据块同样以 01 01 00 00 00 开头(如 01 03 01 01 02 00 00 00 01 03 01),
344
+ * 必须校验"最小信号"——头后 48B 内出现引脚名 ASCII(IN/OUT/EN/指令名…)或操作数对,否则为元数据假头。 */
345
+ function findHeads(data) {
346
+ const heads = []
347
+ for (let i = 0; i < data.length - 12;) {
348
+ if (data[i] === 0x01 && data[i + 1] === 0x01 && data[i + 2] === 0x00 && data[i + 3] === 0x00 && data[i + 4] === 0x00) {
349
+ const b6 = data[i + 5]
350
+ const tail = (b6 <= 0x0F && data[i + 6] === 0x03 && data[i + 7] === 0x01)
351
+ || (b6 <= 0x0F && data[i + 6] <= 0x0F && data[i + 7] === 0x03 && data[i + 8] === 0x01)
352
+ if (tail) {
353
+ const body = data.subarray(i + 5, Math.min(i + 48, data.length))
354
+ const hasAscii = /[\x20-\x7e]{2,}/.test(body.toString('binary'))
355
+ let hasOp = false
356
+ for (let j = 0; j + 4 <= body.length - 7; j++) {
357
+ if (body[j] === 0x02 && body[j + 1] === 0x00 && [0x01, 0x02, 0x04, 0x08].includes(body[j + 2])) { hasOp = true; break }
358
+ }
359
+ // 网络行元数据盒:操作数计数以 "<count> 00 00 03/04" 编码(如 19 00 00 03、20 00 00 04),无 02 00 对
360
+ const hasMeta = /.{2}[\x00-\x7f]?[\x00-\x7f]?[\x00-\x7f]?\x00\x00\x0?[34]/.test(body.toString('binary'))
361
+ if (hasAscii || hasOp || hasMeta) { heads.push(i); i += 1; continue }
362
+ }
363
+ }
364
+ i += 1
365
+ }
366
+ return heads
367
+ }
368
+
369
+ /** 非指令名(引脚/元数据名/噪声),findName 的 ASCII 泛匹配须排除,否则 IN1/OUT/EN/ENO 被当指令名 */
370
+ const NAME_BLACKLIST = new Set(['IN1', 'IN2', 'IN3', 'IN4', 'OUT', 'OUT1', 'OUT2', 'EN', 'ENO', 'IN', 'NOP', 'LD', 'END'])
371
+
372
+ function findName(data, off, nxt) {
373
+ const seg = data.subarray(off, Math.min(nxt, off + 160))
374
+ // 子程序调用盒内嵌被调块名(`E9 03 <xx> 01 00 02 09 03 00 <GBK名>` 已按字节验证:常见中文块名均命中)
375
+ const ci = seg.indexOf(0xE9)
376
+ if (ci >= 0 && ci + 9 < seg.length && seg[ci + 1] === 0x03 && seg[ci + 3] === 0x01
377
+ && seg[ci + 4] === 0x00 && seg[ci + 5] === 0x02 && seg[ci + 6] === 0x09 && seg[ci + 7] === 0x03 && seg[ci + 8] === 0x00) {
378
+ let e = ci + 9
379
+ let cnt = 0
380
+ while (e < seg.length && seg[e] !== 0 && cnt < 30) { e++; cnt++ }
381
+ const nm = gbk(seg.subarray(ci + 9, e)).trim()
382
+ if (nm && (KNOWN_BLOCKS.includes(nm) || LIB_BLOCKS.includes(nm) || /^[\u4e00-\u9fa5][\u4e00-\u9fa5A-Za-z0-9_]{0,39}$/.test(nm))) return nm
383
+ }
384
+ const m = /([A-Z][A-Z0-9_]{2,20})\x00/.exec(seg.toString('binary'))
385
+ if (m && INST_RE.test(m[1])) return m[1]
386
+ // 兜底:文本段里的中文标识符(2–20 字),按出现顺序取第一个
387
+ const cm = /[\u4e00-\u9fa5][\u4e00-\u9fa5A-Za-z0-9_]{1,19}/.exec(gbk(seg.subarray(0, 80)))
388
+ if (cm) return cm[0]
389
+ return '?'
390
+ }
391
+
392
+ // 区域码 0x80 = 计数器 C(编号存在 addr 字段)。旧注 '库内部' 是误读(判据见下条):
393
+ // ① CTU 盒的"计数器号"槽里出现 0x80+编号(计数器只能落 C 区);② 子程序 BOOL 输出参数可绑 0x80+编号 = 用户可选地址;
394
+ // ③ 库存储区引用验证在 V 区(.smartlib 内部变量也是 V/L 区)。
395
+ const AREA = {
396
+ 0x001: 'I', 0x002: 'Q', 0x010: 'V', 0x020: 'M', 0x040: 'T',
397
+ 0x200: 'SM', 0x2000: 'L', 0x080: 'C', 0x103: '未连接',
398
+ }
399
+
400
+ function fmtOperand(type, area, val) {
401
+ if (area === 0x103) return '未连接'
402
+ const a = AREA[area] ?? `?0x${area.toString(16)}`
403
+ if (type === 0x01) { // bit
404
+ if (a === 'T') return `T${val}`
405
+ if (a === 'V') return `V${val >> 3}.${val & 7}` // V 区位布尔
406
+ if (['I', 'Q', 'M', 'SM'].includes(a)) return `${a}${val >> 3}.${val & 7}`
407
+ if (a === 'L') return `L${val >> 3}.${val & 7}`
408
+ if (a === 'C') return `C${val}`
409
+ return `?(${a},${val})`
410
+ }
411
+ const suf = { 0x02: 'B', 0x04: 'W', 0x08: 'D' }[type] ?? '?'
412
+ if (a === 'V') return `V${suf}${val}`
413
+ if (a === 'SM') return `SM${suf}${val}`
414
+ if (a === 'L') return `L${suf}${val}`
415
+ if (a === 'C') return `C${suf}${val}`
416
+ return `?t${type}(${a},${val})`
417
+ }
418
+
419
+ /** 指令实例操作数(02 00 type area addr 11B 对) */
420
+ function extractOperands(data, a, b) {
421
+ const out = []
422
+ for (let s = a; s < b - 12;) {
423
+ const j = data.indexOf(0x02, s)
424
+ if (j < 0 || j + 11 > b) break
425
+ if (data[j + 1] !== 0x00) { s = j + 1; continue }
426
+ const t = data[j + 2]
427
+ if ([0x01, 0x02, 0x04, 0x08].includes(t) && j + 11 <= b) {
428
+ const area = data.readUInt32LE(j + 3)
429
+ const val = data.readUInt32LE(j + 7)
430
+ if (AREA[area] !== undefined) {
431
+ out.push(fmtOperand(t, area, val))
432
+ s = j + 3
433
+ continue
434
+ }
435
+ }
436
+ s = j + 1
437
+ }
438
+ return out
439
+ }
440
+
441
+ /** 符号表(用户 + 系统)行:02 00 <len> <名> 00 01 02 00 01 <d1> <d2> */
442
+ function findSymbols(data, start, end, areaMap, smByteMode) {
443
+ const rows = []
444
+ for (let i = start; i < end - 8;) {
445
+ if (data[i] === 0x02 && data[i + 1] === 0x00) {
446
+ const ln = data[i + 2] | (data[i + 3] << 8)
447
+ if (ln > 0 && ln < 60 && i + 4 + ln + 12 <= end) {
448
+ const nb = data.subarray(i + 4, i + 4 + ln)
449
+ if ([...nb].every(b => b === 0 || (b >= 0x20 && b < 0x7F) || b >= 0x81)) {
450
+ const nm = gbk(nb)
451
+ let j = i + 4 + ln
452
+ if (data[j] === 0) j += 1
453
+ if (data[j] === 0x01 && data[j + 1] === 0x02 && data[j + 2] === 0x00 && data[j + 3] === 0x01) {
454
+ const d1 = data.readUInt32LE(j + 4)
455
+ const d2 = data.readUInt32LE(j + 8)
456
+ if (areaMap[d1] !== undefined) {
457
+ const a = areaMap[d1]
458
+ if (smByteMode) {
459
+ rows.push({ name: nm, addr: d2 < 16 ? `SM0.${d2}` : `SMB${Math.floor(d2 / 8)}.${d2 % 8}` })
460
+ } else {
461
+ rows.push({ name: nm, addr: a === 'T' ? `T${d2}` : `${a}${d2 >> 3}.${d2 & 7}` })
462
+ }
463
+ i = j + 12
464
+ continue
465
+ }
466
+ }
467
+ }
468
+ }
469
+ }
470
+ i += 1
471
+ }
472
+ return rows
473
+ }
474
+
475
+ /** V 区(用户变量)符号行:`02 00 <len:2> <名> 00 01 02 00 <type> <area:4> <addr:4>`
476
+ * 二进制验证:type=1 bit(→Vx.y)/2 B/4 W/8 D;area=0x10 为 V 区。
477
+ * 速比类变量(中文命名的 速比 / 实际速度 / 最高速度 等)只有这种行格式,findSymbols 的 M/I/Q 行不覆盖。 */
478
+ function findVSymbols(data, start, end) {
479
+ const rows = []
480
+ for (let i = start; i < end - 24;) {
481
+ if (data[i] === 0x02 && data[i + 1] === 0x00) {
482
+ const ln = data.readUInt16LE(i + 2)
483
+ if (ln > 0 && ln < 40 && data[i + 4 + ln] === 0 && data[i + 5 + ln] === 0x01 && data[i + 6 + ln] === 0x02 && data[i + 7 + ln] === 0x00) {
484
+ const t = data[i + 8 + ln]
485
+ if ([1, 2, 4, 8].includes(t)) {
486
+ const area = data.readUInt32LE(i + 9 + ln)
487
+ const val = data.readUInt32LE(i + 13 + ln)
488
+ if (area === 0x010) {
489
+ const nm = gbk(data.subarray(i + 4, i + 4 + ln)).trim()
490
+ rows.push({ name: nm, addr: t === 1 ? `V${val >> 3}.${val & 7}` : `V${({ 2: 'B', 4: 'W', 8: 'D' })[t]}${val}` })
491
+ i = i + 17 + ln
492
+ continue
493
+ }
494
+ }
495
+ }
496
+ }
497
+ i += 1
498
+ }
499
+ return rows
500
+ }
501
+
502
+ /** 未接线变量扫描:有符号名、但程序区 0 次引用的变量。
503
+ * 回答"XX 功能为什么没生效/屏上显示不变"类问题的第一嫌疑就是它——变量表里有名字,程序里根本没有操作数引用
504
+ * (典型形态:调好的系数/补偿值在符号表里有名,程序里 0 引用)。
505
+ * 判定:字节级精确匹配操作数编码 `02 00 <type> <area:4LE> <addr:4LE>`,只在程序区(符号表区之前)计数。
506
+ * ⚠️ 符号表条目尾部本身就是同一段字节串(名字行 `... 00 01 02 00 08 10 00 00 00 <addr> ...`),
507
+ * 必须排除符号表区,否则每个变量都会误报"已引用"。 */
508
+ function scanVarRefs(data, symStart, symEnd, userSyms) {
509
+ const AREA = { I: 0x001, Q: 0x002, M: 0x020, T: 0x040, SM: 0x200, V: 0x010 }
510
+ const out = []
511
+ for (const s of userSyms) {
512
+ const m = /^(SM|V|M|I|Q|T)(B|W|D)?(\d+)(?:\.(\d))?$/.exec(String(s.addr ?? '').trim())
513
+ if (!m) continue
514
+ const ab = m[1], sz = m[2], num = Number(m[3]), bit = m[4]
515
+ const area = AREA[ab]
516
+ if (area === undefined || !Number.isFinite(num)) continue
517
+ let type, val
518
+ if (ab === 'T') { type = 1; val = num } else if (bit !== undefined) { type = 1; val = num * 8 + Number(bit) } else if (sz === 'B') { type = 2; val = num } else if (sz === 'W') { type = 4; val = num } else if (sz === 'D') { type = 8; val = num } else continue
519
+ let prog = 0
520
+ // pat[1]:0x00 = 直接操作数、0x01 = 指针实参(&地址)。漏掉指针会把
521
+ // 只被 DataPtr 这类参数用到的变量误报成「未接线」。
522
+ for (const flag of [0x00, 0x01]) {
523
+ const pat = Buffer.alloc(11)
524
+ pat[0] = 0x02; pat[1] = flag; pat[2] = type
525
+ pat.writeUInt32LE(area, 3); pat.writeUInt32LE(val, 7)
526
+ let lo = 0
527
+ while (true) {
528
+ const i = data.indexOf(pat, lo)
529
+ if (i < 0) break
530
+ if (i < symStart) prog += 1
531
+ lo = i + 1
532
+ }
533
+ }
534
+ if (prog === 0) out.push({ addr: s.addr, name: s.name })
535
+ }
536
+ return out
537
+ }
538
+
539
+ /** 字节级操作数/常数定位:解"某变量到底接在哪个引脚""算出来的值有没有下游读者"这类解码报告答不了的问题。
540
+ * 操作数编码 `02 00 <type> <area:4LE> <addr:4LE>` 在文档里按盒顺序排列;相邻操作数间隔 > 0x2D 即视为换了一条记录。
541
+ * 判据:同一盒(同一条记录)内某地址出现 ≥2 次 = 它是该盒的 OUT(写)——3 引脚盒的 OUT 在两个「对」里各出现一次;
542
+ * 只出现 1 次判"读?"(两引脚盒 MOV_W/INC_W 的 OUT 也只出现一次,需人工确认同记录最后一位)。
543
+ * 常数形态 `01 02 <len 02|04> 01 <u32 值>`(比较阈值/赋值立即数)。 */
544
+ const PIN_AREA = { 0x001: 'I', 0x002: 'Q', 0x010: 'V', 0x020: 'M', 0x040: 'T', 0x080: 'C', 0x200: 'SM', 0x2000: 'L' }
545
+ const PIN_TYPE = { 0x01: 'bit', 0x02: 'byte', 0x04: 'word', 0x08: 'dword' }
546
+
547
+ function pinDecodeAt(doc, p) {
548
+ if (doc[p] !== 0x02 || doc[p + 1] !== 0x00) return null
549
+ const t = doc[p + 2]
550
+ const area = doc.readUInt32LE(p + 3)
551
+ const val = doc.readUInt32LE(p + 7)
552
+ if (!PIN_TYPE[t] || PIN_AREA[area] === undefined) return null
553
+ const a = PIN_AREA[area]
554
+ let name
555
+ if (t === 0x01) name = area === 0x040 ? `T${val}` : `${a}${val >> 3}.${val & 7}`
556
+ else if (t === 0x02) name = `${a}B${val}`
557
+ else if (t === 0x04) name = `${a}W${val}`
558
+ else name = `${a}D${val}`
559
+ return { off: p, type: t, area, addr: val, name }
560
+ }
561
+
562
+ /** 指针型实参解码:`02 01 <type> <area:4LE> <addr:4LE>` = 盒的指针参数(如 MBUS_MSG 的 DataPtr)。
563
+ * 旧扫描只认 `02 00`(直接操作数),指针这一类被整批漏掉 —— 被 `&VB160` 点着的字节
564
+ * 在体检报告里报成「0 引用/地址干净」,据此答"没人用"是错的。 */
565
+ function pinPtrDecodeAt(doc, p) {
566
+ if (doc[p] !== 0x02 || doc[p + 1] !== 0x01) return null
567
+ const t = doc[p + 2]
568
+ const area = doc.readUInt32LE(p + 3)
569
+ const val = doc.readUInt32LE(p + 7)
570
+ if (!PIN_TYPE[t] || PIN_AREA[area] === undefined) return null
571
+ const a = PIN_AREA[area]
572
+ const name = t === 0x01 ? (area === 0x040 ? `&T${val}` : `&${a}${val >> 3}.${val & 7}`)
573
+ : t === 0x02 ? `&${a}B${val}` : t === 0x04 ? `&${a}W${val}` : `&${a}D${val}`
574
+ const bytes = t === 0x08 ? 4 : t === 0x04 ? 2 : 1
575
+ return { off: p, type: t, area, aName: a, addr: val, bytes, name }
576
+ }
577
+
578
+ /** 全文指针实参(含所属库调用盒;MBUS_MSG 的 DataPtr 按 Count 扩成缓冲区字节数)。
579
+ * 返回 [{off, name, addr, bytes, aName, box}] —— 供体检/空闲段/字节级定位共用。 */
580
+ function scanPtrRefs(doc) {
581
+ const out = []
582
+ for (let p = 0; p < doc.length - 11; p++) {
583
+ const r = pinPtrDecodeAt(doc, p)
584
+ if (!r) continue
585
+ out.push(r)
586
+ p += 10
587
+ }
588
+ for (const r of out) {
589
+ for (let i = r.off; i > Math.max(0, r.off - 0x400); i--) {
590
+ if (doc[i] !== 0xE9 || doc[i + 1] !== 0x03) continue
591
+ const nm = boxCallNameAt(doc, i)
592
+ if (!nm) continue
593
+ r.box = { off: i, name: nm }
594
+ break
595
+ }
596
+ if (r.box && r.box.name === 'MBUS_MSG') {
597
+ const lo = Math.max(0, r.off - 0x200)
598
+ for (const a of scanBoxArgs(doc, lo, Math.min(doc.length, r.off + 0x200))) {
599
+ if (a.pin === 6 && /^\d+$/.test(a.text)) { r.bytes = Math.max(r.bytes, Number(a.text) * 2); r.count = Number(a.text); break }
600
+ }
601
+ }
602
+ }
603
+ return out
604
+ }
605
+
606
+ /** 常数记录(8 字节,两种编码):`01 02 <len> 01 <u32>` 与库参数里的 `01 01 04 01 <u32>`。
607
+ * 补充第二种——旧判据只认第一种,MBUS_CTRL 的 Baud、MBUS_MSG 的 Addr 这类 4xxxx/波特率常数
608
+ * 在解码结果里整段看不到,只能回读字节。 */
609
+ function pinConstAt(doc, p) {
610
+ if (doc[p] !== 0x01) return null
611
+ const ok1 = doc[p + 1] === 0x02 && [0x01, 0x02, 0x04].includes(doc[p + 2]) && doc[p + 3] === 0x01
612
+ const ok2 = doc[p + 1] === 0x01 && [0x01, 0x02, 0x04].includes(doc[p + 2]) && doc[p + 3] === 0x01
613
+ if (!ok1 && !ok2) return null
614
+ const v = doc.readUInt32LE(p + 4)
615
+ return v >= 0 && v < 100000000 ? { v, size: 8 } : null
616
+ }
617
+
618
+ function pinRecords(doc, lo, hi) {
619
+ const found = []
620
+ for (let p = Math.max(0, lo | 0); p < Math.min(hi | 0, doc.length); p++) {
621
+ const op = pinDecodeAt(doc, p)
622
+ if (op) { found.push(op); p += 10; continue }
623
+ const c = pinConstAt(doc, p)
624
+ if (c !== null) { found.push({ off: p, name: `常数 ${c.v}` }); p += c.size - 1 }
625
+ }
626
+ const recs = []
627
+ let cur = []
628
+ for (const f of found) {
629
+ cur.push(f)
630
+ // 盒切分判据(字节验证):操作数后缀是 `01 00 01 03 01` = 本盒还有下一个操作数;
631
+ // 否则该操作数就是本盒末位(OUT 位)。常数的续接标记在其 8 字节体之后偏 9 位。
632
+ const q = f.off + (String(f.name).startsWith('常数') ? 9 : 11)
633
+ const cont = doc[q] === 0x01 && doc[q + 1] === 0x00 && doc[q + 2] === 0x01 && doc[q + 3] === 0x03 && doc[q + 4] === 0x01
634
+ if (!cont) { recs.push(cur); cur = [] }
635
+ }
636
+ if (cur.length) recs.push(cur)
637
+ return recs
638
+ }
639
+
640
+ function pinDumpAddr(doc, recs, target) {
641
+ const m = /^(SM|V|M|I|Q|T)(B|W|D)?(\d+)(?:\.(\d))?$/.exec(String(target).toUpperCase())
642
+ if (!m) throw new Error('地址形如 VW220 / VD390 / M30.0 / T33')
643
+ const AREA = { I: 0x001, Q: 0x002, M: 0x020, T: 0x040, SM: 0x200, V: 0x010 }
644
+ const area = AREA[m[1]]
645
+ let type, val
646
+ if (m[1] === 'T') { type = 0x01; val = Number(m[3]) }
647
+ else if (m[4] !== undefined) { type = 0x01; val = Number(m[3]) * 8 + Number(m[4]) }
648
+ else if (m[2] === 'B') { type = 0x02; val = Number(m[3]) }
649
+ else if (m[2] === 'W') { type = 0x04; val = Number(m[3]) }
650
+ else if (m[2] === 'D') { type = 0x08; val = Number(m[3]) }
651
+ else throw new Error('地址要带类型,如 VW220 / VD390 / M30.0')
652
+ const pat = Buffer.alloc(11)
653
+ pat[0] = 0x02; pat[1] = 0x00; pat[2] = type
654
+ pat.writeUInt32LE(area, 3); pat.writeUInt32LE(val, 7)
655
+ const hits = []
656
+ let i = 0
657
+ while ((i = doc.indexOf(pat, i)) >= 0) { hits.push(i); i += 1 }
658
+ // 注意:指针实参 `02 01` 是另一套编码,上面只数了直接操作数 ——
659
+ // 漏掉它会把"被 &地址 点着的变量"报成"全文 0 处/没人用"(验证:MBUS_MSG 的 DataPtr=&VB160)。
660
+ const ptrs = scanPtrRefs(doc).filter(r => r.type === type && r.area === area && r.addr === val)
661
+ const L = []
662
+ L.push(`## 字节级定位:${String(target).toUpperCase()} —— 直接操作数 ${hits.length} 处${ptrs.length ? ` + 指针引用 ${ptrs.length} 处` : ''}`)
663
+ L.push('')
664
+ if (!hits.length && !ptrs.length) { L.push('(全文没有该地址的操作数编码:不是程序里的操作数,可能只在符号表/屏侧使用)'); return L.join('\n') }
665
+ if (hits.length) {
666
+ const hitSet = new Set(hits)
667
+ const owner = new Map()
668
+ for (const r of recs) for (const f of r) if (hitSet.has(f.off)) owner.set(f.off, r)
669
+ L.push('| 偏移 | 所在盒 | 盒内操作数序列 | 判定 |')
670
+ L.push('|---|---|---|---|')
671
+ for (const h of hits) {
672
+ const r = owner.get(h)
673
+ if (!r) { L.push(`| 0x${h.toString(16)} | 记录外(符号表/索引区) | — | 不作数 |`); continue }
674
+ const idx = r.findIndex(f => f.off === h)
675
+ const verdict = r.length === 1
676
+ ? '单操作数盒(触点/引脚定义段)'
677
+ : (idx === r.length - 1 ? '**写**(末位=OUT)' : '读(前位=IN)')
678
+ L.push(`| 0x${h.toString(16)} | 0x${r[0].off.toString(16)}–0x${r[r.length - 1].off.toString(16)} | ${r.map(f => f.name).join(' \\| ')} | ${verdict} |`)
679
+ }
680
+ }
681
+ if (ptrs.length) {
682
+ L.push('### 指针引用(盒的指针型实参,不是直接操作数)')
683
+ L.push('')
684
+ L.push('| 偏移 | 所属盒 | 指针实参 | 覆盖字节 |')
685
+ L.push('|---|---|---|---|')
686
+ for (const r of ptrs) L.push(`| 0x${r.off.toString(16)} | ${r.box ? `${r.box.name}@0x${r.box.off.toString(16)}` : '(盒名未对上)'} | ${r.name} | ${r.addr}~${r.addr + r.bytes - 1}${r.count ? `(Count=${r.count})` : '(下限)'} |`)
687
+ L.push('')
688
+ L.push('> 指针实参=把这个地址交给盒用(如 MBUS_MSG 的 DataPtr=&VB160):盒读写从它起的缓冲区。**「直接操作数 0 处」不等于「没人用」** —— 判某地址有没有被用,要把指针一起算。')
689
+ L.push('')
690
+ }
691
+ L.push('> 判据(字节验证):盒内末位=OUT(写)、前位=IN(读)。')
692
+ L.push('> 3 引脚盒(MUL/SUB/DIV)在字节里被拆成两个「对」,每对都是「IN … OUT」,所以同一个 OUT 会出现两次、都判写——这是对的。')
693
+ L.push('> 只出现在「记录外」的引用不算程序引用(符号表/索引区里每个变量都会出现一次)。')
694
+ L.push('> ⚠ **判读/写别信上表的「单操作数盒=触点」**:线圈与 S/R 线圈同样是单操作数、且用符号名写的元素这里根本看不到。要判"某位到底有没有被写"请用 `smart-ladder-elements`(或 `/smart-ladder <工程.smart> <地址>`),本工具只用于引脚级归属与区间原文。')
695
+ return L.join('\n')
696
+ }
697
+
698
+ function pinDumpRange(doc, from, to) {
699
+ const recs = pinRecords(doc, from, to)
700
+ const L = []
701
+ L.push(`## 区间 0x${from.toString(16)}–0x${to.toString(16)}:${recs.length} 条记录`)
702
+ L.push('')
703
+ for (const r of recs) L.push(`- [0x${r[0].off.toString(16)}–0x${r[r.length - 1].off.toString(16)}] ${r.map(f => f.name).join(' | ')}`)
704
+ if (!recs.length) L.push('- (该区间没有操作数/常数记录)')
705
+ return L.join('\n')
706
+ }
707
+
708
+ // ── 库块引脚表────────────────────────────────────────────
709
+ // 注意:解码链的最后一层:此前产物给的是「盒内操作数序列」与「盒内引脚名」两段互不相干的文本,
710
+ // 实参挂到哪个引脚要靠人回读字节;这里把「实参 → 引脚名」直接落到结果里。
711
+ // 引脚名按 S7-200 SMART 标准库引脚顺序;空串 = 该序号验证未对上名字,输出回落成「引脚#N」。
712
+ const LIB_PINS = {
713
+ MBUS_CTRL: ['EN', 'Mode', 'Baud', 'Parity', 'Port', 'Timeout', 'Done', 'Error'],
714
+ MBUS_MSG: ['EN', 'First', 'Slave', 'RW', 'Addr', 'Count', 'DataPtr', 'Done', '', 'Error'],
715
+ TON: ['EN', 'IN', 'PT', 'Q'], TOF: ['EN', 'IN', 'PT', 'Q'], TONR: ['EN', 'IN', 'PT', 'Q'],
716
+ CTU: ['EN', 'CU', 'R', 'PV', 'Q'], CTD: ['EN', 'CD', 'LD', 'PV', 'Q'],
717
+ MOV_B: ['EN', 'IN', 'OUT'], MOV_W: ['EN', 'IN', 'OUT'], MOV_DW: ['EN', 'IN', 'OUT'], MOV_R: ['EN', 'IN', 'OUT'],
718
+ MUL_I: ['EN', 'IN1', 'IN2', 'OUT'], MUL_DI: ['EN', 'IN1', 'IN2', 'OUT'], MUL_R: ['EN', 'IN1', 'IN2', 'OUT'],
719
+ ADD_I: ['EN', 'IN1', 'IN2', 'OUT'], SUB_I: ['EN', 'IN1', 'IN2', 'OUT'], DIV_I: ['EN', 'IN1', 'IN2', 'OUT'],
720
+ INC_W: ['EN', 'IN', 'OUT'], DEC_W: ['EN', 'IN', 'OUT'], INC_DW: ['EN', 'IN', 'OUT'], DEC_DW: ['EN', 'IN', 'OUT'],
721
+ SHL_DW: ['EN', 'IN', 'N', 'OUT'], SHR_DW: ['EN', 'IN', 'N', 'OUT'],
722
+ WAND_DW: ['EN', 'IN1', 'IN2', 'OUT'], WOR_DW: ['EN', 'IN1', 'IN2', 'OUT'],
723
+ }
724
+
725
+ /** 库调用盒的块名:盒头后的 `E9 03 <xx> 01 00 02 00|09 03 00 <名>` */
726
+ function boxCallNameAt(data, i) {
727
+ if (data[i] !== 0xE9 || data[i + 1] !== 0x03) return ''
728
+ if (!(data[i + 3] === 1 && data[i + 4] === 0 && data[i + 5] === 2 && (data[i + 6] === 0 || data[i + 6] === 9) && data[i + 7] === 3 && data[i + 8] === 0)) return ''
729
+ let e = i + 9
730
+ while (e < data.length && data[e] !== 0 && e - i < 60) e++
731
+ return gbk(data.subarray(i + 9, e)).trim()
732
+ }
733
+
734
+ /** 记录尾部的引脚序号:`<pin> 00 0x 03 01`(位/字/指针/常数)或 `01 <pin> 00 00 00 00 03 01`(字节操作数)。
735
+ * 判据=MBUS_CTRL/MBUS_MSG 七个实参记录验证;判不出返回 null(不猜)。 */
736
+ function pinSeqAfter(doc, from, lim) {
737
+ const end = Math.min(lim, from + 40, doc.length - 2)
738
+ for (let e = Math.max(1, from); e < end; e++) {
739
+ if (doc[e] !== 0x03 || doc[e + 1] !== 0x01) continue
740
+ if (doc[e - 1] === 0 && doc[e - 2] === 0 && doc[e - 3] === 0 && doc[e - 4] === 0) return doc[e - 5]
741
+ return doc[e - 3]
742
+ }
743
+ return null
744
+ }
745
+
746
+ /** 区间内的实参(操作数 / 指针 `02 01` / 常数)+各自引脚序号 */
747
+ function scanBoxArgs(data, a, b) {
748
+ const out = []
749
+ for (let p = a; p < b - 12; p++) {
750
+ let text = null, size = 0
751
+ if (data[p] === 0x02 && (data[p + 1] === 0x00 || data[p + 1] === 0x01)) {
752
+ const t = data[p + 2], area = data.readUInt32LE(p + 3), v = data.readUInt32LE(p + 7)
753
+ if ([0x01, 0x02, 0x04, 0x08].includes(t) && AREA[area] !== undefined) {
754
+ text = (data[p + 1] === 0x01 ? '&' : '') + fmtOperand(t, area, v); size = 11
755
+ }
756
+ }
757
+ if (text === null) {
758
+ const c = pinConstAt(data, p)
759
+ if (c) { text = String(c.v); size = c.size }
760
+ }
761
+ if (text === null) continue
762
+ out.push({ off: p, text, pin: pinSeqAfter(data, p + size, Math.min(b, p + size + 40)) })
763
+ p += size - 1
764
+ }
765
+ return out
766
+ }
767
+
768
+ /** 盒级引脚表:把一段里的库调用盒逐个列出「引脚名 = 实参」。同一引脚的定义段/实例段自动去重。 */
769
+ function boxPinout(data, from, to) {
770
+ const calls = []
771
+ for (let i = Math.max(0, from); i < Math.min(to, data.length) - 12; i++) {
772
+ if (data[i] !== 0xE9 || data[i + 1] !== 0x03) continue
773
+ const nm = boxCallNameAt(data, i)
774
+ if (nm) calls.push({ off: i, name: nm })
775
+ }
776
+ const L = []
777
+ L.push(`## 盒级引脚表 0x${from.toString(16)}–0x${to.toString(16)}:${calls.length} 个库调用盒`)
778
+ L.push('')
779
+ for (const c of calls) {
780
+ const table = LIB_PINS[c.name]
781
+ const seen = new Set()
782
+ const rows = []
783
+ for (const a of scanBoxArgs(data, Math.max(from, c.off - 0x300), Math.min(to, c.off + 0x300))) {
784
+ if (a.pin === null || a.pin < 1 || seen.has(a.pin)) continue
785
+ if (table && a.pin > table.length) continue
786
+ if (/^L6[0-3]\./.test(a.text)) continue // 库调用的 EN/ENO 暂存位,不是用户实参
787
+ seen.add(a.pin)
788
+ rows.push(` - ${table && table[a.pin - 1] ? table[a.pin - 1] : '引脚#' + a.pin} = ${a.text}`)
789
+ }
790
+ L.push(`- \`0x${c.off.toString(16).toUpperCase()}\` **${c.name}**${table ? '' : '(无内置引脚表 → 只给引脚序号)'}`)
791
+ L.push(...(rows.length ? rows : [' - (盒前后各 0x300 内没有带引脚序号的实参)']))
792
+ }
793
+ L.push('')
794
+ L.push('> 引脚名按 S7-200 SMART 标准库引脚顺序;表里没有的序号给「引脚#N」。')
795
+ return L.join('\n')
796
+ }
797
+
798
+ // 强行解码:块级操作数流 + 库块接口
799
+ const OPS_TYPES = { 0x01: 'X', 0x02: 'B', 0x04: 'W', 0x08: 'D' }
800
+
801
+ function extractOpsStream(data, a, b) {
802
+ const out = []
803
+ for (let s = a; s < b - 11;) {
804
+ if (data[s] === 0x02 && data[s + 1] === 0x00 && OPS_TYPES[data[s + 2]]) {
805
+ const area = data.readUInt32LE(s + 3)
806
+ const val = data.readUInt32LE(s + 7)
807
+ const f = fmtOperand(data[s + 2], area, val)
808
+ if (f && f !== '未连接') { out.push(f); s += 3; continue }
809
+ }
810
+ s += 1
811
+ }
812
+ return out
813
+ }
814
+
815
+ /** 库块/用户库块接口变量名:模式 02 00 <len u8> <名> */
816
+ function findInterface(data, a, b) {
817
+ const rows = []
818
+ for (let s = a; s < b - 20;) {
819
+ if (data[s] === 0x02 && data[s + 1] === 0x00) {
820
+ const ln = data[s + 2]
821
+ if (ln > 0 && ln <= 24 && s + 3 + ln <= b) {
822
+ const nm = gbk(data.subarray(s + 3, s + 3 + ln))
823
+ if (/^[\u4e00-\u9fa5A-Za-z_][\u4e00-\u9fa5A-Za-z0-9_]{0,23}$/.test(nm)) {
824
+ rows.push({ off: s, name: nm })
825
+ s += 3 + ln
826
+ continue
827
+ }
828
+ }
829
+ }
830
+ s += 1
831
+ }
832
+ return rows
833
+ }
834
+
835
+ /** 符号表区起始(旧版工程 hardcode 约定),新版工程用动态探测兜底 */
836
+ const SYMS_START = 0x64600
837
+ const SYS_SYMS_START = 0x65E1E
838
+ const SYMS_END = 0x6AA50
839
+
840
+ /** 符号表行允许的 area(I/Q/V/M/T/SM),其余(库接口 0x7D0 等)不是符号表 */
841
+ const SYM_AREAS = new Set([0x01, 0x02, 0x10, 0x20, 0x40, 0x200])
842
+
843
+ /** 符号表区动态探测:V2.4+ 保存的 .smart 符号表偏移不再固定 0x64600,
844
+ * hardcode 越界/落空会让用户表与系统表全解空(验证偏移可达 0x49A85)。
845
+ * 返回 {start, sysStart, end};解不出(<2 行)返回 null 由调用方回退 hardcode。 */
846
+ function findSymRange(data) {
847
+ const rows = []
848
+ for (let i = 0; i < data.length - 8; i++) {
849
+ if (data[i] === 0x02 && data[i + 1] === 0x00) {
850
+ const ln = data[i + 2] | (data[i + 3] << 8)
851
+ if (ln > 0 && ln < 60 && i + 4 + ln + 12 <= data.length) {
852
+ let j = i + 4 + ln
853
+ if (data[j] === 0) j += 1
854
+ // type 段不限于位(0x01):V 区字/双字符号(VW/VD)该字节为 0x04/0x08,结构同为
855
+ // `01 02 00 <type> <area:4> <addr:4>`。原判据只认 0x01,会把符号表开头几条 V 区符号
856
+ // 排除在探测之外 —— 起点被定到第一条「位」符号,前面的 VW/VD 符号(验证:画面跳转=VW2000)整条丢失。
857
+ if (data[j] === 0x01 && data[j + 1] === 0x02 && data[j + 2] === 0x00 && [0x01, 0x02, 0x04, 0x08].includes(data[j + 3])) {
858
+ const d1 = data.readUInt32LE(j + 4)
859
+ if (SYM_AREAS.has(d1)) {
860
+ rows.push({ off: i, d1 })
861
+ i = j + 11
862
+ }
863
+ }
864
+ }
865
+ }
866
+ }
867
+ if (rows.length < 2) return null
868
+ const sysRow = rows.find(r => r.d1 === 0x200)
869
+ return { start: rows[0].off, sysStart: (sysRow ?? rows[rows.length - 1]).off, end: rows[rows.length - 1].off + 0x400 }
870
+ }
871
+
872
+ function decodeSmartFile(file, outDir, base) {
873
+ const raw = readFileSync(file)
874
+ const { kind, version, want, data } = decodeContainer(raw)
875
+ const projName = gbk(data.subarray(0x30, 0x36)).replace(/\0/g, '')
876
+ const innerVer = gbk(data.subarray(0x15, 0x2D)).split('\0')[0]
877
+ const pou = findPous(data)
878
+ const heads = findHeads(data)
879
+ const records = []
880
+ let lastNm = '?'
881
+ for (let k = 0; k < heads.length; k++) {
882
+ const off = heads[k]
883
+ let nxt = heads[k + 1] ?? off + 0x1000
884
+ for (const { off: poff } of pou) { if (poff > off) { nxt = Math.min(nxt, poff); break } }
885
+ let nm = findName(data, off, nxt)
886
+ // 接不到名字时沿用上一个名字(就近取名)——承来的名字加 ? 后缀标出"未必是这条指令"。
887
+ // 曾出现测速段整段被标成 MUL_I(实为触点)的误读,标记后一眼可辨。
888
+ if (nm !== '?') lastNm = nm
889
+ else nm = lastNm === '?' ? '?' : `${lastNm}?`
890
+ const ops = extractOperands(data, off, nxt)
891
+ let pouName = '(文件头)'
892
+ for (const { off: poff, name: pnm } of pou) {
893
+ if (poff <= off) pouName = pnm
894
+ else break
895
+ }
896
+ records.push({ off: `0x${off.toString(16).toUpperCase().padStart(6, '0')}`, pou: pouName, instr: nm, operands: ops })
897
+ }
898
+ // 符号表区定位:hardcode 范围能解出(旧版工程)则保持原行为;全空(新版工程)则动态探测
899
+ let sym = { start: SYMS_START, sysStart: SYS_SYMS_START, end: SYMS_END }
900
+ if (Math.min(SYMS_START, data.length) >= data.length || findSymbols(data, Math.min(SYMS_START, data.length), Math.min(SYMS_END, data.length), { 0x01: 'I', 0x02: 'Q', 0x20: 'M', 0x40: 'T', 0x200: 'SM' }, false).length === 0) {
901
+ const dyn = findSymRange(data)
902
+ if (dyn) sym = dyn
903
+ }
904
+ const blockStreams = []
905
+ const blockIfaces = []
906
+ for (let k = 0; k < pou.length; k++) {
907
+ const a = pou[k].off
908
+ const b = Math.min(k + 1 < pou.length ? pou[k + 1].off : data.length, sym.start)
909
+ blockStreams.push({ name: pou[k].name, ops: extractOpsStream(data, a, b) })
910
+ // 接口参数:parseIfaceParams 能解出 L 地址与类型(旧版用 findInterface,只取名字,导致
911
+ // 子程序接口.tsv 的地址列写成 undefined、类型列全空)。解不出类型时才回退成纯名字表。
912
+ const ifsTyped = parseIfaceParams(data, a, b).map(p => ({
913
+ name: p.name,
914
+ addr: fmtOperand(p.type, 0x2000, p.val),
915
+ type: L_TYPE_NAME[p.type] ?? '',
916
+ }))
917
+ blockIfaces.push({
918
+ name: pou[k].name,
919
+ ifs: ifsTyped.length ? ifsTyped : findInterface(data, a, b).map(f => ({ name: f.name, addr: '', type: '' })),
920
+ })
921
+ }
922
+ const userSyms = findSymbols(data, sym.start, sym.end, { 0x01: 'I', 0x02: 'Q', 0x20: 'M', 0x40: 'T', 0x200: 'SM' }, false)
923
+ // 合并 V 区用户变量符号(如速比、实际速度这类中文命名变量),避免漏表
924
+ for (const s of findVSymbols(data, sym.start, sym.end)) {
925
+ if (!userSyms.some(u => u.addr === s.addr && u.name === s.name)) userSyms.push(s)
926
+ }
927
+ const sysSyms = findSymbols(data, sym.sysStart, sym.end, { 0x200: 'SM' }, true)
928
+ // 未接线变量(供报告 2.7b 节):有符号名但程序区 0 引用 —— "功能没接进程序"的第一判据
929
+ const dangling = scanVarRefs(data, sym.start, sym.end, userSyms)
930
+
931
+ const out = outDir ?? defaultArchiveDir(file, base)
932
+ // 源文件指纹:报告是版本快照,工程一改偏移/操作数全变——写进报告与记录.json,用前可比对
933
+ const st = statSync(file)
934
+ const fingerprint = { file: basename(file), size: st.size, mtime: st.mtime.toISOString() }
935
+ let staleWarn = ''
936
+ {
937
+ const prevJson = join(out, '记录.json')
938
+ if (existsSync(prevJson)) {
939
+ try {
940
+ const prev = JSON.parse(readFileSync(prevJson, 'utf8')).fingerprint
941
+ if (prev && (prev.size !== fingerprint.size || prev.mtime !== fingerprint.mtime)) {
942
+ staleWarn = `⚠️ 源文件已变更:旧 ${prev.size} B / ${prev.mtime} → 新 ${fingerprint.size} B / ${fingerprint.mtime};本目录原报告对应旧版本,已覆盖。`
943
+ }
944
+ } catch { /* 旧记录损坏,忽略 */ }
945
+ }
946
+ }
947
+ mkdirSync(out, { recursive: true })
948
+ // 网络行元数据(供解码报告 2.8 节):行首 01 00 00 03 01,行内盒序列 01 01 00 00 00
949
+ const netRows = []
950
+ {
951
+ let ni = 0
952
+ while (ni < data.length - 5) {
953
+ const p = data.indexOf(0x01, ni)
954
+ if (p < 0 || p > data.length - 5) break
955
+ if (data[p] === 0x01 && data[p + 1] === 0x00 && data[p + 2] === 0x00 && data[p + 3] === 0x03 && data[p + 4] === 0x01) {
956
+ let q = p + 5
957
+ let boxCnt = 0
958
+ while (q < data.length - 4) {
959
+ if (data[q] === 0x01 && data[q + 1] === 0x01 && data[q + 2] === 0x00 && data[q + 3] === 0x00 && data[q + 4] === 0x00) { boxCnt += 1; q += 5; continue }
960
+ if (data[q] === 0x01 && data[q + 1] === 0x00 && data[q + 2] === 0x00 && data[q + 3] === 0x03 && data[q + 4] === 0x01) break
961
+ q += 1
962
+ }
963
+ netRows.push({ off: p, boxes: boxCnt })
964
+ ni = p + 5
965
+ } else {
966
+ ni += 1
967
+ }
968
+ }
969
+ }
970
+ // 符号属性/默认值表(供 2.9 节):默认值 01 01 00 00 00 01 00 00 00 00 00 00 00 <val4>;属性(符号ID引用)01 01 02 00 00 00 01 03 01
971
+ const symDefaults = []
972
+ const symAttrs = []
973
+ {
974
+ const HEAD_DEFAULT = Buffer.from([0x01, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
975
+ const HEAD_ATTR = Buffer.from([0x01, 0x01, 0x02, 0x00, 0x00, 0x00, 0x01, 0x03, 0x01])
976
+ let p = 0
977
+ while (true) {
978
+ const j = data.indexOf(HEAD_DEFAULT, p)
979
+ if (j < 0) break
980
+ symDefaults.push({ off: j, value: data.readUInt32LE(j + 13), idx: data[j + 22] })
981
+ p = j + 1
982
+ }
983
+ p = 0
984
+ while (true) {
985
+ const j = data.indexOf(HEAD_ATTR, p)
986
+ if (j < 0) break
987
+ symAttrs.push({ off: j })
988
+ p = j + 1
989
+ }
990
+ }
991
+ const pouStructures = pou.map((p, k) => {
992
+ const bEnd = k + 1 < pou.length ? pou[k + 1].off : data.length
993
+ return { name: p.name, iface: parseIfaceParams(data, p.off, bEnd), calls: findCallSites(data, p.name, pou).length }
994
+ })
995
+ // 落盘形态:AI 件+人看件分两路,同一批记录只落一处。
996
+ // 仍写 解码报告.md 与 记录.json:前者现在是人看入口+AI 索引,后者是兄弟工具(地址占用/变量使用)的输入与已解码目录的识别凭据。
997
+ const reportText = buildSmReport(kind, version, want, projName, innerVer, pou, records, userSyms, sysSyms, blockStreams, blockIfaces, netRows, symDefaults, symAttrs, pouStructures, fingerprint, dangling)
998
+ writeFileSync(join(out, '记录.json'), JSON.stringify({ project: projName, fingerprint, records, userSyms, sysSyms, blockStreams, blockIfaces }, null, 2), 'utf8')
999
+ // 全文文本全录(GBK 中文 + ASCII,含注释/变量名/HMI 标签/系统说明):支撑"程序里有没有 XX 内容/关键词"类查询
1000
+ const texts = extractTexts(data)
1001
+ writeFileSync(join(out, '字符串全录.txt'), texts.map(t => `0x${t.off.toString(16).padStart(6, '0')}\t${t.text}`).join('\n'), 'utf8')
1002
+ let pack = null
1003
+ try {
1004
+ pack = writeSmartPack({
1005
+ out, projName, fingerprint, reportText, smartPath: file,
1006
+ container: (reportText.match(/容器: (.+)/) || [])[1],
1007
+ version: innerVer,
1008
+ records, userSyms, sysSyms, blockStreams, blockIfaces,
1009
+ blockStats: pouStructures,
1010
+ })
1011
+ } catch (e) {
1012
+ // 打包失败不能让解码整个失败:退回旧报告,保证 解码报告.md/记录.json 这对识别凭据仍在
1013
+ writeFileSync(join(out, '解码报告.md'), reportText, 'utf8')
1014
+ pack = { fail: String(e.message) }
1015
+ }
1016
+ const byPou = {}
1017
+ for (const r of records) (byPou[r.pou] ??= []).push(r)
1018
+ const stats = Object.entries(byPou).map(([p, rs]) => `${p}(${rs.length})`).join(' ')
1019
+ return {
1020
+ proceed: `${staleWarn ? staleWarn + '\n' : ''}源文件指纹: ${fingerprint.size.toLocaleString()} B / ${fingerprint.mtime}\n`
1021
+ + `目录(AI 先读这一份;够用就别再读别的文件): ${join(out, '00-目录.md')}\n`
1022
+ + `块统计: ${stats}\n`
1023
+ + `取数方法写在目录的「怎么查」一节(按地址/按块/按网络/按子程序取命中段,不要整份读文件)\n`
1024
+ + `梯形图不落盘,在对话里出(用 smart-lad 取某块某网络的指令序列)`
1025
+ + (pack?.fail ? `\n⚠️ 分件打包失败(已退回旧版单份报告):${pack.fail}` : ''),
1026
+ }
1027
+ }
1028
+
1029
+ /** 单次解析 .smart(复用 decodeSmartFile 的块/指令/符号表逻辑,不写报告文件,供 smartDiff 使用) */
1030
+ function parseSmartOnce(file) {
1031
+ const raw = readFileSync(file)
1032
+ const { data } = decodeContainer(raw)
1033
+ const projName = gbk(data.subarray(0x30, 0x36)).replace(/\0/g, '')
1034
+ const innerVer = gbk(data.subarray(0x15, 0x2D)).split('\0')[0]
1035
+ const pou = findPous(data)
1036
+ const heads = findHeads(data)
1037
+ const records = []
1038
+ for (let k = 0; k < heads.length; k++) {
1039
+ const off = heads[k]
1040
+ let nxt = heads[k + 1] ?? off + 0x1000
1041
+ for (const { off: poff } of pou) { if (poff > off) { nxt = Math.min(nxt, poff); break } }
1042
+ const nm = findName(data, off, nxt) // 真实名(调用盒内嵌块名优先),不做 lastNm 继承
1043
+ const ops = extractOperands(data, off, nxt)
1044
+ let pouName = '(文件头)'
1045
+ for (const { off: poff, name: pnm } of pou) { if (poff <= off) pouName = pnm; else break }
1046
+ records.push({ off: `0x${off.toString(16).toUpperCase().padStart(6, '0')}`, pou: pouName, instr: nm, operands: ops })
1047
+ }
1048
+ let sym = { start: SYMS_START, sysStart: SYS_SYMS_START, end: SYMS_END }
1049
+ if (Math.min(SYMS_START, data.length) >= data.length || findSymbols(data, Math.min(SYMS_START, data.length), Math.min(SYMS_END, data.length), { 0x01: 'I', 0x02: 'Q', 0x20: 'M', 0x40: 'T', 0x200: 'SM' }, false).length === 0) {
1050
+ const dyn = findSymRange(data)
1051
+ if (dyn) sym = dyn
1052
+ }
1053
+ const blockStreams = []
1054
+ for (let k = 0; k < pou.length; k++) {
1055
+ const a = pou[k].off
1056
+ const b = Math.min(k + 1 < pou.length ? pou[k + 1].off : data.length, sym.start)
1057
+ blockStreams.push({ name: pou[k].name, ops: extractOpsStream(data, a, b) })
1058
+ }
1059
+ const userSyms = findSymbols(data, sym.start, sym.end, { 0x01: 'I', 0x02: 'Q', 0x20: 'M', 0x40: 'T', 0x200: 'SM' }, false)
1060
+ // 合并 V 区用户变量符号(如速比、实际速度这类中文命名变量),避免漏表
1061
+ for (const s of findVSymbols(data, sym.start, sym.end)) {
1062
+ if (!userSyms.some(u => u.addr === s.addr && u.name === s.name)) userSyms.push(s)
1063
+ }
1064
+ const sysSyms = findSymbols(data, sym.sysStart, sym.end, { 0x200: 'SM' }, true)
1065
+ return { projName, innerVer, records, blockStreams, userSyms, sysSyms }
1066
+ }
1067
+
1068
+ /** 新旧 SMART 工程对比(块清单/主程序调用链/I-O 引用/符号表差异),输出 markdown 报告
1069
+ * 判据:调用盒内嵌块名 + I/O 锚点判定。 */
1070
+ function smartDiff(oldFile, newFile, outDir, base) {
1071
+ const o = parseSmartOnce(oldFile)
1072
+ const n = parseSmartOnce(newFile)
1073
+ const stat = (p) => { const m = new Map(); for (const r of p.records) m.set(r.pou, (m.get(r.pou) ?? 0) + 1); return m }
1074
+ const oM = stat(o), nM = stat(n)
1075
+ const chain = (p) => {
1076
+ const names = []
1077
+ for (const r of p.records) {
1078
+ if (r.pou !== '主程序') continue
1079
+ if (!r.instr || r.instr === '?' || r.instr === '') continue
1080
+ const last = names[names.length - 1]
1081
+ if (last !== r.instr) names.push(r.instr)
1082
+ }
1083
+ return names.join(' → ')
1084
+ }
1085
+ const ioOf = (p) => {
1086
+ const byBlock = new Map()
1087
+ for (const b of p.blockStreams) {
1088
+ const io = [...new Set(b.ops.filter(x => /^(I|Q)\d+\.\d+$/.test(x)))].sort()
1089
+ if (io.length) byBlock.set(b.name, io)
1090
+ }
1091
+ const all = [...new Set([...byBlock.values()].flat())].sort()
1092
+ return { byBlock, all }
1093
+ }
1094
+ const nIO = ioOf(n), oIO = ioOf(o)
1095
+ const L = []
1096
+ L.push(`# SMART 工程对比:${n.projName}(新) vs ${o.projName}(旧)`)
1097
+ L.push(`- 版本:新 ${n.innerVer};旧 ${o.innerVer}`)
1098
+ L.push('\n## 1. 块清单差异(指令记录数)')
1099
+ L.push('| 块 | 新 | 旧 | 状态 |')
1100
+ L.push('|---|---|---|---|')
1101
+ for (const b of [...new Set([...oM.keys(), ...nM.keys()])]) {
1102
+ const x = nM.get(b) ?? 0, y = oM.get(b) ?? 0
1103
+ let st = '相同'
1104
+ if (x === 0) st = '**新工程缺失**'
1105
+ else if (y === 0) st = '新工程独有'
1106
+ else if (x < y * 0.7) st = `缩水 ${x}/${y}`
1107
+ else if (x > y * 1.3) st = `扩充 ${x}/${y}`
1108
+ L.push(`| ${b} | ${x} | ${y} | ${st} |`)
1109
+ }
1110
+ L.push('\n## 2. 主程序调用链(调用盒内嵌块名)')
1111
+ L.push(`- 新: ${chain(n) || '(未解码到)'}`)
1112
+ L.push(`- 旧: ${chain(o) || '(未解码到)'}`)
1113
+ L.push('\n## 3. I/O 引用对比(块级汇总)')
1114
+ L.push(`- 新输入: ${nIO.all.filter(x => x.startsWith('I')).join(' ') || '无'}`)
1115
+ L.push(`- 旧输入: ${oIO.all.filter(x => x.startsWith('I')).join(' ') || '无'}`)
1116
+ L.push(`- 新输出: ${nIO.all.filter(x => x.startsWith('Q')).join(' ') || '无'}`)
1117
+ L.push(`- 旧输出: ${oIO.all.filter(x => x.startsWith('Q')).join(' ') || '无'}`)
1118
+ L.push(`- 新有旧无: ${nIO.all.filter(x => !oIO.all.includes(x)).join(' ') || '无'}`)
1119
+ L.push(`- 旧有新无: ${oIO.all.filter(x => !nIO.all.includes(x)).join(' ') || '无'}`)
1120
+ L.push('\n### 新工程输出驱动分布(按块)')
1121
+ for (const [b, ios] of nIO.byBlock) L.push(`- ${b}: ${ios.join(' ')}`)
1122
+ L.push('\n### 旧工程输出驱动分布(按块)')
1123
+ for (const [b, ios] of oIO.byBlock) L.push(`- ${b}: ${ios.join(' ')}`)
1124
+ L.push('\n## 4. 符号表差异(用户表)')
1125
+ const symKeys = (p) => new Set(p.userSyms.map(s => `${s.addr}=${s.name}`))
1126
+ const nS = symKeys(n), oS = symKeys(o)
1127
+ L.push(`- 新: ${nS.size} 条;旧: ${oS.size} 条`)
1128
+ for (const s of nS) if (!oS.has(s)) L.push(`- 新增: ${s}`)
1129
+ for (const s of oS) if (!nS.has(s)) L.push(`- 移除: ${s}`)
1130
+ if (nS.size === 0 && oS.size === 0) L.push('(两工程均无用户符号表,或版本格式未支持)')
1131
+ L.push('\n## 5. 变量引用变化(引用计数对比法)')
1132
+ L.push('> 判据:**加标签/改注释/重新导出 = 引用计数不变**;某个变量引用数骤降/消失 = 代码被改动或两版不同源。')
1133
+ L.push('> 注意 "加标签" 会同步新增符号表条目(见第 4 节),但不会减少代码引用——第 4 节新增 + 本节无变化 才可判定为"仅加标签"。')
1134
+ const refCount = (p) => {
1135
+ const m = new Map()
1136
+ for (const r of p.records) for (const o of r.operands) if (/^[VM]/.test(o)) m.set(o, (m.get(o) ?? 0) + 1)
1137
+ return m
1138
+ }
1139
+ const nR = refCount(n), oR = refCount(o)
1140
+ const vars5 = [...new Set([...nR.keys(), ...oR.keys()])].sort()
1141
+ let any5 = false
1142
+ for (const v of vars5) {
1143
+ const a = nR.get(v) ?? 0, b = oR.get(v) ?? 0
1144
+ if (a === b) continue
1145
+ if (a === 0) { any5 = true; L.push(`- \`${v}\` 旧 ${b} → 新 0:**引用消失**(代码改动,非标签)`) }
1146
+ else if (b === 0) { any5 = true; L.push(`- \`${v}\` 旧 0 → 新 ${a}:**新增引用**`) }
1147
+ else if (a < b * 0.5) { any5 = true; L.push(`- \`${v}\` 旧 ${b} → 新 ${a}:骤降 **存疑**`) }
1148
+ else if (a > b * 1.5) { any5 = true; L.push(`- \`${v}\` 旧 ${b} → 新 ${a}:骤增 **存疑**`) }
1149
+ }
1150
+ if (!any5) L.push('- 无显著引用变化(仅标签/注释/导出差异的可能性大)')
1151
+ const report = L.join('\n')
1152
+ const out = outDir ?? defaultArchiveDir(newFile, base)
1153
+ mkdirSync(out, { recursive: true })
1154
+ writeFileSync(join(out, '对比报告.md'), report, 'utf8')
1155
+ return { proceed: `对比报告: ${join(out, '对比报告.md')}`, report }
1156
+ }
1157
+
1158
+ // ─────────────── SMART 子程序内部结构解析(讲解"XX 子程序怎么写"用) ───────────────
1159
+
1160
+ const BOX_PIN_NAMES = new Set(['EN', 'ENO', 'IN', 'IN1', 'IN2', 'IN3', 'IN4', 'OUT', 'OUT1', 'OUT2', 'N'])
1161
+
1162
+ /** 指令盒头:01 01 00 00 00 <1~2 字节序号> 03 01 */
1163
+ function isBoxHead(data, i) {
1164
+ if (!(data[i] === 1 && data[i + 1] === 1 && data[i + 2] === 0 && data[i + 3] === 0 && data[i + 4] === 0)) return false
1165
+ const b5 = data[i + 5], b6 = data[i + 6], b7 = data[i + 7]
1166
+ if (b5 <= 0x0f && b6 === 0x03 && b7 === 0x01) return true
1167
+ return b5 <= 0x0f && b6 <= 0x0f && b7 === 0x03 && data[i + 8] === 0x01
1168
+ }
1169
+
1170
+ /** 盒内元素按偏移顺序提取:指令名/引脚(ASCII)、名称(GBK)、操作数对。每轮必前进 + 迭代上限。 */
1171
+ function scanBoxElements(data, a, b) {
1172
+ const instr = [], pins = [], names = [], ops = []
1173
+ let i = a, guard = 0
1174
+ const cap = (b - a) * 2 + 64
1175
+ while (i < b) {
1176
+ if (++guard > cap) break
1177
+ if (data[i] === 0x02 && data[i + 1] === 0x00) {
1178
+ const t = data[i + 2]
1179
+ if ([0x01, 0x02, 0x04, 0x08].includes(t) && i + 11 <= b) {
1180
+ const area = data.readUInt32LE(i + 3)
1181
+ const val = data.readUInt32LE(i + 7)
1182
+ if (AREA[area] !== undefined) { ops.push(fmtOperand(t, area, val)); i += 11; continue }
1183
+ }
1184
+ const len = data[i + 2] | (data[i + 3] << 8)
1185
+ if (len > 0 && len < 40 && i + 4 + len < b && data[i + 4 + len] === 0) {
1186
+ const nb = data.subarray(i + 4, i + 4 + len)
1187
+ let ok = true
1188
+ for (const x of nb) if (x !== 0 && x < 0x20 && x < 0x81) { ok = false; break }
1189
+ if (ok) {
1190
+ const nm = gbk(nb).trim()
1191
+ if (nm) { names.push(nm); i += 4 + len; continue }
1192
+ }
1193
+ }
1194
+ }
1195
+ const c = data[i]
1196
+ if (c >= 0x20 && c < 0x7f) {
1197
+ let j = i
1198
+ while (j < b && data[j] >= 0x20 && data[j] < 0x7f) j++
1199
+ const s = data.subarray(i, j).toString('ascii')
1200
+ if (/^[A-Z][A-Z0-9_]{1,20}$/.test(s) || /^[<>=]{1,2}[A-Z]{1,2}$/.test(s)) {
1201
+ if (BOX_PIN_NAMES.has(s)) pins.push(s)
1202
+ else instr.push(s)
1203
+ }
1204
+ i = j > i ? j : i + 1
1205
+ continue
1206
+ }
1207
+ if (c >= 0x81 && i + 1 < b) {
1208
+ let j = i
1209
+ while (j + 1 < b && data[j] >= 0x81 && data[j + 1] >= 0x40) j += 2
1210
+ if (j > i) { const s = gbk(data.subarray(i, j)); if (s) names.push(s); i = j; continue }
1211
+ }
1212
+ i += 1
1213
+ }
1214
+ return { instr, pins, names: names.filter(n => n && !n.includes('\uFFFD')), ops }
1215
+ }
1216
+
1217
+ /** 接口参数表:`02 00 <len u16> <GBK 名>` 后 48B 内紧跟 L 区操作数 → {name, type, val}
1218
+ * 只在块末尾 0x1000 字节内解析(接口表在块尾部),并过滤空名/乱码/重复,避免把指令名当参数名。 */
1219
+ function parseIfaceParams(data, a, b) {
1220
+ const res = []
1221
+ const seen = new Set()
1222
+ let i = Math.max(a, b - 0x1000), guard = 0
1223
+ const cap = (b - i) * 2 + 64
1224
+ while (i < b - 15) {
1225
+ if (++guard > cap) break
1226
+ if (data[i] === 0x02 && data[i + 1] === 0x00) {
1227
+ const len = data[i + 2] | (data[i + 3] << 8)
1228
+ if (len > 0 && len < 40 && i + 4 + len + 11 <= b && data[i + 4 + len] === 0) {
1229
+ const nb = data.subarray(i + 4, i + 4 + len)
1230
+ let ok = true
1231
+ for (const x of nb) if (x !== 0 && x < 0x20 && x < 0x81) { ok = false; break }
1232
+ if (ok) {
1233
+ const nm = gbk(nb).trim()
1234
+ if (nm && /[\p{L}\p{N}]/u.test(nm) && !nm.includes('\uFFFD') && !seen.has(nm)) {
1235
+ let hit = null
1236
+ const lim = Math.min(b, i + 4 + len + 48)
1237
+ for (let j = i + 4 + len; j + 11 <= lim; j++) {
1238
+ if (data[j] === 0x02 && data[j + 1] === 0x00 && [0x01, 0x02, 0x04, 0x08].includes(data[j + 2])) {
1239
+ const area = data.readUInt32LE(j + 3)
1240
+ const val = data.readUInt32LE(j + 7)
1241
+ if (area === 0x2000) { hit = { type: data[j + 2], val }; break }
1242
+ }
1243
+ }
1244
+ if (hit) { res.push({ name: nm, ...hit }); seen.add(nm); i += 4 + len; continue }
1245
+ }
1246
+ }
1247
+ }
1248
+ }
1249
+ i += 1
1250
+ }
1251
+ return res
1252
+ }
1253
+
1254
+ /** 调用点:`E9 03 .. 01 00 02 00 03 00 <被调块名 GBK>` → 所属块 + 实参操作数 */
1255
+ function findCallSites(data, name, pou) {
1256
+ const res = []
1257
+ for (let i = 0; i < data.length - 20; i++) {
1258
+ if (data[i] !== 0xE9 || data[i + 1] !== 0x03) continue
1259
+ if (!(data[i + 3] === 0x01 && data[i + 4] === 0x00 && data[i + 5] === 0x02 && (data[i + 6] === 0x00 || data[i + 6] === 0x09) && data[i + 7] === 0x03 && data[i + 8] === 0x00)) continue
1260
+ let e = i + 9
1261
+ while (e < data.length && data[e] !== 0 && e - i < 60) e++
1262
+ const nm = gbk(data.subarray(i + 9, e)).trim()
1263
+ if (nm !== name) continue
1264
+ let h = i
1265
+ while (h > i - 0x500 && h > 0 && !isBoxHead(data, h)) h--
1266
+ let t = i + 9
1267
+ while (t < data.length - 12 && t < i + 0x300 && !isBoxHead(data, t)) t++
1268
+ const ops = extractOperands(data, h, Math.min(t, i + 0x300))
1269
+ let owner = '(文件头)'
1270
+ for (const p of pou) { if (p.off <= h) owner = p.name; else break }
1271
+ res.push({ off: h, owner, ops })
1272
+ }
1273
+ return res
1274
+ }
1275
+
1276
+ const L_TYPE_NAME = { 0x01: 'BOOL', 0x02: 'BYTE', 0x04: 'INT/WORD', 0x08: 'DWORD' }
1277
+
1278
+ /** 生成子程序内部结构报告(markdown) */
1279
+ function buildPouStructureReport(file, data, pou, pouFilter) {
1280
+ const projName = gbk(data.subarray(0x30, 0x36)).replace(/\0/g, '')
1281
+ const L = []
1282
+ L.push('# S7-200 SMART 子程序内部结构')
1283
+ L.push('')
1284
+ L.push('> ✓ 网络拓扑(并联/串联)与沿触点(P/N)**本工程文件里就能解出来**:用工具 `smart-lad`(`node assets/smart-lad.mjs <工程.smart> [块名]`)直接打印每块每网络的 LAD 指令序列(已用导出的 STL 回归:主程序 17/17 逐条一致)。**不必再让用户从软件导出 STL**;导出 STL 只在需要交叉核对时用。')
1285
+ L.push('')
1286
+ L.push(`- 源文件: \`${file}\``)
1287
+ L.push(`- 项目名: ${projName || '—'};块数: ${pou.length}`)
1288
+ L.push('')
1289
+ const targets = pouFilter ? pou.filter(p => p.name === pouFilter) : pou
1290
+ if (!targets.length) {
1291
+ L.push(`> 未找到块「${pouFilter}」;可用块名:${pou.map(p => p.name).join('、')}`)
1292
+ return L.join('\n')
1293
+ }
1294
+ for (const t of targets) {
1295
+ const idx = pou.indexOf(t)
1296
+ const a = t.off
1297
+ const b = idx + 1 < pou.length ? pou[idx + 1].off : data.length
1298
+ const heads = []
1299
+ for (let i = a; i < b - 12; i++) if (isBoxHead(data, i)) heads.push(i)
1300
+ const boxes = heads.map((h, k) => ({ off: h, ...scanBoxElements(data, h, k + 1 < heads.length ? heads[k + 1] : b) }))
1301
+ const iface = parseIfaceParams(data, a, b)
1302
+ const calls = findCallSites(data, t.name, pou)
1303
+ L.push(`## 块:${t.name}`)
1304
+ L.push('')
1305
+ L.push(`- 区间: 0x${a.toString(16).toUpperCase()} ~ 0x${b.toString(16).toUpperCase()};盒数: ${boxes.length}`)
1306
+ L.push('')
1307
+ if (iface.length) {
1308
+ L.push('### 接口参数(L 区)')
1309
+ L.push('')
1310
+ L.push('| 参数名 | L 地址 | 类型 |')
1311
+ L.push('|---|---|---|')
1312
+ for (const p of iface) L.push(`| ${p.name} | ${fmtOperand(p.type, 0x2000, p.val)} | ${L_TYPE_NAME[p.type] ?? '?'} |`)
1313
+ L.push('')
1314
+ }
1315
+ L.push('### 内部结构(盒级,按偏移顺序)')
1316
+ L.push('')
1317
+ boxes.forEach((bx, k) => {
1318
+ const parts = []
1319
+ if (bx.instr.length) parts.push(`指令 **${bx.instr.join(' / ')}**`)
1320
+ if (bx.pins.length) parts.push(`引脚 ${bx.pins.join('/')}`)
1321
+ if (bx.ops.length) parts.push(`操作数 \`${bx.ops.join('; ')}\``)
1322
+ if (bx.names.length) parts.push(`名称 ${bx.names.join('; ')}`)
1323
+ L.push(`${k + 1}. \`0x${bx.off.toString(16).toUpperCase()}\` — ${parts.join(' · ') || '(元数据/空)'}`)
1324
+ })
1325
+ L.push('')
1326
+ if (calls.length) {
1327
+ L.push('### 调用点')
1328
+ L.push('')
1329
+ L.push('| 所属块 | 调用盒偏移 | 实参操作数 |')
1330
+ L.push('|---|---|---|')
1331
+ for (const c of calls) L.push(`| ${c.owner} | \`0x${c.off.toString(16).toUpperCase()}\` | ${c.ops.join('; ') || '—'} |`)
1332
+ L.push('')
1333
+ }
1334
+ }
1335
+ return L.join('\n')
1336
+ }
1337
+
1338
+ function decodeSmartPouStructure(file, pouFilter) {
1339
+ const raw = readFileSync(file)
1340
+ const { data } = decodeContainer(raw)
1341
+ const pou = findPous(data)
1342
+ const report = buildPouStructureReport(file, data, pou, pouFilter)
1343
+ return { proceed: report, report }
1344
+ }
1345
+
1346
+ function decodeSmartLib(file, outDir, base) { const raw = readFileSync(file)
1347
+ const { version, data } = decodeContainer(raw)
1348
+ const texts = extractTexts(data)
1349
+ const vars = []
1350
+ const seen = new Set()
1351
+ for (const { text } of texts) {
1352
+ if (/^[A-Za-z_][A-Za-z0-9_]{1,24}$/.test(text) && !seen.has(text)) {
1353
+ seen.add(text)
1354
+ vars.push(text)
1355
+ }
1356
+ }
1357
+ const comments = texts.filter(t => t.text.length > 30 && /[A-Za-z]/.test(t.text)).slice(0, 200).map(t => t.text)
1358
+ const out = outDir ?? defaultArchiveDir(file, base)
1359
+ mkdirSync(out, { recursive: true })
1360
+ writeFileSync(join(out, '库解码.md'), buildLibReport(version, vars, comments), 'utf8')
1361
+ writeFileSync(join(out, '字符串全录.txt'), texts.map(t => `0x${t.off.toString(6, 16)}\t${t.text}`).join('\n'), 'utf8')
1362
+ return {
1363
+ proceed: `库报告: ${join(out, '库解码.md')}\n字符串全录: ${join(out, '字符串全录.txt')}\n变量 ${vars.length} 个、注释串 ${comments.length} 条`,
1364
+ }
1365
+ }
1366
+
1367
+ function buildSmReport(kind, version, want, projName, innerVer, pou, records, userSyms, sysSyms, blockStreams, blockIfaces, netRows = [], symDefaults = [], symAttrs = [], pouStructures = [], fingerprint = null, dangling = []) {
1368
+ const L = []
1369
+ L.push(`# S7-200 SMART「${projName || '(未命名)'}」解码报告`)
1370
+ L.push('')
1371
+ L.push(`- 容器: ${kind === 'project' ? 'SH3' : 'DEM'} + zlib → ${want.toLocaleString()} B 内部文档`)
1372
+ L.push(`- 文档版本: ${innerVer};项目名: ${projName || '—'};块顺序与项目树一致`)
1373
+ if (fingerprint) L.push(`- **源文件指纹**: ${fingerprint.size.toLocaleString()} B,${fingerprint.mtime}(用本报告前先核对当前源文件;不一致说明报告已过期,须重新解码)`)
1374
+ L.push('')
1375
+ L.push('## 1. 程序块清单')
1376
+ L.push('')
1377
+ L.push('| 顺序 | 块名 |')
1378
+ L.push('|---|---|')
1379
+ pou.forEach((p, k) => L.push(`| ${k + 1} | ${p.name} |`))
1380
+ L.push('')
1381
+ L.push('## 2. 指令序列(按块)')
1382
+ L.push('')
1383
+ let cur = null
1384
+ for (const r of records) {
1385
+ if (r.pou !== cur) {
1386
+ L.push('')
1387
+ L.push(`### ${r.pou}`)
1388
+ L.push('')
1389
+ L.push('| 偏移 | 指令 | 操作数 |')
1390
+ L.push('|---|---|---|')
1391
+ cur = r.pou
1392
+ }
1393
+ L.push(`| ${r.off} | ${r.instr} | ${r.operands.join('; ') || '—'} |`)
1394
+ }
1395
+ L.push('')
1396
+ L.push('## 2.5 强行解码:库块接口 + 块级操作数流')
1397
+ L.push('')
1398
+ L.push('> 库块与用户库子程序(无标准指令盒头,内部结构不可见),')
1399
+ L.push('> 接口变量与操作数以 02 00 type area addr 对形式直接存储。下表按块列出接口名与全部操作数流(指令名不可复原,逻辑靠数据流解读)。')
1400
+ L.push('')
1401
+ for (let k = 0; k < blockIfaces.length; k++) {
1402
+ const n = blockIfaces[k].name
1403
+ const ifs = blockIfaces[k].ifs
1404
+ const ops = blockStreams[k].ops
1405
+ L.push(`### ${n}`)
1406
+ L.push('')
1407
+ if (ifs.length) L.push(`- 接口变量: ${ifs.map(f => `\`${f.name}\``).join(', ')}`)
1408
+ if (ops.length) {
1409
+ const shown = ops.slice(0, 400).join(' ')
1410
+ L.push(`- 操作数流(${ops.length}): ${shown}${ops.length > 400 ? ' …(已截断,完整见记录.json)' : ''}`)
1411
+ } else {
1412
+ L.push('- 操作数流: (无)')
1413
+ }
1414
+ L.push('')
1415
+ }
1416
+ L.push('## 2.6 功能级扫描:算术/换算指令链(找"比例/速比/换算/档位"功能看这里)')
1417
+ L.push('')
1418
+ L.push('> ① 不要按中文符号名搜索——速比/换算系数常以无名 VW 变量实现(有算术链=有换算功能,无名字≠无功能);')
1419
+ L.push('> ② 除号常被实现为"乘倒数/移位/比较分档",档位判别 = 同一变量与多个阈值比较(典型为 MUL_x 后跟一组比较);')
1420
+ L.push('> ③ 比例/速度类功能三条典型实现路径:算术链(乘除)、比较分档(阈值组)、定时/计数换算(INC+T 定时器 测周期)。')
1421
+ L.push('')
1422
+ const ARITH = /^(MUL_I|MUL_DI|MUL_R|DIV_I|DIV_DI|DIV_R|ADD_I|ADD_DI|ADD_R|SUB_I|SUB_DI|SUB_R|SHL_DW|SHR_DW|WAND_DW|WOR_DW|WXOR_DW|BCD_I|I_BCD|I_B|B_I|S_ITR)$/
1423
+ let arithRows = records.filter(r => ARITH.test(r.instr))
1424
+ if (arithRows.length === 0) {
1425
+ L.push('- (本工程无解码出的算术/换算指令——可能被含在库块操作数流中,见 2.5 节)')
1426
+ } else {
1427
+ L.push('| 块 | 偏移 | 指令 | 操作数 |')
1428
+ L.push('|---|---|---|---|')
1429
+ for (const r of arithRows) L.push(`| ${r.pou} | ${r.off} | ${r.instr} | ${r.operands.join('; ') || '—'} |`)
1430
+ }
1431
+ L.push('')
1432
+ // 档位判别:同一变量≥2 次参与的比较类大盒子(MOV_W 混合盒无法逐条切分时,提示按操作数流比对)
1433
+ L.push('## 2.7 信号流:变量引用分布(谁在读写谁——按引用条数排序,前 60)')
1434
+ L.push('')
1435
+ L.push('> 简单指令盒(INC/MUL/MOV 等)的操作数存于编译参数区,盒内只有模板 ID,直接读不出;')
1436
+ L.push('> 用"变量引用分布"从数据流补全——查某个变量(如速比)被哪些块的哪些指令引用,两端对照即得逻辑。')
1437
+ L.push('')
1438
+ const refs = new Map() // 地址 -> Map("块@偏移(指令)" -> 该记录内出现次数)
1439
+ for (const r of records) {
1440
+ for (const o of r.operands) {
1441
+ // 排除 Modbus 通信区(VB4000/VB425x 等),否则前 60 全被通信变量占满
1442
+ if (/^[VM]/.test(o) && !/^VB(4?256|4?255|4?253|4?252|4000|4001|4002|4256|4255|4253|4252)\b/.test(o)) {
1443
+ if (!refs.has(o)) refs.set(o, new Map())
1444
+ const mm = refs.get(o)
1445
+ const k = `${r.pou}@${r.off}(${r.instr})`
1446
+ mm.set(k, (mm.get(k) ?? 0) + 1)
1447
+ }
1448
+ }
1449
+ }
1450
+ const named = new Set(userSyms.map(s => s.addr))
1451
+ const total = (mm) => [...mm.values()].reduce((a, b) => a + b, 0)
1452
+ // 读/写判据:同一盒内出现 ≥2 次必是 OUT(写)——3 引脚盒的 OUT 在两个「对」里各出现一次
1453
+ const isWrite = (mm) => [...mm.values()].some(c => c >= 2)
1454
+ const sorted = [...refs.entries()].sort((a, b) => {
1455
+ const na = named.has(a[0]) ? 1 : 0
1456
+ const nb = named.has(b[0]) ? 1 : 0
1457
+ if (na !== nb) return nb - na
1458
+ return total(b[1]) - total(a[1])
1459
+ }).slice(0, 60)
1460
+ L.push('| 变量 | 引用数 | 读/写 | 位置(前 6 条,×n = 同一盒内出现 n 次) |')
1461
+ L.push('|---|---|---|---|')
1462
+ if (sorted.length === 0) L.push('- (无)')
1463
+ for (const [v, mm] of sorted) {
1464
+ const pos = [...mm.entries()].slice(0, 6).map(([k, c]) => (c > 1 ? `${k}×${c}` : k)).join(' ')
1465
+ L.push(`| \`${v}\` | ${total(mm)} | ${isWrite(mm) ? '写' : '读?'} | ${pos}${mm.size > 6 ? ' …' : ''} |`)
1466
+ }
1467
+ L.push('')
1468
+ L.push('> 读/写判据(推定):**同一盒内出现 ≥2 次 = 写**(3 引脚盒的 OUT 在两个引脚对里各出现一次);只出现 1 次记"读?"——')
1469
+ L.push('> 两引脚盒(MOV_W/INC_W)的 OUT 也只出现一次,要按同记录最后一位人工确认(引脚级定位用 smart-pin-dump)。')
1470
+ L.push('')
1471
+ L.push('## 2.7b 未接线变量(有符号名、程序区 0 次引用)')
1472
+ L.push('')
1473
+ L.push('> 读法:这些变量在符号表里活着,但程序里没有任何操作数引用 —— 遇到「XX 功能为什么没生效 / 屏上值为什么不变」时,先查这张表。')
1474
+ L.push('> 典型成因:加功能只建了变量没画链、改版时删掉了一段逻辑、HMI 还在写一个 PLC 不读的地址。')
1475
+ L.push('> 判定法:字节级精确匹配操作数编码 `02 00 <type> <area:4> <addr:4>`,只在程序区计数(符号表区已排除,否则全表误报)。')
1476
+ L.push('')
1477
+ {
1478
+ const junk = (n) => /^Symbol_\d+$/.test(n ?? '')
1479
+ const func = dangling.filter(d => /^V/.test(d.addr) && !junk(d.name))
1480
+ const mbit = dangling.filter(d => /^M/.test(d.addr) && !junk(d.name))
1481
+ const sysCnt = dangling.length - func.length - mbit.length
1482
+ if (func.length === 0 && mbit.length === 0) {
1483
+ L.push('- (无:所有有名字的功能变量都在程序里被引用过)')
1484
+ } else {
1485
+ if (func.length) {
1486
+ L.push(`- **V 区(功能参数/窗口/结果,共 ${func.length} 个)** —— 屏上能改、程序不读,或算出来没人用,都落在这一组:`)
1487
+ L.push('')
1488
+ L.push('| 地址 | 符号名 |')
1489
+ L.push('|---|---|')
1490
+ for (const d of func) L.push(`| \`${d.addr}\` | ${d.name} |`)
1491
+ L.push('')
1492
+ }
1493
+ if (mbit.length) {
1494
+ L.push(`- **M 区(位,共 ${mbit.length} 个)**:`)
1495
+ L.push('')
1496
+ L.push('| 地址 | 符号名 |')
1497
+ L.push('|---|---|')
1498
+ for (const d of mbit) L.push(`| \`${d.addr}\` | ${d.name} |`)
1499
+ L.push('')
1500
+ }
1501
+ }
1502
+ if (sysCnt > 0) L.push(`- (另有 ${sysCnt} 个 PLC 系统位/自动生成的 \`Symbol_N\` 占位未引用,属正常,未列出)`)
1503
+ }
1504
+ L.push('')
1505
+ L.push('## 2.8 网络行结构(网络拓扑元数据:行首 01 00 00 03 01 + 盒序列 + 操作数计数)')
1506
+ L.push('')
1507
+ L.push('> 每行 = 梯形图一行:`01 00 00 03 01` 行首,盒序列 `01 01 00 00 00 <行号> <盒号> 03 01`,盒后为操作数计数 `<n> 00 00 03/04`(网络元数据盒无 02 00 操作数对)。')
1508
+ L.push('')
1509
+ L.push(`- 网络行总数: ${netRows.length};首行 @${netRows[0] ? '0x' + netRows[0].off.toString(16) : '—'};总盒数: ${netRows.reduce((s, r) => s + r.boxes, 0)}`)
1510
+ L.push('')
1511
+ L.push('## 2.9 符号属性/默认值表(条目头 01 01 02 00 00 00 01 03 01 = 符号ID引用;01 01 00 00 00 01 00 00 00 00 00 00 00 <值> = 默认值)')
1512
+ L.push('')
1513
+ L.push(`- 符号 ID 引用条目: ${symAttrs.length} 条(首末偏移: ${symAttrs.slice(0, 3).map(a => '0x' + a.off.toString(16).toUpperCase()).join(', ')}${symAttrs.length > 3 ? ' …' : ''}${symAttrs.length ? ' 末0x' + symAttrs[symAttrs.length - 1].off.toString(16).toUpperCase() : ''})`)
1514
+ if (symDefaults.length === 0) {
1515
+ L.push('- 默认值条目: 0')
1516
+ } else {
1517
+ const cnt = {}
1518
+ for (const d of symDefaults) cnt[d.value] = (cnt[d.value] ?? 0) + 1
1519
+ L.push(`- 默认值条目: ${symDefaults.length} 条, 值分布: ${Object.entries(cnt).map(([v, n]) => `${v}×${n}`).join(' ; ')}`)
1520
+ L.push('')
1521
+ L.push('| 偏移 | 默认值 | 序号 |')
1522
+ L.push('|---|---|---|')
1523
+ for (const d of symDefaults.slice(0, 20)) L.push(`| 0x${d.off.toString(16).toUpperCase()} | ${d.value} (0x${d.value.toString(16).toUpperCase()}) | ${d.idx} |`)
1524
+ }
1525
+ L.push('')
1526
+ L.push('## 3. 用户符号表')
1527
+ L.push('')
1528
+ L.push('| 地址 | 符号名 |')
1529
+ L.push('|---|---|')
1530
+ for (const s of userSyms) L.push(`| \`${s.addr}\` | ${s.name} |`)
1531
+ L.push(freeSpaceReport(userSyms, records))
1532
+ if (pouStructures.length) {
1533
+ L.push('## 3.5 子程序接口速查(参数 L 地址 + 调用点数)')
1534
+ L.push('')
1535
+ L.push('> 参数 L 地址由块尾接口记录解析;调用点数 = 全工程 `E9 03 .. <块名>` 调用盒计数。逐盒内部结构用工具 `smart-pou-structure`。')
1536
+ L.push('')
1537
+ for (const ps of pouStructures) {
1538
+ const ifc = ps.iface.length
1539
+ ? ps.iface.map(p => `\`${p.name}\`=${fmtOperand(p.type, 0x2000, p.val)}(${L_TYPE_NAME[p.type] ?? '?'})`).join('、')
1540
+ : '(无参数记录)'
1541
+ L.push(`- **${ps.name}**:调用 ${ps.calls} 处 参数:${ifc}`)
1542
+ }
1543
+ L.push('')
1544
+ }
1545
+ // 3.6 静态体检:Q 点多块引用(潜在双线圈)/ 符号表 I-Q 点未引用 / 有名字但程序未用
1546
+ {
1547
+ const qMap = new Map(), iMap = new Map()
1548
+ for (const b of blockStreams ?? []) {
1549
+ for (const op of b.ops ?? []) {
1550
+ if (!/^[QI]\d+\.\d+$/.test(op)) continue
1551
+ const tgt = op[0] === 'Q' ? qMap : iMap
1552
+ if (!tgt.has(op)) tgt.set(op, new Map())
1553
+ const mm = tgt.get(op)
1554
+ mm.set(b.name, (mm.get(b.name) ?? 0) + 1)
1555
+ }
1556
+ }
1557
+ L.push('## 3.6 静态体检')
1558
+ L.push('')
1559
+ const multiQ = [...qMap.entries()].filter(([, m]) => m.size > 1)
1560
+ L.push(`- **Q 输出点多块引用(潜在双线圈)**: ${multiQ.length ? '' : '无'}`)
1561
+ for (const [q, m] of multiQ) L.push(` - \`${q}\`: ${[...m.entries()].map(([b, n]) => `${b}×${n}`).join('、')}`)
1562
+ const named = new Map()
1563
+ for (const s of userSyms ?? []) named.set(s.addr, s.name)
1564
+ const unused = [...named.entries()].filter(([addr]) => /^[IQ]\d+\.\d+$/.test(addr) && !iMap.has(addr) && !qMap.has(addr))
1565
+ L.push(`- **符号表 I/Q 点但程序未引用**: ${unused.length ? unused.map(([a, n]) => `\`${a}\`(${n})`).join('、') : '无'}`)
1566
+ const unnamedQ = [...qMap.keys()].filter(a => !named.has(a))
1567
+ L.push(`- **Q 点无符号名**: ${unnamedQ.length ? unnamedQ.map(a => `\`${a}\``).join('、') : '无'}`)
1568
+ L.push('')
1569
+ }
1570
+ L.push(vOverlapReport(records))
1571
+ L.push('## 4. 系统符号表')
1572
+ L.push('')
1573
+ L.push('| 地址 | 符号名 |')
1574
+ L.push('|---|---|')
1575
+ for (const s of sysSyms) L.push(`| \`${s.addr}\` | ${s.name} |`)
1576
+ L.push('')
1577
+ L.push('## 5. 说明')
1578
+ L.push('')
1579
+ L.push('- 操作数格式 `02 00 <type> <area> <addr>`: type=0x01位/0x02字节/0x04字/0x08双字; area=I/Q/V/M/T/SM/L/库内部')
1580
+ L.push('- V 区位布尔显示为 `V{byte}.{bit}`(如 V101.3);无名 V 位通常由 HMI 或库写入')
1581
+ L.push('- 计数器引用(区域码 0x80)显示为 C{n}:CTU/CTD 盒的计数器号、以及它被当位操作用的地方都落在这里;库存储区引用在 V 区,别混')
1582
+ L.push('- 库块代码受保护:工程内只有接口变量与操作数流(见 2.5 节),指令级 STL 仅在官方软件中可见')
1583
+ L.push('')
1584
+ return L.join('\n')
1585
+ }
1586
+
1587
+ /** 地址占用与空闲段分析(加功能前的地址规划三查询)
1588
+ * - 有符号名 → 一律不动(哪怕符号是"备用开/备用关"这类预留坑)
1589
+ * - 无名字但程序已引用 → 也不动(如无名 VW202 在点动链在用)
1590
+ * - 只从「无符号名 + 无程序引用 + 成组连续」的段里取
1591
+ * type 前缀: W=VW D=VD B=VB X=位(按字节算)
1592
+ */
1593
+ function freeSpaceReport(userSyms, records, ptrRefs) {
1594
+ const named = new Map() // addr → name
1595
+ for (const s of userSyms) named.set(s.addr, s.name)
1596
+ const used = new Set() // 程序引用过的地址(记录 operands 全扫)
1597
+ for (const r of records) for (const o of (r.operands ?? [])) used.add(o)
1598
+ // 指针实参(&地址)也是在用 —— 漏它会把 Modbus 缓冲区这类字节列进空闲段
1599
+ // (如 &VB160 作 MBUS_MSG 的 DataPtr 时覆盖 2 个字)。
1600
+ const ptrKnown = Array.isArray(ptrRefs)
1601
+ for (const r of (ptrRefs ?? [])) {
1602
+ used.add(String(r.name).replace(/^&/, ''))
1603
+ if (r.aName === 'V') for (let b = r.addr; b < r.addr + r.bytes; b++) used.add(`VB${b}`)
1604
+ }
1605
+
1606
+ const parseV = (addr, pref) => {
1607
+ const m = new RegExp(`^${pref}(\\d+)$`).exec(addr)
1608
+ return m ? Number(m[1]) : null
1609
+ }
1610
+ const addrName = new Map() // 'W220' → 名字或 null
1611
+ for (const [addr, name] of named) {
1612
+ let key = null
1613
+ if (/^VW\d+$/.test(addr)) key = 'W' + addr.slice(2)
1614
+ else if (/^VD\d+$/.test(addr)) key = 'D' + addr.slice(2)
1615
+ else if (/^VB\d+$/.test(addr)) key = 'B' + addr.slice(2)
1616
+ else if (/^V\d+\.\d+$/.test(addr)) key = 'B' + Math.floor(Number(addr.slice(1).split('.')[0]) / 8)
1617
+ if (key != null && !addrName.has(key)) addrName.set(key, name)
1618
+ }
1619
+
1620
+ const L = []
1621
+ L.push('')
1622
+ L.push('## 3.5 地址占用与空闲段(加功能时按此选地址)')
1623
+ L.push('')
1624
+ L.push('> 规则:**有符号名的地址一律不动**(含"备用开/备用关"这类预留坑——那是有名字的坑位,动它=毁掉预留功能);**无名字但程序已引用**的也不动;只从「无符号名 + 无引用 + 成组连续」的段里取。')
1625
+ L.push('')
1626
+ L.push('### A. 有符号名(保留,勿动)')
1627
+ L.push('')
1628
+ const namedVW = [], namedVD = [], namedOther = []
1629
+ for (const [addr, name] of named) {
1630
+ if (/^VW/.test(addr)) namedVW.push(`${addr}(${name})`)
1631
+ else if (/^VD/.test(addr)) namedVD.push(`${addr}(${name})`)
1632
+ else namedOther.push(addr)
1633
+ }
1634
+ L.push(`- VW: ${namedVW.join(' ') || '—'}`)
1635
+ L.push(`- VD: ${namedVD.join(' ') || '—'}`)
1636
+ L.push(`- 其他(I/Q/M/SM/V位): ${namedOther.join(' ') || '—'}`)
1637
+ L.push('')
1638
+ L.push('### B. 程序已引用(含无名地址,勿动)')
1639
+ L.push('')
1640
+ const usedVW = [], usedVD = [], usedVB = []
1641
+ for (const o of used) {
1642
+ if (/^VW\d+$/.test(o)) usedVW.push(o)
1643
+ else if (/^VD\d+$/.test(o)) usedVD.push(o)
1644
+ else if (/^VB\d+$/.test(o)) usedVB.push(o)
1645
+ }
1646
+ L.push(`- VW: ${usedVW.slice(0, 120).join(' ') || '—'}${usedVW.length > 120 ? ` …(${usedVW.length})` : ''}`)
1647
+ L.push(`- VD: ${usedVD.slice(0, 120).join(' ') || '—'}${usedVD.length > 120 ? ` …(${usedVD.length})` : ''}`)
1648
+ L.push(`- VB: ${usedVB.slice(0, 120).join(' ') || '—'}${usedVB.length > 120 ? ` …(${usedVB.length})` : ''}`)
1649
+ L.push('')
1650
+
1651
+ // 空闲段:字节级占用判断(VW/VD/VB 共享 V 区字节,独立序列扫会漏重叠——
1652
+ // 案例:VW220/VW222 占字节 220~223,VD220 与之重叠,序列独立扫却判 VD220 空闲)
1653
+ const B = (prefix) => (addr) => {
1654
+ const n = parseV(addr, prefix)
1655
+ return n == null ? null : { n, bytes: [n, n + (prefix === 'VW' ? 1 : prefix === 'VD' ? 3 : 0)] }
1656
+ }
1657
+ const byteTaken = new Set()
1658
+ const markBytes = (addr) => {
1659
+ let spec = null
1660
+ if (/^VW\d+$/.test(addr)) spec = ['VW', Number(addr.slice(2))]
1661
+ else if (/^VD\d+$/.test(addr)) spec = ['VD', Number(addr.slice(2))]
1662
+ else if (/^VB\d+$/.test(addr)) spec = ['VB', Number(addr.slice(2))]
1663
+ if (!spec) return
1664
+ const span = spec[1] + (spec[0] === 'VW' ? 1 : spec[0] === 'VD' ? 3 : 0)
1665
+ for (let b = spec[1]; b <= span; b++) byteTaken.add(b)
1666
+ }
1667
+ for (const o of used) markBytes(o)
1668
+ for (const [addr] of named) markBytes(addr)
1669
+ const freeBy = (prefix, hi) => {
1670
+ const tag = prefix.slice(1)
1671
+ const unit = prefix === 'VW' ? 2 : prefix === 'VD' ? 4 : 1
1672
+ const addrFree = (a, b) => {
1673
+ for (let x = a; x <= b; x++) if (byteTaken.has(x)) return false
1674
+ return true
1675
+ }
1676
+ const spans = []
1677
+ let s = null
1678
+ for (let i = 0; i <= hi; i += unit) {
1679
+ const b = i + unit - 1
1680
+ if (addrFree(i, b)) { if (s == null) s = i }
1681
+ else { if (s != null) { spans.push([s, i - unit]); s = null } }
1682
+ }
1683
+ if (s != null) spans.push([s, hi - ((hi - s) % unit)])
1684
+ return spans.map(([a, b]) => a === b ? `${tag}${a}` : `${tag}${a}~${tag}${b}`)
1685
+ }
1686
+ L.push('### C. 空闲段(候选坑位:无名字 + 无引用 + 字节级无重叠 + 连续≥1个单位)')
1687
+ L.push('')
1688
+ L.push('> 字节级判定:VB/VW/VD 共享 V 区字节,VB20 = VW20 低字节 = VD20 低字节;判空闲按"该地址全部字节均未被任何符号/引用占用"。注意 **同一变量 ID 的 VW/VD 天然冲突**(VW220 占 220~221、VD220 占 220~223),候选坑位务必避开已有 VW 邻居。')
1689
+ L.push('')
1690
+ const freeW = freeBy('VW', 4095), freeD = freeBy('VD', 4095), freeB = freeBy('VB', 4095)
1691
+ L.push(`- VW: ${freeW.join(' ') || '—'}`)
1692
+ L.push(`- VD: ${freeD.join(' ') || '—'}`)
1693
+ L.push(`- VB: ${freeB.join(' ') || '—'}`)
1694
+ L.push('')
1695
+ if (!ptrKnown) {
1696
+ L.push('> ⚠ 本次没扫指针实参(&地址,如 MBUS_MSG 的 DataPtr)—— 空闲段可能落在缓冲区上;选地址前拿 .smart 原文件再跑一次本工具。')
1697
+ L.push('')
1698
+ }
1699
+ return L.join('\n')
1700
+ }
1701
+
1702
+ /** 操作数 → 地址结构(体检用):{area, kind, base, len, bit?}。解析不了返回 null。 */
1703
+ function parseOperandAddr(o) {
1704
+ let m
1705
+ if ((m = /^([IQM])([DWB])(\d+)$/.exec(o))) {
1706
+ const len = m[2] === 'D' ? 4 : m[2] === 'W' ? 2 : 1
1707
+ return { area: m[1], kind: len === 4 ? '双字' : len === 2 ? '字' : '字节', base: +m[3], len }
1708
+ }
1709
+ if ((m = /^V([DWB])(\d+)$/.exec(o))) {
1710
+ const len = m[1] === 'D' ? 4 : m[1] === 'W' ? 2 : 1
1711
+ return { area: 'V', kind: len === 4 ? '双字' : len === 2 ? '字' : '字节', base: +m[2], len }
1712
+ }
1713
+ if ((m = /^(SM|[IQM])(\d+)\.([0-7])$/.exec(o))) return { area: m[1] === 'SM' ? 'SM' : m[1], kind: '位', base: +m[2], len: 1, bit: +m[3] }
1714
+ if ((m = /^V(\d+)\.([0-7])$/.exec(o))) return { area: 'V', kind: '位', base: +m[1], len: 1, bit: +m[2] }
1715
+ if ((m = /^\?t(\d+)\((SM|[IQM]),(\d+)\)$/.exec(o))) {
1716
+ const len = Number(m[1]) === 8 ? 4 : Number(m[1]) === 4 ? 2 : 1
1717
+ return { area: m[2] === 'SM' ? 'SM' : m[2], kind: len === 4 ? '双字' : len === 2 ? '字' : '字节', base: +m[3], len }
1718
+ }
1719
+ return null
1720
+ }
1721
+
1722
+ /** V 区地址冲突体检。
1723
+ * 两个口径:①重叠冲突——两个不同身份的变量压在同一批字节(A级=都有名字,真冲突;B级=都已引用、至少一方无名)
1724
+ * ②连续空间不足(P级)——单看每字节都空,但连不成要放的长度;尾部余数=永久浪费的碎片
1725
+ * 判据两个口径:旧判据只查重叠一维,会漏掉"连续区被切碎"(10 字节空段能放 2 个双字,
1726
+ * 尾部 2 字节被前后占用者夹死,成为永久浪费)。 */
1727
+ function vBytesOf(p) {
1728
+ if (p.bits != null) return [p.base] // 位:占它所在的那一字节
1729
+ const out = []
1730
+ for (let k = 0; k < p.len; k++) out.push(p.base + k)
1731
+ return out
1732
+ }
1733
+ function conflictIdentity(raw, p, named) {
1734
+ if (named.has(raw)) return 'sym:' + named.get(raw)
1735
+ if (/^V[WBD]?\d+/.test(raw) || /^\?t\d+\(V,/.test(raw)) return 'base:' + p.base
1736
+ return 'raw:' + raw
1737
+ }
1738
+ function conflictReport(userSyms, records, ptrRefs, needDoubles, vBytesTotal) {
1739
+ const named = new Map()
1740
+ for (const s of userSyms) named.set(s.addr, s.name)
1741
+ const refs = new Map()
1742
+ const touch = (raw, at) => {
1743
+ const p = parseOperandAddr(raw)
1744
+ if (!p) return
1745
+ if (!refs.has(raw)) refs.set(raw, { p, at: [] })
1746
+ refs.get(raw).at.push(at)
1747
+ }
1748
+ for (const r of records) {
1749
+ const at = `${r.pou}@${r.off}${r.instr && r.instr !== '?' ? '[' + r.instr + ']' : ''}`
1750
+ for (const o of (r.operands ?? [])) if (o) touch(String(o), at)
1751
+ }
1752
+ for (const [addr] of named) if (!refs.has(addr) && parseOperandAddr(addr)) refs.set(addr, { p: parseOperandAddr(addr), at: ['(仅符号表)'] })
1753
+ for (const r of (ptrRefs ?? [])) {
1754
+ const nm = 'VB' + r.addr
1755
+ if (!refs.has(nm)) refs.set(nm, { p: { area: 'V', kind: '字节', base: r.addr, len: Math.max(1, r.bytes || 1) }, at: ['(指针 ' + (r.name ?? '&VB') + ')'] })
1756
+ }
1757
+
1758
+ const list = []
1759
+ for (const [raw, v] of refs) list.push({ raw, p: v.p, at: v.at, id: conflictIdentity(raw, v.p, named), named: named.has(raw) })
1760
+
1761
+ const L = []
1762
+ /* ① 字节占用图(V 区) */
1763
+ const occ = new Array(vBytesTotal).fill(null)
1764
+ for (const x of list) {
1765
+ if (x.p.area !== 'V') continue
1766
+ for (const b of vBytesOf(x.p)) if (b >= 0 && b < vBytesTotal && !occ[b]) occ[b] = x.raw
1767
+ }
1768
+ const usedCnt = occ.filter(Boolean).length
1769
+ const free = []
1770
+ let s = -1
1771
+ for (let i = 1; i < vBytesTotal; i++) {
1772
+ if (!occ[i]) { if (s < 0) s = i }
1773
+ else { if (s >= 0) { free.push([s, i - 1]); s = -1 } }
1774
+ }
1775
+ if (s >= 0) free.push([s, vBytesTotal - 1])
1776
+
1777
+ L.push(`## P 级·碎片告警(连续空闲区被切碎,字节用不上)`)
1778
+ L.push('')
1779
+ L.push('> 判据:一段连续空闲的字节数不是 4 的整数倍 → 多出来的字节放不下任何双字,永久浪费。')
1780
+ L.push('')
1781
+ L.push(`V 区已占 ${usedCnt} / ${vBytesTotal} 字节(${(usedCnt / vBytesTotal * 100).toFixed(1)}%)`)
1782
+ L.push('')
1783
+ const tails = free.filter(([lo, hi]) => (hi - lo + 1) >= 4 && (hi - lo + 1) % 4 !== 0)
1784
+ const smalls = free.filter(([lo, hi]) => (hi - lo + 1) < 4)
1785
+ if (!tails.length) L.push('- (无:所有可放双字的连续段都正好是 4 的整数倍)')
1786
+ for (const [lo, hi] of tails) {
1787
+ const len = hi - lo + 1, tail = len % 4
1788
+ L.push(`- ⚠ ${lo}~${hi}(${len} 字节)→ 可放 ${Math.floor(len / 4)} 个双字,尾部 ${hi - tail + 1}~${hi} 这 ${tail} 字节放不下双字`)
1789
+ L.push(` 被夹在:前 ${occ[lo - 1] || '—'} / 后 ${occ[hi + 1] || '—'}`)
1790
+ }
1791
+ if (smalls.length) {
1792
+ const tot = smalls.reduce((a, f) => a + f[1] - f[0] + 1, 0)
1793
+ L.push('')
1794
+ L.push(`- 另有 ${smalls.length} 段不足 4 字节的零碎空闲,合计 ${tot} 字节,全部放不下双字:${smalls.map(f => `${f[0]}~${f[1]}`).join(' ')}`)
1795
+ }
1796
+
1797
+ /* ② 候选推荐 */
1798
+ if (needDoubles > 0) {
1799
+ L.push('')
1800
+ L.push(`## 候选推荐:要放 ${needDoubles} 个双字(${needDoubles * 4} 字节连续)`)
1801
+ L.push('')
1802
+ const cands = []
1803
+ for (const [lo, hi] of free) { let p = lo; while (p + 3 <= hi) { cands.push({ addr: 'VD' + p, from: p, seg: [lo, hi] }); p += 4 } }
1804
+ cands.sort((a, b) => (a.seg[1] - a.seg[0]) - (b.seg[1] - b.seg[0]) || a.from - b.from)
1805
+ let best = null
1806
+ for (let i = 0; i + needDoubles - 1 < cands.length; i++) {
1807
+ let ok = true
1808
+ for (let k = 1; k < needDoubles; k++) if (cands[i + k].from !== cands[i].from + 4 * k) { ok = false; break }
1809
+ if (!ok) continue
1810
+ const len = cands[i].seg[1] - cands[i].seg[0] + 1
1811
+ if (!best || len < best.len) best = { idx: i, len, seg: cands[i].seg }
1812
+ }
1813
+ if (best) {
1814
+ const addrs = Array.from({ length: needDoubles }, (_, k) => cands[best.idx + k].addr)
1815
+ L.push(`- **首选**:${addrs.join(' ')} (落在空闲段 ${best.seg[0]}~${best.seg[1]},${best.len} 字节)`)
1816
+ } else L.push(`- 找不到连续的 ${needDoubles} 个双字位置`)
1817
+ L.push('')
1818
+ L.push(`- 其他可选(按所在空闲段从小到大):${cands.slice(0, 40).map(c => c.addr).join(' ') || '—'}`)
1819
+ L.push('')
1820
+ L.push('> ⚠ 候选只保证地址空着,**不保证语义合适**(离相关变量远近、是否落在通讯打包区、屏上是否已用);低地址(如 VD2)在 S7-200 有特殊用途,选中后要人工确认。')
1821
+ }
1822
+
1823
+ /* ③ 重叠冲突 A/B/C —— 统一到「位号」空间比较,并对同处去重
1824
+ * 踩坑:第一版对「位 vs 字节」按字节区间比,把同一字节里的不同位判成重叠(假冲突数百条)。
1825
+ * 正解:位 = [base*8+bit, base*8+bit],字节区 = [min*8, (max+1)*8-1],只有位号区间真交叠才算。 */
1826
+ const spanOf = p => {
1827
+ if (p.bits != null) { const b = p.base * 8 + p.bit; return [b, b] }
1828
+ const bs = []
1829
+ for (let k = 0; k < p.len; k++) bs.push(p.base + k)
1830
+ const lo = Math.min(...bs), hi = Math.max(...bs)
1831
+ return [lo * 8, (hi + 1) * 8 - 1]
1832
+ }
1833
+ const spanText = p => (p.bits != null ? `${p.base}.${p.bit}` : `${p.base}~${p.base + p.len - 1}`)
1834
+ const bySpan = new Map()
1835
+ for (const x of list) {
1836
+ const [lo, hi] = spanOf(x.p)
1837
+ const k = `${x.p.area}|${lo}|${hi}`
1838
+ if (!bySpan.has(k)) bySpan.set(k, [])
1839
+ bySpan.get(k).push(x)
1840
+ }
1841
+ const seen = new Set()
1842
+ const hits = []
1843
+ const arr = [...bySpan.entries()]
1844
+ for (let i = 0; i < arr.length; i++) {
1845
+ for (let j = i + 1; j < arr.length; j++) {
1846
+ const [ka, ga] = arr[i], [kb, gb] = arr[j]
1847
+ if (ka.split('|')[0] !== kb.split('|')[0]) continue
1848
+ const a0 = Number(ka.split('|')[1]), a1 = Number(ka.split('|')[2])
1849
+ const b0 = Number(kb.split('|')[1]), b1 = Number(kb.split('|')[2])
1850
+ if (a0 > b1 || b0 > a1) continue // 位号区间无交叠
1851
+ const S = spanText(ga[0].p), T = spanText(gb[0].p)
1852
+ if (S === T) continue // 同一处(同址不同写法)不算
1853
+ const key = S < T ? S + '|' + T : T + '|' + S
1854
+ if (seen.has(key)) continue
1855
+ seen.add(key)
1856
+ hits.push({ A: ga[0], B: gb[0], S, T })
1857
+ }
1858
+ }
1859
+ const isUsed = x => x.at.some(s => s !== '(仅符号表)')
1860
+ const lvl = h => (h.A.named && h.B.named) ? 0 : (isUsed(h.A) && isUsed(h.B) ? 1 : 2)
1861
+ hits.sort((x, y) => lvl(x) - lvl(y) || x.A.p.base - y.A.p.base)
1862
+ const NAMES = ['A 级·真冲突(两个有名字的变量重叠)', 'B 级·疑似(都已引用、至少一方无名)', 'C 级·仅符号表登记(多半是符号表没清理)']
1863
+ L.push('')
1864
+ L.push('## 重叠冲突(同一批字节被两个不同身份的东西占用)')
1865
+ for (const lv of [0, 1, 2]) {
1866
+ const grp = hits.filter(h => lvl(h) === lv)
1867
+ L.push('')
1868
+ L.push(`### ${NAMES[lv]}(${grp.length} 条)`)
1869
+ L.push('')
1870
+ if (!grp.length) { L.push('- (无)'); continue }
1871
+ const show = grp.slice(0, 30)
1872
+ for (const h of show) {
1873
+ const nm = x => x.named ? `${x.raw}(${named.get(x.raw)})` : x.raw
1874
+ L.push(`- ${nm(h.A)} × ${nm(h.B)} 〔字节 ${h.S} 对 ${h.T}〕`)
1875
+ L.push(` - ${h.A.raw}: ${h.A.at.slice(0, 3).join(' ')}${h.A.at.length > 3 ? ` …(${h.A.at.length} 处)` : ''}`)
1876
+ L.push(` - ${h.B.raw}: ${h.B.at.slice(0, 3).join(' ')}${h.B.at.length > 3 ? ` …(${h.B.at.length} 处)` : ''}`)
1877
+ }
1878
+ if (grp.length > show.length) L.push(`- …另有 ${grp.length - show.length} 条同级的,未列`)
1879
+ }
1880
+ return L.join('\n')
1881
+ }
1882
+
1883
+ /** 变量使用体检:某变量的全部引用点 + 与它共用同一批字节的其它地址形式。
1884
+ * 用途:回答"某变量这样用有没有问题"——位/字节/字/双字共用同一批 V 区字节时互相改写。 */
1885
+ function varUsageReport(records, userSyms, target, elemRows, ptrRefs) {
1886
+ const t = parseOperandAddr(target)
1887
+ const L = []
1888
+ L.push('')
1889
+ L.push(`## 变量使用体检:${target}`)
1890
+ L.push('')
1891
+ if (!t) {
1892
+ L.push(`> 无法解析地址「${target}」。支持形式:VD20 / VW220 / VB5 / V20.0 / M10.0 / MB10 / I1.3 / Q0.1。`)
1893
+ return L.join('\n')
1894
+ }
1895
+ L.push(`- 类型:${t.kind}${t.kind === '位' ? `(第 ${t.bit} 位)` : `(${t.len} 字节)`};占用 ${t.area} 区字节 ${t.base}${t.len > 1 ? `~${t.base + t.len - 1}` : ''}`)
1896
+ const nm = (userSyms ?? []).find(s => s.addr === target)
1897
+ L.push(`- 符号名:${nm ? nm.name : '无(不在用户符号表——属内部/临时地址)'}`)
1898
+ L.push('')
1899
+ // 指针实参(&地址)。不扫它,"VB160 和 VW160 冲突没"这类问题会答成"没人用"。
1900
+ const pAll = Array.isArray(ptrRefs) ? ptrRefs : null
1901
+ const pOver = pAll ? pAll.filter(r => r.aName === t.area && !(r.addr > t.base + t.len - 1 || r.addr + r.bytes - 1 < t.base)) : []
1902
+ const hits = records.filter(r => (r.operands ?? []).includes(target))
1903
+ const wr = writeReport(records, userSyms, target)
1904
+ const boxes = sortedBoxes(records)
1905
+ const eRows = Array.isArray(elemRows) ? elemRows.filter(r => r.addr === target) : null
1906
+ const eW = eRows ? eRows.filter(r => r.kind === 2) : null
1907
+ const eR = eRows ? eRows.filter(r => r.kind === 1) : null
1908
+ L.push(`### ① 引用点(${hits.length} 条)${eRows ? ' · 读/写按元素级判定' : wr.length ? ' · 含写入点,见 ①b' : ''}`)
1909
+ L.push('')
1910
+ if (eRows) {
1911
+ const pts = eRows.slice().sort((a, b) => (b.kind - a.kind) || (a.off - b.off)).map(r => `0x${r.off.toString(16).padStart(6, '0')}${r.kind === 1 ? '(读)' : '(写)'}${r.sym ? '*' : ''}`)
1912
+ L.push(`- 元素级判定(权威):**写 ${eW.length} 处 / 读 ${eR.length} 处** → ${pts.join(' ') || '—'}`)
1913
+ if (!eW.length) {
1914
+ // 注意:元素级只覆盖触点/线圈/S-R,盒的 OUT(如 MUL_I 的 OUT)不在其中 ——
1915
+ // 直接写"程序没有任何一条指令写它"是假否定(验证:VW160 被 MUL_I 写,却报"没有人写它")。
1916
+ const bw = hits.filter(h => writeVerdict(h, target, boxes).write).map(h => `${h.off}${h.instr ? `(${h.instr})` : ''}`)
1917
+ if (bw.length) L.push(`- 触点/线圈层面 0 处写 —— 但**盒操作数层面有写点**:${bw.join('、')}(见 ① 表的盒指令);别按"没人写它"处理。`)
1918
+ else L.push(`- **程序没有任何一条指令写它**(只被读)——"会不会被自动复位"这类问题到此收口,不必再看触点条件。`)
1919
+ }
1920
+ L.push('')
1921
+ }
1922
+ if (!hits.length) {
1923
+ if (pOver.length) L.push('> 程序区 0 处**直接操作数**引用 —— 但这批字节被盒的指针实参点着(见 ①c),**不能当没用过**。')
1924
+ else L.push('> 程序区 0 次引用 —— 这个地址建了但程序没读没写(典型:变量表里留的坑位,或功能被改版删掉)。')
1925
+ } else {
1926
+ L.push('| 块 | 偏移 | 指令(盒内实名,可能为空) | 同盒其它操作数 |')
1927
+ L.push('|---|---|---|---|')
1928
+ for (const h of hits) {
1929
+ const others = (h.operands ?? []).filter(o => o !== target)
1930
+ let tag = ''
1931
+ if (eRows) {
1932
+ const hh = parseInt(String(h.off ?? '').replace(/^0x/i, ''), 16)
1933
+ const er = eRows.find(r => Math.abs(r.off - hh) <= 0x40)
1934
+ tag = er ? (er.kind === 2 ? '(**写**·元素级)' : '(读·元素级)') : ''
1935
+ } else {
1936
+ const v = writeVerdict(h, target, boxes)
1937
+ tag = v.mark === '读' ? '' : v.mark.startsWith('疑') ? '(疑似写?)' : '(**写**·盒启发式)'
1938
+ }
1939
+ L.push(`| ${h.pou}${tag} | \`${h.off}\` | ${h.instr || '—'} | ${others.join('; ') || '—'} |`)
1940
+ }
1941
+ L.push('')
1942
+ L.push('> 读表提醒:`SHL_DW`/库块等的"引脚续记录"(同一盒的 IN/OUT 段)有时会单列成一行、同盒其它操作数为空 —— 那是同一盒的第二段,不是新的引用点;偏移紧挨上一条即可判定。')
1943
+ if (!eRows) L.push('> 本表读/写用的是**盒启发式**(同盒重复/网点二次出现/指令名),只作参考;要定死读还是写,用 `smart-ladder-elements` 的元素级判定(给的是 .smart 文件时自带)。')
1944
+ }
1945
+ L.push('')
1946
+ if (eRows) {
1947
+ if (eW.length) {
1948
+ L.push(`### ①b 写入点(${eW.length} 处,元素级判定)`)
1949
+ L.push('')
1950
+ L.push('| 偏移 | 写法 | 含它的盒(块/盒内名称) |')
1951
+ L.push('|---|---|---|')
1952
+ for (const r of eW.slice().sort((a, b) => a.off - b.off)) {
1953
+ const h = hits.map(x => ({ x, hh: parseInt(String(x.off ?? '').replace(/^0x/i, ''), 16) }))
1954
+ .filter(o => Number.isFinite(o.hh) && Math.abs(o.hh - r.off) <= 0x40).sort((a, b) => Math.abs(a.hh - r.off) - Math.abs(b.hh - r.off))[0]
1955
+ const own = h ? `${h.x.pou} / ${(h.x.operands ?? []).filter(o => o !== target).join('; ').slice(0, 50) || (h.x.instr || '—')}` : '(同地址的盒没对上——按元素偏移回看该段)'
1956
+ L.push(`| \`0x${r.off.toString(16).padStart(6, '0')}\` | ${r.sym ? `S-R 线圈「${r.sym}」` : '线圈 / 指令盒 OUT'} | ${own} |`)
1957
+ }
1958
+ L.push('')
1959
+ L.push('> 复位链看条件:同一网络行里该地址第一次出现之前的操作数就是"成立条件"(触点/比较),元素偏移最大的那处通常是最终写入点。')
1960
+ } else {
1961
+ L.push('### ①b 写入点(0 处)')
1962
+ L.push('')
1963
+ L.push('> 元素级扫描:全程序没有任何一条指令写它(含用符号名写的 S/R 线圈)。**"会不会被复位"这类问题到此收口。**')
1964
+ L.push('')
1965
+ }
1966
+ } else if (wr.length) for (const l of wr) L.push(l.replace('### 写入点', '### ①b 写入点'))
1967
+ if (pOver.length) {
1968
+ L.push(`### ①c 指针引用(${pOver.length} 处)—— 盒的指针型实参点着这批字节`)
1969
+ L.push('')
1970
+ L.push('| 指针实参 | 所属盒 | 覆盖字节 |')
1971
+ L.push('|---|---|---|')
1972
+ for (const r of pOver) L.push(`| \`${r.name}\` | ${r.box ? `${r.box.name}@0x${r.box.off.toString(16)}` : '(盒名未对上)'} | ${r.addr}~${r.addr + r.bytes - 1}${r.count ? `(Count=${r.count})` : '(下限)'} |`)
1973
+ L.push('')
1974
+ L.push('> 指针=把这个地址交给盒用(如 MBUS_MSG 的 DataPtr=&VB160):盒会读写从它起的缓冲区,覆盖字节按盒参数推算,推不出时只算到指针类型的下限。**「直接操作数 0 处」不等于「没人用」**。')
1975
+ L.push('')
1976
+ } else if (pAll === null) {
1977
+ L.push('> ⚠ 本次没做指针扫描(已解码目录模式拿不到原始字节)—— `&地址` 这类引用要拿 .smart 原文件跑。')
1978
+ L.push('')
1979
+ }
1980
+ const overlap = new Map()
1981
+ for (const r of records) {
1982
+ for (const o of (r.operands ?? [])) {
1983
+ if (o === target) continue
1984
+ const p = parseOperandAddr(o)
1985
+ if (!p || p.area !== t.area) continue
1986
+ if (p.base > t.base + t.len - 1 || p.base + p.len - 1 < t.base) continue
1987
+ // 修正:目标本身是位时,同字节的"其它位"互不影响(Q1.0 与 Q1.7 不冲突),只列真正包含它的字节/字/双字
1988
+ if (t.kind === '位' && p.kind === '位') continue
1989
+ if (!overlap.has(o)) overlap.set(o, new Set())
1990
+ overlap.get(o).add(`${r.pou}@${r.off}`)
1991
+ }
1992
+ }
1993
+ L.push(`### ② 与 ${target} 共用同一批字节的其它地址形式(重叠体检)`)
1994
+ L.push('')
1995
+ if (!overlap.size) {
1996
+ if (pOver.length) L.push(`> 直接操作数无重叠,但这批字节被指针实参点着(见 ①c)—— 缓冲区覆盖范围更大,别当干净地址用。`)
1997
+ else L.push(`> 无。${target} 占的字节没有被别的形式(位/字节/字/双字)访问过 —— 单看地址是干净的。`)
1998
+ } else {
1999
+ L.push('| 地址形式 | 类型 | 占用字节 | 引用点 |')
2000
+ L.push('|---|---|---|---|')
2001
+ for (const [o, set] of overlap) {
2002
+ const p = parseOperandAddr(o)
2003
+ const pts = [...set]
2004
+ L.push(`| \`${o}\` | ${p.kind} | ${p.base}${p.len > 1 ? `~${p.base + p.len - 1}` : ''} | ${pts.slice(0, 6).join(' ')}${pts.length > 6 ? ` …共${pts.length}处` : ''} |`)
2005
+ }
2006
+ L.push('')
2007
+ L.push('> 判读:**写其中一个会改掉另一个的内容**(如 VD20 整体左移会把 V23.0 一起移走;反过来写 V23.0 会改掉 VD20 的第 24 位)。两者本来就是同一条移位链/同一个位组 → 正常;分属两处互不相干的功能 → 冲突。')
2008
+ }
2009
+ L.push('')
2010
+ return L.join('\n')
2011
+ }
2012
+
2013
+ /** 写点判据:
2014
+ * .smart 的盒记录里,"写一个位"有两种写法——① 同一盒内该地址出现两次(线圈/SET/RST 的旧值+新值);
2015
+ * ② 单操作数盒(触点段)之后紧邻的盒里该地址再出现(LD … / = 同一网络行)。
2016
+ * 两者都命中才敢下结论;指令名是承前继承(带 ? 后缀)时不可信,一律降级为"疑"。
2017
+ * 用途:回答"谁把这个地址写 0/什么时候复位"——光有引用点分布答不了这个问题。 */
2018
+ const WRITE_INST = new Set(['MOV_W', 'MOV_B', 'MOV_DW', 'MOV', 'SET', 'RST', 'FILL', 'SWAP',
2019
+ 'MUL_I', 'MUL_DI', 'DIV_I', 'DIV_DI', 'ADD_I', 'ADD_DI', 'SUB_I', 'SUB_DI', 'INC_W', 'INC_DW', 'DEC_W', 'DEC_DW',
2020
+ 'WAND_DW', 'WOR_DW', 'WXOR_DW', 'SHL_DW', 'SHR_DW', 'ROTL', 'ROTR', 'BCD_I', 'I_BCD', 'B_I', 'I_B',
2021
+ 'TON', 'TOF', 'TONR', 'CTU', 'CTD', 'CTUD', 'S_ITR', 'ENI', 'DISI'])
2022
+
2023
+ /** 盒记录按偏移升序(写点判据要按程序顺序找"下一盒")。 */
2024
+ function sortedBoxes(records) {
2025
+ return (records ?? []).slice().sort((a, b) =>
2026
+ parseInt(String(a.off ?? '').replace(/^0x/i, ''), 16) - parseInt(String(b.off ?? '').replace(/^0x/i, ''), 16))
2027
+ }
2028
+
2029
+ function writeVerdict(rec, target, boxes) {
2030
+ const ops = rec.operands ?? []
2031
+ const idxs = ops.map((o, i) => (o === target ? i : -1)).filter((i) => i >= 0)
2032
+ if (!idxs.length) return { mark: '—', why: '' }
2033
+ const instr = String(rec.instr ?? '')
2034
+ const inherited = instr.endsWith('?')
2035
+ const base = instr.replace(/\?$/, '')
2036
+ if (idxs.length >= 2) {
2037
+ const w = Math.max(...idxs)
2038
+ return { mark: `**写**(同盒第 ${w + 1} 位=新值)`, why: '同一盒内出现两次:线圈/SET/RST 的旧值+新值', write: true }
2039
+ }
2040
+ if (!inherited && WRITE_INST.has(base)) {
2041
+ return { mark: `写?(${base})`, why: '指令名可信且属写出类指令,但这个地址是否吃掉写入结果未辨引脚,按"疑"计', write: true }
2042
+ }
2043
+ const cur = parseInt(String(rec.off ?? '').replace(/^0x/i, ''), 16)
2044
+ if (Number.isFinite(cur) && Array.isArray(boxes)) {
2045
+ // 本盒是单操作数(纯触点段)时往下找:下一个含该地址的盒 —— 线圈会这样,但条件链也会,故只标疑似
2046
+ const nb = boxes.find((b) => parseInt(String(b.off ?? '').replace(/^0x/i, ''), 16) > cur && (b.operands ?? []).includes(target))
2047
+ if (nb) {
2048
+ return { mark: '疑似写?(网点二次出现)', why: `下一盒 ${nb.off} 又出现该地址 —— 但条件链/跳转也会这样,判不了是不是写入`, write: false }
2049
+ }
2050
+ }
2051
+ return { mark: '读', why: inherited ? '承前继承的指令名不可信,且无写出证据' : '盒内无写出证据', write: false }
2052
+ }
2053
+
2054
+ /** 写入点报告:某地址"谁在写、写它的那行为什么成立"。返回附加行数组(空=无写点)。
2055
+ * 只把"同盒两处出现"或"指令名可信的写出类指令"写成结论;邻接二次出现只标疑似——它同时符合"使能链成对跳转",证不死。 */
2056
+ function writeReport(records, userSyms, target) {
2057
+ const hits = (records ?? []).filter((r) => (r.operands ?? []).includes(target))
2058
+ if (!hits.length) return []
2059
+ const boxes = sortedBoxes(records)
2060
+ const C = []
2061
+ for (const h of hits) {
2062
+ const v = writeVerdict(h, target, boxes)
2063
+ if (!v.write) continue
2064
+ C.push(h)
2065
+ }
2066
+ if (!C.length) return []
2067
+ const out = []
2068
+ out.push(`### 写入点(${C.length} 处)`)
2069
+ out.push('')
2070
+ out.push('| 块 | 偏移 | 盒内名称 | 判定 | 依据 |')
2071
+ out.push('|---|---|---|---|---|')
2072
+ for (const h of C) {
2073
+ const v = writeVerdict(h, target, boxes)
2074
+ const others = (h.operands ?? []).filter((o) => o !== target)
2075
+ const nm = String(h.instr ?? '')
2076
+ const nmShow = others.length ? `${nm || '—'}|同盒: ${others.join('; ').slice(0, 60)}` : (nm || '—')
2077
+ out.push(`| ${h.pou} | \`${h.off}\` | ${nmShow} | ${v.mark.replace(/</g, '&lt;')} | ${v.why} |`)
2078
+ }
2079
+ out.push('')
2080
+ const order = []
2081
+ for (const h of C) if (!order.includes(h.pou)) order.push(h.pou)
2082
+ const susp = hits.filter((h) => !C.includes(h) && writeVerdict(h, target, boxes).mark.startsWith('疑'))
2083
+ out.push(`> 判读顺序:写在 **${order.join(' → ')}**。`)
2084
+ out.push('> 复位链看条件:同一网络行里该地址第一次出现之前的操作数是"成立条件"(触点/比较),最后一次出现是写入那一刻。')
2085
+ out.push('> 标「写?」的别当结论用——指令名可信但没辨出哪个引脚吃写入;要钉死用 `smart-pin-dump` 看该偏移的字节记录。')
2086
+ if (susp.length) out.push(`> 另有 ${susp.length} 处**疑似写**(${susp.map((h) => `${h.pou}@${h.off}`).slice(0, 8).join('、')}${susp.length > 8 ? ' …' : ''}):该地址在网点里出现两次,但成对跳转/使能链也长这样,证不死。`)
2087
+ out.push('')
2088
+ return out
2089
+ }
2090
+
2091
+ /** ── 梯形图元素级读/写判定(判据经字节级验证)
2092
+ * 元素描述串 `01 01 XX 00 00 00 YY 03 01`:XX=01 触点(读)、XX=02 写(线圈/带 OUT 的指令盒/S-R 线圈);
2093
+ * YY=01 后接地址操作数、YY=02 后接符号名记录 —— 带符号名写的元素(S/R 复位置位线圈最常见)存的是名字不是地址,
2094
+ * 只按地址扫会漏判成"程序从没写过"。本判定两者都解并回查符号表映射成地址。
2095
+ * 这是"某位到底有没有被写"的唯一权威判据;var-usage 的盒启发式只作参考。 */
2096
+ const ELEM_AREA = { 1: 'I', 2: 'Q', 0x10: 'V', 0x20: 'M', 0x40: 'T', 0x80: 'C', 0x200: 'SM', 0x2000: 'L' }
2097
+ const ELEM_TYPE = { 1: true, 2: true, 4: true, 8: true }
2098
+
2099
+ function elemAddrName(t, area, v) {
2100
+ const a = ELEM_AREA[area]
2101
+ if (t === 1) return area === 0x40 ? 'T' + v : `${a}${v >> 3}.${v & 7}`
2102
+ return `${a}${{ 2: 'B', 4: 'W', 8: 'D' }[t]}${v}`
2103
+ }
2104
+
2105
+ function elemSymMap(data) {
2106
+ const m = new Map()
2107
+ for (let i = 0; i < data.length - 24; i++) {
2108
+ if (data[i] !== 2 || data[i + 1] !== 0) continue
2109
+ const ln = data.readUInt16LE(i + 2)
2110
+ if (!(ln > 0 && ln < 40)) continue
2111
+ if (!(data[i + 4 + ln] === 0 && data[i + 5 + ln] === 1 && data[i + 6 + ln] === 2 && data[i + 7 + ln] === 0)) continue
2112
+ const t = data[i + 8 + ln]
2113
+ if (!ELEM_TYPE[t]) continue
2114
+ const area = data.readUInt32LE(i + 9 + ln), v = data.readUInt32LE(i + 13 + ln)
2115
+ if (ELEM_AREA[area] === undefined) continue
2116
+ const nm = gbk(data.subarray(i + 4, i + 4 + ln)).replace(/\0+$/, '')
2117
+ if (nm && !m.has(nm)) m.set(nm, elemAddrName(t, area, v))
2118
+ i += 17 + ln
2119
+ }
2120
+ return m
2121
+ }
2122
+
2123
+ function ladderElems(data, A, B, symMap) {
2124
+ const out = []
2125
+ for (let p = A; p < B - 30;) {
2126
+ if (data[p] === 1 && data[p + 1] === 1 && data[p + 3] === 0 && data[p + 4] === 0 && data[p + 5] === 0 &&
2127
+ data[p + 7] === 3 && data[p + 8] === 1) {
2128
+ const kind = data[p + 2]
2129
+ if (data[p + 6] === 1 && data[p + 18] === 2 && data[p + 19] === 0) {
2130
+ const t = data[p + 20], area = data.readUInt32LE(p + 21)
2131
+ if (ELEM_TYPE[t] && ELEM_AREA[area] !== undefined) {
2132
+ const nm = elemAddrName(t, area, data.readUInt32LE(p + 25))
2133
+ out.push({ off: p, kind, name: nm, addr: nm })
2134
+ p += 29
2135
+ continue
2136
+ }
2137
+ } else if (data[p + 6] === 2 && data[p + 18] === 1 && data[p + 19] === 0 && data[p + 20] === 1 && data[p + 21] === 0) {
2138
+ const l = data[p + 22]
2139
+ if (l > 0 && l < 60) {
2140
+ const nm = gbk(data.subarray(p + 23, p + 23 + l)).replace(/\0+$/, '')
2141
+ if (/^[\u4e00-\u9fffA-Za-z_0-9]+$/.test(nm)) {
2142
+ const addr = symMap.get(nm)
2143
+ out.push({ off: p, kind, name: addr ? `${nm} (${addr})` : nm, addr, sym: nm })
2144
+ p += 23 + l
2145
+ continue
2146
+ }
2147
+ }
2148
+ }
2149
+ }
2150
+ p++
2151
+ }
2152
+ return out
2153
+ }
2154
+
2155
+ function elemTag(r) {
2156
+ if (r.kind === 1) return '触点(读)'
2157
+ if (r.kind !== 2) return `kind=${r.kind}`
2158
+ return r.sym ? '写(S-R 复位/置位线圈,用符号名写的)' : '写(线圈/指令盒 OUT/S-R)'
2159
+ }
2160
+
2161
+ /** 元素级读/写判定报告。want 可以是逗号分隔的一批地址——批量就是本函数存在的理由:
2162
+ * "这 12 个按钮位到底有没有被写"以前要 12 次调用 + 手工汇总。 */
2163
+ function ladderElementsReport(data, want) {
2164
+ const wants = String(want).split(/[,,\s]+/).filter(Boolean).map(s => s.toUpperCase())
2165
+ const all = ladderElems(data, 0x900, data.length, elemSymMap(data))
2166
+ const L = []
2167
+ if (wants.length > 1) {
2168
+ L.push(`## 批量读/写判定(${wants.length} 个地址)`)
2169
+ L.push('')
2170
+ L.push('| 地址 | 写 | 读 | 落点(写在前) |')
2171
+ L.push('|---|---|---|---|')
2172
+ for (const w of wants) {
2173
+ const rows = all.filter(r => r.addr === w)
2174
+ const wc = rows.filter(r => r.kind === 2).length
2175
+ const rc = rows.filter(r => r.kind === 1).length
2176
+ const pts = rows.slice().sort((a, b) => (b.kind - a.kind) || (a.off - b.off)).map(r => `0x${r.off.toString(16).padStart(6, '0')}${r.sym ? '*' : ''}`)
2177
+ L.push(`| \`${w}\` | ${wc ? `**${wc}**` : '0'} | ${rc} | ${pts.slice(0, 8).join(' ') || '—'}${pts.length > 8 ? ` …共${pts.length}处` : ''} |`)
2178
+ }
2179
+ L.push('')
2180
+ const zero = wants.filter(w => !all.some(r => r.addr === w && r.kind === 2))
2181
+ if (zero.length) L.push(`> **程序从没写过**(只被读):${zero.join('、')}`)
2182
+ const hasW = wants.filter(w => all.some(r => r.addr === w && r.kind === 2))
2183
+ if (hasW.length) L.push(`> 有写入的:${hasW.join('、')}——逐处明细见下。`)
2184
+ L.push('')
2185
+ }
2186
+ for (const w of wants) {
2187
+ const rows = all.filter(r => r.addr === w)
2188
+ L.push(`## ${w} —— 程序区落点 ${rows.length} 处(不含符号表/记录外)`)
2189
+ for (const r of rows) L.push(` 0x${r.off.toString(16).padStart(6, '0')} ${elemTag(r)}${r.sym ? ' ' + r.name : ''}`)
2190
+ if (rows.length && !rows.some(r => r.kind === 2)) L.push(' → 全程序没有任何一条指令写它(只被读)')
2191
+ else if (rows.some(r => r.kind === 2 && r.sym)) L.push(' ⚠ 含"用符号名写"的元素(S/R 线圈等)——这类只按地址扫会漏判成"从没写过"')
2192
+ L.push('')
2193
+ }
2194
+ L.push('> 判据:元素 = 描述串 9B `01 01 XX 00 00 00 YY 03 01` + 实例 9B + 操作数(YY=01 地址/YY=02 符号名记录);XX=01 触点、XX=02 写。带 `*` 的落点是符号名写的元素回查出的地址。')
2195
+ L.push('> S/R 的个数(如 R M28.0, 2)与 P/N 边沿触点在网络元数据里,本工具不解;串并联拓扑同样不解,别按元素顺序推 AND/OR。')
2196
+ return L.join('\n')
2197
+ }
2198
+
2199
+ function ladderElementsRange(data, A, B) {
2200
+ const rows = ladderElems(data, A, B, elemSymMap(data))
2201
+ const L = rows.map(r => `0x${r.off.toString(16).padStart(6, '0')} ${r.kind === 1 ? '触点' : r.kind === 2 ? '写 ' : 'kind=' + r.kind} ${r.name}`)
2202
+ L.push(``, `元素 ${rows.length} 个(区间 0x${A.toString(16)}-0x${B.toString(16)})`)
2203
+ return L.join('\n')
2204
+ }
2205
+
2206
+ /** V 区字节重叠体检:扫全工程操作数,找出"同一批字节被两种以上地址形式访问"的组。
2207
+ * 典型隐患:一个 VD 被整体移位,其内部某个位(如 V23.0 = VD20 的 bit24)又被单独当标志读写。
2208
+ * 过滤:只扫 V 区、跳过 ≥4000 的 Modbus/库缓冲;只保留含"位"的组(纯 VB/VW/VD 之间的打包重叠属正常,不列)。 */
2209
+ function vOverlapReport(records) {
2210
+ const forms = new Map()
2211
+ for (const r of records ?? []) {
2212
+ for (const o of (r.operands ?? [])) {
2213
+ const p = parseOperandAddr(o)
2214
+ if (!p || p.area !== 'V' || p.base >= 4000) continue
2215
+ if (!forms.has(o)) forms.set(o, new Set())
2216
+ forms.get(o).add(`${r.pou}@${r.off}`)
2217
+ }
2218
+ }
2219
+ const seen = new Set()
2220
+ const groups = []
2221
+ for (const o of forms.keys()) {
2222
+ if (seen.has(o)) continue
2223
+ const grp = new Set([o])
2224
+ for (let grew = true; grew;) {
2225
+ grew = false
2226
+ for (const o2 of forms.keys()) {
2227
+ if (grp.has(o2)) continue
2228
+ const q = parseOperandAddr(o2)
2229
+ for (const m of grp) {
2230
+ const p2 = parseOperandAddr(m)
2231
+ if (!(q.base > p2.base + p2.len - 1 || q.base + q.len - 1 < p2.base)) { grp.add(o2); grew = true; break }
2232
+ }
2233
+ }
2234
+ }
2235
+ for (const g of grp) seen.add(g)
2236
+ if (grp.size < 2) continue
2237
+ const arr = [...grp].sort((a, b) => parseOperandAddr(b).len - parseOperandAddr(a).len)
2238
+ // 修正:只保留"位 + 更大单位"的真重叠;纯位组(同字节不同位,如 V50.0/V50.2)互不影响,不列
2239
+ if (!(arr.some(a => parseOperandAddr(a).kind === '位') && arr.some(a => parseOperandAddr(a).len > 1))) continue
2240
+ groups.push(arr)
2241
+ }
2242
+ const L = []
2243
+ L.push('## 3.7 字节重叠体检(V 区,自动扫描)')
2244
+ L.push('')
2245
+ if (!groups.length) {
2246
+ L.push('> 未发现"位与字/双字共用同一批字节"的情况。')
2247
+ L.push('')
2248
+ return L.join('\n')
2249
+ }
2250
+ L.push('> 判定:同一批 V 区字节被两种以上地址形式访问(典型:一个 VD 被整体移位,其内部某位又被单独当标志用)。**写其中一个会改掉另一个的内容**——属同一条移位链/位组即正常,分属两处独立功能就是冲突。逐变量细查用工具 `smart-var-usage`。纯 VB/VW/VD 之间的打包重叠(含 ≥4000 的 Modbus 缓冲)不列。')
2251
+ L.push('')
2252
+ L.push('| 重叠组 | 各形式的类型与引用点 |')
2253
+ L.push('|---|---|')
2254
+ const ordered = groups.sort((a, b) => parseOperandAddr(a[a.length - 1]).base - parseOperandAddr(b[b.length - 1]).base)
2255
+ for (const g of ordered) {
2256
+ const cells = g.map(o => {
2257
+ const p = parseOperandAddr(o)
2258
+ const pts = [...(forms.get(o) ?? [])]
2259
+ return `\`${o}\`(${p.kind}):${pts.slice(0, 3).join(' ')}${pts.length > 3 ? ` …共${pts.length}处` : ''}`
2260
+ })
2261
+ L.push(`| ${g.map(o => `\`${o}\``).join(' / ')} | ${cells.join('<br>')} |`)
2262
+ }
2263
+ L.push('')
2264
+ return L.join('\n')
2265
+ }
2266
+
2267
+ function buildLibReport(version, vars, comments) { const L = []
2268
+ L.push('# SMART 库文件解码报告')
2269
+ L.push('')
2270
+ L.push(`- 库版本串: ${version}`)
2271
+ L.push('')
2272
+ L.push('## 1. 库块变量/引脚名')
2273
+ L.push('')
2274
+ vars.forEach((v, k) => L.push(`${k + 1}. ${v}`))
2275
+ L.push('')
2276
+ L.push('## 2. 源码注释流(英文,片段)')
2277
+ L.push('')
2278
+ comments.forEach(c => L.push(`> ${c.replace(/\n/g, ' ')}`))
2279
+ L.push('')
2280
+ return L.join('\n')
2281
+ }
2282
+
2283
+ // ───────────────────────── 工程版本对比 ─────────────────────────
2284
+
2285
+ const md5 = (p) => createHash('md5').update(readFileSync(p)).digest('hex')
2286
+
2287
+ function listFiles(dir) {
2288
+ if (!existsSync(dir)) return []
2289
+ return readdirSync(dir, { withFileTypes: true })
2290
+ .filter((e) => e.isFile())
2291
+ .map((e) => e.name)
2292
+ .sort()
2293
+ }
2294
+
2295
+ function readLines(p) {
2296
+ return readFileSync(p, 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/)
2297
+ }
2298
+
2299
+ function modeOf(filePath) {
2300
+ const head = readFileSync(filePath, 'utf8').slice(0, 300)
2301
+ const m = head.match(/# 模式:\s*([\w-]+)/)
2302
+ return m ? m[1] : 'unknown'
2303
+ }
2304
+
2305
+ function isProjectFile(p) {
2306
+ return /\.project$/i.test(p) && existsSync(p)
2307
+ }
2308
+ function isDecodedDir(p) {
2309
+ return existsSync(p) && existsSync(join(p, 'pous'))
2310
+ }
2311
+
2312
+ /** 归一化输入:.project → 解码目录;已解码目录 → 原样。 */
2313
+ function resolveInput(p) {
2314
+ const abs = resolve(p)
2315
+ if (isProjectFile(abs)) return { kind: 'project', path: abs }
2316
+ if (isDecodedDir(abs)) return { kind: 'decoded', path: abs }
2317
+ if (existsSync(abs)) return { kind: 'invalid', reason: `既不是 .project 文件,也没有 pous\\ 子目录(不是已解码目录): ${p}` }
2318
+ return { kind: 'invalid', reason: `路径不存在: ${p}` }
2319
+ }
2320
+
2321
+ /** 解码 .project 到 `<工作区>\解码归档\<工程名>\导出_对比_<ts>`(diff 专用;原写工程旁)。 */
2322
+ async function decodeForDiff(ctx, proj, signal, base) {
2323
+ const dir = dirname(proj)
2324
+ const outd = join(defaultArchiveDir(proj, base), `导出_对比_${timestamp()}`)
2325
+ precreateOutDir(outd)
2326
+ const result = await runPy(ctx, 'read_project.py', [proj, outd], dir, signal)
2327
+ if (!result.ok) throw new Error(result.text)
2328
+ return outd
2329
+ }
2330
+
2331
+ /** 找两个文本的差异区段(逐行对齐,连续差异合并,±ctx 上下文)。 */
2332
+ function diffRegions(oldLines, newLines, ctx = 3) {
2333
+ const max = Math.max(oldLines.length, newLines.length)
2334
+ const bad = []
2335
+ for (let i = 0; i < max; i++) {
2336
+ const a = i < oldLines.length ? oldLines[i] : null
2337
+ const b = i < newLines.length ? newLines[i] : null
2338
+ if (a !== b) bad.push(i)
2339
+ }
2340
+ const regions = []
2341
+ let i = 0
2342
+ while (i < bad.length) {
2343
+ let j = i
2344
+ while (j + 1 < bad.length && bad[j + 1] === bad[j] + 1) j++
2345
+ regions.push({ first: bad[i], last: bad[j] })
2346
+ i = j + 1
2347
+ }
2348
+ const merged = []
2349
+ for (const r of regions) {
2350
+ const last = merged.at(-1)
2351
+ if (last && r.first - last.last <= 2 * ctx + 1) last.last = r.last
2352
+ else merged.push({ ...r })
2353
+ }
2354
+ return merged.map((r) => ({
2355
+ first: r.first,
2356
+ last: r.last,
2357
+ oldLines: oldLines.slice(Math.max(0, r.first - ctx), Math.min(oldLines.length, r.last + 1 + ctx)),
2358
+ newLines: newLines.slice(Math.max(0, r.first - ctx), Math.min(newLines.length, r.last + 1 + ctx)),
2359
+ }))
2360
+ }
2361
+
2362
+ /** 仅空白差异判定 */
2363
+ function whitespaceOnly(oldLines, newLines) {
2364
+ const stripAll = (arr) => arr.map((l) => l.replace(/\s+/g, '')).join('\n')
2365
+ if (stripAll(oldLines) === stripAll(newLines)) return true
2366
+ const nonEmpty = (arr) => arr.map((l) => l.trim()).filter((l) => l.length > 0).join('\n')
2367
+ return nonEmpty(oldLines) === nonEmpty(newLines)
2368
+ }
2369
+
2370
+ const CODE_LIKE = /(:=|IF\b|AND\b|OR\b|THEN\b|END_IF|FOR\b|CASE\b|\(CLK|PROGRAM|FUNCTION_BLOCK|VAR_|END_VAR|MC_|gb_|HSet_|DI_|DO_|HAlm_)/
2371
+
2372
+ const strContent = (d) => d.replace(/^\[\d+\]\s*/, '')
2373
+
2374
+ /** 对比两个已解码目录,返回 markdown 报告(纯函数)。 */
2375
+ export function compareDecodedDirs(oldDir, newDir, opts = {}) {
2376
+ const L = []
2377
+ const push = (s = '') => L.push(s)
2378
+ const { labelOld = oldDir, labelNew = newDir, maxStrSamples = 15 } = opts
2379
+
2380
+ const oldPous = join(oldDir, 'pous')
2381
+ const newPous = join(newDir, 'pous')
2382
+ const oldFiles = listFiles(oldPous)
2383
+ const newFiles = listFiles(newPous)
2384
+ const oldH = Object.fromEntries(oldFiles.map((f) => [f, md5(join(oldPous, f))]))
2385
+ const newH = Object.fromEntries(newFiles.map((f) => [f, md5(join(newPous, f))]))
2386
+
2387
+ const changed = [], added = [], removed = []
2388
+ for (const f of newFiles) {
2389
+ if (oldH[f] !== undefined) {
2390
+ if (oldH[f] !== newH[f]) changed.push(f)
2391
+ } else added.push(f)
2392
+ }
2393
+ for (const f of oldFiles) if (newH[f] === undefined) removed.push(f)
2394
+
2395
+ const realChanges = []
2396
+ const whitespaceOnlyFiles = []
2397
+ const noiseFiles = []
2398
+
2399
+ for (const f of changed) {
2400
+ const op = join(oldPous, f)
2401
+ const np = join(newPous, f)
2402
+ const mode = modeOf(np) || modeOf(op)
2403
+ const oldLines = readLines(op)
2404
+ const newLines = readLines(np)
2405
+ if (mode === 'record-container') {
2406
+ if (whitespaceOnly(oldLines, newLines)) {
2407
+ whitespaceOnlyFiles.push(f)
2408
+ } else {
2409
+ realChanges.push({ file: f, regions: diffRegions(oldLines, newLines) })
2410
+ }
2411
+ } else {
2412
+ const regions = diffRegions(oldLines, newLines)
2413
+ noiseFiles.push({ file: f, mode, nRegions: regions.length, first: regions[0]?.first ?? -1, last: regions.at(-1)?.last ?? -1 })
2414
+ }
2415
+ }
2416
+
2417
+ const strDiff = (() => {
2418
+ const op = join(oldDir, '全部字符串.txt')
2419
+ const np = join(newDir, '全部字符串.txt')
2420
+ if (!existsSync(op) || !existsSync(np)) return null
2421
+ const o = readLines(op)
2422
+ const n = readLines(np)
2423
+ const diffs = []
2424
+ const max = Math.max(o.length, n.length)
2425
+ for (let i = 0; i < max; i++) {
2426
+ const a = i < o.length ? o[i] : '<EOF>'
2427
+ const b = i < n.length ? n[i] : '<EOF>'
2428
+ if (a !== b) diffs.push({ i, old: a, new: b })
2429
+ }
2430
+ const oldResidue = diffs.filter((d) => CODE_LIKE.test(strContent(d.old)) && !CODE_LIKE.test(strContent(d.new)))
2431
+ const newText = diffs.filter((d) => CODE_LIKE.test(strContent(d.new)) && !CODE_LIKE.test(strContent(d.old)))
2432
+ const bothCode = diffs.filter((d) => CODE_LIKE.test(strContent(d.old)) && CODE_LIKE.test(strContent(d.new)))
2433
+ return {
2434
+ total: diffs.length,
2435
+ oldResidue,
2436
+ newText,
2437
+ bothCode,
2438
+ samples: diffs.slice(0, maxStrSamples),
2439
+ }
2440
+ })()
2441
+
2442
+ const auxDiff = (name) => {
2443
+ const op = join(oldDir, name)
2444
+ const np = join(newDir, name)
2445
+ if (!existsSync(op) || !existsSync(np)) return null
2446
+ const o = readLines(op)
2447
+ const n = readLines(np)
2448
+ const max = Math.max(o.length, n.length)
2449
+ let cnt = 0
2450
+ const samples = []
2451
+ for (let i = 0; i < max; i++) {
2452
+ const a = i < o.length ? o[i] : ''
2453
+ const b = i < n.length ? n[i] : ''
2454
+ if (a !== b) {
2455
+ cnt++
2456
+ if (samples.length < 8) samples.push({ i, old: a, new: b })
2457
+ }
2458
+ }
2459
+ return { cnt, samples }
2460
+ }
2461
+
2462
+ push('# 工程版本对比报告')
2463
+ push()
2464
+ push(`- 旧:${labelOld}`)
2465
+ push(`- 新:${labelNew}`)
2466
+ push(`- 生成:${new Date().toLocaleString('zh-CN')}`)
2467
+ push()
2468
+
2469
+ push('## 1. POU 清单')
2470
+ push()
2471
+ push('| 项 | 数量 |')
2472
+ push('|---|---|')
2473
+ push(`| 旧工程对象文件 | ${oldFiles.length} |`)
2474
+ push(`| 新工程对象文件 | ${newFiles.length} |`)
2475
+ push(`| 内容变化 | ${changed.length} |`)
2476
+ push(`| 新增 | ${added.length} |`)
2477
+ push(`| 删除 | ${removed.length} |`)
2478
+ push()
2479
+ if (added.length) push(`- 新增文件:${added.join('、')}`)
2480
+ if (removed.length) push(`- 删除文件:${removed.join('、')}`)
2481
+ push()
2482
+
2483
+ push('## 2. 真实逻辑变化(record-container 源码非空白差异)')
2484
+ push()
2485
+ if (realChanges.length === 0) {
2486
+ push('**未发现真实逻辑变化** —— record-container 源码(GVL/A0x/P0x/FB 等)新旧一致,或仅有空白差异。')
2487
+ } else {
2488
+ for (const rc of realChanges) {
2489
+ push(`### ${rc.file}(${rc.regions.length} 个差异区段)`)
2490
+ push()
2491
+ for (const [k, rg] of rc.regions.entries()) {
2492
+ push(`<details><summary>区段 ${k + 1}(旧行 ${rg.first + 1}–${rg.last + 1})</summary>`)
2493
+ push()
2494
+ push('```diff')
2495
+ const ctx = Math.max(rg.oldLines.length, rg.newLines.length)
2496
+ for (let i = 0; i < ctx; i++) {
2497
+ const a = rg.oldLines[i] ?? ''
2498
+ const b = rg.newLines[i] ?? ''
2499
+ if (a === b) push(` ${a}`)
2500
+ else {
2501
+ if (a !== '') push(`-${a}`)
2502
+ if (b !== '') push(`+${b}`)
2503
+ }
2504
+ }
2505
+ push('```')
2506
+ push()
2507
+ push('</details>')
2508
+ }
2509
+ }
2510
+ }
2511
+ push()
2512
+
2513
+ push('## 3. 仅空白差异(无语义变化)')
2514
+ push()
2515
+ push(whitespaceOnlyFiles.length ? `- ${whitespaceOnlyFiles.join('、')}` : '- 无')
2516
+ push()
2517
+
2518
+ push('## 4. 噪音差异(fallback/task 模式,多为字符串表重建或解码索引漂移,建议人工复核)')
2519
+ push()
2520
+ if (noiseFiles.length === 0) {
2521
+ push('- 无')
2522
+ } else {
2523
+ for (const nf of noiseFiles) {
2524
+ push(`- ${nf.file}(${nf.mode},${nf.nRegions} 区段,行 ${nf.first + 1}–${nf.last + 1})`)
2525
+ }
2526
+ }
2527
+ push()
2528
+
2529
+ push('## 5. 字符串表(全部字符串.txt,同索引对比)')
2530
+ push()
2531
+ if (strDiff === null) {
2532
+ push('- 两侧缺少 全部字符串.txt,跳过')
2533
+ } else {
2534
+ push(`- 差异条目:${strDiff.total} 条`)
2535
+ push(`- 分类:旧工程残留串(旧侧代码样)${strDiff.oldResidue.length} 条;新增文本串(新侧代码样)${strDiff.newText.length} 条;双向代码串修改 ${strDiff.bothCode.length} 条`)
2536
+ push(`- 结论指引:新增文本串与双向修改 → 说明有源码文本改动,需与第 2 节对照确认;仅旧残留串 → 多为历史编辑残留被清理,纯保存噪音。`)
2537
+ if (strDiff.newText.length) {
2538
+ push()
2539
+ push(`新增文本串(前 ${Math.min(8, strDiff.newText.length)} 条):`)
2540
+ push()
2541
+ push('```')
2542
+ for (const d of strDiff.newText.slice(0, 8)) push(`[${d.i}] ${strContent(d.new)}`)
2543
+ push('```')
2544
+ }
2545
+ if (strDiff.samples.length) {
2546
+ push()
2547
+ push('差异样例(前若干条):')
2548
+ push()
2549
+ push('```diff')
2550
+ for (const d of strDiff.samples) {
2551
+ push(`[${d.i}]`)
2552
+ if (d.old !== '<EOF>') push(`- ${strContent(d.old)}`)
2553
+ if (d.new !== '<EOF>') push(`+ ${strContent(d.new)}`)
2554
+ }
2555
+ push('```')
2556
+ }
2557
+ }
2558
+ push()
2559
+
2560
+ push('## 6. 对象表 / 项目树')
2561
+ push()
2562
+ for (const fn of ['对象表.md', '项目树.md']) {
2563
+ const d = auxDiff(fn)
2564
+ push(`- ${fn}:${d === null ? '(缺失,跳过)' : d.cnt === 0 ? '无差异' : `${d.cnt} 行差异`}`)
2565
+ if (d && d.cnt > 0) {
2566
+ for (const s of d.samples) {
2567
+ push(` - 行 ${s.i + 1} OLD: ${s.old || '(空)'}`)
2568
+ push(` NEW: ${s.new || '(空)'}`)
2569
+ }
2570
+ }
2571
+ }
2572
+ push()
2573
+
2574
+ const conclusion = []
2575
+ if (realChanges.length === 0 && added.length === 0 && removed.length === 0) {
2576
+ conclusion.push('**结论:程序逻辑无变化。** 差异仅来自字符串表重建 / 空白 / 解码噪音,时序图与工艺文档可保持有效。')
2577
+ } else {
2578
+ conclusion.push(`**结论:发现 ${realChanges.length} 个源码对象有真实逻辑变化${added.length ? `、${added.length} 个新增对象` : ''}${removed.length ? `、${removed.length} 个删除对象` : ''},请按第 2 节逐项核对。`)
2579
+ }
2580
+ if (noiseFiles.length) conclusion.push(`提示:${noiseFiles.length} 个 fallback/task 文件有差异,多为解码噪音,如涉及 Modbus/设备配置建议人工复核。`)
2581
+ push(conclusion.join('\n'))
2582
+ push()
2583
+
2584
+ return L.join('\n')
2585
+ }
2586
+
2587
+ /** diff 工具核心动作:归一化两个输入并产报告。 */
2588
+ async function runDiff(ctx, oldP, newP, signal, base) {
2589
+ const ro = resolveInput(oldP)
2590
+ const rn = resolveInput(newP)
2591
+ if (ro.kind === 'invalid') throw new Error(`old 无效:${ro.reason}`)
2592
+ if (rn.kind === 'invalid') throw new Error(`new 无效:${rn.reason}`)
2593
+ const decodedOld = ro.kind === 'project' ? await decodeForDiff(ctx, ro.path, signal, base) : ro.path
2594
+ const decodedNew = rn.kind === 'project' ? await decodeForDiff(ctx, rn.path, signal, base) : rn.path
2595
+ const notes = []
2596
+ if (ro.kind === 'project') notes.push(`旧工程已解码到:${decodedOld}`)
2597
+ if (rn.kind === 'project') notes.push(`新工程已解码到:${decodedNew}`)
2598
+ const report = compareDecodedDirs(decodedOld, decodedNew, {
2599
+ labelOld: ro.kind === 'project' ? `${ro.path}(本次解码)` : ro.path,
2600
+ labelNew: rn.kind === 'project' ? `${rn.path}(本次解码)` : rn.path,
2601
+ })
2602
+ return { notes, report }
2603
+ }
2604
+
2605
+ // ───────────────────────── 应用注册 ─────────────────────────
2606
+
2607
+ export function apply(ctx, config = {}) {
2608
+ // 2026-11-05:读插件配置的归档根目录(config.archiveDir)。不配置时保持旧行为不变。
2609
+ const cfg = config && typeof config === 'object' ? config : {}
2610
+ if (typeof cfg.archiveDir === 'string' && cfg.archiveDir.trim()) ARCHIVE_ROOT = cfg.archiveDir.trim()
2611
+
2612
+ // ── 工具一:统一入口(四格式自动识别分发)──
2613
+ ctx.tools.register({
2614
+ name: 'plc-decode',
2615
+ description: 'PLC 工程文件统一解码入口:自动识别四格式并分发解码——.project(CoDeSys/LeadSysStudio,含雷赛 ZIP)、.dvp(台达 WPLSoft/ISPSoft)、.smart(西门子 S7-200 SMART 工程)、.smartlib(西门子 SMART 库)。提供任一 PLC 工程文件、要求读取/理解/核对其中程序时调用。触摸屏工程(.hmismart/.fsprj)不在本插件范围,用 HMI 解码工具。只读,不改动工程文件。',
2616
+ parameters: {
2617
+ type: 'object',
2618
+ properties: {
2619
+ file: { type: 'string', description: 'PLC 工程文件路径(.project/.dvp/.smart/.smartlib,绝对路径或相对当前工作目录)。' },
2620
+ outDir: { type: 'string', description: '可选:输出目录;缺省写到「<归档根>\\<工程所在文件夹名>\\」(归档根默认「解码归档」,可用插件配置 archiveDir 改,见 /plc-archive;四格式一致,不写工程目录)。' },
2621
+ },
2622
+ required: ['file'],
2623
+ additionalProperties: false,
2624
+ },
2625
+ output: {
2626
+ schema: {
2627
+ type: 'object',
2628
+ properties: {
2629
+ ok: { type: 'boolean' },
2630
+ text: { type: 'string' },
2631
+ },
2632
+ required: ['ok', 'text'],
2633
+ additionalProperties: false,
2634
+ },
2635
+ render: (_args, value) => [{ type: 'text', text: value.text }],
2636
+ },
2637
+ async execute(args, exec) {
2638
+ const file = typeof args.file === 'string' ? args.file.trim() : ''
2639
+ if (!file) throw new Error('参数 file 不能为空')
2640
+ const abs = resolve(file)
2641
+ if (!existsSync(abs)) throw new Error(`文件不存在: ${file}`)
2642
+ const outDir = typeof args.outDir === 'string' && args.outDir.trim() ? resolve(args.outDir.trim()) : undefined
2643
+ const { kind } = identify(abs)
2644
+ if (kind === 'project' || kind === 'dvp') {
2645
+ const result = await decodeCodesysProject(ctx, abs, exec.signal, exec, outDir, sessionBase(exec?.agent))
2646
+ const tail = String(result.kind).includes('加密') ? '00-目录.md' : '00-索引.md'
2647
+ return { ok: true, text: `已解码完成(类型: ${result.kind})\n输出目录: ${result.outd}\n${result.text}\n下一步: 先读 ${join(result.outd, tail)}` }
2648
+ }
2649
+ if (kind === 'hmismart') {
2650
+ throw new Error(
2651
+ '这是触摸屏(HMI)工程,不在本插件的范围:\n'
2652
+ + '· .hmismart(西门子 WinCC flexible SMART)请用 HMI 解码工具\n'
2653
+ + '· .fsprj 或含 HMI\\Frame 的 FStudio 工程目录请用触摸屏工程工具\n'
2654
+ + '本插件(plc-decode)只处理 PLC 工程:.project / .dvp / .smart / .smartlib。'
2655
+ )
2656
+ }
2657
+ const result = kind === 'smart' ? decodeSmartFile(abs, outDir, sessionBase(exec?.agent)) : decodeSmartLib(abs, outDir, sessionBase(exec?.agent))
2658
+ return { ok: true, text: `已解码完成(类型: ${kind}):\n${result.proceed}` }
2659
+ },
2660
+ })
2661
+
2662
+ // ── 工具二:SMART 子程序内部结构解析(讲解「XX 子程序怎么写」用)──
2663
+ // ── 工具四·B:SMART 子程序内部结构解析(讲解"XX 子程序怎么写"用)──
2664
+ ctx.tools.register({
2665
+ name: 'smart-pou-structure',
2666
+ description: '解析西门子 S7-200 SMART 工程(.smart)中某个子程序的内部结构:接口参数(L 区地址+类型)、盒级指令链(指令名/引脚/操作数/符号名,按偏移顺序)、调用点及实参。用户问"XX 子程序是怎么写的/内部怎么实现/帮我讲解子程序"时调用。只读,不改动工程文件。',
2667
+ parameters: {
2668
+ type: 'object',
2669
+ properties: {
2670
+ file: { type: 'string', description: '.smart 文件路径(绝对路径或相对工作目录)。' },
2671
+ pou: { type: 'string', description: '可选:块名;缺省输出全部块的结构(较长,建议指定块名)。' },
2672
+ outDir: { type: 'string', description: '可选:报告另存目录;缺省只在返回文本里给报告,不落盘。' },
2673
+ },
2674
+ required: ['file'],
2675
+ additionalProperties: false,
2676
+ },
2677
+ output: {
2678
+ schema: {
2679
+ type: 'object',
2680
+ properties: {
2681
+ ok: { type: 'boolean' },
2682
+ text: { type: 'string' },
2683
+ },
2684
+ required: ['ok', 'text'],
2685
+ additionalProperties: false,
2686
+ },
2687
+ render: (_args, value) => [{ type: 'text', text: value.text }],
2688
+ },
2689
+ async execute(args) {
2690
+ const file = typeof args.file === 'string' ? args.file.trim() : ''
2691
+ if (!file) throw new Error('参数 file 不能为空')
2692
+ const abs = resolve(file)
2693
+ if (!existsSync(abs)) throw new Error(`文件不存在: ${file}`)
2694
+ const pouFilter = typeof args.pou === 'string' && args.pou.trim() ? args.pou.trim() : undefined
2695
+ const { report } = decodeSmartPouStructure(abs, pouFilter)
2696
+ const outDir = typeof args.outDir === 'string' && args.outDir.trim() ? resolve(args.outDir.trim()) : undefined
2697
+ if (outDir) {
2698
+ mkdirSync(outDir, { recursive: true })
2699
+ const name = pouFilter ? `${pouFilter}_内部结构.md` : '全部子程序_内部结构.md'
2700
+ writeFileSync(join(outDir, name), report, 'utf8')
2701
+ return { ok: true, text: `${report}\n\n---\n报告已存: ${join(outDir, name)}` }
2702
+ }
2703
+ return { ok: true, text: report }
2704
+ },
2705
+ })
2706
+
2707
+ // ── 工具五:新旧工程对比(.project / 已解码目录)──
2708
+ ctx.tools.register({
2709
+ name: 'codesys-diff-project',
2710
+ description: '对比新旧两个 CoDeSys/LeadSysStudio 工程(各自可为 .project 或已解码目录),输出逻辑变化报告:POU 清单/真实逻辑变化差异区段/空白差异/字符串表/对象表对比,自动区分真实逻辑变化与保存噪音(字符串表重建、空白、解码索引漂移)。',
2711
+ parameters: {
2712
+ type: 'object',
2713
+ properties: {
2714
+ old: {
2715
+ type: 'string',
2716
+ description: '旧工程:.project 文件路径,或已解码目录路径(含 pous\\ 与 全部字符串.txt)。',
2717
+ },
2718
+ new: {
2719
+ type: 'string',
2720
+ description: '新工程:.project 文件路径,或已解码目录路径。',
2721
+ },
2722
+ },
2723
+ required: ['old', 'new'],
2724
+ additionalProperties: false,
2725
+ },
2726
+ output: {
2727
+ schema: {
2728
+ type: 'object',
2729
+ properties: {
2730
+ ok: { type: 'boolean' },
2731
+ text: { type: 'string' },
2732
+ },
2733
+ required: ['ok', 'text'],
2734
+ additionalProperties: false,
2735
+ },
2736
+ render: (_args, value) => [{ type: 'text', text: value.text }],
2737
+ },
2738
+ async execute(args, exec) {
2739
+ const oldP = String(args.old ?? '').trim()
2740
+ const newP = String(args.new ?? '').trim()
2741
+ if (!oldP || !newP) throw new Error('参数 old / new 不能为空')
2742
+ const { notes, report } = await runDiff(ctx, oldP, newP, exec.signal, sessionBase(exec?.agent))
2743
+ return { ok: true, text: notes.length ? `${notes.join('\n')}\n\n${report}` : report }
2744
+ },
2745
+ })
2746
+
2747
+ // ── 工具六:SMART 新旧工程对比 ──
2748
+ ctx.tools.register({
2749
+ name: 'smart-diff-project',
2750
+ description: '对比新旧两个西门子 S7-200 SMART 工程(.smart),输出逻辑变化报告:块清单差异/主程序调用链对比(调用盒内嵌块名)/I-O 引用对比(按块)/符号表差异。用于"旧机改新机、对比移植、校验改错"类任务。只读,不改动工程文件。',
2751
+ parameters: {
2752
+ type: 'object',
2753
+ properties: {
2754
+ old: { type: 'string', description: '旧工程 .smart 文件路径(绝对路径或相对工作目录)。' },
2755
+ new: { type: 'string', description: '新工程 .smart 文件路径。' },
2756
+ outDir: { type: 'string', description: '可选:输出目录;缺省写到「<归档根>\\<新工程所在文件夹名>\\」(归档根默认「解码归档」,可用插件配置 archiveDir 改)。' },
2757
+ },
2758
+ required: ['old', 'new'],
2759
+ additionalProperties: false,
2760
+ },
2761
+ output: {
2762
+ schema: {
2763
+ type: 'object',
2764
+ properties: {
2765
+ ok: { type: 'boolean' },
2766
+ text: { type: 'string' },
2767
+ },
2768
+ required: ['ok', 'text'],
2769
+ additionalProperties: false,
2770
+ },
2771
+ render: (_args, value) => [{ type: 'text', text: value.text }],
2772
+ },
2773
+ async execute(args, exec) {
2774
+ const oldP = String(args.old ?? '').trim()
2775
+ const newP = String(args.new ?? '').trim()
2776
+ if (!oldP || !newP) throw new Error('参数 old / new 不能为空')
2777
+ for (const q of [oldP, newP]) {
2778
+ const ab = resolve(q)
2779
+ if (!existsSync(ab)) throw new Error(`文件不存在: ${q}`)
2780
+ }
2781
+ const outDir = typeof args.outDir === 'string' && args.outDir.trim() ? resolve(args.outDir.trim()) : undefined
2782
+ const { proceed, report } = smartDiff(resolve(oldP), resolve(newP), outDir, sessionBase(exec?.agent))
2783
+ return { ok: true, text: `${proceed}\n\n${report}` }
2784
+ },
2785
+ })
2786
+
2787
+ // ── 工具七:SMART 地址空闲分析(加功能前的地址规划)──
2788
+ ctx.tools.register({
2789
+ name: 'smart-free-space',
2790
+ description: '西门子 S7-200 SMART 工程地址占用与空闲段分析:输出 ①有符号名清单(含"备用"类预留坑,一律勿动)②程序已引用清单 ③真正空闲段(无名字+无引用+连续)。用于"在已有工程上加功能、按工程习惯规划新地址"类任务。只读,不改动工程文件。',
2791
+ parameters: {
2792
+ type: 'object',
2793
+ properties: {
2794
+ file: { type: 'string', description: '.smart 工程文件路径(绝对或相对工作目录);或已解码目录路径(含 解码报告.md 与 记录.json)。' },
2795
+ },
2796
+ required: ['file'],
2797
+ additionalProperties: false,
2798
+ },
2799
+ output: {
2800
+ schema: {
2801
+ type: 'object',
2802
+ properties: {
2803
+ ok: { type: 'boolean' },
2804
+ text: { type: 'string' },
2805
+ },
2806
+ required: ['ok', 'text'],
2807
+ additionalProperties: false,
2808
+ },
2809
+ render: (_args, value) => [{ type: 'text', text: value.text }],
2810
+ },
2811
+ async execute(args, exec) {
2812
+ const p = String(args.file ?? '').trim()
2813
+ if (!p) throw new Error('参数 file 不能为空')
2814
+ const abs = resolve(p)
2815
+ if (!existsSync(abs)) throw new Error(`文件不存在: ${p}`)
2816
+ const isDir = existsSync(join(abs, '记录.json')) && existsSync(join(abs, '解码报告.md'))
2817
+ let records, userSyms, ptrRefs = null
2818
+ if (isDir) {
2819
+ const j = JSON.parse(readFileSync(join(abs, '记录.json'), 'utf8'))
2820
+ records = j.records ?? []
2821
+ userSyms = [...(j.userSyms ?? [])]
2822
+ } else {
2823
+ const o = parseSmartOnce(abs)
2824
+ records = o.records
2825
+ userSyms = o.userSyms
2826
+ try { const { data } = decodeContainer(readFileSync(abs)); ptrRefs = scanPtrRefs(data) } catch { ptrRefs = null }
2827
+ }
2828
+ return { ok: true, text: `地址占用与空闲段分析(${isDir ? '已解码目录' : basename(abs)}):\n${freeSpaceReport(userSyms, records, ptrRefs)}` }
2829
+ },
2830
+ })
2831
+
2832
+ // ── 工具:地址冲突体检(重叠 + 连续空间不足)──
2833
+ ctx.tools.register({
2834
+ name: 'smart-conflict',
2835
+ description: '西门子 S7-200 SMART 地址冲突体检(两个口径):①**重叠冲突**——两个不同身份的变量压在同一批字节(A级=双方都有符号名,真冲突;B级=双方都已引用、至少一方无名,疑似;C级=仅符号表登记);②**连续空间不足(碎片)**——某个连续空闲区长度不是 4 的整数倍时,尾部那几字节放不下双字、永久浪费(旧检测只有重叠一维,会漏掉这种)。还可给 needDoubles 参数直接推荐候选地址。用于"某地址能不能用/还有哪些地址有冲突/加变量该放哪"类问题。只读,不改动工程文件。',
2836
+ parameters: {
2837
+ type: 'object',
2838
+ properties: {
2839
+ file: { type: 'string', description: '.smart 工程文件路径(绝对或相对工作目录);或已解码目录(含 记录.json 与 解码报告.md)。' },
2840
+ needDoubles: { type: 'number', description: '可选:要放几个双字(VD)。给了就额外输出候选地址推荐,优先选所在空闲段最小的(碎渣最少)。' },
2841
+ },
2842
+ required: ['file'],
2843
+ additionalProperties: false,
2844
+ },
2845
+ output: {
2846
+ schema: {
2847
+ type: 'object',
2848
+ properties: {
2849
+ ok: { type: 'boolean' },
2850
+ text: { type: 'string' },
2851
+ },
2852
+ required: ['ok', 'text'],
2853
+ additionalProperties: false,
2854
+ },
2855
+ render: (_args, value) => [{ type: 'text', text: value.text }],
2856
+ },
2857
+ async execute(args, exec) {
2858
+ const p = String(args.file ?? '').trim()
2859
+ if (!p) throw new Error('参数 file 不能为空')
2860
+ const abs = resolve(p)
2861
+ if (!existsSync(abs)) throw new Error(`文件不存在: ${p}`)
2862
+ const isDir = existsSync(join(abs, '记录.json')) && existsSync(join(abs, '解码报告.md'))
2863
+ let records, userSyms, ptrRefs = null
2864
+ if (isDir) {
2865
+ const j = JSON.parse(readFileSync(join(abs, '记录.json'), 'utf8'))
2866
+ records = j.records ?? []
2867
+ userSyms = [...(j.userSyms ?? [])]
2868
+ } else {
2869
+ const o = parseSmartOnce(abs)
2870
+ records = o.records
2871
+ userSyms = o.userSyms
2872
+ try { const { data } = decodeContainer(readFileSync(abs)); ptrRefs = scanPtrRefs(data) } catch { ptrRefs = null }
2873
+ }
2874
+ const need = Number(args.needDoubles ?? 0) || 0
2875
+ const body = conflictReport(userSyms, records, ptrRefs, need, 4096)
2876
+ return { ok: true, text: `地址冲突体检(${isDir ? '已解码目录' : basename(abs)}):\n${body}` }
2877
+ },
2878
+ })
2879
+
2880
+ // ── 工具八:变量使用体检(查某变量用在哪、和谁共用字节)──
2881
+ ctx.tools.register({
2882
+ name: 'smart-var-usage',
2883
+ description: '西门子 S7-200 SMART 变量使用体检:给一个地址(VD20/VW220/VB5/V20.0/M10.0),输出 ①它的全部引用点(块+偏移+同盒其它操作数+盒内实名指令)②与它共用同一批字节的其它地址形式(位/字节/字/双字重叠体检)③符号名 ④**写入点判定**(谁在写这个地址:线圈/SET/RST 由"同盒出现两次"或"网点二次出现"定死,指令名可信的写出类指令标"写?",并给出块内判读顺序与复位链看条件的方法)。用于回答"某变量这样用有没有问题/都在哪用到/**谁在写它、什么时候复位**"。只读,不改动工程文件。',
2884
+ parameters: {
2885
+ type: 'object',
2886
+ properties: {
2887
+ file: { type: 'string', description: '.smart 工程文件路径,或已解码目录(含 记录.json 与 解码报告.md)。' },
2888
+ target: { type: 'string', description: '要体检的地址,如 VD20、VW220、V20.0、M10.0。' },
2889
+ },
2890
+ required: ['file', 'target'],
2891
+ additionalProperties: false,
2892
+ },
2893
+ output: {
2894
+ schema: {
2895
+ type: 'object',
2896
+ properties: {
2897
+ ok: { type: 'boolean' },
2898
+ text: { type: 'string' },
2899
+ },
2900
+ required: ['ok', 'text'],
2901
+ additionalProperties: false,
2902
+ },
2903
+ render: (_args, value) => [{ type: 'text', text: value.text }],
2904
+ },
2905
+ async execute(args) {
2906
+ const p = String(args.file ?? '').trim()
2907
+ const target = String(args.target ?? '').trim().toUpperCase()
2908
+ if (!p) throw new Error('参数 file 不能为空')
2909
+ if (!target) throw new Error('参数 target 不能为空(如 VD20)')
2910
+ const abs = resolve(p)
2911
+ if (!existsSync(abs)) throw new Error(`文件不存在: ${p}`)
2912
+ const isDir = existsSync(join(abs, '记录.json')) && existsSync(join(abs, '解码报告.md'))
2913
+ let records, userSyms, elemRows, ptrRefs = null
2914
+ if (isDir) {
2915
+ const j = JSON.parse(readFileSync(join(abs, '记录.json'), 'utf8'))
2916
+ records = j.records ?? []
2917
+ userSyms = [...(j.userSyms ?? [])]
2918
+ } else {
2919
+ const o = parseSmartOnce(abs)
2920
+ records = o.records
2921
+ userSyms = o.userSyms
2922
+ try {
2923
+ const { data } = decodeContainer(readFileSync(abs))
2924
+ elemRows = ladderElems(data, 0x900, data.length, elemSymMap(data))
2925
+ ptrRefs = scanPtrRefs(data) // 指针实参(&地址)单独一套编码,不扫它会把被指针点着的地址报成"0 引用"
2926
+ } catch { elemRows = null }
2927
+ }
2928
+ return { ok: true, text: `变量使用体检(${isDir ? '已解码目录' : basename(abs)}):\n${varUsageReport(records, userSyms, target, elemRows, ptrRefs)}` }
2929
+ },
2930
+ })
2931
+
2932
+ // ── 工具九:字节级引脚/操作数定位──
2933
+ ctx.tools.register({
2934
+ name: 'smart-pin-dump',
2935
+ description: '西门子 S7-200 SMART 字节级定位:① 给地址(VW220/VD390/M30.0)→ 列出它在工程文件里的全部字节出现位置与每处所在记录的操作数序列;② 给偏移区间(十六进制)→ 按记录列出该区间的操作数流与常数原文。用于回答"算出来的值有没有下游读者""两个盒为什么写同一个地址"这类引脚级问题。**判"某位到底有没有被写"别用本工具**——它按操作数间隔切盒,线圈会被报成"单操作数盒(触点)",且用符号名写的元素(S/R 线圈等)看不到;判读/写请用 smart-ladder-elements。只读,不改动工程文件。',
2936
+ parameters: {
2937
+ type: 'object',
2938
+ properties: {
2939
+ file: { type: 'string', description: '.smart 工程文件路径。' },
2940
+ addr: { type: 'string', description: '要定位的地址,如 VW220、VD390、M30.0(与 from/to 二选一)。' },
2941
+ from: { type: 'string', description: '区间起点,十六进制偏移,如 C3F0。' },
2942
+ to: { type: 'string', description: '区间终点,十六进制偏移,如 C560。' },
2943
+ },
2944
+ required: ['file'],
2945
+ additionalProperties: false,
2946
+ },
2947
+ output: {
2948
+ schema: {
2949
+ type: 'object',
2950
+ properties: {
2951
+ ok: { type: 'boolean' },
2952
+ text: { type: 'string' },
2953
+ },
2954
+ required: ['ok', 'text'],
2955
+ additionalProperties: false,
2956
+ },
2957
+ render: (_args, value) => [{ type: 'text', text: value.text }],
2958
+ },
2959
+ async execute(args) {
2960
+ const p = String(args.file ?? '').trim()
2961
+ if (!p) throw new Error('参数 file 不能为空')
2962
+ const abs = resolve(p)
2963
+ if (!existsSync(abs)) throw new Error(`文件不存在: ${p}`)
2964
+ const { data } = decodeContainer(readFileSync(abs))
2965
+ const addr = String(args.addr ?? '').trim()
2966
+ if (addr) {
2967
+ const recs = pinRecords(data, 0, data.length)
2968
+ return { ok: true, text: `字节级定位(${basename(abs)}):\n${pinDumpAddr(data, recs, addr)}` }
2969
+ }
2970
+ const from = parseInt(String(args.from ?? '').replace(/^0x/i, ''), 16)
2971
+ const to = parseInt(String(args.to ?? '').replace(/^0x/i, ''), 16)
2972
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) throw new Error('需要 addr,或给出有效的 from/to(十六进制)')
2973
+ return { ok: true, text: `字节级区间导出(${basename(abs)}):\n${pinDumpRange(data, from, to)}` }
2974
+ },
2975
+ })
2976
+
2977
+ // ── 工具十一:盒级引脚表──
2978
+ ctx.tools.register({
2979
+ name: 'smart-box-pins',
2980
+ description: '西门子 S7-200 SMART 盒级引脚表:按偏移区间(或整块)列出每个库调用盒(MBUS_CTRL/MBUS_MSG/TON/CTU/MOV_* 等)的「引脚名 = 实参」,把实参直接对到引脚上,不必再回读字节。由调用盒名 + 引脚序号 + 标准库引脚表解出;引脚序号判不出就跳过、超表就回落成「引脚#N」。只读,不改动工程文件。',
2981
+ parameters: {
2982
+ type: 'object',
2983
+ properties: {
2984
+ file: { type: 'string', description: '.smart 工程文件路径。' },
2985
+ from: { type: 'string', description: '区间起点,十六进制偏移(如 5DC8)。' },
2986
+ to: { type: 'string', description: '区间终点,十六进制偏移(如 9000)。' },
2987
+ },
2988
+ required: ['file', 'from', 'to'],
2989
+ additionalProperties: false,
2990
+ },
2991
+ output: {
2992
+ schema: {
2993
+ type: 'object',
2994
+ properties: {
2995
+ ok: { type: 'boolean' },
2996
+ text: { type: 'string' },
2997
+ },
2998
+ required: ['ok', 'text'],
2999
+ additionalProperties: false,
3000
+ },
3001
+ render: (_args, value) => [{ type: 'text', text: value.text }],
3002
+ },
3003
+ async execute(args) {
3004
+ const p = String(args.file ?? '').trim()
3005
+ if (!p) throw new Error('参数 file 不能为空')
3006
+ const abs = resolve(p)
3007
+ if (!existsSync(abs)) throw new Error(`文件不存在: ${p}`)
3008
+ const { data } = decodeContainer(readFileSync(abs))
3009
+ const from = parseInt(String(args.from ?? '').replace(/^0x/i, ''), 16)
3010
+ const to = parseInt(String(args.to ?? '').replace(/^0x/i, ''), 16)
3011
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) throw new Error('需要有效的 from/to(十六进制)')
3012
+ return { ok: true, text: `盒级引脚表(${basename(abs)}):\n${boxPinout(data, from, to)}` }
3013
+ },
3014
+ })
3015
+
3016
+ // ── 工具十:梯形图元素级读/写判定(判定函数已提到模块级,供 var-usage 共用)──
3017
+ ctx.tools.register({
3018
+ name: 'smart-ladder-elements',
3019
+ description: '西门子 S7-200 SMART 梯形图元素级读/写判定:给一个地址或**一批地址**(逗号分隔,如 M29.0,M29.1,M29.2)→ 批量给"每个地址几处写、几处读、落点在哪"的表,并列出每处是「触点(读)」还是「写(线圈、带 OUT 的指令盒、S-R 置位复位线圈)」;用符号名写的元素一并解出并回查符号表映射成地址。用于回答"这些位到底有没有被写""模式切换时是谁把它复位的"这类引用分布与字节定位都答不准的问题——带符号名的元素(S/R 线圈最常见)存的是名字不是地址,只按地址扫会漏判成"从没写过"。给 from/to 时按偏移顺序列出该段全部元素。只读,不改动工程文件。',
3020
+ parameters: {
3021
+ type: 'object',
3022
+ properties: {
3023
+ file: { type: 'string', description: '.smart 工程文件路径。' },
3024
+ addr: { type: 'string', description: '要判的地址,如 M28.2、VW220、M30.0;**一批地址用逗号分隔**(如 M29.0,M29.1,M29.2),批量会先出一张"每个地址几处写/几处读"的表(与 from/to 二选一)。' },
3025
+ from: { type: 'string', description: '区间起点,十六进制偏移(如 1E59)。' },
3026
+ to: { type: 'string', description: '区间终点,十六进制偏移(如 5DC8)。' },
3027
+ },
3028
+ required: ['file'],
3029
+ additionalProperties: false,
3030
+ },
3031
+ output: {
3032
+ schema: {
3033
+ type: 'object',
3034
+ properties: {
3035
+ ok: { type: 'boolean' },
3036
+ text: { type: 'string' },
3037
+ },
3038
+ required: ['ok', 'text'],
3039
+ additionalProperties: false,
3040
+ },
3041
+ render: (_args, value) => [{ type: 'text', text: value.text }],
3042
+ },
3043
+ async execute(args) {
3044
+ const p = String(args.file ?? '').trim()
3045
+ if (!p) throw new Error('参数 file 不能为空')
3046
+ const abs = resolve(p)
3047
+ if (!existsSync(abs)) throw new Error(`文件不存在: ${p}`)
3048
+ const { data } = decodeContainer(readFileSync(abs))
3049
+ const addr = String(args.addr ?? '').trim().toUpperCase()
3050
+ if (addr) return { ok: true, text: `元素级读/写判定(${basename(abs)}):\n${ladderElementsReport(data, addr)}` }
3051
+ const from = parseInt(String(args.from ?? '').replace(/^0x/i, ''), 16)
3052
+ const to = parseInt(String(args.to ?? '').replace(/^0x/i, ''), 16)
3053
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) throw new Error('需要 addr,或给出有效的 from/to(十六进制)')
3054
+ return { ok: true, text: `梯形图元素序列(${basename(abs)}):\n${ladderElementsRange(data, from, to)}` }
3055
+ },
3056
+ })
3057
+
3058
+ // ── 工具十一:网络级轨迹(触点/写元件/指令盒合到同一偏移轴;走 assets/smart-net-trace.mjs)──
3059
+ ctx.tools.register({
3060
+ name: 'smart-net-trace',
3061
+ description: '西门子 S7-200 SMART 网络级轨迹:给偏移区间,按偏移顺序把这一段里的「触点(读)/写元件(线圈、带 OUT 的盒、S-R)/指令盒(指令名 + 引脚名 + 操作数 + 常数)/常数」合成一条可读轨迹。**要看串/并联与边沿请用 `smart-lad`(那条已解出、可出 LD/A/O/LPS/LRD/LPP)**;本工具只给元件先后与端点号,别按先后推 AND/OR。只读,不改动工程文件。',
3062
+ parameters: {
3063
+ type: 'object',
3064
+ properties: {
3065
+ file: { type: 'string', description: '.smart 工程文件路径。' },
3066
+ from: { type: 'string', description: '区间起点,十六进制偏移(如 5DC8)。' },
3067
+ to: { type: 'string', description: '区间终点,十六进制偏移(如 9000)。' },
3068
+ raw: { type: 'boolean', description: 'true 时连每个指令盒窗口的原始 hex 一起打印(核对引脚归属时用)。' },
3069
+ },
3070
+ required: ['file', 'from', 'to'],
3071
+ additionalProperties: false,
3072
+ },
3073
+ output: {
3074
+ schema: {
3075
+ type: 'object',
3076
+ properties: {
3077
+ ok: { type: 'boolean' },
3078
+ text: { type: 'string' },
3079
+ },
3080
+ required: ['ok', 'text'],
3081
+ additionalProperties: false,
3082
+ },
3083
+ render: (_args, value) => [{ type: 'text', text: value.text }],
3084
+ },
3085
+ async execute(args, exec) {
3086
+ const p = String(args.file ?? '').trim()
3087
+ if (!p) throw new Error('参数 file 不能为空')
3088
+ const abs = resolve(p)
3089
+ if (!existsSync(abs)) throw new Error(`文件不存在: ${p}`)
3090
+ const from = String(args.from ?? '').replace(/^0x/i, '')
3091
+ const to = String(args.to ?? '').replace(/^0x/i, '')
3092
+ if (!/^[0-9a-fA-F]+$/.test(from) || !/^[0-9a-fA-F]+$/.test(to)) throw new Error('需要有效的 from/to(十六进制偏移)')
3093
+ const script = join(ASSETS_DIR, 'smart-net-trace.mjs')
3094
+ if (!existsSync(script)) throw new Error(`插件资源缺失: ${script}`)
3095
+ const policy = ctx.sandboxPolicy && exec && exec.agent
3096
+ ? ctx.sandboxPolicy.resolve({ session: exec.agent.session })
3097
+ : undefined
3098
+ const spec = ctx.shell.resolve({
3099
+ command: `& node ${psq(script)} ${psq(abs)} ${from} ${to}${args.raw ? ' --raw' : ''}`,
3100
+ workdir: dirname(abs),
3101
+ timeoutMs: TIMEOUT_MS,
3102
+ ...policy ? { sandboxPolicy: policy } : {},
3103
+ ...exec && exec.signal ? { signal: exec.signal } : {},
3104
+ })
3105
+ const res = await shellResult(ctx, spec)
3106
+ const out = String(res.stdout?.text ?? '').trim()
3107
+ const err = String(res.stderr?.text ?? '').trim()
3108
+ if (res.exitCode !== 0) throw new Error(`网络轨迹脚本退出码 ${res.exitCode}\n${err || out || '(无输出)'}`)
3109
+ return { ok: true, text: `网络级轨迹(${basename(abs)} 0x${from}-0x${to}):\n${out}` }
3110
+ },
3111
+ })
3112
+
3113
+ // ── 工具十二:从 .smart 直接还原 LAD 指令序列(= 串/并联连线,不必导出 STL)──
3114
+ ctx.tools.register({
3115
+ name: 'smart-lad',
3116
+ description: '西门子 S7-200 SMART 连线还原(指令序列):直接读 .smart,按块、按网络打印该段的 LAD 指令序列——LD/A/O/AN/ON(触点串联/并联/常闭)、EU/ED(上升/下降沿)、LPS/LRD/LPP(并联支路)、=/S/R(线圈)、比较(LDW>/OW< 等,含第二个操作数)与指令盒占位。序号即指令位次。用它回答"这段怎么串并联、哪几条是并联支路、边沿在哪",**不必让用户从软件导出 STL**(已用导出的 STL 逐网络回归:主程序 17/17 完全一致)。只读,不改动工程文件。',
3117
+ parameters: {
3118
+ type: 'object',
3119
+ properties: {
3120
+ file: { type: 'string', description: '.smart 工程文件路径。' },
3121
+ block: { type: 'string', description: '可选:块名(如 主程序/自动);不填则输出全部块。' },
3122
+ net: { type: 'string', description: '可选:只看该块的第 N 个网络(配合 block 用)。' },
3123
+ },
3124
+ required: ['file'],
3125
+ additionalProperties: false,
3126
+ },
3127
+ output: {
3128
+ schema: {
3129
+ type: 'object',
3130
+ properties: {
3131
+ ok: { type: 'boolean' },
3132
+ text: { type: 'string' },
3133
+ },
3134
+ required: ['ok', 'text'],
3135
+ additionalProperties: false,
3136
+ },
3137
+ render: (_args, value) => [{ type: 'text', text: value.text }],
3138
+ },
3139
+ async execute(args, exec) {
3140
+ const p = String(args.file ?? '').trim()
3141
+ if (!p) throw new Error('参数 file 不能为空')
3142
+ const abs = resolve(p)
3143
+ if (!existsSync(abs)) throw new Error(`文件不存在: ${p}`)
3144
+ const script = join(ASSETS_DIR, 'smart-lad.mjs')
3145
+ if (!existsSync(script)) throw new Error(`插件资源缺失: ${script}`)
3146
+ const block = String(args.block ?? '').trim()
3147
+ const net = String(args.net ?? '').trim()
3148
+ const policy = ctx.sandboxPolicy && exec && exec.agent
3149
+ ? ctx.sandboxPolicy.resolve({ session: exec.agent.session })
3150
+ : undefined
3151
+ const spec = ctx.shell.resolve({
3152
+ command: `& node ${psq(script)} ${psq(abs)}${block ? ' ' + psq(block) : ''}${net ? ' --net=' + psq(net) : ''}`,
3153
+ workdir: dirname(abs),
3154
+ timeoutMs: TIMEOUT_MS,
3155
+ ...policy ? { sandboxPolicy: policy } : {},
3156
+ ...exec && exec.signal ? { signal: exec.signal } : {},
3157
+ })
3158
+ const res = await shellResult(ctx, spec)
3159
+ const out = String(res.stdout?.text ?? '').trim()
3160
+ const err = String(res.stderr?.text ?? '').trim()
3161
+ if (res.exitCode !== 0) throw new Error(`连线还原脚本退出码 ${res.exitCode}\n${err || out || '(无输出)'}`)
3162
+ return { ok: true, text: `LAD 指令序列(${basename(abs)}${block ? ' · ' + block : ''}):\n${out}` }
3163
+ },
3164
+ })
3165
+
3166
+ ctx.commands.register({
3167
+ name: 'smart-lad',
3168
+ description: 'S7-200 SMART 连线还原:打印某块每网络的 LAD 指令序列(串/并联/边沿/比较/盒)',
3169
+ input: { hint: '<工程.smart> [块名] [网络号]' },
3170
+ handler: async ({ rawInput }) => {
3171
+ try {
3172
+ const [file, block, net] = splitArgs(rawInput)
3173
+ if (!file) throw new Error('用法: /smart-lad <工程.smart> [块名] [网络号]')
3174
+ const abs = resolve(file)
3175
+ if (!existsSync(abs)) throw new Error(`文件不存在: ${file}`)
3176
+ const script = join(ASSETS_DIR, 'smart-lad.mjs')
3177
+ if (!existsSync(script)) throw new Error(`插件资源缺失: ${script}`)
3178
+ const spec = ctx.shell.resolve({
3179
+ command: `& node ${psq(script)} ${psq(abs)}${block ? ' ' + psq(block) : ''}${net ? ' --net=' + psq(net) : ''}`,
3180
+ workdir: dirname(abs),
3181
+ timeoutMs: TIMEOUT_MS,
3182
+ })
3183
+ const res = await shellResult(ctx, spec)
3184
+ const out = String(res.stdout?.text ?? '').trim()
3185
+ const err = String(res.stderr?.text ?? '').trim()
3186
+ if (res.exitCode !== 0) throw new Error(err || out || '脚本失败')
3187
+ return out
3188
+ } catch (e) {
3189
+ return `连线还原失败: ${e.message}`
3190
+ }
3191
+ },
3192
+ })
3193
+
3194
+ ctx.commands.register({
3195
+ name: 'smart-ladder',
3196
+ description: 'S7-200 SMART 梯形图元素级读/写判定(某地址的全部落点 + 每处是触点还是写)',
3197
+ input: { hint: '<工程.smart> <地址,如 M28.2|起hex 止hex>' },
3198
+ handler: async ({ rawInput }) => {
3199
+ try {
3200
+ const [file, a2, a3] = splitArgs(rawInput)
3201
+ if (!file) throw new Error('用法: /smart-ladder <工程.smart> <地址 | 起hex 止hex>')
3202
+ const abs = resolve(file)
3203
+ if (!existsSync(abs)) throw new Error(`文件不存在: ${file}`)
3204
+ const { data } = decodeContainer(readFileSync(abs))
3205
+ if (a2 && !/^[0-9a-fA-F]{3,8}$/.test(a2)) {
3206
+ return { kind: 'success', text: `元素级读/写判定(${basename(abs)}):\n${ladderElementsReport(data, a2.toUpperCase())}` }
3207
+ }
3208
+ const from = parseInt(String(a2 || '').replace(/^0x/i, ''), 16)
3209
+ const to = parseInt(String(a3 || '').replace(/^0x/i, ''), 16)
3210
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) throw new Error('第二个参数应是地址,或给出"起hex 止hex"')
3211
+ return { kind: 'success', text: `梯形图元素序列(${basename(abs)}):\n${ladderElementsRange(data, from, to)}` }
3212
+ } catch (error) {
3213
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
3214
+ }
3215
+ },
3216
+ })
3217
+
3218
+ // ── 命令 ──
3219
+ ctx.commands.register({
3220
+ name: 'plc-archive',
3221
+ description: '查看解码归档地址:当前生效的根目录、默认落点、怎么改配置',
3222
+ input: { hint: '(无参数)' },
3223
+ handler: async () => ({
3224
+ kind: 'success',
3225
+ text: [
3226
+ `当前生效的归档根目录: ${ARCHIVE_ROOT}`,
3227
+ `解析方式: ${isAbsolute(ARCHIVE_ROOT) ? '绝对路径,原样使用' : `相对路径,按会话工作区解析(本进程 cwd = ${process.cwd()})`}`,
3228
+ '默认落点: <归档根>\\<工程文件所在文件夹名>\\(同一文件夹里有多个工程文件时用 <文件夹名>_<文件名>)',
3229
+ '',
3230
+ '改配置(改完重启 dsh 生效):在 DSH profile 的 cordis.patch.yml 里给本插件那行加 config:',
3231
+ ' - id: dsh-plc-decode',
3232
+ ' name: dsh-plc-decode',
3233
+ ' config:',
3234
+ ' archiveDir: D:/plc/解码归档',
3235
+ '',
3236
+ '只改这一次、不写配置:解码时传 outDir 参数(工具参数或 /plc-decode 的第二个参数)。',
3237
+ ].join('\n'),
3238
+ }),
3239
+ })
3240
+
3241
+ ctx.commands.register({
3242
+ name: 'plc-decode',
3243
+ description: 'PLC 工程统一解码入口(自动识别 .project/.dvp/.smart/.smartlib)',
3244
+ input: { hint: '<工程文件路径> [输出目录]' },
3245
+ handler: async ({ rawInput, signal, agent }) => {
3246
+ try {
3247
+ const parts = splitArgs(rawInput)
3248
+ const file = parts[0]
3249
+ const outDir = parts[1] ? resolve(parts[1]) : undefined
3250
+ if (!file) return ({ kind: 'error', text: '用法: /plc-decode <工程文件路径> [输出目录]' })
3251
+ const abs = resolve(file)
3252
+ if (!existsSync(abs)) return ({ kind: 'error', text: `文件不存在: ${file}` })
3253
+ const { kind } = identify(abs)
3254
+ if (kind === 'project' || kind === 'dvp') {
3255
+ // 注意:原来把 dirname(abs) 当 signal 传(实参错位),且返回值取的是 smart 专用的 proceed → 显示 undefined
3256
+ const r = await decodeCodesysProject(ctx, abs, signal, undefined, outDir, sessionBase(agent))
3257
+ return ({ kind: 'success', text: `已解码完成(类型: ${kind})\n输出目录: ${r.outd}\n${r.text}` })
3258
+ }
3259
+ const result = kind === 'smart' ? decodeSmartFile(abs, outDir, sessionBase(agent)) : decodeSmartLib(abs, outDir, sessionBase(agent))
3260
+ return ({ kind: 'success', text: `已解码完成(类型: ${kind}):\n${result.proceed}` })
3261
+ } catch (error) {
3262
+ return ({ kind: 'error', text: error instanceof Error ? error.message : String(error) })
3263
+ }
3264
+ },
3265
+ })
3266
+
3267
+ ctx.commands.register({
3268
+ name: 'codesys-read',
3269
+ description: '解码 .project / .dvp 工程(.project→ST 源码与报告;.dvp→解码报告)',
3270
+ input: { hint: '<工程.project/.dvp 路径>' },
3271
+ handler: async ({ rawInput, signal }) => {
3272
+ const [proj] = splitArgs(rawInput)
3273
+ try {
3274
+ const { outd, kind, text } = await decodeCodesysProject(ctx, proj, signal)
3275
+ const tail = kind === 'dvp'
3276
+ ? `解码报告在 ${outd}\\解码报告.md`
3277
+ : `源码在 ${outd}\\pous\\ 下(看程序读它),索引在 ${outd}\\00-索引.md`
3278
+ return { kind: 'success', text: `已解码完成,输出目录:\n${outd}\n\n${text}\n\n${tail}` }
3279
+ } catch (error) {
3280
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
3281
+ }
3282
+ },
3283
+ })
3284
+
3285
+ ctx.commands.register({
3286
+ name: 'dvp-read',
3287
+ description: '解码台达 DVP 工程为结构与指令表报告',
3288
+ input: { hint: '<工程.dvp 路径>' },
3289
+ handler: async ({ rawInput, signal }) => {
3290
+ const [dvp] = splitArgs(rawInput)
3291
+ try {
3292
+ const { text } = await decodeDvpText(ctx, dvp, signal)
3293
+ return { kind: 'success', text }
3294
+ } catch (error) {
3295
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
3296
+ }
3297
+ },
3298
+ })
3299
+
3300
+ ctx.commands.register({
3301
+ name: 'codesys-check',
3302
+ description: '解码 .project 工程后做等价编译静态校验(结构体/调用/地址唯一/未声明符号;.dvp 暂不支持校验)',
3303
+ input: { hint: '<工程.project 路径>' },
3304
+ handler: async ({ rawInput, signal, agent }) => {
3305
+ const [proj] = splitArgs(rawInput)
3306
+ try {
3307
+ const { abs, dir, kind } = requireProject(proj, '用法: /codesys-check <工程.project 路径>')
3308
+ if (kind === 'dvp') throw new Error('校验仅支持 .project(CoDeSys V3.5)工程;.dvp 请用 /codesys-read 解码。')
3309
+ const outd = join(defaultArchiveDir(abs, sessionBase(agent)), `导出_校验_${timestamp()}`)
3310
+ precreateOutDir(outd)
3311
+ const read = await runPy(ctx, 'read_project.py', [abs, outd], dir, signal)
3312
+ if (!read.ok) throw new Error(read.text)
3313
+ const check = await runPy(ctx, 'check_project.py', [join(outd, 'pous')], dir, signal)
3314
+ if (!check.ok) throw new Error(check.text)
3315
+ return { kind: 'success', text: `校验完成(源码目录 ${outd}):\n\n${check.text}` }
3316
+ } catch (error) {
3317
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
3318
+ }
3319
+ },
3320
+ })
3321
+
3322
+ ctx.commands.register({
3323
+ name: 'smart-conflict',
3324
+ description: '地址冲突体检:重叠冲突(A/B/C 级)+ 连续空间被切碎(P 级碎片),可给「要放几个双字」出候选地址',
3325
+ input: { hint: '<.smart 路径> [要放的双字个数]' },
3326
+ handler: async ({ rawInput, agent }) => {
3327
+ try {
3328
+ const parts = splitArgs(rawInput)
3329
+ const file = parts[0]
3330
+ if (!file) return ({ kind: 'error', text: '用法: /smart-conflict <.smart 路径> [要放的双字个数]' })
3331
+ const abs = resolve(file)
3332
+ if (!existsSync(abs)) return ({ kind: 'error', text: `文件不存在: ${file}` })
3333
+ const o = parseSmartOnce(abs)
3334
+ let ptrRefs = null
3335
+ try { const { data } = decodeContainer(readFileSync(abs)); ptrRefs = scanPtrRefs(data) } catch { ptrRefs = null }
3336
+ const need = Number(parts[1] ?? 0) || 0
3337
+ return ({ kind: 'success', text: conflictReport(o.userSyms, o.records, ptrRefs, need, 4096) })
3338
+ } catch (error) {
3339
+ return ({ kind: 'error', text: error instanceof Error ? error.message : String(error) })
3340
+ }
3341
+ },
3342
+ })
3343
+
3344
+ ctx.commands.register({
3345
+ name: 'smart-read',
3346
+ description: '解码 .smart 工程 / .smartlib 库文件(默认输出到工作区 解码归档\\<工程名>\\,可用 <路径> <输出目录> 指定)',
3347
+ input: { hint: '<.smart/.smartlib 路径> [输出目录]' },
3348
+ handler: async ({ rawInput, agent }) => {
3349
+ try {
3350
+ const parts = splitArgs(rawInput)
3351
+ const file = parts[0]
3352
+ const outDir = parts[1] ? resolve(parts[1]) : undefined
3353
+ if (!file) return ({ kind: 'error', text: '用法: /smart-read <.smart/.smartlib 路径> [输出目录]' })
3354
+ const abs = resolve(file)
3355
+ if (!existsSync(abs)) return ({ kind: 'error', text: `文件不存在: ${file}` })
3356
+ const raw = readFileSync(abs)
3357
+ const result = raw[0] === 0x44 ? decodeSmartLib(abs, outDir, sessionBase(agent)) : decodeSmartFile(abs, outDir, sessionBase(agent))
3358
+ return ({ kind: 'success', text: result.proceed })
3359
+ } catch (error) {
3360
+ return ({ kind: 'error', text: error instanceof Error ? error.message : String(error) })
3361
+ }
3362
+ },
3363
+ })
3364
+
3365
+ ctx.commands.register({
3366
+ name: 'codesys-diff',
3367
+ description: '对比新旧两个 CoDeSys 工程(.project 或已解码目录),输出逻辑变化报告',
3368
+ input: { hint: '<旧工程 .project 或解码目录> <新工程 .project 或解码目录>' },
3369
+ handler: async ({ rawInput, signal, agent }) => {
3370
+ const [oldP, newP] = splitArgs(rawInput)
3371
+ try {
3372
+ if (!oldP || !newP) throw new Error('用法: /codesys-diff <旧工程或目录> <新工程或目录>(带空格路径请加引号)')
3373
+ const { notes, report } = await runDiff(ctx, oldP, newP, signal, sessionBase(agent))
3374
+ return { kind: 'success', text: notes.length ? `${notes.join('\n')}\n\n${report}` : report }
3375
+ } catch (error) {
3376
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
3377
+ }
3378
+ },
3379
+ })
3380
+
3381
+ ctx.commands.register({
3382
+ name: 'smart-diff',
3383
+ description: '对比新旧两个 SMART 工程(块清单/主程序调用链/I-O 引用/符号表差异)',
3384
+ input: { hint: '<旧.smart 路径> <新.smart 路径> [输出目录]' },
3385
+ handler: async ({ rawInput, agent }) => {
3386
+ try {
3387
+ const parts = splitArgs(rawInput)
3388
+ if (parts.length < 2) return ({ kind: 'error', text: '用法: /smart-diff <旧.smart 路径> <新.smart 路径> [输出目录](带空格路径请加引号)' })
3389
+ for (const q of parts.slice(0, 2)) {
3390
+ if (!existsSync(resolve(q))) return ({ kind: 'error', text: `文件不存在: ${q}` })
3391
+ }
3392
+ const outDir = parts[2] ? resolve(parts[2]) : undefined
3393
+ const { proceed, report } = smartDiff(resolve(parts[0]), resolve(parts[1]), outDir, sessionBase(agent))
3394
+ return { kind: 'success', text: `${proceed}\n\n${report}` }
3395
+ } catch (error) {
3396
+ return ({ kind: 'error', text: error instanceof Error ? error.message : String(error) })
3397
+ }
3398
+ },
3399
+ })
3400
+
3401
+ ctx.commands.register({
3402
+ name: 'smart-free-space',
3403
+ description: 'SMART 工程地址占用与空闲段分析(加功能前选新地址用:有符号名的勿动、备用坑勿动、只取无名字无引用连续空闲段)',
3404
+ input: { hint: '<.smart 路径 或 已解码目录>' },
3405
+ handler: async ({ rawInput }) => {
3406
+ try {
3407
+ const [p] = splitArgs(rawInput)
3408
+ if (!p) return ({ kind: 'error', text: '用法: /smart-free-space <.smart 路径 或 已解码目录>' })
3409
+ const abs = resolve(p)
3410
+ if (!existsSync(abs)) return ({ kind: 'error', text: `文件不存在: ${p}` })
3411
+ const isDir = existsSync(join(abs, '记录.json')) && existsSync(join(abs, '解码报告.md'))
3412
+ let records, userSyms
3413
+ if (isDir) {
3414
+ const j = JSON.parse(readFileSync(join(abs, '记录.json'), 'utf8'))
3415
+ records = j.records ?? []
3416
+ userSyms = [...(j.userSyms ?? [])]
3417
+ } else {
3418
+ const o = parseSmartOnce(abs)
3419
+ records = o.records
3420
+ userSyms = o.userSyms
3421
+ }
3422
+ return ({ kind: 'success', text: `地址占用与空闲段分析(${isDir ? '已解码目录' : basename(abs)}):\n${freeSpaceReport(userSyms, records)}` })
3423
+ } catch (error) {
3424
+ return ({ kind: 'error', text: error instanceof Error ? error.message : String(error) })
3425
+ }
3426
+ },
3427
+ })
3428
+ }
3429
+
3430
+ // 内部导出(诊断/CLI 验证用,不参与工具的 ctx 注册):与工具相同的解码入口
3431
+ export const _decodeSmartFile = decodeSmartFile
3432
+ export const _parseSmartOnce = parseSmartOnce
3433
+ export const _smartDiff = smartDiff
3434
+ export const _decodeSmartPouStructure = decodeSmartPouStructure
3435
+ export const _conflictReport = conflictReport