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/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
+ ]