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.
@@ -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)