vb6decompiler 0.2.0__py3-none-any.whl
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.
- vb6decompiler/__init__.py +11 -0
- vb6decompiler/blob.py +1067 -0
- vb6decompiler/cli.py +292 -0
- vb6decompiler/datareport.py +886 -0
- vb6decompiler/frm.py +1107 -0
- vb6decompiler/literals.py +275 -0
- vb6decompiler/mask.py +192 -0
- vb6decompiler/pe.py +387 -0
- vb6decompiler/procs.py +444 -0
- vb6decompiler/sqlstitch.py +227 -0
- vb6decompiler-0.2.0.dist-info/METADATA +279 -0
- vb6decompiler-0.2.0.dist-info/RECORD +16 -0
- vb6decompiler-0.2.0.dist-info/WHEEL +5 -0
- vb6decompiler-0.2.0.dist-info/entry_points.txt +2 -0
- vb6decompiler-0.2.0.dist-info/licenses/LICENSE +21 -0
- vb6decompiler-0.2.0.dist-info/top_level.txt +1 -0
vb6decompiler/blob.py
ADDED
|
@@ -0,0 +1,1067 @@
|
|
|
1
|
+
"""Form-data blob parser for natively compiled VB6 executables.
|
|
2
|
+
|
|
3
|
+
What a blob is
|
|
4
|
+
--------------
|
|
5
|
+
A VB6 program compiled to native code keeps the *design-time* description of
|
|
6
|
+
every form in the executable's data area. Each form has one blob: a form
|
|
7
|
+
record, the form-level properties, and then one record per control. This
|
|
8
|
+
module locates those blobs in a PE file, decodes the record grammar, and
|
|
9
|
+
recovers per-control geometry, captions, control-array indices, the
|
|
10
|
+
non-default ``Visible`` flag and ``SSTab`` page titles/membership.
|
|
11
|
+
|
|
12
|
+
Record grammar (measured; the layout below is what the parser implements)
|
|
13
|
+
-----------------------------------------------------------------------
|
|
14
|
+
::
|
|
15
|
+
|
|
16
|
+
<size:int32> <idx:uint8> <namelen:uint16> <name> 0x00 <class:uint8> <properties...>
|
|
17
|
+
|
|
18
|
+
* ``size`` is what the record declares about itself. Measured on a real
|
|
19
|
+
target it is the record's byte length minus one for control records, and for
|
|
20
|
+
the form record it covers only the header/property block, not the control
|
|
21
|
+
records that follow. The scanner therefore uses it as *evidence* that a
|
|
22
|
+
name-shaped hit is a real record header (and to find array headers), never as
|
|
23
|
+
a way to jump to the next record. If bit 31 (``0x80000000``) is set the
|
|
24
|
+
control belongs to a **control array** and the header gains a ``uint16``
|
|
25
|
+
array index::
|
|
26
|
+
|
|
27
|
+
<size|0x80000000:int32> <idx:uint8> <index:uint16> <namelen:uint16> <name> 0x00 ...
|
|
28
|
+
|
|
29
|
+
* ``class`` is the VB6 control-class byte (see :data:`CLASS_NAME`). ``0xFF``
|
|
30
|
+
means an OCX: the class name follows as a length-prefixed string
|
|
31
|
+
(``<len:uint16> "MSComctlLib.ProgressBar" 0x00``).
|
|
32
|
+
* Every remaining property is a ``<pid:uint8> <value>`` pair. The value type
|
|
33
|
+
depends on ``pid``, i.e. on the property's position in that class's property
|
|
34
|
+
table. Geometry is written as one property holding ``4 x int16``
|
|
35
|
+
(Left, Top, Width, Height) under a class-specific ``pid`` — see
|
|
36
|
+
:data:`GEOM_PID`.
|
|
37
|
+
* Geometry widens to ``4 x int32`` when a value does not fit in int16: the
|
|
38
|
+
property is then ``<pid> 0x00 0x80 <4 x int32>`` (the ``0x8000`` marker is a
|
|
39
|
+
uint16 flag after the pid byte).
|
|
40
|
+
* Strings are ``<len:uint16> <bytes> 0x00`` in CP1254 (Turkish code page) for
|
|
41
|
+
control properties, and UTF-16LE for ``SSTab`` page data.
|
|
42
|
+
* A blob may contain more than one form object (class ``0x0D``): the main form
|
|
43
|
+
plus nested form objects. Each one opens a segment, and controls belong to
|
|
44
|
+
the segment they appear in.
|
|
45
|
+
* Classes that carry no ``Left/Top/Width/Height`` (``VB.Line``, ``VB.Timer``,
|
|
46
|
+
``VB.Menu``) and non-visual OCXs (``ImageList``, ``CommonDialog``) are not
|
|
47
|
+
emitted as controls.
|
|
48
|
+
|
|
49
|
+
Two derived facts, both measured against ground truth
|
|
50
|
+
-----------------------------------------------------
|
|
51
|
+
* ``Visible`` is only stored when it is **not** the default. A hidden control
|
|
52
|
+
is marked by the class-specific ``<pid> 0x00`` pair immediately after the
|
|
53
|
+
geometry value (:data:`VISIBLE_PID`). For classes without a calibrated rule
|
|
54
|
+
:attr:`Control.visible` stays ``None`` — unknown, not "visible".
|
|
55
|
+
* ``SSTab`` records embed page titles as UTF-16 strings followed by a fixed
|
|
56
|
+
GUID signature; UTF-16 control names without a signature belong to the
|
|
57
|
+
current page.
|
|
58
|
+
|
|
59
|
+
The form's client rectangle is pid ``0x35`` followed by four int32 values
|
|
60
|
+
``(ClientLeft, ClientTop, ClientWidth, ClientHeight)``. Reading the first one
|
|
61
|
+
as a block length (which also "works" on many forms) is wrong: it is
|
|
62
|
+
``ClientLeft``, and the misreading rejects every form whose client origin is
|
|
63
|
+
larger than the assumed size limit.
|
|
64
|
+
|
|
65
|
+
What this cannot do
|
|
66
|
+
-------------------
|
|
67
|
+
* Controls are identified by ``(name, index)`` only. A control that the blob
|
|
68
|
+
stores twice (the same name and index in two records) yields both records;
|
|
69
|
+
the caller keeps the first.
|
|
70
|
+
* ``SSTab`` membership lists the controls the ``SSTab`` record itself names. A
|
|
71
|
+
``Frame`` placed on a page owns its children, and those children are not
|
|
72
|
+
listed in the ``SSTab`` record — their page is not recoverable from the blob.
|
|
73
|
+
* A form whose compiled blob differs from the ``.frm`` text (rebuilt without
|
|
74
|
+
the sources being in sync) will mismatch; :func:`validate` reports it rather
|
|
75
|
+
than smoothing it over.
|
|
76
|
+
* Classes outside :data:`GEOM_PID` and :data:`VISIBLE_PID` are emitted with
|
|
77
|
+
:data:`GEOM_PID_OCX` geometry and ``visible=None``. That is a calibrated
|
|
78
|
+
default, not a verified fact for a new target: run :func:`validate`.
|
|
79
|
+
|
|
80
|
+
Calibration
|
|
81
|
+
-----------
|
|
82
|
+
``GEOM_PID``, ``POST_PIDS``, ``VISIBLE_PID`` and the scan windows were derived
|
|
83
|
+
from a real target by comparing the blob output against the ``.frm`` design
|
|
84
|
+
text shipped with that program. They are not guesses and must not be
|
|
85
|
+
"tidied": each table's comment names what it was calibrated against. Run
|
|
86
|
+
:func:`validate` against a ``.frm`` directory to re-measure coverage on a new
|
|
87
|
+
target before trusting the output; anything not covered is reported, never
|
|
88
|
+
filled in.
|
|
89
|
+
|
|
90
|
+
Usage::
|
|
91
|
+
|
|
92
|
+
from vb6decompiler import blob
|
|
93
|
+
|
|
94
|
+
forms = blob.extract_forms("APP.EXE")
|
|
95
|
+
report = blob.validate("APP.EXE", "path/to/decompile")
|
|
96
|
+
print(report.controls_matched, "/", report.controls_total)
|
|
97
|
+
"""
|
|
98
|
+
from __future__ import annotations
|
|
99
|
+
|
|
100
|
+
import glob
|
|
101
|
+
import os
|
|
102
|
+
import re
|
|
103
|
+
import struct
|
|
104
|
+
from collections import Counter, defaultdict
|
|
105
|
+
from dataclasses import dataclass, field
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------------------------------
|
|
108
|
+
# Class tables
|
|
109
|
+
# --------------------------------------------------------------------------
|
|
110
|
+
#: VB6 control-class byte -> ``.frm`` type name. The numeric values are the
|
|
111
|
+
#: documented VB6 control class ids; ``0x0D`` is the form object itself.
|
|
112
|
+
CLASS_NAME = {
|
|
113
|
+
0x00: "VB.PictureBox",
|
|
114
|
+
0x01: "VB.Label",
|
|
115
|
+
0x02: "VB.TextBox",
|
|
116
|
+
0x03: "VB.Frame",
|
|
117
|
+
0x04: "VB.CommandButton",
|
|
118
|
+
0x05: "VB.CheckBox",
|
|
119
|
+
0x06: "VB.OptionButton",
|
|
120
|
+
0x07: "VB.ComboBox",
|
|
121
|
+
0x08: "VB.ListBox",
|
|
122
|
+
0x09: "VB.HScrollBar",
|
|
123
|
+
0x0A: "VB.VScrollBar",
|
|
124
|
+
0x0B: "VB.Timer",
|
|
125
|
+
0x0C: "VB.DriveListBox",
|
|
126
|
+
0x0D: "VB.Form", # embedded form object: opens a new blob segment
|
|
127
|
+
0x12: "VB.FileListBox",
|
|
128
|
+
0x16: "VB.Shape",
|
|
129
|
+
0x17: "VB.Line",
|
|
130
|
+
0x18: "VB.Image",
|
|
131
|
+
0xFF: None, # OCX: the class name is a string inside the record
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#: Class -> pid of the property that carries ``4 x int16`` geometry.
|
|
135
|
+
#:
|
|
136
|
+
#: Calibrated against the ``.frm`` design blocks of the calibration target:
|
|
137
|
+
#: every control of a class used the same pid, so one value per class. The pid
|
|
138
|
+
#: is the class's property-table position, which is why it differs between
|
|
139
|
+
#: classes instead of being a fixed field number.
|
|
140
|
+
GEOM_PID = {
|
|
141
|
+
"VB.Label": 0x05,
|
|
142
|
+
"VB.PictureBox": 0x05,
|
|
143
|
+
"VB.Frame": 0x05,
|
|
144
|
+
"VB.CheckBox": 0x05,
|
|
145
|
+
"VB.OptionButton": 0x05,
|
|
146
|
+
"VB.ComboBox": 0x05,
|
|
147
|
+
"VB.TextBox": 0x04,
|
|
148
|
+
"VB.CommandButton": 0x04,
|
|
149
|
+
"VB.ListBox": 0x04,
|
|
150
|
+
"VB.FileListBox": 0x04,
|
|
151
|
+
"VB.Shape": 0x04,
|
|
152
|
+
"VB.Image": 0x03,
|
|
153
|
+
"VB.VScrollBar": 0x02,
|
|
154
|
+
"VB.HScrollBar": 0x02,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#: Geometry pid used by every OCX (``0xFF``) class. Measured: the OCX property
|
|
158
|
+
#: table is laid out by VB's OLE-control stub, so the pid does not vary with
|
|
159
|
+
#: the OCX's own property list.
|
|
160
|
+
GEOM_PID_OCX = 0x03
|
|
161
|
+
|
|
162
|
+
#: Pids that are observed to follow a geometry value when the next property is
|
|
163
|
+
#: written. Used only to reject wrong candidate offsets during the scan; a
|
|
164
|
+
#: correct geometry followed by a pid outside this set would be missed, which
|
|
165
|
+
#: the validator would show as a missing control rather than a wrong one.
|
|
166
|
+
POST_PIDS = {0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0E, 0x0F, 0x11, 0x12, 0x17, 0xFF}
|
|
167
|
+
|
|
168
|
+
#: Classes that carry no ``Left/Top/Width/Height`` (they use X1/Y1/X2/Y2 or no
|
|
169
|
+
#: position at all): excluded from the geometry schema instead of guessed.
|
|
170
|
+
SKIP_TYPES = {
|
|
171
|
+
"VB.Form",
|
|
172
|
+
"VB.Line",
|
|
173
|
+
"VB.Timer",
|
|
174
|
+
"VB.Menu",
|
|
175
|
+
"MSComctlLib.ImageList",
|
|
176
|
+
"MSComDlg.CommonDialog",
|
|
177
|
+
"MSComctlLib.CommonDialog",
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#: Class -> pid of the ``Visible`` property. A hidden control writes
|
|
181
|
+
#: ``<pid> 0x00`` straight after the geometry value; the default (visible) is
|
|
182
|
+
#: not written at all, so absence means "default", not "known true".
|
|
183
|
+
#:
|
|
184
|
+
#: Calibrated against the ``Visible`` values of the calibration target's
|
|
185
|
+
#: ``.frm`` blocks with zero false positives where a rule is applied. Classes
|
|
186
|
+
#: not listed (CommandButton, Image, PictureBox, Shape, OptionButton, ...) have
|
|
187
|
+
#: no usable marker and are left unknown.
|
|
188
|
+
VISIBLE_PID = {
|
|
189
|
+
"VB.Frame": 0x0A,
|
|
190
|
+
"VB.Label": 0x0A,
|
|
191
|
+
"VB.ComboBox": 0x0A,
|
|
192
|
+
"VB.CheckBox": 0x0A,
|
|
193
|
+
"VB.TextBox": 0x09,
|
|
194
|
+
"VB.ListBox": 0x09,
|
|
195
|
+
"MSMask.MaskEdBox": 0x07,
|
|
196
|
+
"MSComctlLib.ProgressBar": 0x07,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#: GUID signatures that separate an ``SSTab`` page title from the page's
|
|
200
|
+
#: control names in the record's property area (two byte orders are observed
|
|
201
|
+
#: in the wild; both are accepted).
|
|
202
|
+
SSTAB_SIGNATURES = (
|
|
203
|
+
bytes.fromhex("52e30b918fce119de300aa004bb851"),
|
|
204
|
+
bytes.fromhex("52e30b91918fce119de300aa004bb851"),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
#: Record names are at most 64 bytes in practice; the upper bound keeps the
|
|
208
|
+
#: byte scan from matching arbitrary binary runs.
|
|
209
|
+
MAX_NAME_LEN = 64
|
|
210
|
+
|
|
211
|
+
#: How close to the end of a long record the trailing geometry candidate must
|
|
212
|
+
#: sit before it is preferred over the first candidate. Records that embed an
|
|
213
|
+
#: image or OLE payload place the geometry after the payload, and the payload's
|
|
214
|
+
#: bytes can contain a pid byte followed by a plausible 4 x int16 by accident.
|
|
215
|
+
#: Calibrated on the reference target by sweeping the window and counting
|
|
216
|
+
#: geometry mismatches against ``.frm`` ground truth (see ``validate()``).
|
|
217
|
+
GEOM_TAIL_WINDOW = 192
|
|
218
|
+
|
|
219
|
+
_GUI_NAME_RE = re.compile(rb"[\x02-\x40]\x00[A-Za-z][A-Za-z0-9_]{1,40}\x00")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# --------------------------------------------------------------------------
|
|
223
|
+
# PE image access
|
|
224
|
+
# --------------------------------------------------------------------------
|
|
225
|
+
@dataclass(frozen=True)
|
|
226
|
+
class Section:
|
|
227
|
+
"""One PE section mapped into the file."""
|
|
228
|
+
|
|
229
|
+
name: str
|
|
230
|
+
virtual_address: int
|
|
231
|
+
virtual_size: int
|
|
232
|
+
raw_pointer: int
|
|
233
|
+
raw_size: int
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def span(self) -> int:
|
|
237
|
+
return max(self.virtual_size, self.raw_size)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def load_sections(path: str) -> tuple[bytes, tuple[Section, ...]]:
|
|
241
|
+
"""Return the whole file plus its PE section table.
|
|
242
|
+
|
|
243
|
+
Raises :class:`ValueError` when the file has no sane PE header — the
|
|
244
|
+
caller gets an explicit failure instead of an empty (and therefore
|
|
245
|
+
ambiguous) result.
|
|
246
|
+
"""
|
|
247
|
+
with open(path, "rb") as fh:
|
|
248
|
+
data = fh.read()
|
|
249
|
+
if len(data) < 0x40 or data[:2] != b"MZ":
|
|
250
|
+
raise ValueError(f"{path}: no MZ header, not a PE file")
|
|
251
|
+
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
|
|
252
|
+
if e_lfanew + 24 > len(data) or data[e_lfanew:e_lfanew + 4] != b"PE\0\0":
|
|
253
|
+
raise ValueError(f"{path}: no PE signature at 0x{e_lfanew:x}")
|
|
254
|
+
coff = e_lfanew + 4
|
|
255
|
+
nsec, = struct.unpack_from("<H", data, coff + 2)
|
|
256
|
+
opt_size, = struct.unpack_from("<H", data, coff + 16)
|
|
257
|
+
opt = coff + 20
|
|
258
|
+
image_base, = struct.unpack_from("<I", data, opt + 28)
|
|
259
|
+
sections = []
|
|
260
|
+
for i in range(nsec):
|
|
261
|
+
off = opt + opt_size + i * 40
|
|
262
|
+
if off + 40 > len(data):
|
|
263
|
+
raise ValueError(f"{path}: truncated section table")
|
|
264
|
+
raw_name = data[off:off + 8].rstrip(b"\x00")
|
|
265
|
+
v_size, v_addr, r_size, r_ptr = struct.unpack_from("<IIII", data, off + 8)
|
|
266
|
+
sections.append(Section(raw_name.decode("latin-1"), image_base + v_addr,
|
|
267
|
+
v_size, r_ptr, r_size))
|
|
268
|
+
return data, tuple(sections)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def read_va(data: bytes, sections, va: int, size: int) -> bytes | None:
|
|
272
|
+
"""Read ``size`` bytes at virtual address ``va`` (``None`` if unmapped)."""
|
|
273
|
+
for sec in sections:
|
|
274
|
+
if sec.virtual_address <= va < sec.virtual_address + sec.span:
|
|
275
|
+
off = sec.raw_pointer + (va - sec.virtual_address)
|
|
276
|
+
chunk = data[off:off + size]
|
|
277
|
+
return chunk if len(chunk) == size else None
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# --------------------------------------------------------------------------
|
|
282
|
+
# Blob discovery
|
|
283
|
+
# --------------------------------------------------------------------------
|
|
284
|
+
@dataclass(frozen=True)
|
|
285
|
+
class FormBlob:
|
|
286
|
+
"""A form's design-time blob, already cut to its own segment."""
|
|
287
|
+
|
|
288
|
+
name: str
|
|
289
|
+
offset: int # file offset of the record header
|
|
290
|
+
size: int # declared size field (measured: record length - 1; metadata only)
|
|
291
|
+
data: bytes # segment bytes: this form record up to the next one
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def find_form_records(data: bytes) -> list[tuple[int, int, str, int]]:
|
|
295
|
+
"""Locate form records (class ``0x0D``) in a raw image.
|
|
296
|
+
|
|
297
|
+
Returns ``[(record_start, name_pos, name, size)]``. The scan matches the
|
|
298
|
+
``namelen``/``name``/``0x00`` shape anywhere in the file, then requires a
|
|
299
|
+
plausible record header in front of it and the form class byte after it,
|
|
300
|
+
so no section name or import table is assumed. VB6 stores these blobs as
|
|
301
|
+
plain data inside a code or data section depending on the linker.
|
|
302
|
+
"""
|
|
303
|
+
out = []
|
|
304
|
+
for m in _GUI_NAME_RE.finditer(data):
|
|
305
|
+
name_pos = m.start()
|
|
306
|
+
namelen, = struct.unpack_from("<H", data, name_pos)
|
|
307
|
+
if namelen != len(m.group(0)) - 3:
|
|
308
|
+
continue
|
|
309
|
+
cls_p = m.end()
|
|
310
|
+
if cls_p >= len(data) or data[cls_p] != 0x0D:
|
|
311
|
+
continue
|
|
312
|
+
start = None
|
|
313
|
+
size = 0
|
|
314
|
+
# Control-array header first (bit 31 set), then the plain header.
|
|
315
|
+
if name_pos >= 7:
|
|
316
|
+
word, = struct.unpack_from("<I", data, name_pos - 7)
|
|
317
|
+
if word & 0x80000000:
|
|
318
|
+
candidate = word & 0x7FFFFFFF
|
|
319
|
+
if 8 <= candidate <= 0x7FFFFFFF:
|
|
320
|
+
start, size = name_pos - 7, candidate
|
|
321
|
+
if start is None and name_pos >= 5:
|
|
322
|
+
candidate, = struct.unpack_from("<I", data, name_pos - 5)
|
|
323
|
+
if 8 <= candidate <= 0x7FFFFFFF:
|
|
324
|
+
start, size = name_pos - 5, candidate
|
|
325
|
+
if start is None or size < (cls_p - start) + 1:
|
|
326
|
+
continue
|
|
327
|
+
out.append((start, name_pos, m.group(0)[2:-1].decode("ascii"), size))
|
|
328
|
+
return out
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def iter_form_blobs(path: str) -> list[FormBlob]:
|
|
332
|
+
"""Return every form blob in ``path``, cut to the form's own segment.
|
|
333
|
+
|
|
334
|
+
A blob may hold several form objects; a segment ends where the next form
|
|
335
|
+
record starts, which is exactly the region this form's controls live in.
|
|
336
|
+
The declared record ``size`` is used as an upper bound only — the segment
|
|
337
|
+
boundary is the next form record, because nested form objects follow the
|
|
338
|
+
parent record inside the same declared size.
|
|
339
|
+
"""
|
|
340
|
+
data, _sections = load_sections(path)
|
|
341
|
+
records = find_form_records(data)
|
|
342
|
+
blobs = []
|
|
343
|
+
for i, (start, _name_pos, name, size) in enumerate(records):
|
|
344
|
+
# A form's region runs to the next form record. The declared record
|
|
345
|
+
# ``size`` is *not* a reliable length here (it is much smaller than the
|
|
346
|
+
# region that holds the controls), so it is kept as metadata only.
|
|
347
|
+
# Sibling objects living inside the region (DataReport blobs, see
|
|
348
|
+
# ``_is_embedded_object``) are excluded when the records are parsed.
|
|
349
|
+
if i + 1 < len(records):
|
|
350
|
+
end = records[i + 1][0]
|
|
351
|
+
else:
|
|
352
|
+
end = len(data)
|
|
353
|
+
blobs.append(FormBlob(name=name, offset=start, size=size,
|
|
354
|
+
data=data[start:end]))
|
|
355
|
+
return blobs
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# --------------------------------------------------------------------------
|
|
359
|
+
# Result types
|
|
360
|
+
# --------------------------------------------------------------------------
|
|
361
|
+
@dataclass
|
|
362
|
+
class Control:
|
|
363
|
+
"""One control recovered from a blob.
|
|
364
|
+
|
|
365
|
+
``index`` is the control-array index (``None`` for a plain control).
|
|
366
|
+
``visible`` is ``True``/``False`` only where a calibrated rule applies and
|
|
367
|
+
the marker is present; ``None`` means unknown, never "visible".
|
|
368
|
+
``tab_page`` is the 1-based ``SSTab`` page for a control nested inside an
|
|
369
|
+
``SSTab``, ``None`` when the control is not a tab member.
|
|
370
|
+
``pages`` holds the page titles for an ``SSTab`` control itself.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
name: str
|
|
374
|
+
type: str
|
|
375
|
+
caption: str | None
|
|
376
|
+
left: int
|
|
377
|
+
top: int
|
|
378
|
+
width: int
|
|
379
|
+
height: int
|
|
380
|
+
index: int | None = None
|
|
381
|
+
visible: bool | None = None
|
|
382
|
+
pages: tuple[str, ...] = ()
|
|
383
|
+
tab_page: int | None = None
|
|
384
|
+
# Filled for SSTab controls only; consumed by extract_form, not exported.
|
|
385
|
+
_sstab_members: dict[str, int] = field(default_factory=dict, repr=False,
|
|
386
|
+
compare=False)
|
|
387
|
+
|
|
388
|
+
@property
|
|
389
|
+
def key(self) -> tuple[str, int | None]:
|
|
390
|
+
"""``(name, index)`` key that identifies a control inside its form."""
|
|
391
|
+
return (self.name, self.index)
|
|
392
|
+
|
|
393
|
+
def geometry(self) -> tuple[int, int, int, int]:
|
|
394
|
+
return (self.left, self.top, self.width, self.height)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@dataclass
|
|
398
|
+
class Form:
|
|
399
|
+
"""A parsed form."""
|
|
400
|
+
|
|
401
|
+
name: str
|
|
402
|
+
controls: list[Control] = field(default_factory=list)
|
|
403
|
+
width: int | None = None
|
|
404
|
+
height: int | None = None
|
|
405
|
+
client_left: int | None = None
|
|
406
|
+
client_top: int | None = None
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@dataclass
|
|
410
|
+
class ValidationReport:
|
|
411
|
+
"""Coverage of the parser against ``.frm`` ground truth.
|
|
412
|
+
|
|
413
|
+
Every count carries its denominator. ``forms_total`` counts the ``.frm``
|
|
414
|
+
files that yielded a design block; ``forms_matched`` counts those where
|
|
415
|
+
every ground-truth control matched on type and geometry and no extra
|
|
416
|
+
control was produced. ``forms_without_blob`` and ``forms_without_records``
|
|
417
|
+
name the forms that could not be compared at all, so a low denominator is
|
|
418
|
+
visible instead of silently absorbed.
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
blob_count: int = 0
|
|
422
|
+
truth_forms: int = 0
|
|
423
|
+
forms_matched: int = 0
|
|
424
|
+
controls_total: int = 0
|
|
425
|
+
controls_matched: int = 0
|
|
426
|
+
client_size_total: int = 0
|
|
427
|
+
client_size_matched: int = 0
|
|
428
|
+
visible_checked: int = 0
|
|
429
|
+
visible_matched: int = 0
|
|
430
|
+
visible_false_positive: int = 0
|
|
431
|
+
sstab_members_checked: int = 0
|
|
432
|
+
sstab_members_matched: int = 0
|
|
433
|
+
ground_truth_available: bool = False
|
|
434
|
+
forms_without_blob: list[tuple[str, int]] = field(default_factory=list)
|
|
435
|
+
forms_without_records: list[tuple[str, int]] = field(default_factory=list)
|
|
436
|
+
form_mismatches: list[tuple[str, int, int]] = field(default_factory=list)
|
|
437
|
+
field_diffs: Counter = field(default_factory=Counter)
|
|
438
|
+
|
|
439
|
+
@property
|
|
440
|
+
def control_rate(self) -> float:
|
|
441
|
+
return 100.0 * self.controls_matched / self.controls_total if self.controls_total else 0.0
|
|
442
|
+
|
|
443
|
+
@property
|
|
444
|
+
def form_rate(self) -> float:
|
|
445
|
+
return 100.0 * self.forms_matched / self.truth_forms if self.truth_forms else 0.0
|
|
446
|
+
|
|
447
|
+
def summary(self) -> str:
|
|
448
|
+
lines = [
|
|
449
|
+
f"blobs: {self.blob_count} ground-truth forms: {self.truth_forms}",
|
|
450
|
+
f"controls matched: {self.controls_matched}/{self.controls_total} "
|
|
451
|
+
f"= {self.control_rate:.2f}% (type + Left/Top/Width/Height)",
|
|
452
|
+
f"forms fully matched: {self.forms_matched}/{self.truth_forms} = {self.form_rate:.2f}%",
|
|
453
|
+
]
|
|
454
|
+
if self.client_size_total:
|
|
455
|
+
lines.append(f"client size matched: {self.client_size_matched}/{self.client_size_total}")
|
|
456
|
+
if self.visible_checked:
|
|
457
|
+
lines.append(
|
|
458
|
+
f"visible: {self.visible_matched}/{self.visible_checked} matched, "
|
|
459
|
+
f"{self.visible_false_positive} hidden-claims wrong (FP)")
|
|
460
|
+
if self.sstab_members_checked:
|
|
461
|
+
lines.append(f"sstab members: {self.sstab_members_matched}/"
|
|
462
|
+
f"{self.sstab_members_checked}")
|
|
463
|
+
if self.field_diffs:
|
|
464
|
+
lines.append(f"field diffs: {self.field_diffs.most_common(10)}")
|
|
465
|
+
if not self.ground_truth_available:
|
|
466
|
+
lines.append("no .frm directory given — coverage NOT measured")
|
|
467
|
+
if self.forms_without_blob:
|
|
468
|
+
lines.append(f"no blob found: {len(self.forms_without_blob)} "
|
|
469
|
+
f"{[n for n, _ in self.forms_without_blob[:10]]}")
|
|
470
|
+
if self.forms_without_records:
|
|
471
|
+
lines.append(f"blob but no control records: {len(self.forms_without_records)} "
|
|
472
|
+
f"{[n for n, _ in self.forms_without_records[:10]]}")
|
|
473
|
+
if self.form_mismatches:
|
|
474
|
+
lines.append(f"form mismatches: {len(self.form_mismatches)}, first 10: "
|
|
475
|
+
f"{self.form_mismatches[:10]}")
|
|
476
|
+
return "\n".join(lines)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# --------------------------------------------------------------------------
|
|
480
|
+
# Primitive readers
|
|
481
|
+
# --------------------------------------------------------------------------
|
|
482
|
+
def read_str(b: bytes, p: int) -> tuple[str | None, int]:
|
|
483
|
+
"""Read ``<len16> <bytes> 0x00`` as CP1254; ``(None, p)`` if not one."""
|
|
484
|
+
if p + 2 > len(b):
|
|
485
|
+
return None, p
|
|
486
|
+
n, = struct.unpack_from("<H", b, p)
|
|
487
|
+
if n == 0 or n > 255 or p + 3 + n > len(b):
|
|
488
|
+
return None, p
|
|
489
|
+
raw = b[p + 2:p + 2 + n]
|
|
490
|
+
if b[p + 2 + n] != 0x00:
|
|
491
|
+
return None, p
|
|
492
|
+
if any(c < 0x20 and c != 0x09 for c in raw):
|
|
493
|
+
return None, p
|
|
494
|
+
return raw.decode("cp1254", "replace"), p + 3 + n
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def read_utf16(b: bytes, p: int) -> tuple[str | None, int]:
|
|
498
|
+
"""Read a UTF-16LE string with a byte-length prefix (``SSTab`` page data)."""
|
|
499
|
+
if p + 2 > len(b):
|
|
500
|
+
return None, p
|
|
501
|
+
n, = struct.unpack_from("<H", b, p)
|
|
502
|
+
if n < 2 or n > 400 or n % 2 or p + 2 + n > len(b):
|
|
503
|
+
return None, p
|
|
504
|
+
try:
|
|
505
|
+
text = b[p + 2:p + 2 + n].decode("utf-16-le")
|
|
506
|
+
except UnicodeDecodeError:
|
|
507
|
+
return None, p
|
|
508
|
+
if not text or any(ord(ch) < 0x20 and ch != "\t" for ch in text):
|
|
509
|
+
return None, p
|
|
510
|
+
return text, p + 2 + n
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def plausible(geo) -> bool:
|
|
514
|
+
"""Sanity bounds for a geometry tuple.
|
|
515
|
+
|
|
516
|
+
Left/Top may be negative (frames, scrolled containers); sizes must be
|
|
517
|
+
positive. The limits are far outside any real form and only reject
|
|
518
|
+
offsets that landed in unrelated bytes.
|
|
519
|
+
"""
|
|
520
|
+
left, top, width, height = geo
|
|
521
|
+
return (-400000 <= left <= 400000 and -400000 <= top <= 400000
|
|
522
|
+
and 0 < width <= 100000 and 0 < height <= 100000)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# --------------------------------------------------------------------------
|
|
526
|
+
# Record scanning
|
|
527
|
+
# --------------------------------------------------------------------------
|
|
528
|
+
def record_starts(b: bytes) -> list[tuple[int, str, int, int | None]]:
|
|
529
|
+
"""Find control-record starts structurally inside a blob.
|
|
530
|
+
|
|
531
|
+
Returns ``[(name_pos, name, class_pos, array_index)]`` where ``name_pos`` is
|
|
532
|
+
the offset of the ``namelen`` field. The walk is byte-by-byte on purpose:
|
|
533
|
+
record lengths are not chained reliably (control arrays and OCX records do
|
|
534
|
+
not always agree with their ``size`` field), so the shape of the header is
|
|
535
|
+
the evidence.
|
|
536
|
+
"""
|
|
537
|
+
out = []
|
|
538
|
+
n = len(b)
|
|
539
|
+
p = 0
|
|
540
|
+
while p < n - 8:
|
|
541
|
+
namelen, = struct.unpack_from("<H", b, p)
|
|
542
|
+
if 2 <= namelen <= MAX_NAME_LEN and p + 2 + namelen + 1 < n:
|
|
543
|
+
raw = b[p + 2:p + 2 + namelen]
|
|
544
|
+
if (raw[:1].isalpha() and all(0x30 <= c <= 0x7A or c == 0x5F for c in raw)
|
|
545
|
+
and b[p + 2 + namelen] == 0x00):
|
|
546
|
+
size = None
|
|
547
|
+
index = None
|
|
548
|
+
if p >= 7:
|
|
549
|
+
word, = struct.unpack_from("<I", b, p - 7)
|
|
550
|
+
if word & 0x80000000:
|
|
551
|
+
size = word & 0x7FFFFFFF
|
|
552
|
+
index, = struct.unpack_from("<H", b, p - 2)
|
|
553
|
+
if size is None and p >= 5:
|
|
554
|
+
size, = struct.unpack_from("<I", b, p - 5)
|
|
555
|
+
if size is not None and 8 <= size <= 60000:
|
|
556
|
+
cls_p = p + 3 + namelen
|
|
557
|
+
if cls_p < n:
|
|
558
|
+
out.append((p, raw.decode("ascii"), cls_p, index))
|
|
559
|
+
p = cls_p
|
|
560
|
+
continue
|
|
561
|
+
p += 1
|
|
562
|
+
return out
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _geometry(b: bytes, start: int, end: int, gpid: int):
|
|
566
|
+
"""Find the geometry property at/after ``start``.
|
|
567
|
+
|
|
568
|
+
Returns ``(values, value_pos, wide)`` or ``None``. Three passes, measured:
|
|
569
|
+
a narrow window after the caption (covers the vast majority of controls),
|
|
570
|
+
then the whole record with the post-geometry pid check, then the whole
|
|
571
|
+
record without it (records whose next property is unusual or absent).
|
|
572
|
+
"""
|
|
573
|
+
for limit, need_post in ((start + 64, True), (end, True), (end, False)):
|
|
574
|
+
candidates = []
|
|
575
|
+
last_q = -1
|
|
576
|
+
for q in range(start, min(limit, end) - 8):
|
|
577
|
+
if b[q - 1] != gpid:
|
|
578
|
+
continue
|
|
579
|
+
wide = None
|
|
580
|
+
if b[q] == 0x00 and b[q + 1] == 0x80 and q + 18 <= len(b):
|
|
581
|
+
as32 = struct.unpack_from("<iiii", b, q + 2)
|
|
582
|
+
if plausible(as32) and any(abs(v) > 32767 for v in as32[:2]):
|
|
583
|
+
wide = as32
|
|
584
|
+
if wide is not None:
|
|
585
|
+
if need_post and b[q + 18] not in POST_PIDS:
|
|
586
|
+
continue
|
|
587
|
+
candidates.append((wide, q, True))
|
|
588
|
+
last_q = q
|
|
589
|
+
continue
|
|
590
|
+
cand = struct.unpack_from("<hhhh", b, q)
|
|
591
|
+
if need_post and b[q + 8] not in POST_PIDS:
|
|
592
|
+
continue
|
|
593
|
+
if plausible(cand):
|
|
594
|
+
candidates.append((cand, q, False))
|
|
595
|
+
last_q = q
|
|
596
|
+
if candidates:
|
|
597
|
+
# Embedded image/OLE data before the geometry pushes the real
|
|
598
|
+
# property towards the end of a long record; when the first
|
|
599
|
+
# candidate is far from both ends, prefer the trailing one.
|
|
600
|
+
chosen = candidates[0]
|
|
601
|
+
if len(candidates) > 1 and (end - start) > 500 and (end - last_q) <= GEOM_TAIL_WINDOW:
|
|
602
|
+
chosen = candidates[-1]
|
|
603
|
+
return chosen
|
|
604
|
+
return None
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _sstab_has_signature(b: bytes, p: int, window: int = 48) -> bool:
|
|
608
|
+
return any(sig in b[p:p + window] for sig in SSTAB_SIGNATURES)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def sstab_region(b: bytes, start: int, end: int) -> tuple[list[str], dict[str, int]]:
|
|
612
|
+
"""Return ``(page_titles, {control_name: page_number})`` for one SSTab.
|
|
613
|
+
|
|
614
|
+
A page title is a UTF-16 string immediately followed by a GUID signature;
|
|
615
|
+
UTF-16 identifiers without a signature are controls on the current page.
|
|
616
|
+
Page numbers are 1-based to match the ``SSTab.Tab`` semantics. Reading
|
|
617
|
+
stops at the end of the control's record.
|
|
618
|
+
"""
|
|
619
|
+
pages: list[str] = []
|
|
620
|
+
members: dict[str, int] = {}
|
|
621
|
+
current = -1
|
|
622
|
+
p = start
|
|
623
|
+
while p < end:
|
|
624
|
+
text, nxt = read_utf16(b, p)
|
|
625
|
+
if text is None:
|
|
626
|
+
p += 1
|
|
627
|
+
continue
|
|
628
|
+
if _sstab_has_signature(b, nxt):
|
|
629
|
+
current += 1
|
|
630
|
+
pages.append(text)
|
|
631
|
+
p = nxt
|
|
632
|
+
continue
|
|
633
|
+
if current >= 0 and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,40}", text):
|
|
634
|
+
members.setdefault(text, current + 1)
|
|
635
|
+
p = nxt if nxt > p else p + 1
|
|
636
|
+
return pages, members
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _is_embedded_object(type_name: str) -> bool:
|
|
640
|
+
"""True for records that are sibling objects, not controls of the form.
|
|
641
|
+
|
|
642
|
+
A DataReport is compiled into the same data region as the forms; its record
|
|
643
|
+
carries class ``0xFF`` and a runtime class name whose leaf is
|
|
644
|
+
``DataReport...``. It has no Left/Top/Width/Height and is not declared in
|
|
645
|
+
the form's ``.frm``, so emitting it would invent a control. The same test
|
|
646
|
+
covers the non-visual classes listed in :data:`SKIP_TYPES`.
|
|
647
|
+
"""
|
|
648
|
+
if type_name in SKIP_TYPES:
|
|
649
|
+
return True
|
|
650
|
+
return type_name.rsplit(".", 1)[-1].startswith("DataReport")
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _parse_control(b: bytes, name: str, cls_p: int, hdr_index: int | None,
|
|
654
|
+
end: int) -> Control | None:
|
|
655
|
+
"""Decode one control record; ``None`` when it carries no recoverable geometry."""
|
|
656
|
+
cls_byte = b[cls_p]
|
|
657
|
+
p = cls_p + 1
|
|
658
|
+
if cls_byte == 0xFF:
|
|
659
|
+
type_name, p = read_str(b, p)
|
|
660
|
+
if type_name is None:
|
|
661
|
+
return None
|
|
662
|
+
else:
|
|
663
|
+
type_name = CLASS_NAME.get(cls_byte)
|
|
664
|
+
if type_name is None:
|
|
665
|
+
return None
|
|
666
|
+
if _is_embedded_object(type_name):
|
|
667
|
+
return None
|
|
668
|
+
|
|
669
|
+
gpid = GEOM_PID.get(type_name, GEOM_PID_OCX)
|
|
670
|
+
caption = None
|
|
671
|
+
|
|
672
|
+
# pid 0x01 is Caption on a plain control and decodes as a string; on an
|
|
673
|
+
# array element the same slot can hold the index as a uint16, hence the
|
|
674
|
+
# 3-byte skip when no string shape matches.
|
|
675
|
+
if p < len(b) and b[p] == 0x01:
|
|
676
|
+
cap, nxt = read_str(b, p + 1)
|
|
677
|
+
if cap is not None:
|
|
678
|
+
caption = cap
|
|
679
|
+
p = nxt
|
|
680
|
+
elif p + 3 < len(b):
|
|
681
|
+
p += 3
|
|
682
|
+
# pid 0x02 = Index (uint16); it follows the caption on array elements.
|
|
683
|
+
if (p + 4 < len(b) and b[p] == 0x02 and b[p + 3] == gpid
|
|
684
|
+
and plausible(struct.unpack_from("<hhhh", b, p + 4))):
|
|
685
|
+
p += 3
|
|
686
|
+
|
|
687
|
+
found = _geometry(b, p, end, gpid)
|
|
688
|
+
if found is None:
|
|
689
|
+
return None
|
|
690
|
+
(left, top, width, height), geo_pos, wide = found
|
|
691
|
+
|
|
692
|
+
# Visible marker sits directly after the geometry value.
|
|
693
|
+
visible: bool | None = None
|
|
694
|
+
rule_pid = VISIBLE_PID.get(type_name)
|
|
695
|
+
post = geo_pos + (16 if wide else 8)
|
|
696
|
+
if rule_pid is not None and post + 1 < len(b):
|
|
697
|
+
if b[post] == rule_pid and b[post + 1] == 0x00:
|
|
698
|
+
visible = False
|
|
699
|
+
|
|
700
|
+
pages: tuple[str, ...] = ()
|
|
701
|
+
members: dict[str, int] = {}
|
|
702
|
+
if type_name.rsplit(".", 1)[-1] == "SSTab":
|
|
703
|
+
pages, members = sstab_region(b, post, min(end, len(b)))
|
|
704
|
+
pages = tuple(pages)
|
|
705
|
+
|
|
706
|
+
# Page membership is resolved by the caller, which owns the sibling list.
|
|
707
|
+
return Control(name=name, type=type_name, caption=caption, left=left, top=top,
|
|
708
|
+
width=width, height=height, index=hdr_index, visible=visible,
|
|
709
|
+
pages=pages, _sstab_members=members)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _form_client_size(b: bytes, name_pos: int, end: int) -> tuple[int, int, int, int] | None:
|
|
713
|
+
"""Recover ``(ClientLeft, ClientTop, ClientWidth, ClientHeight)``.
|
|
714
|
+
|
|
715
|
+
The form record writes pid ``0x35`` followed by four int32 values in that
|
|
716
|
+
order. Bounds are measured from real forms: client left/top 0..20000,
|
|
717
|
+
width/height 200..30000 twips. Reads as "the first int is a block length"
|
|
718
|
+
match only part of the forms, which is why all four values are required to
|
|
719
|
+
look sane before the block is accepted.
|
|
720
|
+
"""
|
|
721
|
+
for p in range(max(0, name_pos), min(end, len(b)) - 17):
|
|
722
|
+
if b[p] != 0x35:
|
|
723
|
+
continue
|
|
724
|
+
left, top, width, height = struct.unpack_from("<iiii", b, p + 1)
|
|
725
|
+
if (0 <= left <= 20000 and 0 <= top <= 20000
|
|
726
|
+
and 200 <= width <= 30000 and 200 <= height <= 30000):
|
|
727
|
+
return (left, top, width, height)
|
|
728
|
+
return None
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# --------------------------------------------------------------------------
|
|
732
|
+
# Blob -> Form
|
|
733
|
+
# --------------------------------------------------------------------------
|
|
734
|
+
def extract_form(blob: FormBlob) -> Form | None:
|
|
735
|
+
"""Parse one blob into a :class:`Form`; ``None`` if no records are found."""
|
|
736
|
+
b = blob.data
|
|
737
|
+
starts = record_starts(b)
|
|
738
|
+
if not starts:
|
|
739
|
+
return None
|
|
740
|
+
|
|
741
|
+
# Segment selection: a blob cut at the next form record normally holds a
|
|
742
|
+
# single segment, but a caller may pass an uncut blob (several form
|
|
743
|
+
# objects). Keep the segment whose form record carries this blob's name.
|
|
744
|
+
own = None
|
|
745
|
+
seg_start, seg_end = 0, len(b)
|
|
746
|
+
for i, (p, name, cls_p, _idx) in enumerate(starts):
|
|
747
|
+
if b[cls_p] != 0x0D:
|
|
748
|
+
continue
|
|
749
|
+
if own is None and name == blob.name:
|
|
750
|
+
own, seg_start = i, p
|
|
751
|
+
elif own is not None and i > own:
|
|
752
|
+
seg_end = p
|
|
753
|
+
break
|
|
754
|
+
if own is None:
|
|
755
|
+
seg_start, seg_end = 0, len(b)
|
|
756
|
+
|
|
757
|
+
controls: list[Control] = []
|
|
758
|
+
for i, (p, name, cls_p, idx) in enumerate(starts):
|
|
759
|
+
if not seg_start <= p < seg_end:
|
|
760
|
+
continue
|
|
761
|
+
end = starts[i + 1][0] if i + 1 < len(starts) else len(b)
|
|
762
|
+
end = min(end, seg_end)
|
|
763
|
+
ctrl = _parse_control(b, name, cls_p, idx, end)
|
|
764
|
+
if ctrl is not None:
|
|
765
|
+
controls.append(ctrl)
|
|
766
|
+
|
|
767
|
+
# Attach SSTab page membership to the sibling controls.
|
|
768
|
+
for tab in controls:
|
|
769
|
+
members = getattr(tab, "_sstab_members", None)
|
|
770
|
+
if not members:
|
|
771
|
+
continue
|
|
772
|
+
by_name = {}
|
|
773
|
+
for ctrl in controls:
|
|
774
|
+
by_name.setdefault(ctrl.name, ctrl)
|
|
775
|
+
for member_name, page in members.items():
|
|
776
|
+
target = by_name.get(member_name)
|
|
777
|
+
if target is not None and target is not tab:
|
|
778
|
+
target.tab_page = page
|
|
779
|
+
|
|
780
|
+
name_pos = starts[own][0] if own is not None else 0
|
|
781
|
+
size = _form_client_size(b, name_pos, seg_end)
|
|
782
|
+
form = Form(name=blob.name, controls=controls,
|
|
783
|
+
width=size[2] if size else None,
|
|
784
|
+
height=size[3] if size else None,
|
|
785
|
+
client_left=size[0] if size else None,
|
|
786
|
+
client_top=size[1] if size else None)
|
|
787
|
+
return form
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def extract_forms(path: str) -> list[Form]:
|
|
791
|
+
"""Parse every form blob in ``path`` (one :class:`Form` per blob)."""
|
|
792
|
+
forms = []
|
|
793
|
+
for blob in iter_form_blobs(path):
|
|
794
|
+
form = extract_form(blob)
|
|
795
|
+
if form is not None:
|
|
796
|
+
forms.append(form)
|
|
797
|
+
return forms
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
# --------------------------------------------------------------------------
|
|
801
|
+
# .frm ground truth
|
|
802
|
+
# --------------------------------------------------------------------------
|
|
803
|
+
_RE_BEGIN = re.compile(r"^(\s*)Begin\s+([\w.]+)\s+([\w]+)\s*$")
|
|
804
|
+
_RE_PROP = re.compile(r"^(\s*)(\w+)\s*=\s*(-?\d+|\"[^\"]*\")\s*$")
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _strip_comment(line: str) -> str:
|
|
808
|
+
"""Drop a trailing ``' comment`` without touching apostrophes in strings.
|
|
809
|
+
|
|
810
|
+
``.frm`` properties often carry ``Visible = 0 'True``; the value regex
|
|
811
|
+
is anchored, so the comment must go first. A ``'`` inside a quoted value
|
|
812
|
+
("Budget's") is not a comment and is preserved.
|
|
813
|
+
"""
|
|
814
|
+
in_string = False
|
|
815
|
+
for i, ch in enumerate(line):
|
|
816
|
+
if ch == '"':
|
|
817
|
+
in_string = not in_string
|
|
818
|
+
elif ch == "'" and not in_string:
|
|
819
|
+
return line[:i]
|
|
820
|
+
return line
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
@dataclass
|
|
824
|
+
class TruthControl:
|
|
825
|
+
"""A control from ``.frm`` design text."""
|
|
826
|
+
|
|
827
|
+
name: str
|
|
828
|
+
type: str
|
|
829
|
+
left: int
|
|
830
|
+
top: int
|
|
831
|
+
width: int
|
|
832
|
+
height: int
|
|
833
|
+
caption: str | None = None
|
|
834
|
+
index: int | None = None
|
|
835
|
+
visible: int | None = None
|
|
836
|
+
parent: str | None = None
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def parse_frm(path: str) -> tuple[str | None, list[TruthControl], dict | None]:
|
|
840
|
+
"""Parse the design block of a ``.frm`` file.
|
|
841
|
+
|
|
842
|
+
Returns ``(form_name, controls, form_props)``. Only controls that declare
|
|
843
|
+
all of Left/Top/Width/Height are returned, mirroring what the blob schema
|
|
844
|
+
can represent (lines, timers and menus are out of scope on both sides).
|
|
845
|
+
The nesting stack gives each control its parent, which is what ``SSTab``
|
|
846
|
+
membership is derived from.
|
|
847
|
+
"""
|
|
848
|
+
stack: list[dict] = []
|
|
849
|
+
flat: list[dict] = []
|
|
850
|
+
form_name = None
|
|
851
|
+
form_props: dict | None = None
|
|
852
|
+
with open(path, encoding="cp1254", errors="replace") as fh:
|
|
853
|
+
for raw_line in fh:
|
|
854
|
+
if raw_line.startswith("Attribute VB_Name"):
|
|
855
|
+
break
|
|
856
|
+
line = _strip_comment(raw_line)
|
|
857
|
+
m = _RE_BEGIN.match(line)
|
|
858
|
+
if m:
|
|
859
|
+
node = {"name": m.group(3), "type": m.group(2), "props": {},
|
|
860
|
+
"parent": stack[-1]["name"] if stack else None}
|
|
861
|
+
stack.append(node)
|
|
862
|
+
if len(stack) == 1 and m.group(2).endswith("Form"):
|
|
863
|
+
form_name, form_props = m.group(3), node["props"]
|
|
864
|
+
continue
|
|
865
|
+
if line.strip() == "End":
|
|
866
|
+
if stack:
|
|
867
|
+
flat.append(stack.pop())
|
|
868
|
+
continue
|
|
869
|
+
mp = _RE_PROP.match(line)
|
|
870
|
+
if mp and stack:
|
|
871
|
+
value = mp.group(3)
|
|
872
|
+
stack[-1]["props"][mp.group(2)] = (
|
|
873
|
+
value[1:-1] if value.startswith('"') else int(value))
|
|
874
|
+
|
|
875
|
+
controls = []
|
|
876
|
+
for node in flat:
|
|
877
|
+
props = node["props"]
|
|
878
|
+
if node["type"].endswith("Form"):
|
|
879
|
+
continue
|
|
880
|
+
if not all(k in props for k in ("Left", "Top", "Width", "Height")):
|
|
881
|
+
continue
|
|
882
|
+
caption = props.get("Caption")
|
|
883
|
+
controls.append(TruthControl(
|
|
884
|
+
name=node["name"], type=node["type"],
|
|
885
|
+
left=props["Left"], top=props["Top"],
|
|
886
|
+
width=props["Width"], height=props["Height"],
|
|
887
|
+
caption=caption if isinstance(caption, str) else None,
|
|
888
|
+
index=props.get("Index"), visible=props.get("Visible"),
|
|
889
|
+
parent=node["parent"]))
|
|
890
|
+
return form_name, controls, form_props
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _sstab_truth(controls: list[TruthControl]) -> dict[str, dict[str, int]]:
|
|
894
|
+
"""``{sstab_name: {direct_member_name: 1}}`` from a flat ``.frm`` list.
|
|
895
|
+
|
|
896
|
+
Only controls whose parent *is* the ``SSTab`` count: a ``Frame`` placed on a
|
|
897
|
+
page owns its own children, and the blob lists the frame, not the frame's
|
|
898
|
+
children, in the ``SSTab`` record. Page numbers are not compared — the
|
|
899
|
+
design block does not spell them out per control, and the blob's page order
|
|
900
|
+
is the only source for that.
|
|
901
|
+
"""
|
|
902
|
+
tabs = {c.name for c in controls if c.type.rsplit(".", 1)[-1] == "SSTab"}
|
|
903
|
+
out: dict[str, dict[str, int]] = {t: {} for t in tabs}
|
|
904
|
+
for ctrl in controls:
|
|
905
|
+
if ctrl.parent in tabs:
|
|
906
|
+
out[ctrl.parent][ctrl.name] = 1
|
|
907
|
+
return out
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
# --------------------------------------------------------------------------
|
|
911
|
+
# Validation
|
|
912
|
+
# --------------------------------------------------------------------------
|
|
913
|
+
def validate(exe_path: str, frm_dir: str | None = None) -> ValidationReport:
|
|
914
|
+
"""Measure the parser against ``.frm`` ground truth.
|
|
915
|
+
|
|
916
|
+
``frm_dir`` is the directory holding the ``.frm`` design text of the same
|
|
917
|
+
build. When it is ``None`` only the blob side is reported (blob and form
|
|
918
|
+
counts, no coverage percentage) — a missing ground truth is reported as
|
|
919
|
+
missing, not as a pass.
|
|
920
|
+
|
|
921
|
+
Comparison is per control on ``(name, index)``, case-insensitive because
|
|
922
|
+
VB identifiers are; the compared fields are type and the four geometry
|
|
923
|
+
values. Captions are counted as a separate diff so a caption mismatch
|
|
924
|
+
cannot hide a correct geometry.
|
|
925
|
+
"""
|
|
926
|
+
report = ValidationReport()
|
|
927
|
+
blobs = iter_form_blobs(exe_path)
|
|
928
|
+
report.blob_count = len(blobs)
|
|
929
|
+
|
|
930
|
+
by_name: dict[str, list[FormBlob]] = defaultdict(list)
|
|
931
|
+
for blob in blobs:
|
|
932
|
+
by_name[blob.name.lower()].append(blob)
|
|
933
|
+
|
|
934
|
+
report.ground_truth_available = frm_dir is not None
|
|
935
|
+
if frm_dir is None:
|
|
936
|
+
return report
|
|
937
|
+
|
|
938
|
+
parsed = {}
|
|
939
|
+
for path in sorted(glob.glob(os.path.join(frm_dir, "*.frm"))):
|
|
940
|
+
name, ctrls, props = parse_frm(path)
|
|
941
|
+
if name and ctrls:
|
|
942
|
+
parsed[name.lower()] = (name, ctrls, props)
|
|
943
|
+
|
|
944
|
+
report.truth_forms = len(parsed)
|
|
945
|
+
report.ground_truth_available = True
|
|
946
|
+
for key, (name, truth, props) in sorted(parsed.items()):
|
|
947
|
+
candidates = by_name.get(key)
|
|
948
|
+
if not candidates:
|
|
949
|
+
report.forms_without_blob.append((name, len(truth)))
|
|
950
|
+
continue
|
|
951
|
+
form = None
|
|
952
|
+
for blob in sorted(candidates, key=lambda x: len(x.data), reverse=True):
|
|
953
|
+
candidate = extract_form(blob)
|
|
954
|
+
if candidate and candidate.controls:
|
|
955
|
+
form = candidate
|
|
956
|
+
break
|
|
957
|
+
if form is None or not form.controls:
|
|
958
|
+
report.forms_without_records.append((name, len(truth)))
|
|
959
|
+
continue
|
|
960
|
+
|
|
961
|
+
got: dict[tuple[str, int | None], Control] = {}
|
|
962
|
+
for ctrl in form.controls:
|
|
963
|
+
got.setdefault((ctrl.name.lower(), ctrl.index), ctrl)
|
|
964
|
+
|
|
965
|
+
bad = 0
|
|
966
|
+
for truth_ctrl in truth:
|
|
967
|
+
ctrl = got.get((truth_ctrl.name.lower(), truth_ctrl.index))
|
|
968
|
+
if ctrl is None:
|
|
969
|
+
report.field_diffs["control missing"] += 1
|
|
970
|
+
bad += 1
|
|
971
|
+
continue
|
|
972
|
+
for name_field in ("type", "left", "top", "width", "height"):
|
|
973
|
+
if getattr(ctrl, name_field) != getattr(truth_ctrl, name_field):
|
|
974
|
+
report.field_diffs[name_field] += 1
|
|
975
|
+
if (truth_ctrl.caption is not None
|
|
976
|
+
and ctrl.caption != truth_ctrl.caption):
|
|
977
|
+
report.field_diffs["caption"] += 1
|
|
978
|
+
if truth_ctrl.visible is not None:
|
|
979
|
+
# The blob rule only ever claims "hidden"; a claim on a control
|
|
980
|
+
# the .frm says is visible is a false positive and is counted
|
|
981
|
+
# separately so the rule can be pruned per class.
|
|
982
|
+
report.visible_checked += 1
|
|
983
|
+
if truth_ctrl.visible == 0:
|
|
984
|
+
if ctrl.visible is False:
|
|
985
|
+
report.visible_matched += 1
|
|
986
|
+
elif ctrl.visible is False:
|
|
987
|
+
report.visible_false_positive += 1
|
|
988
|
+
if all(getattr(ctrl, f) == getattr(truth_ctrl, f)
|
|
989
|
+
for f in ("type", "left", "top", "width", "height")):
|
|
990
|
+
report.controls_matched += 1
|
|
991
|
+
else:
|
|
992
|
+
bad += 1
|
|
993
|
+
report.controls_total += len(truth)
|
|
994
|
+
|
|
995
|
+
extra = len(form.controls) - len(truth)
|
|
996
|
+
if extra > 0:
|
|
997
|
+
report.field_diffs["extra records"] += extra
|
|
998
|
+
if bad == 0 and extra <= 0:
|
|
999
|
+
report.forms_matched += 1
|
|
1000
|
+
else:
|
|
1001
|
+
report.form_mismatches.append((name, len(form.controls), len(truth)))
|
|
1002
|
+
|
|
1003
|
+
# Client rectangle, only for forms whose .frm declares it.
|
|
1004
|
+
if props is not None and "ClientWidth" in props and "ClientHeight" in props:
|
|
1005
|
+
report.client_size_total += 1
|
|
1006
|
+
if (form.width == props["ClientWidth"]
|
|
1007
|
+
and form.height == props["ClientHeight"]):
|
|
1008
|
+
report.client_size_matched += 1
|
|
1009
|
+
for attr, prop in (("client_left", "ClientLeft"),
|
|
1010
|
+
("client_top", "ClientTop")):
|
|
1011
|
+
if prop in props and getattr(form, attr) != props[prop]:
|
|
1012
|
+
report.field_diffs["client " + prop] += 1
|
|
1013
|
+
|
|
1014
|
+
# SSTab membership (names only; page numbers need nesting detail the
|
|
1015
|
+
# design block does not spell out for pages after the first).
|
|
1016
|
+
truth_tabs = _sstab_truth(truth)
|
|
1017
|
+
for tab in form.controls:
|
|
1018
|
+
if not tab.pages or tab.name not in truth_tabs:
|
|
1019
|
+
continue
|
|
1020
|
+
expected = set(truth_tabs[tab.name])
|
|
1021
|
+
members = {c.name for c in form.controls
|
|
1022
|
+
if c.tab_page is not None and c.name in expected}
|
|
1023
|
+
report.sstab_members_checked += len(expected)
|
|
1024
|
+
report.sstab_members_matched += len(members)
|
|
1025
|
+
return report
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1029
|
+
"""Command line entry: ``python -m vb6decompiler.blob EXE [--frm DIR]``.
|
|
1030
|
+
|
|
1031
|
+
``--validate`` and ``--frm-dir`` are accepted as aliases so this module and
|
|
1032
|
+
``vb6d blob`` take the same flags; a tool with two spellings for one thing is a
|
|
1033
|
+
trap for whoever reads the docs next.
|
|
1034
|
+
"""
|
|
1035
|
+
import argparse
|
|
1036
|
+
|
|
1037
|
+
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
1038
|
+
parser.add_argument("exe", help="path to the VB6 executable")
|
|
1039
|
+
parser.add_argument("--frm", "--frm-dir", dest="frm", default=None,
|
|
1040
|
+
help="directory with the matching .frm files (ground truth)")
|
|
1041
|
+
parser.add_argument("--validate", action="store_true",
|
|
1042
|
+
help="accepted for parity with `vb6d blob`; validation is the default")
|
|
1043
|
+
parser.add_argument("--list", action="store_true",
|
|
1044
|
+
help="print one line per form instead of the report")
|
|
1045
|
+
args = parser.parse_args(argv)
|
|
1046
|
+
|
|
1047
|
+
if args.list:
|
|
1048
|
+
for form in extract_forms(args.exe):
|
|
1049
|
+
print(f"{form.name}\t{len(form.controls)}\t{form.width}x{form.height}")
|
|
1050
|
+
return 0
|
|
1051
|
+
report = validate(args.exe, args.frm)
|
|
1052
|
+
print(report.summary())
|
|
1053
|
+
return 0
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
if __name__ == "__main__":
|
|
1057
|
+
raise SystemExit(main())
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
__all__ = [
|
|
1061
|
+
"CLASS_NAME", "GEOM_PID", "GEOM_PID_OCX", "POST_PIDS", "SKIP_TYPES",
|
|
1062
|
+
"VISIBLE_PID", "SSTAB_SIGNATURES", "Section", "FormBlob", "Control", "Form",
|
|
1063
|
+
"ValidationReport", "TruthControl", "load_sections", "read_va",
|
|
1064
|
+
"find_form_records", "iter_form_blobs", "record_starts", "read_str",
|
|
1065
|
+
"read_utf16", "plausible", "sstab_region", "extract_form", "extract_forms",
|
|
1066
|
+
"parse_frm", "validate", "main",
|
|
1067
|
+
]
|