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/LICENSE +21 -0
- package/README.en.md +85 -0
- package/README.md +128 -2
- package/assets/codesys-tools/check_project.py +390 -0
- package/assets/codesys-tools/dvp_decode.py +588 -0
- package/assets/codesys-tools/precompile_extract.py +146 -0
- package/assets/codesys-tools/read_project.py +506 -0
- package/assets/decode-pack.mjs +206 -0
- package/assets/icon-180.png +0 -0
- package/assets/smart-lad.mjs +299 -0
- package/assets/smart-net-trace.mjs +199 -0
- package/cordis.patch.yml +4 -0
- package/index.js +3435 -0
- package/invariant.js +10 -0
- package/package.json +57 -4
- package/scripts/smoke.mjs +157 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""加密 .project 的降级解码通道。
|
|
4
|
+
|
|
5
|
+
背景:CoDeSys/LeadSysStudio 可以把工程保存成加密 .project(不是 zip,整文件高熵)。
|
|
6
|
+
这种工程读不出 ST 源码与梯形图,但同一目录里通常留着编译缓存
|
|
7
|
+
(`<工程名>.precompilecache`,实际是 zip,内含一个序列化的 cache 文件),
|
|
8
|
+
缓存里有全部标识符与中文变量名/注释。
|
|
9
|
+
|
|
10
|
+
本脚本只从缓存里抽两类可核验的东西,不做任何猜测:
|
|
11
|
+
① ASCII 标识符(长度 >= 4,出现即记录)
|
|
12
|
+
② GBK 中文串(连续双字节,能严格解成中文,长度 >= 3 字)
|
|
13
|
+
|
|
14
|
+
输出:<outdir>/00-目录.md、符号名.txt、中文名与注释.txt
|
|
15
|
+
用法:python precompile_extract.py <工程目录或 .project 路径> <输出目录>
|
|
16
|
+
"""
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
import zipfile
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def find_cache(proj_path):
|
|
24
|
+
"""定位同目录下的 *.precompilecache(可能多个,取最大的)。"""
|
|
25
|
+
d = proj_path if os.path.isdir(proj_path) else os.path.dirname(proj_path)
|
|
26
|
+
cands = []
|
|
27
|
+
try:
|
|
28
|
+
for n in os.listdir(d):
|
|
29
|
+
if n.lower().endswith('.precompilecache'):
|
|
30
|
+
p = os.path.join(d, n)
|
|
31
|
+
cands.append((os.path.getsize(p), p))
|
|
32
|
+
except OSError:
|
|
33
|
+
return None
|
|
34
|
+
if not cands:
|
|
35
|
+
return None
|
|
36
|
+
cands.sort(reverse=True)
|
|
37
|
+
return cands[0][1]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_cache_bytes(cache_path):
|
|
41
|
+
"""precompilecache 是 zip(内含一个同名条目);也兼容裸缓存文件。"""
|
|
42
|
+
try:
|
|
43
|
+
with zipfile.ZipFile(cache_path) as z:
|
|
44
|
+
names = z.namelist()
|
|
45
|
+
if names:
|
|
46
|
+
return z.read(names[0]), names[0]
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
with open(cache_path, 'rb') as f:
|
|
50
|
+
return f.read(), os.path.basename(cache_path)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
ASCII_RE = re.compile(rb'[\x20-\x7e]{4,}')
|
|
54
|
+
CN_RE = re.compile(r'[\u4e00-\u9fa5]{2,}')
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def extract(data):
|
|
58
|
+
names = []
|
|
59
|
+
for m in ASCII_RE.finditer(data):
|
|
60
|
+
s = m.group().decode('ascii', 'ignore').strip()
|
|
61
|
+
if s:
|
|
62
|
+
names.append(s)
|
|
63
|
+
cns = []
|
|
64
|
+
i, n = 0, len(data) - 1
|
|
65
|
+
while i < n:
|
|
66
|
+
b0, b1 = data[i], data[i + 1]
|
|
67
|
+
if 0x81 <= b0 <= 0xFE and 0x40 <= b1 <= 0xFE and b1 != 0x7F:
|
|
68
|
+
j = i
|
|
69
|
+
while j < n and 0x81 <= data[j] <= 0xFE and 0x40 <= data[j + 1] <= 0xFE and data[j + 1] != 0x7F:
|
|
70
|
+
j += 2
|
|
71
|
+
if j - i >= 6:
|
|
72
|
+
try:
|
|
73
|
+
t = data[i:j].decode('gbk')
|
|
74
|
+
except Exception:
|
|
75
|
+
t = ''
|
|
76
|
+
if t and CN_RE.search(t):
|
|
77
|
+
cns.append(t)
|
|
78
|
+
i = j
|
|
79
|
+
else:
|
|
80
|
+
i += 1
|
|
81
|
+
return names, cns
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
KEYWORD = re.compile(
|
|
85
|
+
r'^(IF|THEN|ELSE|ELSIF|END_IF|CASE|OF|FOR|TO|BY|DO|END_FOR|WHILE|END_WHILE|REPEAT|UNTIL|'
|
|
86
|
+
r'VAR|VAR_INPUT|VAR_OUTPUT|VAR_IN_OUT|END_VAR|PROGRAM|FUNCTION|FUNCTION_BLOCK|END_PROGRAM|'
|
|
87
|
+
r'END_FUNCTION|END_FUNCTION_BLOCK|TRUE|FALSE|AND|OR|NOT|XOR|MOD|AT|RETAIN|CONSTANT)$')
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main():
|
|
91
|
+
if len(sys.argv) < 3:
|
|
92
|
+
print('用法: precompile_extract.py <工程目录或 .project> <输出目录>')
|
|
93
|
+
return 2
|
|
94
|
+
proj, outd = sys.argv[1], sys.argv[2]
|
|
95
|
+
cache = find_cache(proj)
|
|
96
|
+
if not cache:
|
|
97
|
+
print('FAIL 未找到 .precompilecache:该工程既不是 zip,也没有可用的编译缓存')
|
|
98
|
+
return 1
|
|
99
|
+
data, inner = read_cache_bytes(cache)
|
|
100
|
+
names, cns = extract(data)
|
|
101
|
+
# 去重并保留出现顺序;纯数字/纯符号的噪声去掉
|
|
102
|
+
seen, uniq_names = set(), []
|
|
103
|
+
for s in names:
|
|
104
|
+
if len(s) < 4 or s in seen:
|
|
105
|
+
continue
|
|
106
|
+
if not re.search(r'[A-Za-z_]', s):
|
|
107
|
+
continue
|
|
108
|
+
seen.add(s)
|
|
109
|
+
uniq_names.append(s)
|
|
110
|
+
seen_cn, uniq_cn = set(), []
|
|
111
|
+
for s in cns:
|
|
112
|
+
if s in seen_cn:
|
|
113
|
+
continue
|
|
114
|
+
seen_cn.add(s)
|
|
115
|
+
uniq_cn.append(s)
|
|
116
|
+
|
|
117
|
+
os.makedirs(outd, exist_ok=True)
|
|
118
|
+
with open(os.path.join(outd, '符号名.txt'), 'w', encoding='utf-8') as f:
|
|
119
|
+
f.write('\n'.join(uniq_names) + '\n')
|
|
120
|
+
with open(os.path.join(outd, '中文名与注释.txt'), 'w', encoding='utf-8') as f:
|
|
121
|
+
f.write('\n'.join(uniq_cn) + '\n')
|
|
122
|
+
|
|
123
|
+
proj_name = os.path.basename(proj)
|
|
124
|
+
size_mb = round(len(data) / 1048576, 1)
|
|
125
|
+
md = []
|
|
126
|
+
md.append('# %s · 降级解码(加密工程)\n' % proj_name)
|
|
127
|
+
md.append('## 这是什么\n')
|
|
128
|
+
md.append('- 源工程 `.project` 不是 zip(整文件高熵),是 CoDeSys/LeadSysStudio 的**加密保存**:源码与梯形图读不出来。\n')
|
|
129
|
+
md.append('- 本目录的内容来自同目录编译缓存 `%s`(解压后 %s MB,条目 `%s`),只抽可核验的名字,不做猜测。\n' % (os.path.basename(cache), size_mb, inner))
|
|
130
|
+
md.append('- **能回答**:这台机器有哪些变量/功能块/中文名与注释(约 %d 个标识符、%d 条中文串)。\n' % (len(uniq_names), len(uniq_cn)))
|
|
131
|
+
md.append('- **不能回答**:某段逻辑怎么接(串并联/边沿)、某地址谁写谁读、梯形图——这些只在加密的源码里。要这些,只能让原作者在软件里「另存为非加密工程」或导出 ST/源程序。\n')
|
|
132
|
+
md.append('\n## 文件\n\n| 文件 | 内容 |\n|---|---|\n')
|
|
133
|
+
md.append('| 符号名.txt | 全部 ASCII 标识符(变量名/块名/指令名/库常量,按出现顺序去重) |\n')
|
|
134
|
+
md.append('| 中文名与注释.txt | 全部 GBK 中文串(变量中文名、注释、报警文本) |\n')
|
|
135
|
+
md.append('\n## 怎么用\n')
|
|
136
|
+
md.append('- 问「这台机器有哪些功能点/报警/参数」→ 读 中文名与注释.txt(按关键词 grep)。\n')
|
|
137
|
+
md.append('- 问「某个变量叫什么、属于哪个块」→ 在 符号名.txt 里 grep 前缀(如 `ACS_`、`ADDAXIS_`)。\n')
|
|
138
|
+
md.append('- 不要拿这两个文件推逻辑:它们只有名字,没有程序结构。\n')
|
|
139
|
+
with open(os.path.join(outd, '00-目录.md'), 'w', encoding='utf-8') as f:
|
|
140
|
+
f.write(''.join(md))
|
|
141
|
+
print('OK 降级解码完成: %s -> %s(标识符 %d、中文串 %d)' % (os.path.basename(cache), outd, len(uniq_names), len(uniq_cn)))
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == '__main__':
|
|
146
|
+
sys.exit(main())
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
read_project.py — 读取 CoDeSys V3.5 / LeadSysStudio 工程文件 (.project) 的内容。
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
python read_project.py "C:\\projects\\demo.project" [-o 输出目录]
|
|
7
|
+
|
|
8
|
+
功能:
|
|
9
|
+
1. 解包 .project (ZIP)
|
|
10
|
+
2. 解码共享字符串表 __shared_data_storage_string_table__.auxiliary
|
|
11
|
+
(varint 总数 + [id varint][len varint][UTF-8 字节] 记录, id 0..N 顺序)
|
|
12
|
+
3. 解析每个 {GUID}.object 的内容引用, 按序恢复 POU/GVL/DUT 源码
|
|
13
|
+
4. 导出: 工程内容报告.md + pous/<名称>.txt + 对象表 + 全部字符串
|
|
14
|
+
|
|
15
|
+
格式要点 (逆向自 3.5.15.40 内核工程, 已验证零剩余消费):
|
|
16
|
+
* .project 是 ZIP; 内部 {GUID}.meta/.object/.auxiliary
|
|
17
|
+
* 所有文件: 16 字节头 (02 20 09 28 + 12×00) + 4 字节 LE 长度 + 负载
|
|
18
|
+
* 负载节点: 0x0f <varint 类型> <内容>
|
|
19
|
+
- 类型 0x03: 内联 UTF-16 字符串 (如 ProjectRoot 的 IArchivable XML)
|
|
20
|
+
- 类型 0x3F: 单个 2 字节 LE 字符串引用
|
|
21
|
+
- 记录容器 (POU/GVL/DUT 等): 若干 section, 每个 =
|
|
22
|
+
配置 varint (最后一个=记录数) + 0x0f 0x20 + 记录×N
|
|
23
|
+
记录 = <A varint><补齐=9-实际A字节数><B varint><0x20>
|
|
24
|
+
最后一条记录可无 0x20 (直接接下一 section 配置)
|
|
25
|
+
B = 内容字符串引用 (按序)
|
|
26
|
+
- 设备对象 (类型 0x18/0x4D 等): 另类结构, 回退为"提取全部引用"
|
|
27
|
+
"""
|
|
28
|
+
import io
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import struct
|
|
32
|
+
import sys
|
|
33
|
+
import zipfile
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__" and hasattr(sys.stdout, "reconfigure"):
|
|
36
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
37
|
+
|
|
38
|
+
MAGIC = b"\x02\x20\x09\x28"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_varint(b, i):
|
|
42
|
+
v = 0
|
|
43
|
+
sh = 0
|
|
44
|
+
while True:
|
|
45
|
+
x = b[i]
|
|
46
|
+
i += 1
|
|
47
|
+
v |= (x & 0x7F) << sh
|
|
48
|
+
if not (x & 0x80):
|
|
49
|
+
return v, i
|
|
50
|
+
sh += 7
|
|
51
|
+
if sh > 70:
|
|
52
|
+
raise ValueError("varint too long")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_strings(path):
|
|
56
|
+
with open(path, "rb") as f:
|
|
57
|
+
data = f.read()
|
|
58
|
+
i = 0
|
|
59
|
+
count, i = read_varint(data, i)
|
|
60
|
+
out = []
|
|
61
|
+
for _ in range(count):
|
|
62
|
+
_, i = read_varint(data, i)
|
|
63
|
+
slen, i = read_varint(data, i)
|
|
64
|
+
out.append(data[i:i + slen])
|
|
65
|
+
i += slen
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def decode(b):
|
|
70
|
+
return b.decode("utf-8", "replace")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_payload(path):
|
|
74
|
+
data = open(path, "rb").read()
|
|
75
|
+
ln = struct.unpack("<I", data[16:20])[0]
|
|
76
|
+
return data[20:20 + ln]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_inline_string(payload):
|
|
80
|
+
"""类型 0x03 内联 UTF-16 字符串 (如 IArchivable XML)。"""
|
|
81
|
+
if not payload or payload[0] != 0x0F:
|
|
82
|
+
return None
|
|
83
|
+
try:
|
|
84
|
+
t, i = read_varint(payload, 1)
|
|
85
|
+
if t != 0x03:
|
|
86
|
+
return None
|
|
87
|
+
ln, i = read_varint(payload, i)
|
|
88
|
+
if i < len(payload) and payload[i] == 0x02:
|
|
89
|
+
i += 1
|
|
90
|
+
raw = payload[i:i + ln]
|
|
91
|
+
if raw[:2] == b"\xff\xfe":
|
|
92
|
+
return raw[2:].decode("utf-16-le", "replace")
|
|
93
|
+
return raw.decode("utf-16-le", "replace")
|
|
94
|
+
except Exception:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_record_container(payload):
|
|
99
|
+
"""
|
|
100
|
+
解析记录容器。返回 (sections, leftover, err)。
|
|
101
|
+
section = (config, [(A, B), ...]); B = 内容字符串引用。
|
|
102
|
+
语法: [A varint][补齐 9-实际A字节][B varint][0x20]; 末条可无 0x20。
|
|
103
|
+
"""
|
|
104
|
+
p = payload
|
|
105
|
+
if not p or p[0] != 0x0F:
|
|
106
|
+
return [], p, "not 0x0f"
|
|
107
|
+
try:
|
|
108
|
+
t, i = read_varint(p, 1)
|
|
109
|
+
except Exception:
|
|
110
|
+
return [], p, "bad type"
|
|
111
|
+
if t in (0x03, 0x3F):
|
|
112
|
+
return [], p, "leaf type"
|
|
113
|
+
sections = []
|
|
114
|
+
while i < len(p):
|
|
115
|
+
config = []
|
|
116
|
+
while i < len(p):
|
|
117
|
+
if p[i] == 0x0F:
|
|
118
|
+
# 0x0f 可能是段标记 (0f 20), 也可能是配置 varint 值 15 (如记录数 N=15)。
|
|
119
|
+
# 判定: 后一字节是 0x20 → 段标记; 否则当作配置 varint 15 继续读。
|
|
120
|
+
if i + 1 < len(p) and p[i + 1] == 0x20:
|
|
121
|
+
break
|
|
122
|
+
try:
|
|
123
|
+
v, i = read_varint(p, i)
|
|
124
|
+
except Exception:
|
|
125
|
+
return sections, p[i:], "bad config varint"
|
|
126
|
+
config.append(v)
|
|
127
|
+
if i >= len(p):
|
|
128
|
+
break
|
|
129
|
+
if i + 1 >= len(p) or p[i + 1] != 0x20:
|
|
130
|
+
return sections, p[i:], f"bad section marker @{i}"
|
|
131
|
+
i += 2
|
|
132
|
+
n = config[-1]
|
|
133
|
+
recs = []
|
|
134
|
+
for r in range(n):
|
|
135
|
+
if i >= len(p):
|
|
136
|
+
return sections, p[i:], f"ran out rec{r}"
|
|
137
|
+
a0 = i
|
|
138
|
+
try:
|
|
139
|
+
A, i = read_varint(p, i)
|
|
140
|
+
except Exception:
|
|
141
|
+
return sections, p[i:], f"bad A rec{r}"
|
|
142
|
+
alen = i - a0
|
|
143
|
+
pad = 9 - alen
|
|
144
|
+
if pad < 0 or i + pad > len(p):
|
|
145
|
+
return sections, p[i:], f"pad overflow rec{r} A={A}"
|
|
146
|
+
i += pad
|
|
147
|
+
try:
|
|
148
|
+
B, i = read_varint(p, i)
|
|
149
|
+
except Exception:
|
|
150
|
+
return sections, p[i:], f"bad B rec{r}"
|
|
151
|
+
if i >= len(p):
|
|
152
|
+
return sections, p[i:], f"eof rec{r}"
|
|
153
|
+
if p[i] == 0x20:
|
|
154
|
+
i += 1
|
|
155
|
+
elif r != n - 1:
|
|
156
|
+
return sections, p[i:], f"no term rec{r} A={A} B={B} byte={p[i]:02x}"
|
|
157
|
+
recs.append((A, B))
|
|
158
|
+
sections.append((config, recs))
|
|
159
|
+
return sections, b"", None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def parse_task_config(payload, strings):
|
|
163
|
+
"""
|
|
164
|
+
任务对象 (节点类型 0x2a): 返回 (参数列表, 挂载程序名列表) 或 None。
|
|
165
|
+
|
|
166
|
+
结构 (逆向自 3.5.15.40 内核, 已验证 MainTask):
|
|
167
|
+
0f 2a <配置 varints…> 0f 29 <调用列表>
|
|
168
|
+
配置区引用: 任务类型(Cyclic)、优先级(1)、周期(t#…ms)、看门狗等字符串。
|
|
169
|
+
调用列表每项: <程序名引用> <17 空串> <41 'True'>,按顺序重复 = 挂载顺序。
|
|
170
|
+
"""
|
|
171
|
+
if not payload or payload[0] != 0x0F:
|
|
172
|
+
return None
|
|
173
|
+
t, i = read_varint(payload, 1)
|
|
174
|
+
if t != 0x2A:
|
|
175
|
+
return None
|
|
176
|
+
params = []
|
|
177
|
+
while i < len(payload) and payload[i] != 0x0F:
|
|
178
|
+
v, i = read_varint(payload, i)
|
|
179
|
+
if v < len(strings):
|
|
180
|
+
s = decode(strings[v]).strip()
|
|
181
|
+
if s and not (len(s) == 36 and "-" in s):
|
|
182
|
+
params.append(s)
|
|
183
|
+
calls = []
|
|
184
|
+
if i < len(payload) and payload[i] == 0x0F:
|
|
185
|
+
i += 1 # 跳过 0x0F 节点标记 (0x0F 本身会被误读成 varint 15)
|
|
186
|
+
while i < len(payload):
|
|
187
|
+
try:
|
|
188
|
+
v, i = read_varint(payload, i)
|
|
189
|
+
except Exception:
|
|
190
|
+
break
|
|
191
|
+
if v == 17 or v >= len(strings):
|
|
192
|
+
continue
|
|
193
|
+
s = decode(strings[v]).strip()
|
|
194
|
+
if not s or (len(s) == 36 and "-" in s):
|
|
195
|
+
continue
|
|
196
|
+
if s in ("True", "False", "ms"):
|
|
197
|
+
continue
|
|
198
|
+
if s not in calls:
|
|
199
|
+
calls.append(s)
|
|
200
|
+
return params, calls
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
ZERO_GUID = "00000000-0000-0000-0000-000000000000"
|
|
204
|
+
META_PARENT_MARK = 1468 # 'd9b2b2cc-...' 系统 GUID, 标记"文件夹父节点"字段
|
|
205
|
+
APP_REF = 57 # 'ff8d0207-...' Application (Plc Logic)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def parse_meta(meta, self_guid_lower, strings):
|
|
209
|
+
"""
|
|
210
|
+
解析 .meta 负载 -> (对象名, 父GUID小写|None, 父ref)。
|
|
211
|
+
|
|
212
|
+
逆向规律 (CoDeSys 3.5.15.40 内核, 45 对象全验证):
|
|
213
|
+
* 普通 POU (PROGRAM/FB/DUT/GVL/文件夹) 结构:
|
|
214
|
+
0f 19 <self> 57(Application) <name> ... [bc0b(1468) <文件夹父>] <时间戳>
|
|
215
|
+
- 1468 段 = 文件夹父节点 (有则在该文件夹下)
|
|
216
|
+
- 无 1468 段 = 直接挂 Application (如 GVL/库管理器/Task Configuration)
|
|
217
|
+
* 方法/动作: 0f 19 <self> <父(FB/PROGRAM)> <name> ... (父在第 2 位)
|
|
218
|
+
* 设备对象: 0f 01 <self> <父(Device/从站/TaskConfig)> <name> ...
|
|
219
|
+
"""
|
|
220
|
+
refs = []
|
|
221
|
+
i = 0
|
|
222
|
+
while i < len(meta):
|
|
223
|
+
try:
|
|
224
|
+
v, i = read_varint(meta, i)
|
|
225
|
+
except Exception:
|
|
226
|
+
break
|
|
227
|
+
refs.append(v)
|
|
228
|
+
# self 定位: 匹配自身 GUID
|
|
229
|
+
self_pos = None
|
|
230
|
+
for j, v in enumerate(refs):
|
|
231
|
+
if v < len(strings):
|
|
232
|
+
s = decode(strings[v]).strip().lower()
|
|
233
|
+
if s == self_guid_lower:
|
|
234
|
+
self_pos = j
|
|
235
|
+
break
|
|
236
|
+
# 父: 1468 段优先, 否则 self 后第一个 varint
|
|
237
|
+
parent_ref = None
|
|
238
|
+
for j, v in enumerate(refs):
|
|
239
|
+
if v == META_PARENT_MARK and j + 1 < len(refs):
|
|
240
|
+
parent_ref = refs[j + 1]
|
|
241
|
+
break
|
|
242
|
+
if parent_ref is None and self_pos is not None and self_pos + 1 < len(refs):
|
|
243
|
+
parent_ref = refs[self_pos + 1]
|
|
244
|
+
# 对象名: self 后第一个有意义字符串 (跳过 57/父GUID/空/True)
|
|
245
|
+
name = None
|
|
246
|
+
if self_pos is not None:
|
|
247
|
+
for v in refs[self_pos + 1:]:
|
|
248
|
+
if v == 17 or v >= len(strings) or v == APP_REF:
|
|
249
|
+
continue
|
|
250
|
+
s = decode(strings[v]).strip()
|
|
251
|
+
if not s or (len(s) == 36 and "-" in s):
|
|
252
|
+
continue
|
|
253
|
+
if s in ("True", "False"):
|
|
254
|
+
continue
|
|
255
|
+
name = s
|
|
256
|
+
break
|
|
257
|
+
pg = None
|
|
258
|
+
if parent_ref is not None and parent_ref < len(strings):
|
|
259
|
+
s = decode(strings[parent_ref]).strip().lower()
|
|
260
|
+
pg = s if (len(s) == 36 and "-" in s and s != ZERO_GUID) else None
|
|
261
|
+
return name, pg, parent_ref
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def all_refs(payload, limit=5470):
|
|
265
|
+
"""回退: 提取负载中所有 < limit 的 varint 引用。"""
|
|
266
|
+
out = []
|
|
267
|
+
i = 0
|
|
268
|
+
while i < len(payload):
|
|
269
|
+
try:
|
|
270
|
+
v, i = read_varint(payload, i)
|
|
271
|
+
except Exception:
|
|
272
|
+
break
|
|
273
|
+
if v < limit:
|
|
274
|
+
out.append(v)
|
|
275
|
+
return out
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------- 主流程 ----------------
|
|
279
|
+
|
|
280
|
+
KNOWN_NAMES = {
|
|
281
|
+
"7fd59d4c-5e8e-4f82-8521-507f6d68255d": "SoftMotion Axis Pool",
|
|
282
|
+
"6470a90f-b7cb-43ac-9ae5-94b2338b4573": "ProjectRoot(工程根)",
|
|
283
|
+
"7698bf3c-fcdd-4dab-9409-f5391e731c77": "Device",
|
|
284
|
+
"11029987-8dbd-472f-be5f-2feeb1cced7d": "Plc Logic / Application",
|
|
285
|
+
"e332f54a-a446-4af3-9e2e-ee2298cf2522": "库管理器",
|
|
286
|
+
"8e2fb6e9-2c5b-4160-903c-506ed61d09a9": "Task Configuration",
|
|
287
|
+
"cd7ea0c0-e176-4cfc-be4c-6555f6c9cbc5": "MainTask",
|
|
288
|
+
"11c0fc3a-9bcf-4dd8-ac38-efb93363e521": "Project Information",
|
|
289
|
+
"21fe9988-c007-4d22-b201-ba54de71a744": "Device Diagnose",
|
|
290
|
+
"45e6260f-8633-4368-a580-2131dc496e98": "Network Configuration",
|
|
291
|
+
"67dfde7e-3e58-4922-9bf7-b260004fc0ec": "LocalBus Config",
|
|
292
|
+
"f6ace620-d49c-4e7a-96de-0df8b862119b": "Resource Usage",
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def extract(project_path, out_dir):
|
|
297
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
298
|
+
extract_dir = os.path.join(out_dir, "extracted")
|
|
299
|
+
os.makedirs(extract_dir, exist_ok=True)
|
|
300
|
+
|
|
301
|
+
with zipfile.ZipFile(project_path) as z:
|
|
302
|
+
z.extractall(extract_dir)
|
|
303
|
+
print(f"[1] 解包完成: {extract_dir}")
|
|
304
|
+
|
|
305
|
+
str_path = os.path.join(extract_dir, "__shared_data_storage_string_table__.auxiliary")
|
|
306
|
+
strings = load_strings(str_path)
|
|
307
|
+
with open(os.path.join(out_dir, "全部字符串.txt"), "w", encoding="utf-8") as f:
|
|
308
|
+
for n, s in enumerate(strings):
|
|
309
|
+
f.write(f"[{n}]\t{decode(s)}\n")
|
|
310
|
+
print(f"[2] 字符串表: {len(strings)} 条")
|
|
311
|
+
|
|
312
|
+
# GUID -> 字符串索引
|
|
313
|
+
guid_idx = {}
|
|
314
|
+
for n, s in enumerate(strings):
|
|
315
|
+
t = decode(s).strip().lower()
|
|
316
|
+
if len(t) == 36 and "-" in t:
|
|
317
|
+
guid_idx.setdefault(t, n)
|
|
318
|
+
|
|
319
|
+
objs = []
|
|
320
|
+
for fn in sorted(os.listdir(extract_dir)):
|
|
321
|
+
if fn.endswith(".object"):
|
|
322
|
+
objs.append((fn[:-7], fn[:-7].lower()))
|
|
323
|
+
objs.sort(key=lambda x: guid_idx.get(x[1], 10 ** 9))
|
|
324
|
+
|
|
325
|
+
# meta 解析: 对象名 + 父节点 (解构项目树)
|
|
326
|
+
meta_map = {} # guid_lower -> (name, parent_guid|None, parent_ref)
|
|
327
|
+
for fn in os.listdir(extract_dir):
|
|
328
|
+
if fn.endswith(".meta"):
|
|
329
|
+
guid = fn[:-5]
|
|
330
|
+
try:
|
|
331
|
+
data = open(os.path.join(extract_dir, fn), "rb").read()
|
|
332
|
+
plen = struct.unpack("<I", data[16:20])[0]
|
|
333
|
+
meta = data[20:20 + plen]
|
|
334
|
+
meta_map[guid.lower()] = parse_meta(meta, guid.lower(), strings)
|
|
335
|
+
except Exception:
|
|
336
|
+
meta_map.setdefault(guid.lower(), (None, None, None))
|
|
337
|
+
|
|
338
|
+
pous_dir = os.path.join(out_dir, "pous")
|
|
339
|
+
os.makedirs(pous_dir, exist_ok=True)
|
|
340
|
+
obj_lines = []
|
|
341
|
+
report = []
|
|
342
|
+
report.append("# 工程内容报告")
|
|
343
|
+
report.append("")
|
|
344
|
+
report.append(f"来源: `{project_path}`")
|
|
345
|
+
report.append("")
|
|
346
|
+
|
|
347
|
+
for name, guid in objs:
|
|
348
|
+
idx = guid_idx.get(guid)
|
|
349
|
+
# 通用名称推导: 对象自身 GUID 字符串在字符串表中的下一条
|
|
350
|
+
# 非空且非 GUID 的字符串通常即对象名称 (每个工程都适用)。
|
|
351
|
+
derived = None
|
|
352
|
+
if idx is not None:
|
|
353
|
+
for j in range(idx + 1, min(idx + 6, len(strings))):
|
|
354
|
+
cand = decode(strings[j]).strip()
|
|
355
|
+
if cand and not (len(cand) == 36 and "-" in cand):
|
|
356
|
+
derived = cand
|
|
357
|
+
break
|
|
358
|
+
# 对象名优先取 meta(工程内自带的名字),否则查标准对象表 / 由父节点推导
|
|
359
|
+
meta_name = meta_map.get(guid, (None, None, None))[0]
|
|
360
|
+
label = meta_name or KNOWN_NAMES.get(name, derived or "?")
|
|
361
|
+
obj_path = os.path.join(extract_dir, name + ".object")
|
|
362
|
+
payload = load_payload(obj_path)
|
|
363
|
+
xml = is_inline_string(payload)
|
|
364
|
+
sections, left, err = parse_record_container(payload)
|
|
365
|
+
|
|
366
|
+
if err is None and sections:
|
|
367
|
+
# 记录容器: 每 section 的 B 引用 = 内容; 含 PROGRAM/FB/TYPE/VAR_GLOBAL
|
|
368
|
+
# 关键字的 section 视为"声明头", 排到最前。
|
|
369
|
+
sec_texts = []
|
|
370
|
+
for cfg, recs in sections:
|
|
371
|
+
# 同一 section 内同一字符串 id 会被多次引用 (如各块的 END_IF / 重复赋值行),
|
|
372
|
+
# 必须按原序全量保留, 不能去重。
|
|
373
|
+
tlist = []
|
|
374
|
+
for A, B in recs:
|
|
375
|
+
if B is not None and B < len(strings):
|
|
376
|
+
tlist.append(B)
|
|
377
|
+
sec_texts.append(tlist)
|
|
378
|
+
HEADER_RE = re.compile(r"^\s*(PROGRAM|FUNCTION_BLOCK|FUNCTION|TYPE|VAR_GLOBAL)\b")
|
|
379
|
+
header = [t for t in sec_texts if any(
|
|
380
|
+
HEADER_RE.match(decode(strings[i])) for i in t)]
|
|
381
|
+
body = [t for t in sec_texts if t not in header]
|
|
382
|
+
content_ids = []
|
|
383
|
+
for grp in header + body: # 声明头在前, 实现体在后
|
|
384
|
+
for cid in grp:
|
|
385
|
+
content_ids.append(cid)
|
|
386
|
+
mode = "record-container"
|
|
387
|
+
else:
|
|
388
|
+
task = parse_task_config(payload, strings)
|
|
389
|
+
if task is not None:
|
|
390
|
+
# 任务对象: 输出参数 + 挂载程序
|
|
391
|
+
mode = "task"
|
|
392
|
+
tparams, tcalls = task
|
|
393
|
+
lines = []
|
|
394
|
+
lines.append("// 任务参数: " + (" | ".join(tparams) if tparams else "(无)"))
|
|
395
|
+
lines.append("// 挂载程序:")
|
|
396
|
+
if tcalls:
|
|
397
|
+
lines += [f"// - {c}" for c in tcalls]
|
|
398
|
+
else:
|
|
399
|
+
lines.append("// (无)")
|
|
400
|
+
content_ids = []
|
|
401
|
+
else:
|
|
402
|
+
# 回退: 全部引用
|
|
403
|
+
content_ids = []
|
|
404
|
+
for r in all_refs(payload):
|
|
405
|
+
if r < len(strings) and r not in content_ids:
|
|
406
|
+
content_ids.append(r)
|
|
407
|
+
mode = "fallback"
|
|
408
|
+
|
|
409
|
+
# 内容文本:
|
|
410
|
+
# record-container 模式: B 引用按原序全量输出 —— CoDeSys 共享字符串表中
|
|
411
|
+
# 相同文本共用同一 id, 同一对象内多处引用同一 id 是常态 (如各块的 END_IF),
|
|
412
|
+
# 按 id/内容去重会丢行, 导致源码缺闭合语句。
|
|
413
|
+
# task 模式: lines 已在任务分支构造。
|
|
414
|
+
# fallback 模式: 保留按 id+内容去重 (该模式本就是"提取全部引用", 去重防膨胀)。
|
|
415
|
+
if mode == "record-container":
|
|
416
|
+
lines = [decode(strings[cid]) for cid in content_ids]
|
|
417
|
+
elif mode == "task":
|
|
418
|
+
pass
|
|
419
|
+
else:
|
|
420
|
+
lines = []
|
|
421
|
+
seen = set()
|
|
422
|
+
for cid in content_ids:
|
|
423
|
+
s = decode(strings[cid])
|
|
424
|
+
if s.strip() and s not in seen:
|
|
425
|
+
seen.add(s)
|
|
426
|
+
lines.append(s)
|
|
427
|
+
|
|
428
|
+
m_parent = meta_map.get(guid, (None, None, None))[1] or ""
|
|
429
|
+
obj_lines.append((name, label, m_parent, idx, len(payload), mode,
|
|
430
|
+
len(content_ids), xml is not None, err or ""))
|
|
431
|
+
|
|
432
|
+
if xml:
|
|
433
|
+
xml_path = os.path.join(out_dir, f"{re.sub(r'[^\\w一-鿿-]+', '_', label)}.xml")
|
|
434
|
+
with open(xml_path, "w", encoding="utf-8") as f:
|
|
435
|
+
f.write(xml)
|
|
436
|
+
lines.append(f"[XML 已导出: {os.path.basename(xml_path)}]")
|
|
437
|
+
|
|
438
|
+
if lines:
|
|
439
|
+
safe = re.sub(r"[^\w一-鿿-]+", "_", label) or name[:8]
|
|
440
|
+
with open(os.path.join(pous_dir, safe + ".txt"), "w", encoding="utf-8") as f:
|
|
441
|
+
f.write(f"# {label}\n# GUID: {name}\n# 模式: {mode}\n\n")
|
|
442
|
+
f.write("\n".join(lines))
|
|
443
|
+
|
|
444
|
+
report.append(f"## {label} (`{name[:8]}`…, {mode}, {len(content_ids)} 串)")
|
|
445
|
+
report.append("")
|
|
446
|
+
if lines:
|
|
447
|
+
report.append("```")
|
|
448
|
+
report.append("\n".join(lines[:400]))
|
|
449
|
+
if len(lines) > 400:
|
|
450
|
+
report.append(f"... (共 {len(lines)} 行, 已截断)")
|
|
451
|
+
report.append("```")
|
|
452
|
+
report.append("")
|
|
453
|
+
|
|
454
|
+
# 对象表 (含父节点列)
|
|
455
|
+
label_of = {}
|
|
456
|
+
for o in obj_lines:
|
|
457
|
+
label_of[o[0].lower()] = o[1]
|
|
458
|
+
with open(os.path.join(out_dir, "对象表.md"), "w", encoding="utf-8") as f:
|
|
459
|
+
f.write("| 对象 | 名称 | 父对象 | 字符串idx | 负载字节 | 模式 | 内容串 | XML | 错误 |\n")
|
|
460
|
+
f.write("|---|---|---|---|---|---|---|---|---|\n")
|
|
461
|
+
for name, label, m_parent, idx, plen, mode, cn, isxml, err in obj_lines:
|
|
462
|
+
plabel = label_of.get(m_parent, m_parent[:8]) if m_parent else ""
|
|
463
|
+
f.write(f"| `{name[:8]}` | {label} | {plabel} | {idx} | {plen} | {mode} | {cn} | {isxml} | {err or ''} |\n")
|
|
464
|
+
|
|
465
|
+
# 项目树
|
|
466
|
+
tree_lines = ["# 项目树", "", f"来源: `{project_path}`", ""]
|
|
467
|
+
root = "6470a90f-b7cb-43ac-9ae5-94b2338b4573"
|
|
468
|
+
tree_visited = set()
|
|
469
|
+
def tree_render(g, indent):
|
|
470
|
+
if g in tree_visited:
|
|
471
|
+
return
|
|
472
|
+
tree_visited.add(g)
|
|
473
|
+
tree_lines.append(" " * indent + "- " + (label_of.get(g, g[:8])))
|
|
474
|
+
for c in sorted(label_of):
|
|
475
|
+
pc = meta_map.get(c, (None, None, None))[1]
|
|
476
|
+
if g == root:
|
|
477
|
+
# 根级对象: 父不在本工程或为根 (全零) 的都挂 ProjectRoot 下
|
|
478
|
+
if pc is None or pc not in label_of:
|
|
479
|
+
tree_render(c, indent + 1)
|
|
480
|
+
elif pc == g:
|
|
481
|
+
tree_render(c, indent + 1)
|
|
482
|
+
tree_render(root, 0)
|
|
483
|
+
# 仍有未挂入的 (父不在本工程或为根) -> 挂到 ProjectRoot 下
|
|
484
|
+
for g in sorted(label_of):
|
|
485
|
+
if g not in tree_visited:
|
|
486
|
+
pg = meta_map.get(g, (None, None, None))[1]
|
|
487
|
+
tree_lines.append("- {} (父: {})".format(
|
|
488
|
+
label_of.get(g, g[:8]), label_of.get(pg, pg or "根")))
|
|
489
|
+
with open(os.path.join(out_dir, "项目树.md"), "w", encoding="utf-8") as f:
|
|
490
|
+
f.write("\n".join(tree_lines))
|
|
491
|
+
|
|
492
|
+
with open(os.path.join(out_dir, "工程内容报告.md"), "w", encoding="utf-8") as f:
|
|
493
|
+
f.write("\n".join(report))
|
|
494
|
+
|
|
495
|
+
print(f"[3] 导出 {len(objs)} 个对象完成 → {out_dir}")
|
|
496
|
+
print(f" 对象表: 对象表.md, 报告: 工程内容报告.md, 全部字符串: 全部字符串.txt")
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
if __name__ == "__main__":
|
|
500
|
+
if len(sys.argv) < 2:
|
|
501
|
+
print(__doc__)
|
|
502
|
+
sys.exit(1)
|
|
503
|
+
proj = sys.argv[1]
|
|
504
|
+
outd = sys.argv[2] if len(sys.argv) > 2 else os.path.join(
|
|
505
|
+
os.path.dirname(os.path.abspath(proj)), "工程导出")
|
|
506
|
+
extract(proj, outd)
|