dsh-plc-decode 0.2.1 → 0.2.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.
@@ -3,7 +3,7 @@
3
3
  dvp_decode.py — 台达 WPLSoft/ISPSoft DVP 工程文件解码器
4
4
 
5
5
  用法:
6
- python dvp_decode.py "C:\\projects\\demo.dvp" [-o 输出目录]
6
+ python dvp_decode.py <工程文件.dvp> [-o 输出目录]
7
7
 
8
8
  功能:
9
9
  1. 文件结构(机型 / TypeBegin 块 / SMT 块分段)
@@ -33,6 +33,7 @@ dvp_decode.py — 台达 WPLSoft/ISPSoft DVP 工程文件解码器
33
33
  import sys
34
34
  import os
35
35
  import re
36
+ import json
36
37
 
37
38
  def read_bytes(path):
38
39
  with open(path, "rb") as f:
@@ -113,6 +114,7 @@ ROWIDX_PREFIX = {0x14, 0x16}
113
114
  # (段号, 行号, 字节原文),main 末尾聚合成「未识别记录」一节。
114
115
  # 这不是猜语义,是把「读不出来」变成看得见的账——用户能据此对照 WPLSoft 指令表。
115
116
  UNKNOWN_RECS = []
117
+ CALIB = {}
116
118
 
117
119
  # 触点/线圈指令前缀:
118
120
  # 12 触点族(LD/AND/T 页)、32=OUT、22=AND/ANI
@@ -328,7 +330,7 @@ def _to_loading(cp):
328
330
  return cp
329
331
 
330
332
 
