dsh-plc-decode 0.2.0 → 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.
- package/README.en.md +115 -27
- package/README.md +112 -135
- package/assets/codesys-tools/dvp_decode.py +53 -4
- package/assets/codesys-tools/read_project.py +1 -1
- package/assets/decode-pack.mjs +2 -2
- package/assets/dvp-ladder.mjs +174 -0
- package/assets/ebpro-decode.mjs +305 -0
- package/assets/{zzuser-hmi-decode-workflow.md → hmi-decode-workflow.md} +11 -13
- package/assets/hmismart-decode.mjs +453 -452
- package/assets/hmismart-report.mjs +9 -14
- package/assets/lad-branches.mjs +147 -0
- package/assets/ladder-render.mjs +118 -0
- package/assets/ladder-workflow.md +80 -0
- package/assets/smart-ladder-auto.mjs +182 -0
- package/assets/stl-to-ladder.mjs +299 -0
- package/hmi.js +38 -185
- package/index.js +71 -10
- package/ladder.js +381 -0
- package/package.json +7 -6
- package/scripts/smoke.mjs +2 -2
- package/assets/FStudio/346/226/207/346/234/254/345/272/223/345/257/274/345/205/245/346/240/274/345/274/217.md +0 -45
- package/assets/wincc_i18n.py +0 -504
- package/assets/zzuser-fstudio-i18n-workflow.md +0 -165
- package/assets/zzuser-nb-textlib-workflow.md +0 -93
- package/assets/zzuser-wincc-text-i18n-workflow.md +0 -96
- package/invariant.js +0 -10
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
dvp_decode.py — 台达 WPLSoft/ISPSoft DVP 工程文件解码器
|
|
4
4
|
|
|
5
5
|
用法:
|
|
6
|
-
python dvp_decode.py
|
|
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)
|
package/assets/decode-pack.mjs
CHANGED
|
@@ -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 += `-
|
|
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} 个网络;要哪段用
|
|
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()
|