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,588 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ dvp_decode.py — 台达 WPLSoft/ISPSoft DVP 工程文件解码器
4
+
5
+ 用法:
6
+ python dvp_decode.py "C:\\projects\\demo.dvp" [-o 输出目录]
7
+
8
+ 功能:
9
+ 1. 文件结构(机型 / TypeBegin 块 / SMT 块分段)
10
+ 2. 段落注释 SegCmt(每段功能说明)
11
+ 3. 设备注释 Device Comment(I/O 定义全量,0x0A 分隔解析)
12
+ 4. 程序指令表(全段行分组反汇编;无 <SMT> 标记的工程走整块 fallback)
13
+ 5. 生成 <输出目录>\\解码报告.md(或打印到 stdout)
14
+
15
+ 编码规则:
16
+ 指令前缀 42 00 XX <A> <B>(5 字节;A/B 多为 00,尾=10 时疑为链式/复数操作数指令):
17
+ 00=SET, 07=RST, 03=TMR, 28=MOV, 3A=SUB, F6=DIV16,
18
+ 56=SFTL?(M M K K), 60=ZRST?(M M 区间), C4=MODRW?(K H D——站号/寄存器/数据),
19
+ 38=MUL16?, F4=CMP?(比较), 3C=MULD?(速度×速比→频率), 69=DBON(On 位判定),
20
+ 40=INC?(计数), 41=INC?, 54=ZCP?(区间比较), F5/F7/39/3B=DIVK?(换算链),
21
+ 29=DMOV?, 06=HS?, 0F=END?
22
+ 操作数前缀 80/86/88 0X(3 档,值=设备号直编码 LE):
23
+ 02=D 寄存器, 03=H 立即数, 04=K 立即数, 05=M 位(86/88 多见于扩展区/变体), 07=T 定时器,
24
+ 01=C 计数器, 0A=Y 输出位
25
+ 行头 12 05 E8 03 00 XX(6 字节,XX=行类型)
26
+ 行尾 TT 19 NN 00 00(待对照); 空填充 52 00 00 00 00;
27
+ 操作数链连接 90 xx xx xx yy(5 字节,输出 |); 栈指令: 56=MPS, 59=MRD, 54=MPP
28
+ 紧凑位指令: 32 0a <n> = OUT Y{n};22 0a <n>/22 09 <n> = AND 触点读 Y/X;
29
+ 19/c9 等未定前缀保持原样
30
+ 操作数显示口径: H 立即数按十六进制;X/Y 按八进制(DVP 端子编号为八进制,
31
+ 字节 8→软件 X10、15→X17);M/T/C/D/K 为十进制。设备注释的 X/Y 键同步换算。
32
+ """
33
+ import sys
34
+ import os
35
+ import re
36
+
37
+ def read_bytes(path):
38
+ with open(path, "rb") as f:
39
+ return f.read()
40
+
41
+ def find_all(text, needle):
42
+ out = []
43
+ idx = text.find(needle)
44
+ while idx >= 0:
45
+ out.append(idx)
46
+ idx = text.find(needle, idx + 1)
47
+ return out
48
+
49
+ def disp_num(t, val):
50
+ """操作数按**软件显示口径**输出。
51
+ 文件内部一律存十进制数值,软件按设备类型换进制显示:
52
+ H 立即数 → 十六进制(字节值 134=0x86、8193=0x2001);
53
+ X / Y → 八进制(DVP I/O 端子编号本就是八进制,无 X8/X9;字节值 8 = 软件 X10,
54
+ 字节值 15 = 软件 X17;ZRST 区间 Y8~Y15 即软件 Y10~Y17);
55
+ M / T / C / D / K → 十进制(与软件一致)。"""
56
+ if t == "H":
57
+ return format(val, "X")
58
+ if t in ("X", "Y"):
59
+ return format(val, "o")
60
+ return str(val)
61
+
62
+ def decode_operand(rec):
63
+ # 操作数类型前缀有 0x80 / 0x86 / 0x88 三档(疑似"页"变体:0x86 见于 M2000+ 区,
64
+ # 0x88 见于 M40 等;值均为设备号直编码,直接显示编号即可)。
65
+ # v17: 0x84 也是位组前缀(16 位组 K4M<n>),旧版只认 80/86/88 → 整条操作数被丢。
66
+ if rec[0] not in (0x80, 0x84, 0x86, 0x88):
67
+ return None
68
+ sub = rec[1]
69
+ val = rec[2] | (rec[3] << 8)
70
+ # 类型表:0x09=X 输入位(紧凑位指令 22 09 08 = AND X8,与设备注释的 X 编号闭合);
71
+ # 0x0A=Y 输出位;0x01=C 计数器(值=编号)。
72
+ # 注意:type=0x15 虽值域常与 M 编号重合,但存在 40,41,42…71 完美递增的记录,
73
+ # 判为梯形图行号索引而非 M 触点——不进入类型表。
74
+ mapping = {0x02: "D", 0x03: "H", 0x04: "K", 0x05: "M", 0x07: "T",
75
+ 0x01: "C", 0x09: "X", 0x0A: "Y",
76
+ # 0x08 = 变址寄存器 E(如 MOV D E / RST M E)
77
+ 0x08: "E", 0x85: "M" # 0x85 = 带变址的位组(RST M620E3)
78
+ }
79
+ return (mapping.get(sub, f"80_{sub:02X}"), val)
80
+
81
+ # 紧凑位指令前缀表(5 字节记录 = [cmd][type][val_lo][val_hi][00],
82
+ # 触点/线圈类指令,无 42 00 前缀)。
83
+ # 已定名(与 WPLSoft 指令表配对):
84
+ # 前缀 12 = 触点族(LD/AND 由行内位置界定),类型字节区分设备:
85
+ # 12 05 <n> = LD M<n>
86
+ # 12 09 <n> = LD X<n>
87
+ # 12 07 <n> = AND T<n>
88
+ # 前缀 32 = OUT;前缀 22 = AND 触点
89
+ # 前缀 c6 19 <n> = ANDF X<n>(下降沿触点)
90
+ # 前缀 b4/b6/b9 19/09 <n> = ANDP X<n> 变体(上升沿触点;
91
+ # 首字节疑为分支位置标记——MPS/MRD/MPP 后缀不同,待更多对照,保留 ?)
92
+ # 未定名前缀(均具触点/线圈特征,? 前缀待 WPLSoft 对照,不冒充确认):
93
+ # 0x14/0x16/0x26/0x19/0xC9/0xB9/0xB2/0xC2/0xB4/0xC6/0xC4/0xB6
94
+ # 0x19 两义:既可能是行号索引,也可能是触点(命中设备注释的在指令流里按触点;
95
+ # 显示时保留 ? 标记,需 WPLSoft 对照确认)。
96
+ COMPACT_CMD = {
97
+ # v7: 见上方 decode_compact 与 COMPACT_NAMES/COMPACT_Q 定义
98
+ }
99
+ # type 0x15 语义由前缀决定:
100
+ # 前缀 14/16 → 行号索引(存在 40,41,…71 递增的记录)——主循环按 rec[1]==0x15 且前缀∈{14,16} 吞
101
+ # 前缀 26/19/其他 → M 触点
102
+ # 故: 0x15 进类型表(M),但 decode_compact 只对「非行号前缀」应用 0x15 判定
103
+ COMPACT_TYPES = {0x05: "M", 0x09: "X", 0x0A: "Y",
104
+ 0x07: "T", 0x01: "C1>", 0x02: "D", 0x03: "H", 0x04: "K",
105
+ 0x19: "X", 0x15: "M",
106
+ # 0x1A = Y 页(B/C 系沿触点的 Y 页变体);缺这一页会整条沿触点丢失,
107
+ # 计数器看上去"没有脉冲源"。
108
+ 0x1A: "Y"}
109
+ # 行号索引前缀(保留吞 5B 语义):14/16 为行号标记
110
+ ROWIDX_PREFIX = {0x14, 0x16}
111
+
112
+ # 2026-11-05:认不出的记录台账。disasm 每跳一条未识别记录就往这里记一笔
113
+ # (段号, 行号, 字节原文),main 末尾聚合成「未识别记录」一节。
114
+ # 这不是猜语义,是把「读不出来」变成看得见的账——用户能据此对照 WPLSoft 指令表。
115
+ UNKNOWN_RECS = []
116
+
117
+ # 触点/线圈指令前缀:
118
+ # 12 触点族(LD/AND/T 页)、32=OUT、22=AND/ANI
119
+ # B 系(B4/B6/B2/B9)= ANDP(上升沿,置位侧)
120
+ # C 系(C6/C9/C2/C4)= ANDF(下降沿,复位侧)
121
+ # 同一设备 M 的"B→SET / C→RST"对=位置信号到达/离开。
122
+ # 类型页: 0x05=M、0x09=X、0x0A=Y、0x19=X 脉冲页、0x15=M 触点
123
+ COMPACT_NAMES = {
124
+ (0x12, 0x05): "LD", (0x12, 0x09): "LD", (0x12, 0x07): "AND", (0x12, 0x02): "LD",
125
+ (0x12, 0x0A): "LD", (0x12, 0x19): "LD", (0x12, 0x15): "LD",
126
+ (0x32, 0x05): "OUT", (0x32, 0x0A): "OUT", (0x32, 0x19): "OUT", (0x32, 0x15): "OUT",
127
+ (0x22, 0x05): "ANI", (0x22, 0x09): "AND", (0x22, 0x0A): "AND",
128
+ (0x22, 0x19): "AND", (0x22, 0x02): "AND", (0x22, 0x07): "AND", (0x22, 0x15): "AND",
129
+ (0x26, 0x19): "AND", (0x26, 0x15): "AND",
130
+ (0x14, 0x19): "LD", (0x14, 0x05): "LD", (0x14, 0x0A): "LD",
131
+ (0x16, 0x05): "LD", (0x16, 0x0A): "LD", (0x16, 0x19): "LD",
132
+ }
133
+ # B/C 系沿触点:B=ANDP、C=ANDF,全类型页
134
+ # 2026-11-05:B 系补 0x07(T 页)/ 0x1A(Y 页)——C 系早就有 0x1A 的实证注释,
135
+ # B 系漏页会让上升沿触点整条消失;本机 16 份工程实测 B 系出现类型页 {05,07,09,15,17,19,1A},
136
+ # 其中 0x17 页语义未定,暂不映射(宁缺不猜),其余补齐。
137
+ for _p in (0xB4, 0xB6, 0xB2, 0xB9):
138
+ for _t in (0x05, 0x07, 0x09, 0x0A, 0x15, 0x19, 0x1A):
139
+ COMPACT_NAMES[(_p, _t)] = "ANDP"
140
+ for _p in (0xC6, 0xC9, 0xC2, 0xC4):
141
+ for _t in (0x05, 0x09, 0x0A, 0x15, 0x19, 0x1A):
142
+ COMPACT_NAMES[(_p, _t)] = "ANDF"
143
+ # 未定名前缀(0x19 两义:行号索引变体 vs 触点)——按类型页分流:
144
+ # 19 07/0A/09(T/Y/X 类型页)= AND 触点
145
+ # 19 05(M 类型页)两义:命中设备注释 M 表的按触点;否则按行号吞。
146
+ COMPACT_Q = {}
147
+ COMPACT_NAMES[(0x19, 0x07)] = "AND" # T 页触点
148
+ COMPACT_NAMES[(0x19, 0x0A)] = "AND" # Y 页触点
149
+ COMPACT_NAMES[(0x19, 0x09)] = "AND" # X 页触点
150
+ COMPACT_NAMES[(0x19, 0x05)] = "AND" # M 页触点
151
+ # 14/16 + 0x15 也是 **M 触点**(值=编号),不是行号索引——
152
+ # 字节序列 `12 05 … | 14 15 … | 12 09 …` 对 WPLSoft 梯形图是 M·M·X 串联。
153
+ # 早期「递增序列=行号索引」的观察据此存疑,按触点理解更稳。
154
+ COMPACT_NAMES[(0x14, 0x15)] = "AND"
155
+ COMPACT_NAMES[(0x16, 0x15)] = "AND"
156
+ # 0x24 + 0x19 也是触点(X 页),漏映射会逐字节跳过、把触点丢了。
157
+ COMPACT_NAMES[(0x24, 0x19)] = "AND"
158
+
159
+ def decode_compact(rec):
160
+ """紧凑位指令 5 字节记录(触点/线圈),不在 42 00 前缀体系内。
161
+ 名字优先级: COMPACT_NAMES(已核对)> COMPACT_Q(? 待对照)。
162
+ 0x15 类型仅在前缀非行号索引时参与判定(否则由主循环吞 5B 为行号)。
163
+ 仅当 cmd 已知、type 在表内、尾部为 00 时判定,避免误吞其它未知记录。"""
164
+ if rec[1] not in COMPACT_TYPES:
165
+ return None
166
+ if rec[4] != 0x00:
167
+ return None
168
+ # 14/16 + 0x15 是 M 触点(见 COMPACT_NAMES 注释),不再当行号索引吞掉
169
+ # if rec[1] == 0x15 and rec[0] in ROWIDX_PREFIX:
170
+ # return None
171
+ val = rec[2] | (rec[3] << 8)
172
+ name = COMPACT_NAMES.get((rec[0], rec[1])) or COMPACT_Q.get(rec[0])
173
+ if name is None:
174
+ return None
175
+ # 0x19+0x05 一律当 M 触点:行号索引的前缀是 14/16(见 ROWIDX_PREFIX),
176
+ # 19 页上不需要再挡。
177
+ t = COMPACT_TYPES[rec[1]]
178
+ if t == "C1>":
179
+ return f"{name} C{val}"
180
+ return f"{name} {t}{disp_num(t, val)}"
181
+
182
+ CMD_MAP = {
183
+ # 指令名映射(按 WPLSoft 指令表与操作数形态核对;带 ? 的仍属推断)
184
+ 0x00: "SET", 0x07: "RST", 0x03: "TMR", 0x3A: "SUB",
185
+ 0x28: "MOV", # 操作数形态 K→D / H→D / D→D
186
+ 0x56: "SFTLP", # 脉冲型移位寄存器(M M K K)
187
+ 0x60: "ZRST", # 区域清零(M M 区间)
188
+ 0xC4: "MODWR", # MODBUS 寄存器写(K H D)
189
+ 0x38: "MUL16", # 16 位乘(D K D;与 0xF4 同指令不同操作数形态)
190
+ 0xF6: "DIV16", # 16 位除(D K D)
191
+ 0xF4: "MUL16", # 16 位乘(D D D)
192
+ 0x3C: "DMUL", # 32 位乘(C K D)
193
+ # 乘/除一族:两个功能码分别对应 16 位 / 32 位(或常数 / 寄存器)两种操作数形态
194
+ 0x39: "ADD", # 3 操作数 D|D|D:加法
195
+ 0x3B: "DIV", # 3 操作数 D|D|D:除法(长度/数量换算)
196
+ 0xF5: "MUL", # 3 操作数 D|D|K/D:乘法(直径→周长 这类换算)
197
+ 0xF7: "DIV", # 3 操作数 D|D|D:除法(周长→一圈距离 这类换算)
198
+ 0x3D: "MUL", # 3 操作数 D|D|K:乘法(长度→脉冲 这类换算)
199
+ 0x3F: "DIV", # 3 操作数 D|D|D/K:除法(脉冲数换算)
200
+ 0x0F: "END", # 程序结束标记
201
+ 0x41: "INC", 0x40: "INC", # 单 D 操作数递增计数
202
+ 0x54: "ZCP", # 3 操作数 K K D:区间比较
203
+ 0x06: "DCNT", # C K:高速计数器设定(参数 K 与字节 K 之间有取模偏移)
204
+ 0x05: "CNT", # 16 位计数器(API 05)
205
+ 0x29: "DMOV", # 32 位复制(C→D)
206
+ 0x68: "BON", # On 位判定(API 44 的 16 位形态);位组前缀 0x84=16 位、0x88=32 位
207
+ 0x69: "DBON", # On 位判定(API 44 的 32 位形态);S=来源位组、D=结果位、N=位号(来自寄存器)
208
+ }
209
+
210
+ # ── 推断映射(按操作数形态推断)──
211
+ # 这些**不是**核对过的名字:只按「操作数形态 + 同族相邻码 + 工程语义」推断,
212
+ # 输出一律带 `?` 前缀(如 ?DRVI),报告末尾附依据表。核对手册后应逐条转正或改判。
213
+ GUESS_CMD = {
214
+ 0x3E: ("DIV", "3 操作数 D K D:16 位除(与 0x3B 同名的另一操作数形态)", "已核"),
215
+ 0x0D: ("PLS", "单一位操作数,紧跟 RST/AND 序列,靠后续触点取一个扫描周期的脉冲;与 0x00=SET(持续置位)用法区分", "中"),
216
+ 0x1A: ("RST", "带位组/变址的操作数:与 0x07=RST 是同一条指令的位组变址形态", "已核"),
217
+ 0x94: ("ALT", "仅 1 个位操作数:交替输出", "已核"),
218
+ 0x8D: ("DABSD", "4 操作数 D|D|M|K:绝对位置检出(不是高速计数置位)", "已核"),
219
+ 0x8C: ("DABSR", "4 操作数 D|C|M|K:与 0x8D=DABSD 同族,参数换成计数器 → 绝对位置检出复位", "中"),
220
+ 0x8A: ("SER", "4 操作数 D|D|D|K:数据检索", "已核"),
221
+ 0x5A: ("WSFLP", "4 操作数 D|D|K|K:位移写入(第一个操作数常由随机数产生)", "已核"),
222
+ 0x5D: ("DDRVI", "4 操作数 K|K|Y|Y:32 位相对定位", "中"),
223
+ 0x5F: ("DDRVA", "4 操作数 K|D|Y|Y:32 位绝对定位", "已核"),
224
+ 0x87: ("DPLSR", "4 操作数 D|K|D|Y:带加减速脉冲输出", "已核"),
225
+ 0xAB: ("DVSPO", "4 操作数 D|D|D|Y:变速脉冲输出", "已核"),
226
+ 0xF1: ("DMOVR", "3 操作数 32 位浮点常数|D:32 位浮点传送(旧标 DMOV 是误读)", "已核"),
227
+ 0x72: ("FLT", "2 操作数 D|D:整数转浮点(浮点链起点)", "已核"),
228
+ 0x22: ("INT", "2 操作数 D|D:浮点转整数(浮点链末端)", "已核"),
229
+ 0x83: ("DPLSY", "3 操作数 D|K|Y:32 位脉冲输出", "已核"),
230
+ 0x1C: ("FEND", "无操作数:位于中断程序之前 → 第一扫描结束", "已核"),
231
+ 0x16: ("IRET", "无操作数:位输出之后、END 之前 → 中断返回(不是 FEND)", "已核"),
232
+ 0x4C: ("MODRW", "5 操作数 K K K D K:MODBUS 多寄存器读写", "已核"),
233
+ 0x71: ("DSQR", "2 操作数 D|D:32 位平方根(旧标 XCH 是误读)", "已核"),
234
+ 0x2E: ("BMOV", "3 操作数 D D K:块传送(不是区间比较)", "已核"),
235
+ }
236
+ GUESS_CMP_REL = {
237
+ 0x00: "=", 0x02: "<>", 0x04: ">", 0x06: "<", 0x08: "<=", 0x0a: ">=",
238
+ # 奇数尾字节按「OR 逻辑 + 前一档关系」推断
239
+ 0x01: "OR=", 0x03: "OR<>", 0x05: "OR>", 0x07: "OR<", 0x09: "OR<=", 0x0b: "OR>=",
240
+ # 0x1f:定时器当前值与常数/寄存器比较,后接 RST/SET/OUT 或 ANI,
241
+ # 按"到达设定值"判 >=
242
+ 0x1f: ">=",
243
+ }
244
+
245
+ def guess_cmd(code):
246
+ g = GUESS_CMD.get(code)
247
+ if not g or g[0] == '??':
248
+ return None
249
+ # 把握=已核/中 的当已定用(不带 ?);只有"低"仍带 ? 提示存疑
250
+ return g[0] if g[2] in ('已核', '中') else f"?{g[0]}"
251
+
252
+ def guess_cmp(rec):
253
+ """比较族:关系码已定(08、0a 已核对,其余按步长 2 推),逻辑连接(LD/AND/OR)由网络位置决定、不逐条标。
254
+ 输出 CMP(关系) —— 不带 ?,关系本身当已定用;要区分 LD/AND 时看它在网络里的位置。"""
255
+ rel = GUESS_CMP_REL.get(rec[2])
256
+ if not rel:
257
+ return None
258
+ return f"CMP({rel})"
259
+
260
+ # 未定名功能码(按操作数形态整理,等 WPLSoft 指令表核对后再定名,不冒充):
261
+ # 0x5D / 0x5F 4 操作数(脉冲数、频率、脉冲输出 Y、方向 Y)——脉冲定位输出族。
262
+ # 0x5F 只出现在 Y0 且脉冲数取 16 位量级;0x5D 出现在 Y0/Y2/Y3,
263
+ # 第一操作数可为 32 位常数(长距离常量)或 D 寄存器对
264
+ # → 判为 16 位/32 位两种形态(DRVI/DDRVI 一族,相对/绝对未定)。功能码第 4 字节 = 0x01。
265
+ # 0x83 3 操作数(频率、K、Y 输出)——变速/连续脉冲输出(K=不计数连续走),点动段用。
266
+ # 0x0D 首操作数为位,其后跟一条比较记录 + 位/常数(多见于长行程/联锁段)。
267
+ # 0x1C 无操作数,夹在 RST Y 与下一个 LD M 之间(网络/分支标记类)。
268
+ # 0x16 无操作数,程序末尾 END 之前(疑 FEND)。
269
+ # 0x18 出现在 <SMT> 段头正前方,非程序区记录,不要当指令。
270
+
271
+ # 比较指令族: [e2/e4/e6/e9][00/10][尾][00][00/10] + 操作数对。
272
+ # 0b 00 00 00 00 为 ORB/ANB 块连接。
273
+ # 已定名(操作数对匹配度闭环):
274
+ # e4 10 0a + (C, D) = LD>= C D
275
+ # e2 00 08 + (C, D) = AND<= C D
276
+ # e6 10 0a + (C, D) = LD<= C D
277
+ # e6 10 08 + (C, D) = AND<= C D
278
+ # e4 10 02 + (D, D) = AND(32 位双字)
279
+ # e9 00 0a + (D, D) = AND>=
280
+ # 注意: 同一码在逻辑栈首/块中语境下可能显示 LD/AND 差异;本实现按常见语境定名,
281
+ # 已知偏差以注释标注,不冒充。
282
+ CMP_NAMES = {
283
+ (0xe4, 0x10, 0x0a): "LD>=",
284
+ (0xe2, 0x00, 0x08): "AND<=",
285
+ (0xe6, 0x10, 0x0a): "LD<=",
286
+ (0xe6, 0x10, 0x08): "AND<=",
287
+ (0xe9, 0x00, 0x0a): "AND>=",
288
+ (0xe9, 0x00, 0x08): "AND<=",
289
+ (0xe2, 0x00, 0x0a): "AND<=",
290
+ (0xe4, 0x10, 0x02): "AND", # 32 位双字
291
+ }
292
+ CMP_TAIL_EXTRA = {0x00, 0x01, 0x02, 0x05, 0x06, 0x08, 0x0a, 0x0b}
293
+
294
+ # 比较族首字节取值(WPLSoft 内部码,非 DVP API 号):e2/e4/e6/e9/ea。
295
+ # 除 CMP_NAMES 里已定名的组合外,其余同族字节头按未定名处理。
296
+ CMP_HEAD = {0xe2, 0xe4, 0xe6, 0xe9, 0xea}
297
+
298
+ def cmp_name(rec):
299
+ """比较指令名;未定组合返回 None(由调用方按 ? 前缀处理)。"""
300
+ return CMP_NAMES.get((rec[0], rec[1], rec[2]))
301
+
302
+ # 行尾/分隔/链标记(整体吞 5B,不输出;已定名:链尾标记 0b、连接 61 10、
303
+ # 分支尾 68 10 / 57 10、空链 5a、换行分隔 34/39)
304
+ MISC_SKIP = {
305
+ 0x0b: b'\x0b\x00\x00\x00\x00',
306
+ 0x5a: b'\x5a\x00\x00\x00\x00',
307
+ 0x34: b'\x34\x00\x00\x00\x00',
308
+ 0x39: b'\x39\x00\x00\x00\x00',
309
+ 0x61: b'\x61\x10\x00\x00\x00',
310
+ 0x68: b'\x68\x10\x00\x00\x00',
311
+ 0x57: b'\x57\x10\x00\x00\x00',
312
+ }
313
+
314
+ def _at_row_start(toks):
315
+ """该条触点是否位于一个新分支的起点(决定 LD 还是 AND)。
316
+ 只有两种情况算起点:行首,或紧跟在 MPS/MRD/MPP/ORB/ANB 之后。
317
+ 注意不能把 LD/LDI 之后也算起点——那会把串联触点误改成起始触点。"""
318
+ if not toks:
319
+ return True
320
+ return toks[-1].split()[0] in ('MPS', 'MRD', 'MPP', 'ORB', 'ANB')
321
+
322
+
323
+ def _to_loading(cp):
324
+ """把串联触点改写成起始触点:ANDP→LDP、ANDF→LDF、ANI→LDI、AND→LD。"""
325
+ for a, b in (('ANDP ', 'LDP '), ('ANDF ', 'LDF '), ('ANI ', 'LDI '), ('AND ', 'LD ')):
326
+ if cp.startswith(a):
327
+ return b + cp[len(a):]
328
+ return cp
329
+
330
+
331
+ def disasm(seg, segno=0):
332
+ """段数据区 → 行分组指令 token 流
333
+ - 比较指令族 ?CMP(e2/e4/e6/e9 + 双操作数,指令名待对照,统一 ? 前缀)
334
+ - 行尾/分隔记录整体吞 5B(0b/61 10/68 10/57 10/5a/34/39)
335
+ - 段尾 0xFF 填充区截断
336
+ - C 计数器类型 0x01 映射 "C"(值=编号)
337
+ - 行头 5 字节 `12 05 E8 03 00` = LD M1000(0x03E8=1000,每网络首触点)
338
+ - 主循环接入 decode_compact
339
+ - 0x90 为操作数链连接记录(输出 |)
340
+ """
341
+ # 段尾 0xff 填充截断(连续 200B 以上)
342
+ ff_at = seg.find(b'\xff' * 200)
343
+ if ff_at > 0:
344
+ seg = seg[:ff_at]
345
+ marks = [i for i in range(len(seg) - 3) if seg[i] == 0x12 and seg[i+1] == 0x05 and seg[i+2] == 0xE8]
346
+ # 段首「第一个 LD M1000 行头之前」的内容不能被丢:从 marks[0] 起切块会把每段
347
+ # 首部整块静默吞掉(段首可能躺着功能指令)。
348
+ if not marks or marks[0] != 0:
349
+ marks.insert(0, 0)
350
+ marks.append(len(seg))
351
+ rows = []
352
+ for li in range(len(marks) - 1):
353
+ chunk = seg[marks[li]:marks[li+1]]
354
+ i = 0
355
+ toks = []
356
+ if len(chunk) >= 5 and chunk[0] == 0x12 and chunk[1] == 0x05 and chunk[2] == 0xE8:
357
+ toks.append("LD M1000") # 行头 = 运转监视常开触点(0x03E8=1000)
358
+ i = 5
359
+ while i + 5 <= len(chunk):
360
+ rec = chunk[i:i+5]
361
+ b = rec[0]
362
+ if b in (0x52, 0x00):
363
+ # 全零 5 字节不能当 ANB:程序区里全零也用作记录间隔/填充,
364
+ # 误读会整行冒出假 ANB。ANB 与填充的区分尚无判据,先保持「跳过」。
365
+ i += 1
366
+ elif b == 0x0b:
367
+ # 0b 00 00 00 00 = ORB(与指令表逐条对得上;紧随其后的全零记录是 ANB)
368
+ toks.append('ORB'); i += 5
369
+ elif b in (0x56, 0x59, 0x54):
370
+ # 2026-11-05:栈指令记录是完整 5 字节(实测 `56 10 00 00 00` / `59 00 00 00 00`
371
+ # / `54 10 00 00 00`),旧版 i += 1 把后 4 字节留进主循环:第 2 字节 0x10
372
+ # 会被当作未知记录,尾字节不是 0x00 时只跳 1 字节 → 后续记录整体错位。
373
+ toks.append({0x56: "MPS", 0x59: "MRD", 0x54: "MPP"}[b]); i += 5
374
+ elif b == 0x90:
375
+ # 0x90 记录携带 32 位数据的**高位字**(rec[4]=0x10 表示后面还有操作数)。
376
+ # 高位字要并进前一个常数(如 K34463 + H1 = K99999),
377
+ # 拆成两段会看错数量级。
378
+ hi = rec[2] | (rec[3] << 8)
379
+ m = re.match(r'^([KH])([0-9A-F]+)$', toks[-1]) if toks else None
380
+ if hi and m:
381
+ kind, digits = m.group(1), m.group(2)
382
+ try:
383
+ # 2026-11-05:①H 型常数内部存的是十六进制,旧版按十进制 int() 解析
384
+ # 要么 ValueError(整条不拼)要么算错;②K 是 32 位有符号——高半字
385
+ # 全 1 时是负数,旧版无符号拼接把 K-200000 显示成 K4294767296。
386
+ val = int(digits, 16 if kind == 'H' else 10) + (hi << 16)
387
+ if kind == 'K' and val >= 0x80000000:
388
+ val -= 0x100000000
389
+ toks[-1] = f"H{format(val, 'X')}" if kind == 'H' else f"K{val}"
390
+ i += 5
391
+ continue
392
+ except ValueError:
393
+ pass
394
+ toks.append(f"|H{hi}|" if hi else "|")
395
+ i += 5
396
+ elif b in (0x42, 0x44, 0x49):
397
+ # 2026-11-05:0x44 / 0x49 是与 0x42 同形的功能指令记录——同为 5 字节,
398
+ # rec[2] 是功能码,直接查同一张 CMD_MAP/GUESS_CMD(实证 9 条全部对上:
399
+ # 44 10 5D 01 10 → 码 5D = DDRVI;44 10 00 D0 00 / 49 00 00 D0 00 → 码 00 = SET;
400
+ # 49 00 29 00 10 → 码 29 = DMOV)。
401
+ # 旧版不认这两种,尾字节 0x10 的记录落进 else 逐字节跳 → 整条指令名丢失且错位。
402
+ if b == 0x42 and rec[2] == 0x18:
403
+ # 段首非程序记录(不在任何网络里、无操作数),不是指令,整体吞 5B
404
+ i += 5
405
+ continue
406
+ toks.append(CMD_MAP.get(rec[2]) or guess_cmd(rec[2]) or f"??{rec[2]:02X}")
407
+ i += 5
408
+ else:
409
+ op = decode_operand(rec)
410
+ if op:
411
+ t, v = op
412
+ toks.append(f"{t}{disp_num(t, v)}")
413
+ i += 5
414
+ else:
415
+ cname = cmp_name(rec) if rec[3] == 0x00 and rec[4] in (0x00, 0x10) else None
416
+ if cname:
417
+ toks.append(cname); i += 5
418
+ elif rec in MISC_SKIP.values():
419
+ i += 5
420
+ elif rec[0] in CMP_HEAD and rec[3] == 0x00 and rec[4] in (0x00, 0x10):
421
+ # 未定组合:先按关系码推断给出 ?CMP(关系),推断不出才退回带字节头的占位
422
+ toks.append(guess_cmp(rec) or f"?CMP[{rec[0]:02x}{rec[1]:02x}{rec[2]:02x}]"); i += 5
423
+ else:
424
+ cp = decode_compact(rec)
425
+ if cp:
426
+ # LD/AND 由**行内位置**定,不由前缀定:MPS/MRD 之后的
427
+ # 第一条触点是 LDP/LDF/LD;前缀表把同一前缀一律标成
428
+ # ANDP/AND 会让整行前缀错。
429
+ if _at_row_start(toks):
430
+ cp = _to_loading(cp)
431
+ toks.append(cp)
432
+ i += 5
433
+ elif rec[1] == 0x15 and rec[0] in ROWIDX_PREFIX:
434
+ # 行号索引记录(14/16 + 15 行号 00 00)——仅 14/16 前缀
435
+ # 语义为程序行号索引,整体吞 5B,防内部字节误读为指令;
436
+ # 26/19 等前缀的 0x15 是 M 触点(decode_compact 已处理)
437
+ i += 5
438
+ else:
439
+ # 未识别记录若是标准 5 字节外形(尾字节 00),整吞,
440
+ # 不再逐字节跳:逐字节跳会让**后续所有记录整体错位**,
441
+ # 是指令序列与软件对不上的主要来源。
442
+ # 2026-11-05:同时记进台账 + 行内留痕,不再静默消失。
443
+ step = 5 if rec[4] == 0x00 else 1
444
+ UNKNOWN_RECS.append((segno, li, rec[:step].hex(' ')))
445
+ if step == 5:
446
+ toks.append(f"〔?{rec[0]:02x}{rec[1]:02x}{rec[2]:02x}〕")
447
+ i += step
448
+ rows.append(" ".join(toks))
449
+ return rows
450
+
451
+ def main():
452
+ argv = sys.argv[1:]
453
+ if not argv:
454
+ print("用法: python dvp_decode.py <file.dvp> [-o 输出目录]")
455
+ return
456
+ path = argv[0]
457
+ out_dir = None
458
+ if "-o" in argv:
459
+ out_dir = argv[argv.index("-o") + 1]
460
+
461
+ data = read_bytes(path)
462
+ text = data.decode("ascii", "replace")
463
+ UNKNOWN_RECS.clear()
464
+ lines = []
465
+
466
+ # ---- 1. 结构 ----
467
+ lines.append("=" * 60)
468
+ lines.append("【文件结构】")
469
+ lines.append("=" * 60)
470
+ for m in re.finditer(r"Function=([A-Za-z ]+)", text):
471
+ lines.append(f" TypeBegin: Function={m.group(1)}")
472
+ smt = find_all(text, "<SMT>")
473
+ lines.append(f" SMT 块数: {len(smt)} (程序段数)")
474
+
475
+ # ---- 2. SegCmt 段落注释 ----
476
+ seg_start = text.find("<SegCmt>")
477
+ seg_end = text.find("</SegCmt>", seg_start)
478
+ if seg_start >= 0:
479
+ raw = data[seg_start + 9:seg_end]
480
+ gbk = raw.decode("gbk", "replace")
481
+ lines.append("")
482
+ lines.append("=" * 60)
483
+ lines.append("【段落注释 SegCmt】")
484
+ lines.append("=" * 60)
485
+ for m in re.finditer(r"<(\d+)><Cnt=(\d+)>", gbk):
486
+ no = int(m.group(1)); cnt = int(m.group(2))
487
+ cs = m.end()
488
+ ce = gbk.find("<", cs)
489
+ if ce < 0: ce = len(gbk)
490
+ cmt = gbk[cs:ce].replace("\r", "").replace("\n", " ").strip()
491
+ lines.append(f" 段[{no}] 行数={cnt}: {cmt}")
492
+
493
+ # ---- 3. 设备注释(字节级 0x0A 分隔解析;按 0x00 找终止符会只解出一条)----
494
+ dev_start = text.find("Function=Device Comment")
495
+ if dev_start >= 0:
496
+ db = text.find("<DataBegin>", dev_start) + len("<DataBegin>")
497
+ de = text.find("<DataEnd>", db)
498
+ seg = data[db:de] if de > db else data[db:db + 4000]
499
+ lines.append("")
500
+ lines.append("=" * 60)
501
+ lines.append("【设备注释 Device Comment(全部)】")
502
+ lines.append("=" * 60)
503
+ n = 0
504
+ for rec in seg.split(b"\x0A"):
505
+ m = re.match(rb"^([A-Z]+)([0-9]+)\$&(.*)$", rec, re.S)
506
+ if not m:
507
+ continue
508
+ cmt_b = m.group(3)
509
+ try:
510
+ cmt = cmt_b.decode("gbk").strip()
511
+ except Exception:
512
+ continue
513
+ if not cmt:
514
+ continue
515
+ name = f"{m.group(1).decode()}{disp_num(m.group(1).decode(), int(m.group(2)))}"
516
+ lines.append(f" {name} = {cmt}")
517
+ n += 1
518
+ lines.append(f" (共 {n} 条)")
519
+
520
+ # ---- 4. 程序指令表 ----
521
+ lines.append("")
522
+ lines.append("=" * 60)
523
+ lines.append("【程序指令表(行分组反汇编)】")
524
+ lines.append("=" * 60)
525
+ # fallback:部分 WPLSoft 版本存出的 .dvp 不写 <SMT> 分段标记,
526
+ # 程序整块躺在 Initial Value 块的 <DataEnd> 与下一个 <TypeBegin> 之间
527
+ # (末行是 END)。无标记时按整块反汇编。
528
+ spans = []
529
+ if smt:
530
+ for si, pos in enumerate(smt):
531
+ de = smt[si + 1] if si + 1 < len(smt) else text.find("<TypeBegin>", pos)
532
+ spans.append((si, pos + 36, de))
533
+ else:
534
+ iv = text.find("Function=Initial Value")
535
+ if iv >= 0:
536
+ de0 = text.find("<DataEnd>", text.find("<DataBegin>", iv))
537
+ nxt = text.find("<TypeBegin>", de0 + 1)
538
+ if de0 > 0 and nxt > de0:
539
+ spans.append((0, de0 + len("<DataEnd>"), nxt))
540
+ lines.append(" (本工程无 <SMT> 分段标记,按 Initial Value 块后的整块数据反汇编)")
541
+ for si, ds, de in spans:
542
+ rows = disasm(data[ds:de], si)
543
+ lines.append(f"--- 段{si} ---")
544
+ for li, r in enumerate(rows):
545
+ lines.append(f" 行{li}: {r}")
546
+
547
+ # ---- 4.5 未识别记录台账 ----
548
+ lines.append("")
549
+ lines.append("=" * 60)
550
+ lines.append("【未识别记录(认不出→已跳过;指令表里缺的东西在这里查)】")
551
+ lines.append("=" * 60)
552
+ if not UNKNOWN_RECS:
553
+ lines.append(" 无:本次解码的 5 字节记录全部落进已知指令/触点/操作数/填充。")
554
+ else:
555
+ agg = {}
556
+ for sg, li, hx in UNKNOWN_RECS:
557
+ agg.setdefault(hx, []).append((sg, li))
558
+ lines.append(f" 共 {len(UNKNOWN_RECS)} 条({len(agg)} 种字节形态):")
559
+ for hx, pos in sorted(agg.items(), key=lambda kv: -len(kv[1])):
560
+ where = "、".join(f"段{a}行{b}" for a, b in pos[:6])
561
+ more = f" 等 {len(pos)} 处" if len(pos) > 6 else ""
562
+ lines.append(f" ×{len(pos):<4} {hx:<15} 首见 {where}{more}")
563
+ lines.append(" 这些字节没被认成指令或触点,对应行里可能少了一条——拿字节形态去 WPLSoft")
564
+ lines.append(" 指令表对照;核出来再补 COMPACT_NAMES / CMD_MAP,别按猜测直接补。")
565
+
566
+ # ---- 5. 推断对照表 ----
567
+ lines.append("")
568
+ lines.append("=" * 60)
569
+ lines.append("【推断对照表(未经手册核对)】")
570
+ lines.append("=" * 60)
571
+ lines.append(" 输出里 ? 开头的指令名=推断,不是核对过的名字;拿它下结论前,先核台达指令表。")
572
+ for code, (nm, why, conf) in sorted(GUESS_CMD.items()):
573
+ lines.append(f" 功能码 {code:02X} → ?{nm}(把握 {conf}):{why}")
574
+ lines.append(" 比较族 ?CMP(关系):尾字节 00 = / 02 <> / 04 > / 06 < / 08 <= / 0a >=;08、0a 已核对,其余按步长 2 推断。")
575
+ lines.append(" 仍未定:以 ?? 开头的功能码(形态不足以推断)、以 ?CMP[ 开头的带码占位(关系码不在上表)。")
576
+
577
+ report = "\n".join(lines)
578
+ if out_dir:
579
+ os.makedirs(out_dir, exist_ok=True)
580
+ with open(os.path.join(out_dir, "解码报告.md"), "w", encoding="utf-8") as f:
581
+ f.write(report)
582
+ print(f"报告已保存: {os.path.join(out_dir, '解码报告.md')}")
583
+ else:
584
+ print(report)
585
+
586
+ if __name__ == "__main__":
587
+ main()
588
+