331
- def disasm(seg, segno=0):
333
+ def disasm(seg, segno=0, collect=None, calib=None):
332
334
  """段数据区 → 行分组指令 token 流
333
335
  - 比较指令族 ?CMP(e2/e4/e6/e9 + 双操作数,指令名待对照,统一 ? 前缀)
334
336
  - 行尾/分隔记录整体吞 5B(0b/61 10/68 10/57 10/5a/34/39)
@@ -403,7 +405,7 @@ def disasm(seg, segno=0):
403
405
  # 段首非程序记录(不在任何网络里、无操作数),不是指令,整体吞 5B
404
406
  i += 5
405
407
  continue
406
- toks.append(CMD_MAP.get(rec[2]) or guess_cmd(rec[2]) or f"??{rec[2]:02X}")
408
+ toks.append(_cal_name(calib, f"cmd:{rec[2]:02X}") or CMD_MAP.get(rec[2]) or guess_cmd(rec[2]) or f"??{rec[2]:02X}")
407
409
  i += 5
408
410
  else:
409
411
  op = decode_operand(rec)
@@ -446,8 +448,41 @@ def disasm(seg, segno=0):
446
448
  toks.append(f"〔?{rec[0]:02x}{rec[1]:02x}{rec[2]:02x}〕")
447
449
  i += step
448
450
  rows.append(" ".join(toks))
451
+ if collect is not None:
452
+ collect.append((segno, li, list(toks)))
449
453
  return rows
450
454
 
455
+
456
+ # ---- 指令流结构化(--json 输出给梯形图用;与报告同一份 token) ----
457
+ _OPERAND_RE = re.compile(r'^(?:K-?\d+|H[0-9A-F]+|[DXMYTCSV]\d+(?:\.\d+)?|F-?[\d.]+)$')
458
+
459
+ def _cal_name(calib, key):
460
+ v = (calib or {}).get(key)
461
+ if isinstance(v, dict):
462
+ v = v.get("name")
463
+ return v
464
+
465
+
466
+ def to_insts(toks):
467
+ """token 串 → [{op,args}]:操作数挂到前一条指令上;| 分隔符丢弃。"""
468
+ insts = []
469
+ for t in toks:
470
+ if t == '|' or (t.startswith('|H') and t.endswith('|')):
471
+ continue
472
+ if t in ('MPS', 'MRD', 'MPP', 'ORB', 'ANB') or t.startswith('〔?') or t.startswith('?CMP') or t.startswith('??'):
473
+ insts.append({'op': t, 'args': []})
474
+ continue
475
+ if ' ' in t:
476
+ op, _, arg = t.partition(' ')
477
+ insts.append({'op': op, 'args': [arg]})
478
+ continue
479
+ if _OPERAND_RE.match(t):
480
+ if insts:
481
+ insts[-1]['args'].append(t)
482
+ continue
483
+ insts.append({'op': t, 'args': []})
484
+ return insts
485
+
451
486
  def main():
452
487
  argv = sys.argv[1:]
453
488
  if not argv:
@@ -455,13 +490,22 @@ def main():
455
490
  return
456
491
  path = argv[0]
457
492
  out_dir = None
493
+ json_path = argv[argv.index("--json") + 1] if "--json" in argv else None
494
+ calib_path = argv[argv.index("--calib") + 1] if "--calib" in argv else None
458
495
  if "-o" in argv:
459
496
  out_dir = argv[argv.index("-o") + 1]
460
497
 
461
498
  data = read_bytes(path)
462
499
  text = data.decode("ascii", "replace")
463
500
  UNKNOWN_RECS.clear()
501
+ if calib_path and os.path.exists(calib_path):
502
+ try:
503
+ with open(calib_path, encoding="utf-8") as f:
504
+ CALIB.update((json.load(f).get("dvp") or {}))
505
+ except Exception:
506
+ pass
464
507
  lines = []
508
+ insts_out = []
465
509
 
466
510
  # ---- 1. 结构 ----
467
511
  lines.append("=" * 60)
@@ -539,7 +583,7 @@ def main():
539
583
  spans.append((0, de0 + len("<DataEnd>"), nxt))
540
584
  lines.append(" (本工程无 <SMT> 分段标记,按 Initial Value 块后的整块数据反汇编)")
541
585
  for si, ds, de in spans:
542
- rows = disasm(data[ds:de], si)
586
+ rows = disasm(data[ds:de], si, insts_out, CALIB)
543
587
  lines.append(f"--- 段{si} ---")
544
588
  for li, r in enumerate(rows):
545
589
  lines.append(f" 行{li}: {r}")
@@ -574,6 +618,11 @@ def main():
574
618
  lines.append(" 比较族 ?CMP(关系):尾字节 00 = / 02 <> / 04 > / 06 < / 08 <= / 0a >=;08、0a 已核对,其余按步长 2 推断。")
575
619
  lines.append(" 仍未定:以 ?? 开头的功能码(形态不足以推断)、以 ?CMP[ 开头的带码占位(关系码不在上表)。")
576
620
 
621
+ if json_path:
622
+ os.makedirs(os.path.dirname(os.path.abspath(json_path)), exist_ok=True)
623
+ with open(json_path, "w", encoding="utf-8") as f:
624
+ json.dump([{"seg": sg, "line": li, "insts": to_insts(toks)} for sg, li, toks in insts_out], f, ensure_ascii=False)
625
+
577
626
  report = "\n".join(lines)
578
627
  if out_dir:
579
628
  os.makedirs(out_dir, exist_ok=True)
@@ -3,7 +3,7 @@
3
3
  read_project.py — 读取 CoDeSys V3.5 / LeadSysStudio 工程文件 (.project) 的内容。
4
4
 
5
5
  用法:
6
- python read_project.py "C:\\projects\\demo.project" [-o 输出目录]
6
+ python read_project.py <工程文件.project> [-o 输出目录]
7
7
 
8
8
  功能:
9
9
  1. 解包 .project (ZIP)
@@ -73,7 +73,7 @@ export function writeSourcePack({ outd, kind, projName, projFile }) {
73
73
  if (has('项目树.md')) nav += `| 项目树.md | ${num(sizeOf('项目树.md'))} B | AI | 项目树 |\n`
74
74
  if (kind === 'dvp') {
75
75
  nav += `\n## 怎么查(按问题挑一条,别整份读)\n\n`
76
- nav += `- 某段指令表 → 按下文「网络行号表」取段(行号=解码报告里的行,从 1 起)\n`
76
+ nav += `- 某段梯形图 → \`ladder-dvp\` from=<段号> to=<段号>(段号=网络号,从 1 起;不是报告行号)\n`
77
77
  nav += `- 某地址在哪些网络出现 → grep 地址(如 \`grep -n "D2340" 解码报告.md\`),只读命中的那几行\n`
78
78
  nav += `- 找中文注释/设备名 → grep 解码报告.md(dvp 只出这一份文本,不生成 全部字符串.txt)\n\n`
79
79
  nav += `## 读法\n\n- **看程序**:台达 DVP 没有 pous/ST,程序就是 \`解码报告.md\` 的指令表(按 \`LD M1000\` 分网络,末尾 END)。\n- **AI**:目录里有 \`程序解读.md\` 就先读它(网络级功能地图+I/O 表+机制链+未确证清单),再回指令表逐段核对。报告里 \`?名\`(如 \`?DRVI\`)与 \`?CMP(关系)\` 是**推断**指令名——报告末尾有「推断对照表」(码/推测名/依据/把握),拿它下结论前先核台达指令表;\`??xx\` 与 \`?CMP[六位]\` 是推断不出、保持未定的。\n- 读之前核对源工程文件修改时间是否与本次解码一致(工程一改,导出即过期)。\n`
@@ -88,7 +88,7 @@ export function writeSourcePack({ outd, kind, projName, projFile }) {
88
88
  }
89
89
  if (starts.length) {
90
90
  const CAP = 50
91
- nav += `\n## 网络定位(共 ${starts.length} 个网络;要哪段用 按行号取段,别整份读)\n\n| # | 起始行 | 首条 |\n|---|---|---|\n`
91
+ nav += `\n## 网络定位(共 ${starts.length} 个网络;要哪段用 ladder-dvp 按段号取(=上表 #),别整份读)\n\n| # | 报告行 | 首条 |\n|---|---|---|\n`
92
92
  for (let k = 0; k < Math.min(starts.length, CAP); k++) {
93
93
  const from = starts[k]
94
94
  const head = (lines[from - 1] || '').trim().replace(/^行\s*\d+\s*[::]\s*/, '').slice(0, 46).replace(/\|/g, '\\|')
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dvp-ladder.mjs —— 台达 DVP 文字梯形图(从解码器指令表还原,不自己读字节)
4
+ *
5
+ * 数据源唯一:`dvp_decode.py --json` 输出的每条指令(op + args,含 ORB/ANB/MPS/MRD/MPP)。
6
+ * 报告里解得出什么,图上就画什么:未定名功能码画 `?`/`??` 盒,未识别记录画成触点并在说明里点名,
7
+ * 不再静默省略。版式与西门子那侧同一个渲染器(lad-branches)。
8
+ *
9
+ * CLI:node dvp-ladder.mjs <insts.json> [from] [to]
10
+ */
11
+ import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
12
+ import { execFile } from 'node:child_process'
13
+ import { promisify } from 'node:util'
14
+ import { tmpdir } from 'node:os'
15
+ import { dirname, join } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ const pExecFile = promisify(execFile)
19
+ const ASSETS_DIR = dirname(fileURLToPath(import.meta.url))
20
+ const LAD_BRANCHES = join(ASSETS_DIR, 'lad-branches.mjs')
21
+
22
+ const CONTACTS = [
23
+ [/^(LD|AND|OR)$/, (a) => `┤${a}├`],
24
+ [/^(LDI|ANI|ORI)$/, (a) => `┤/${a}├`],
25
+ [/^(LDP|ANDP|ORP)$/, (a) => `┤${a}↑├`],
26
+ [/^(LDF|ANDF|ORF)$/, (a) => `┤${a}↓├`],
27
+ ]
28
+ const CONTACT_RE = /^(LD|LDI|LDP|LDF|AND|ANI|ANDP|ANDF|OR|ORI|ORP|ORF)$/
29
+ const CMP_RE = /^(\?)?CMP[((]([^))]*)[))]$/
30
+
31
+ function contactGlyph(op, args) {
32
+ const a = args.join(' ')
33
+ for (const [re, f] of CONTACTS) if (re.test(op)) return f(a)
34
+ return `┤${op}${a ? ' ' + a : ''}├`
35
+ }
36
+
37
+ /** 指令流 → 支路行;acc 是「母线到输出」的导线文字,ORB/ANB 按块合并。 */
38
+ function branchRows(insts) {
39
+ const rows = []
40
+ const notes = []
41
+ let acc = ''
42
+ let hasAcc = false
43
+ const blocks = []
44
+ const stack = []
45
+ const pushAcc = () => { if (hasAcc) { blocks.push(acc); acc = ''; hasAcc = false } }
46
+ const add = (s) => { acc += s; hasAcc = true }
47
+
48
+ // 行首 LD M1000 是母线本身(rail),不再画成第一个触点
49
+ const list = insts.slice()
50
+ if (list[0] && list[0].op === 'LD' && (list[0].args ?? [])[0] === 'M1000') list.shift()
51
+ for (const raw of list) {
52
+ const op = String(raw.op ?? '')
53
+ const args = (raw.args ?? []).map(String)
54
+ if (CONTACT_RE.test(op)) {
55
+ const g = contactGlyph(op, args)
56
+ if (op.startsWith('OR')) add('∥' + g)
57
+ else if (op.startsWith('LD')) { pushAcc(); add(g) }
58
+ else add(g)
59
+ continue
60
+ }
61
+ const cm = CMP_RE.exec(op)
62
+ if (cm) {
63
+ const g = `┤${cm[2] || '?'} ${args.join(' ')}├`
64
+ if (!hasAcc && rows.length) add(rows[rows.length - 1].wire + '∥' + g)
65
+ else add(g)
66
+ continue
67
+ }
68
+ if (op.startsWith('〔?') || op.startsWith('?CMP[')) {
69
+ add(`┤${op}├`)
70
+ notes.push(`未识别记录 ${op}(报告「未识别记录」一节可对字节)`)
71
+ continue
72
+ }
73
+ if (op === 'MPS') { stack.push(acc); continue }
74
+ if (op === 'MRD') { acc = stack[stack.length - 1] ?? ''; hasAcc = acc !== ''; continue }
75
+ if (op === 'MPP') { acc = stack.pop() ?? ''; hasAcc = acc !== ''; continue }
76
+ if (op === 'ORB') {
77
+ const b = blocks.pop()
78
+ if (b !== undefined) { acc = b + '∥' + acc; hasAcc = true }
79
+ else notes.push('ORB 前没有可合并的上一个块(未还原)')
80
+ continue
81
+ }
82
+ if (op === 'ANB') {
83
+ const b = blocks.pop()
84
+ if (b !== undefined) { acc = b + acc; hasAcc = true }
85
+ else notes.push('ANB 前没有可合并的上一个块(未还原)')
86
+ continue
87
+ }
88
+ // TMR/CTR 这类「带 EN、输出还继续走」的盒:画在导线上,不当终止输出
89
+ if (op === 'TMR' || op === 'CTR') { add(`[${op} ${args.join(' ')}]`); continue }
90
+ // 其余一律当输出:OUT / SET / RST / 功能盒;输出不消费条件(同一串条件可带多个线圈)
91
+ rows.push({ wire: acc, op, args })
92
+ }
93
+ if (hasAcc && !rows.length) notes.push(`末尾还剩一段未接输出的条件(${acc})`)
94
+ return { rows, notes }
95
+ }
96
+
97
+ function toSpecRows(rows) {
98
+ return rows.map((r, i) => {
99
+ const mark = rows.length === 1 ? '──' : i === 0 ? '┬──' : i === rows.length - 1 ? '└──' : '├──'
100
+ const wire = mark + r.wire
101
+ if (/^(SET|RST)$/.test(r.op)) return { wire, coil: `${r.op} ${r.args.join(' ')}` }
102
+ if (r.op === 'OUT') return { wire, coil: r.args[0] ?? '' }
103
+ if (r.args.length === 0) return { wire, coil: r.op }
104
+ return {
105
+ wire,
106
+ box: {
107
+ name: r.op,
108
+ pins: [
109
+ { name: 'EN', src: 'cond' },
110
+ ...r.args.map((a, k) => ({ name: k === r.args.length - 1 ? 'OUT' : `IN${k + 1}`, src: a })),
111
+ ],
112
+ },
113
+ }
114
+ })
115
+ }
116
+
117
+ async function renderNet(spec) {
118
+ const dir = mkdtempSync(join(tmpdir(), 'dvp-lad-'))
119
+ const f = join(dir, 'n.json')
120
+ writeFileSync(f, JSON.stringify(spec), 'utf8')
121
+ const { stdout } = await pExecFile(process.execPath, [LAD_BRANCHES, f], { encoding: 'utf8', maxBuffer: 8 << 20 })
122
+ return stdout.replace(/\s+$/, '')
123
+ }
124
+
125
+ /** json:[{ seg, line, insts:[{op,args}] }];段号按「首条是 LD M1000」的网络编号,与报告网络号一致。 */
126
+ export async function dvpLadder({ json, from, to, title, outFile }) {
127
+ const data = JSON.parse(readFileSync(json, 'utf8'))
128
+ const nets = data.filter((r) => {
129
+ const first = (r.insts ?? [])[0] ?? {}
130
+ return first.op === 'LD' && (first.args ?? [])[0] === 'M1000'
131
+ })
132
+ if (!nets.length) throw new Error('指令表里没有网络(首条 LD M1000)')
133
+
134
+ const lo = from ? Number(from) : 1
135
+ const hi = to ? Number(to) : nets.length
136
+ const parts = [
137
+ title ?? '台达 DVP 梯形图(由解码器指令表还原)',
138
+ '',
139
+ '说明:段 N=网络 N(与解码报告的网络号一致,不是报告行号);图上每个元件都来自解码器指令表,未定名指令画 ?/?? 盒。',
140
+ '',
141
+ ]
142
+ const GROUP = 6
143
+ for (let k = 0; k < nets.length; k++) {
144
+ const no = k + 1
145
+ if (no < lo || no > hi) continue
146
+ const { rows, notes } = branchRows(nets[k].insts ?? [])
147
+ if (!rows.length) {
148
+ parts.push(`段 ${no}(无输出,仅条件)`, '')
149
+ continue
150
+ }
151
+ const specRows = toSpecRows(rows)
152
+ for (let g = 0; g < specRows.length; g += GROUP) {
153
+ const grp = specRows.slice(g, g + GROUP)
154
+ const spec = {
155
+ title: specRows.length > GROUP ? `段 ${no}(${g + 1}-${g + grp.length}/${specRows.length} 支)` : `段 ${no}`,
156
+ rail: 'M1000',
157
+ rows: grp,
158
+ comments: notes,
159
+ }
160
+ parts.push(await renderNet(spec), '')
161
+ }
162
+ }
163
+ const out = parts.join('\n')
164
+ if (outFile) writeFileSync(outFile, out, 'utf8')
165
+ return out
166
+ }
167
+
168
+ /* CLI */
169
+ if (process.argv[1] && process.argv[1].endsWith('dvp-ladder.mjs')) {
170
+ const [json, from, to] = process.argv.slice(2)
171
+ dvpLadder({ json, from, to })
172
+ .then((t) => process.stdout.write(t))
173
+ .catch((e) => { console.error(String(e)); process.exit(1) })
174
+ }
@@ -0,0 +1,305 @@
1
+ // ebpro-decode.mjs —— 威纶通 EasyBuilder Pro 工程解码(.emtp / 编译产物 .exob)
2
+ //
3
+ // 格式:.emtp 工程本体是「头 + 一大块压缩/加密数据」,读不出结构;同工程「编译」(F7)
4
+ // 生成的 .exob 是 UTF-8 明文,里面能读的有三段:
5
+ // LABE_LIB 文字库:逐条 [1B 名长][1B 状态1长][1B 状态2长] + 三段 UTF-8(只有源语言)
6
+ // 画面对象记录 'HA' + 2 字节类型码 + 若干记录;记录 = 21 字节头(+0..+7 文字度量 u16,
7
+ // +12 语言槽 id,+14..+17 文本字节数 u32)+ UTF-8 文本 + 0x00
8
+ // 其余段(TAG_DATA / MACRO_ID …)内容未定,本解码器不解析、不猜。
9
+ //
10
+ // 用法:node ebpro-decode.mjs <工程.emtp | 编译产物.exob | 工程目录> [输出目录] [--exob 路径]
11
+ // 输出(默认 <cwd>/解码归档/<工程名>/HMI解码/):
12
+ // 00-格式说明.md 来源、结构、判定口径、能解与不能解
13
+ // 10-画面.tsv 画面名(推定) / 编号(画面标题对象偏移) / 文字对象数
14
+ // 11-文字对象.tsv 所属画面(推定) / 类型码 / 语言槽 / 文字 / 度量 x y w h / 对象偏移
15
+ // 12-报警事件.tsv 对象偏移 / 语言槽 / 文本
16
+ // 13-文字库.tsv 条目名 / 状态1 / 状态2
17
+ // HMI解码报告.md 概述 + 画面清单 + 语言槽分布 + 边界
18
+ // 08-统计.json 同一批数据的结构化版
19
+ // 只读:不修改工程与编译产物。
20
+ import fs from 'node:fs'
21
+ import path from 'node:path'
22
+
23
+ const TYPES = new Set([0x1410, 0x1408, 0x0402, 0x0401])
24
+ const CTRL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/
25
+ const CJK = /[\u4e00-\u9fff]/
26
+ const LATIN = /[A-Za-z]{2,}/
27
+
28
+ const argv = process.argv.slice(2)
29
+ const flags = { exob: '' }
30
+ const pos = []
31
+ for (let i = 0; i < argv.length; i++) {
32
+ if (argv[i] === '--exob') flags.exob = argv[++i] || ''
33
+ else pos.push(argv[i])
34
+ }
35
+ if (!pos[0]) {
36
+ console.error('用法: node ebpro-decode.mjs <工程.emtp | 编译产物.exob | 工程目录> [输出目录] [--exob 路径]')
37
+ process.exit(2)
38
+ }
39
+
40
+ function fail(msg) { console.error(msg); process.exit(3) }
41
+
42
+ /** 输入 → { exob, emtp }:.emtp/.目录 自动找同目录同名 .exob */
43
+ function resolveInputs(arg, exobArg) {
44
+ const abs = path.resolve(arg)
45
+ if (!fs.existsSync(abs)) fail(`路径不存在: ${arg}`)
46
+ if (fs.statSync(abs).isDirectory()) {
47
+ const files = fs.readdirSync(abs)
48
+ const exobs = files.filter((f) => f.toLowerCase().endsWith('.exob'))
49
+ .map((f) => path.join(abs, f)).sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)
50
+ const emtps = files.filter((f) => f.toLowerCase().endsWith('.emtp'))
51
+ .map((f) => path.join(abs, f)).sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)
52
+ if (!exobs.length) fail(`目录里没有 .exob 编译产物:${abs}\n先在 EasyBuilder Pro 里编译(F7)再跑。`)
53
+ return { exob: exobArg ? path.resolve(exobArg) : exobs[0], emtp: emtps[0] || '' }
54
+ }
55
+ const low = abs.toLowerCase()
56
+ if (low.endsWith('.emtp')) {
57
+ if (exobArg) return { exob: path.resolve(exobArg), emtp: abs }
58
+ const sib = abs.replace(/\.emtp$/i, '.exob')
59
+ if (!fs.existsSync(sib)) fail(`找不到同名编译产物:${sib}\n.emtp 本体压缩+加密,读不动;在 EasyBuilder Pro 里编译(F7)生成 .exob 后再跑。`)
60
+ return { exob: sib, emtp: abs }
61
+ }
62
+ if (low.endsWith('.exob')) {
63
+ const sib = abs.replace(/\.exob$/i, '.emtp')
64
+ return { exob: abs, emtp: fs.existsSync(sib) ? sib : '' }
65
+ }
66
+ fail(`不认的输入:${arg}(要 .emtp 工程、.exob 编译产物或工程目录)`)
67
+ }
68
+
69
+ /** 工程本体头部:FF FE FF + 1 字节长 + UTF-16LE 的版本串(其余压缩+加密) */
70
+ function emtpMeta(p) {
71
+ if (!p || !fs.existsSync(p)) return null
72
+ const d = fs.readFileSync(p).subarray(0, 64)
73
+ const i = d.indexOf(Buffer.from([0xff, 0xfe, 0xff]))
74
+ const meta = { path: p, size: fs.statSync(p).size, mtime: fs.statSync(p).mtime }
75
+ if (i >= 0 && i + 4 <= d.length) {
76
+ const len = d[i + 3]
77
+ meta.version = d.subarray(i + 4, i + 4 + len * 2).toString('utf16le')
78
+ }
79
+ return meta
80
+ }
81
+
82
+ /** LABE_LIB:条目名 + 状态1 + 状态2(只有源语言,没有译文) */
83
+ function parseLib(buf) {
84
+ const at = buf.indexOf(Buffer.from('LABE_LIB'))
85
+ if (at < 0) return []
86
+ let p = at + 8 + 2
87
+ const out = []
88
+ while (p + 3 < buf.length) {
89
+ const nl = buf[p], t1 = buf[p + 1], t2 = buf[p + 2]
90
+ if (nl === 0 || nl > 100 || t1 > 200 || t2 > 200) break
91
+ const q = p + 3
92
+ if (q + nl + t1 + t2 > buf.length) break
93
+ const name = buf.subarray(q, q + nl).toString('utf8')
94
+ const s1 = buf.subarray(q + nl, q + nl + t1).toString('utf8')
95
+ const s2 = buf.subarray(q + nl + t1, q + nl + t1 + t2).toString('utf8')
96
+ out.push({ name, s1, s2 })
97
+ p = q + nl + t1 + t2
98
+ }
99
+ return out
100
+ }
101
+
102
+ /** 记录流:21 字节头 + 文本 + 0x00 */
103
+ function parseRecords(buf, start, limit) {
104
+ let p = start
105
+ const out = []
106
+ while (p + 21 <= limit) {
107
+ const x = buf.readUInt16LE(p)
108
+ const y = buf.readUInt16LE(p + 2)
109
+ const w = buf.readUInt16LE(p + 4)
110
+ const h = buf.readUInt16LE(p + 6)
111
+ const lang = buf[p + 12]
112
+ const len = buf.readUInt32LE(p + 14)
113
+ const tsp = p + 21
114
+ if (len === 0) { out.push({ x, y, w, h, lang, text: '', at: tsp }); p = tsp; continue }
115
+ if (len > 400 || tsp + len >= limit) break
116
+ const text = buf.subarray(tsp, tsp + len).toString('utf8')
117
+ if (buf[tsp + len] !== 0) break
118
+ out.push({ x, y, w, h, lang, text, at: tsp })
119
+ p = tsp + len + 1
120
+ }
121
+ return { recs: out, next: p }
122
+ }
123
+
124
+ /** 对象:'HA' + 2 字节类型码 + 记录 */
125
+ function parseObjects(buf) {
126
+ const out = []
127
+ let p = 0
128
+ while (true) {
129
+ const j = buf.indexOf(Buffer.from('HA'), p)
130
+ if (j < 0 || j + 4 > buf.length) break
131
+ const ty = (buf[j + 2] << 8) | buf[j + 3]
132
+ if (!TYPES.has(ty)) { p = j + 1; continue }
133
+ const { recs, next } = parseRecords(buf, j + 5, Math.min(j + 6000, buf.length))
134
+ if (recs.length) { out.push({ off: j, type: ty, recs }); p = next } else p = j + 1
135
+ }
136
+ return out
137
+ }
138
+
139
+ /** 能当文字用的记录:有字、无控制字符、语言槽 0..23(文件里夹着另一种 24 字节结构,会解出 NUL 串) */
140
+ const usable = (r) => {
141
+ const t = r.text.trim()
142
+ return t !== '' && !CTRL.test(t) && r.lang >= 0 && r.lang <= 23
143
+ }
144
+
145
+ const fmtTime = (d) => {
146
+ const p = (n) => String(n).padStart(2, '0')
147
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
148
+ }
149
+
150
+ function main() {
151
+ const { exob, emtp } = resolveInputs(pos[0], flags.exob)
152
+ const outDir = path.resolve(pos[1] || path.join(process.cwd(), '解码归档', path.basename(emtp || exob, path.extname(emtp || exob)), 'HMI解码'))
153
+ fs.mkdirSync(outDir, { recursive: true })
154
+
155
+ const buf = fs.readFileSync(exob)
156
+ const lib = parseLib(buf)
157
+ const objs = parseObjects(buf)
158
+ if (!objs.length) fail(`没解析到画面对象:${exob}\n可能这份 .exob 不是 EasyBuilder Pro 编出来的。`)
159
+
160
+ // 语言槽:源语言=含中文最多的槽(并列取小),目标语言=拉丁字母最多的槽
161
+ const cjk = new Map(), latin = new Map()
162
+ for (const o of objs) {
163
+ for (const r of o.recs) {
164
+ if (!usable(r)) continue
165
+ if (CJK.test(r.text)) cjk.set(r.lang, (cjk.get(r.lang) || 0) + 1)
166
+ else if (LATIN.test(r.text)) latin.set(r.lang, (latin.get(r.lang) || 0) + 1)
167
+ }
168
+ }
169
+ const pick = (m, dflt) => [...m.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0])[0]?.[0] ?? dflt
170
+ const zhLang = pick(cjk, null)
171
+ const enLang = pick(latin, null)
172
+
173
+ // 画面:大号标签对象(0x1408 且度量 ≥ 150×35)
174
+ const screens = objs.filter((o) => o.type === 0x1408
175
+ && o.recs.some((r) => usable(r) && r.w >= 150 && r.h >= 35))
176
+ .sort((a, b) => a.off - b.off)
177
+ .map((o) => {
178
+ let name = ''
179
+ for (const r of o.recs) if (usable(r) && r.lang === zhLang) { name = r.text.trim(); break }
180
+ if (!name) for (const r of o.recs) if (usable(r)) { name = r.text.trim(); break }
181
+ return { off: o.off, name: name || '(无标题文字)' }
182
+ })
183
+
184
+ const screenOf = (off) => {
185
+ let name = '(首屏之前)'
186
+ for (const s of screens) if (off >= s.off) name = s.name
187
+ return name
188
+ }
189
+
190
+ // 文字对象:同一对象内「同语言同文字」重复的只留一条
191
+ const words = []
192
+ for (const o of objs) {
193
+ const seen = new Set()
194
+ const recs = []
195
+ for (const r of o.recs) {
196
+ if (!usable(r)) continue
197
+ const key = JSON.stringify([r.lang, r.text.trim()])
198
+ if (seen.has(key)) continue
199
+ seen.add(key)
200
+ recs.push(r)
201
+ }
202
+ if (recs.length) words.push({ off: o.off, type: o.type, screen: screenOf(o.off), recs })
203
+ }
204
+ const events = words.filter((w) => w.type === 0x0401)
205
+ const screenWords = words.filter((w) => w.type !== 0x0401)
206
+
207
+ const langDist = {}
208
+ for (const w of words) for (const r of w.recs) langDist[r.lang] = (langDist[r.lang] || 0) + 1
209
+
210
+ const stat = {
211
+ source: {
212
+ exob: { path: exob, size: buf.length, mtime: fmtTime(fs.statSync(exob).mtime) },
213
+ emtp: emtp ? { path: emtp, size: fs.statSync(emtp).size, mtime: fmtTime(fs.statSync(emtp).mtime), version: emtpMeta(emtp)?.version } : null,
214
+ },
215
+ languages: { source: zhLang, target: enLang, distribution: langDist },
216
+ counts: { screens: screens.length, screenTextObjects: screenWords.length, events: events.length, library: lib.length },
217
+ screens,
218
+ objects: words,
219
+ library: lib,
220
+ }
221
+
222
+ const w = (name, content) => fs.writeFileSync(path.join(outDir, name), content, 'utf8')
223
+ const tsv = (head, rows) => head + '\n' + rows.map((r) => r.join('\t')).join('\n') + '\n'
224
+
225
+ w('10-画面.tsv', tsv('画面名(推定)\t编号(标题对象偏移)\t文字对象数',
226
+ screens.map((s) => [s.name, '0x' + s.off.toString(16).toUpperCase(), screenWords.filter((x) => x.screen === s.name).length])))
227
+
228
+ w('11-文字对象.tsv', tsv('所属画面(推定)\t类型码\t语言槽\t文字\t度量x\t度量y\t宽\t高\t对象偏移',
229
+ screenWords.flatMap((x) => x.recs.map((r) => [x.screen, '0x' + x.type.toString(16).toUpperCase().padStart(4, '0'),
230
+ r.lang, r.text.trim().replace(/\t/g, ' '), r.x, r.y, r.w, r.h, '0x' + x.off.toString(16).toUpperCase()]))))
231
+
232
+ w('12-报警事件.tsv', tsv('对象偏移\t语言槽\t文本',
233
+ events.flatMap((x) => x.recs.map((r) => ['0x' + x.off.toString(16).toUpperCase(), r.lang, r.text.trim().replace(/\t/g, ' ')]))))
234
+
235
+ w('13-文字库.tsv', tsv('条目名\t状态1\t状态2',
236
+ lib.map((e) => [e.name, e.s1.trim(), e.s2.trim()])))
237
+
238
+ const stale = emtp && fs.statSync(emtp).mtimeMs > fs.statSync(exob).mtimeMs + 60000
239
+
240
+ w('00-格式说明.md', [
241
+ '# 威纶通 EasyBuilder Pro(.emtp / .exob)格式说明',
242
+ '',
243
+ '| 文件 | 是什么 | 能不能读 |',
244
+ '|---|---|---|',
245
+ '| `.emtp` | 工程本体 | 头 4 字节后有 `FF FE FF + 1 字节长 + UTF-16LE` 的工程版本串;其后是压缩+加密数据,读不出结构 |',
246
+ '| `.exob` | 编译产物(软件里编译一次,与工程同名同目录) | UTF-8 明文:文字库段 `LABE_LIB` + 画面对象记录 + 若干未定段 |',
247
+ '',
248
+ '## 结构(本解码器用到的那两段)',
249
+ '',
250
+ '- `LABE_LIB`:标记后跳 2 字节,逐条 `[1B 名长][1B 状态1长][1B 状态2长]` + 三段 UTF-8。这一段**只有源语言**,没有译文。',
251
+ '- 对象记录:`HA` + 2 字节类型码 + 若干记录;记录 = 21 字节头(`+0..+7` 文字度量 u16 x/y/w/h,`+12` 语言槽 id,`+14..+17` 文本字节数 u32)+ UTF-8 文本 + `0x00`。',
252
+ '- 类型码原值:`0x1408` / `0x1410` / `0x0402` / `0x0401`;含义未判死,表里只用码不写类别名。',
253
+ '',
254
+ '## 判定口径',
255
+ '',
256
+ '- 语言槽:源语言=含中文最多的槽(并列取小);目标语言=拉丁字母最多的槽。',
257
+ '- 画面:以「度量 ≥ 150×35 的 0x1408 标签对象」当画面标题,其后的对象归这个画面;没有标题对象的画面会并到上一个。',
258
+ '- 噪声过滤:文本含控制字符、或语言槽 id 不在 0..23 的记录不是文字(文件里夹着另一种 24 字节结构,按本格式会解出 NUL 串)。',
259
+ '',
260
+ '## 能解与不能解',
261
+ '',
262
+ '- 能解:画面名(推定)、文字对象(所属画面/类型码/语言槽/文字/度量/偏移)、报警事件文本、文字库条目、工程版本。',
263
+ '- 不能解:工程本体 `.emtp`(压缩+加密);控件几何与画面坐标;PLC 地址与变量绑定(编译产物里没有可读的标签表段)。',
264
+ '- 度量列 x/y/w/h 是字串外框度量,不是画面坐标。',
265
+ '',
266
+ ].join('\n'))
267
+
268
+ const L = []
269
+ L.push(`# ${path.basename(emtp || exob, path.extname(emtp || exob))} · EasyBuilder Pro HMI 解码报告`)
270
+ L.push('')
271
+ L.push(`- 编译产物: ${path.basename(exob)}(${fmtTime(fs.statSync(exob).mtime)},${(buf.length / 1048576).toFixed(2)} MB,UTF-8 明文段)`)
272
+ L.push(emtp ? `- 工程文件: ${path.basename(emtp)}(${(fs.statSync(emtp).size / 1048576).toFixed(2)} MB,工程版本 ${emtpMeta(emtp)?.version || '未知'};本体压缩+加密)` : '- 工程文件: (未提供,只给了编译产物)')
273
+ L.push(`- 规模: 画面 ${screens.length}(推定)/ 文字对象 ${screenWords.length} / 报警事件 ${events.length} / 文字库 ${lib.length} 条`)
274
+ L.push(`- 语言槽: 源 ${zhLang ?? '未识别'} / 目标 ${enLang ?? '未识别'};分布 ${Object.entries(langDist).sort((a, b) => a[0] - b[0]).map(([k, v]) => `${k} 槽 ${v} 条`).join(',')}`)
275
+ if (stale) L.push(`- 注意: 工程比编译产物新,表是上一次编译的状态;要最新就在软件里重新编译再跑`)
276
+ L.push('')
277
+ L.push('## 画面清单')
278
+ L.push('')
279
+ L.push('| 画面名(推定) | 文字对象数 | 标题对象偏移 |')
280
+ L.push('|---|---|---|')
281
+ for (const s of screens) L.push(`| ${s.name.replace(/\|/g, '/')} | ${screenWords.filter((x) => x.screen === s.name).length} | 0x${s.off.toString(16).toUpperCase()} |`)
282
+ L.push('')
283
+ L.push('## 报警事件(前 40)')
284
+ L.push('')
285
+ L.push('| 语言槽 | 文本 |')
286
+ L.push('|---|---|')
287
+ for (const x of events.slice(0, 40)) for (const r of x.recs) L.push(`| ${r.lang} | ${r.text.trim().replace(/\|/g, '/')} |`)
288
+ L.push('')
289
+ L.push('## 文字库(前 40)')
290
+ L.push('')
291
+ L.push('| 条目名 | 状态1 | 状态2 |')
292
+ L.push('|---|---|---|')
293
+ for (const e of lib.slice(0, 40)) L.push(`| ${e.name.replace(/\|/g, '/')} | ${e.s1.trim().replace(/\|/g, '/')} | ${e.s2.trim().replace(/\|/g, '/')} |`)
294
+ L.push('')
295
+ L.push('先读这份;原始表在 10-画面.tsv / 11-文字对象.tsv / 12-报警事件.tsv / 13-文字库.tsv,结构化数据在 08-统计.json。')
296
+ w('HMI解码报告.md', L.join('\n') + '\n')
297
+ w('08-统计.json', JSON.stringify(stat, null, 1))
298
+
299
+ console.log(`OK ${path.basename(emtp || exob)} → ${outDir}`)
300
+ console.log(` 画面 ${screens.length}(推定)/ 文字对象 ${screenWords.length} / 报警事件 ${events.length} / 文字库 ${lib.length} 条`)
301
+ console.log(` 语言槽: 源 ${zhLang ?? '?'} / 目标 ${enLang ?? '?'}`)
302
+ if (stale) console.log(' 注意: 工程比编译产物新,表是上一次编译的状态')
303
+ }
304
+
305
+ main()