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.
@@ -0,0 +1,886 @@
1
+ """DataReport designs (``MSDataReportRuntimeLib.DataReport``) inside VB6 report DLLs.
2
+
3
+ What this recovers, and what it does not
4
+ ----------------------------------------
5
+ A VB6 DataReport designer object is compiled into the host DLL as a *design blob*
6
+ next to the object table entry. The blob is not a ``.dsr`` text file and it is not
7
+ the same grammar as an ordinary form blob: it is a serialised property stream with
8
+ its own string encoding. This module walks that stream and returns, per design, the
9
+ ordered list of records (sections and controls) with whatever geometry the stream
10
+ actually stores.
11
+
12
+ It does **not** recover:
13
+
14
+ * ``DataField`` / ``DataMember`` — not present in the blob at all; the program
15
+ assigns them from code at run time.
16
+ * Fonts and GUID blocks — a separate ``len8 + CP1254`` region, outside the layout.
17
+ * Any geometry VB6 chose not to serialise (see the property rules below). Those
18
+ fields stay ``None``. An absent integer is reported as unknown, never as 0.
19
+
20
+ Measured grammar (413 designs, 8 report DLLs of one shipping VB6 install)
21
+ ------------------------------------------------------------------------
22
+ 1. **Strings are ``len32 + UTF-16LE``.** The form-blob grammar
23
+ (``len16 + CP1254 + 0x00``, see :mod:`vb6decompiler.blob`) finds *nothing* here;
24
+ feeding a DataReport blob to that reader yields zero records.
25
+ 2. **A string whose character count is odd is followed by a ``00 00`` alignment
26
+ pad** (measured on 15,032 of 15,120 odd-length events). The scanner must advance
27
+ one byte at a time and resynchronise; a four-byte stride reads the pad as the top
28
+ half of the next length and loses every following event. The pad is not cosmetic:
29
+ dropping it shifts every property array that follows by two bytes, after which
30
+ 7,639 of 14,592 "full" records carry a coordinate above 400,000 twips, against 10
31
+ of 13,032 with the pad. It is applied only when the two bytes really are zero, so
32
+ the 88 events without a pad still parse.
33
+ 3. **Name versus caption is decided by the target's naming convention**, not by
34
+ the property stream: a record name matches
35
+ ``lbl*/txt*/fun*/Label*/Line*/Shape*/Image*`` (DataReport report controls) and a
36
+ section record is ``SectionN``. Everything else is a caption.
37
+ 4. **A caption is the string immediately after the control's name** and only when
38
+ that string is neither a section nor another control name. VB6 does not
39
+ serialise a property equal to its default, so an empty ``Caption`` is written as
40
+ the control's *own* name repeated. That repetition is kept verbatim in
41
+ ``caption`` and flagged by :attr:`Record.caption_is_default`; it is never
42
+ rewritten into an empty string, and it is never treated as a caption the
43
+ designer wrote.
44
+ 5. **Properties after the name are plain int32s, up to the next string event.**
45
+ When four or more are present the first four are ``Left, Top, Width, Height``.
46
+ Because of rule 6 some records are shorter; the measured discriminators are
47
+ ``150 <= dword <= 300`` (a font height, ~225 twips) and ``dword in 1..4`` (a
48
+ small enum such as ``Alignment``).
49
+ 6. **T (and sometimes W) are genuinely missing.** VB6 omits properties equal to the
50
+ default, so two shorter layouts appear:
51
+
52
+ * ``(L, W, H, X)`` — ``dword[2]`` is 150..300 and ``dword[3]`` is 1..4. ``Top``
53
+ was not serialised.
54
+ * ``(L, H, X, Y)`` — ``dword[1]`` is 150..300 and ``dword[2]`` is 1..4. Both
55
+ ``Top`` and ``Width`` were not serialised. Measured on 62 records; every one
56
+ of them had ``dword[1] == 225``.
57
+
58
+ ``Top`` is filled in **only** where the design itself proves the value: the
59
+ modal ``Top`` of that design's *complete* Detail records (measured mode = 30
60
+ twips). Such a value is flagged as inferred (``top_inferred``) so a consumer can
61
+ tell it apart from a stored one. ``Width`` is never inferred by this module.
62
+ 7. **Section ownership is positional.** A control belongs to the last section record
63
+ that precedes it. Controls per section, measured over all 437 designs:
64
+ ``Section1`` 4,293 (2,016 ``txt`` + 1,853 ``Text`` data boxes), ``Section2``
65
+ 11,890 (4,980 ``Label*``; the page-number fields ``%p``/``%P`` and the page caption
66
+ land here), ``Section3`` **27**, ``Section4`` 136 (66 ``Line`` rules plus the
67
+ company name), ``Section5`` 2,979 (219 captioned totals). The roles follow VB6's
68
+ creation order and the content agrees for four of the five. ``Section3`` =
69
+ PageFooter is the weak one and stays labelled as a default, not a finding: 22 of
70
+ its 27 controls are ``lblNote*`` labels and 20 of them carry a blank caption, and
71
+ the only role-revealing name in the whole corpus is a control called
72
+ ``lblPageEnd`` ("page end") in one report DLL. ``Section1`` measured 227 twips
73
+ high in 329 designs (one text line), and every one of the 413 designs carries all
74
+ five section records.
75
+ 8. **The design's record extent is self-describing.** The record starts five bytes
76
+ before the ``len16`` name length: ``<size:int32> <idx:uint8> <namelen:uint16>
77
+ <name> 0x00 <class:0xFF> <len16 class name>``. Checked against the designer blobs
78
+ of a VB6 structure dump on 413/413 designs: the ``size`` field is the record's own
79
+ length, the record ends nine bytes before the blob's end, and a 98-byte designer
80
+ header sits in front of it. Scanning the record alone yields the same string
81
+ events as scanning the whole blob, so no blob extent has to be guessed.
82
+ 9. **A design name is not a design.** Locating designs by the class-name bytes
83
+ alone yields two false positives in every DLL — the class name also occurs in the
84
+ type-library/import region without a record header. The five bytes before the
85
+ name length must agree, otherwise the hit is dropped.
86
+ 10. **Join key is (file, design name).** Six design names occur in two DLLs with
87
+ different evidence; a dict keyed by name alone silently overwrites one of them.
88
+ :func:`read_paths` keeps every design and :func:`key` exposes the composite key.
89
+
90
+ Usage::
91
+
92
+ python -m vb6decompiler.datareport --validate "C:/path/to/report/dlls"
93
+ python -m vb6decompiler.datareport --csv out.csv rptOrders.dll
94
+ """
95
+
96
+ from __future__ import annotations
97
+
98
+ import argparse
99
+ import csv
100
+ import json
101
+ import os
102
+ import re
103
+ import struct
104
+ import sys
105
+ from collections import Counter
106
+ from dataclasses import dataclass, field
107
+ from typing import Iterable, Iterator, Sequence
108
+
109
+ from . import pe
110
+
111
+ #: Exact class name of a DataReport designer object in a compiled VB6 binary.
112
+ #: Measured byte-for-byte; it is the only reliable anchor for a design record.
113
+ DATAREPORT_CLASS = "MSDataReportRuntimeLib.DataReport"
114
+
115
+ #: Section records. VB6 names them ``SectionN`` in creation order.
116
+ SECTION_RE = re.compile(r"^Section\d+$")
117
+
118
+ #: VB6's default DataReport section roles, confirmed by the content of each block
119
+ #: on this target (see rule 7 in the module docstring).
120
+ SECTION_ROLE = {
121
+ "Section1": "Detail",
122
+ "Section2": "PageHeader",
123
+ "Section3": "PageFooter",
124
+ "Section4": "rptHeader",
125
+ "Section5": "rptFooter",
126
+ }
127
+
128
+ #: Control-name prefixes of the target's reporting convention. Kept verbatim from
129
+ #: the calibration run: the list is what the designer actually produced
130
+ #: (``lbl``/``txt`` report fields, ``fun``/``Text`` totals, ``Line``/``Shape``
131
+ #: rules, ``Image`` logos). A name outside this list is treated as a caption.
132
+ CONTROL_NAME_RE = re.compile(
133
+ r"^(lbl|txt|fun|Text|Function|Line|Shape|Label|Image|Img|shp|lin|chk)\w*$"
134
+ )
135
+
136
+ #: String length limits for ``len32 + UTF-16LE`` events. 200 characters is well
137
+ #: above the longest real caption (~60) and low enough that random bytes inside
138
+ #: embedded GUID/font blocks fail the range test immediately.
139
+ STRING_MIN_CHARS = 1
140
+ STRING_MAX_CHARS = 200
141
+
142
+ #: Font height window used to recognise the sparse property layouts (rule 6).
143
+ #: Calibrated on this target: the text height is 225 twips for the shipped fonts,
144
+ #: 150..300 covers every font the reports use and excludes real ``Left``/``Top``
145
+ #: values, which start above 400.
146
+ FONT_HEIGHT_MIN = 150
147
+ FONT_HEIGHT_MAX = 300
148
+
149
+ #: Small-enum window for the trailing dword of a sparse property array
150
+ #: (``Alignment``/``Visible``-style flags: measured values 1..4 only).
151
+ SMALL_ENUM = (1, 2, 3, 4)
152
+
153
+ #: A property array longer than this is not a property array. The string scanner
154
+ #: skipped a character (usually inside an embedded picture) and the int32s are
155
+ #: payload, not properties. Such a record is reported with ``geometry="unreliable"``
156
+ #: and no coordinates rather than with invented ones.
157
+ MAX_PROPERTY_INTS = 8
158
+
159
+ #: VB6's default name for the Detail band. Only Detail records get an inferred
160
+ #: ``Top`` (see :func:`_classify_geometry`).
161
+ DETAIL_SECTION = "Section1"
162
+
163
+ #: Bytes of a design record header in front of the ``len16`` name length:
164
+ #: ``<size:int32> <idx:uint8>``. Measured from the record's size field back to the
165
+ #: record start; used to rewrite a located offset into a VA.
166
+ _RECORD_HDR_BYTES = 5
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # data model
171
+ # ---------------------------------------------------------------------------
172
+
173
+
174
+ @dataclass(frozen=True)
175
+ class Record:
176
+ """One string event of a design, with the property array that follows it.
177
+
178
+ ``kind`` is ``"section"``, ``"control"`` or ``"value"`` (an orphan string that
179
+ followed neither a control name nor a section — normally a caption whose control
180
+ name the scanner could not classify; kept so nothing is silently dropped).
181
+ """
182
+
183
+ index: int
184
+ kind: str
185
+ name: str
186
+ caption: str
187
+ section: str
188
+ section_role: str
189
+ left: int | None = None
190
+ top: int | None = None
191
+ width: int | None = None
192
+ height: int | None = None
193
+ geometry: str = "none"
194
+ top_inferred: bool = False
195
+ ints: tuple[int, ...] = ()
196
+ offset: int = 0
197
+ end: int = 0
198
+
199
+ @property
200
+ def is_control(self) -> bool:
201
+ return self.kind == "control"
202
+
203
+ @property
204
+ def has_full_geometry(self) -> bool:
205
+ """All four coordinates are *known*, i.e. none of them is inferred."""
206
+ return None not in (self.left, self.top, self.width, self.height) and not self.top_inferred
207
+
208
+ @property
209
+ def caption_is_default(self) -> bool:
210
+ """True when the "caption" is the control's own name repeated.
211
+
212
+ VB6 does not serialise a property equal to its default, so an empty
213
+ ``Caption`` is written as the name again. The raw repetition stays in
214
+ :attr:`caption` (nothing is invented); this flag is how a caller learns that
215
+ the ``.dsr`` caption is empty.
216
+ """
217
+ return bool(self.caption) and self.caption == self.name
218
+
219
+ def as_dict(self) -> dict:
220
+ return {
221
+ "index": self.index,
222
+ "kind": self.kind,
223
+ "name": self.name,
224
+ "caption": self.caption,
225
+ "section": self.section,
226
+ "section_role": self.section_role,
227
+ "left": self.left,
228
+ "top": self.top,
229
+ "width": self.width,
230
+ "height": self.height,
231
+ "geometry": self.geometry,
232
+ "top_inferred": self.top_inferred,
233
+ "int_count": len(self.ints),
234
+ "ints": " ".join(str(v) for v in self.ints),
235
+ "offset": hex(self.offset),
236
+ "end": hex(self.end),
237
+ }
238
+
239
+
240
+ @dataclass(frozen=True)
241
+ class Design:
242
+ """One DataReport design: a report class plus its parsed records.
243
+
244
+ ``va`` is the virtual address of the record's *size field* (the start of the
245
+ record), not of the designer blob that encloses it: a 98-byte designer header
246
+ precedes it and nine bytes follow it. ``header_fields`` are the two int32s
247
+ immediately before the size field, recorded but not interpreted — on the
248
+ measured target the second one equals the width that a VB6 structure dump
249
+ reports for the same design.
250
+ """
251
+
252
+ name: str
253
+ path: str
254
+ class_name: str
255
+ va: int
256
+ size: int
257
+ header_fields: tuple[int, int]
258
+ records: tuple[Record, ...] = ()
259
+
260
+ @property
261
+ def key(self) -> tuple[str, str]:
262
+ """Composite join key.
263
+
264
+ *(lower-cased file name, design name)* — rule 10: six names in this target
265
+ appear in two DLLs, so the file has to be part of the key.
266
+ """
267
+ return (os.path.basename(self.path).lower(), self.name)
268
+
269
+ @property
270
+ def sections(self) -> tuple[Record, ...]:
271
+ return tuple(r for r in self.records if r.kind == "section")
272
+
273
+ @property
274
+ def controls(self) -> tuple[Record, ...]:
275
+ return tuple(r for r in self.records if r.kind == "control")
276
+
277
+ @property
278
+ def values(self) -> tuple[Record, ...]:
279
+ return tuple(r for r in self.records if r.kind == "value")
280
+
281
+ def section_heights(self) -> dict[str, int | None]:
282
+ return {r.name: r.height for r in self.sections}
283
+
284
+ def as_dict(self) -> dict:
285
+ return {
286
+ "name": self.name,
287
+ "path": self.path,
288
+ "class": self.class_name,
289
+ "va": hex(self.va),
290
+ "size": self.size,
291
+ "header_fields": list(self.header_fields),
292
+ "records": [r.as_dict() for r in self.records],
293
+ }
294
+
295
+
296
+ @dataclass(frozen=True)
297
+ class DesignHead:
298
+ """A located design record before its string events are parsed."""
299
+
300
+ name: str
301
+ va: int
302
+ size: int
303
+ offset: int
304
+ header_fields: tuple[int, int]
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # string events: len32 + UTF-16LE
309
+ # ---------------------------------------------------------------------------
310
+
311
+
312
+ def _read_string(buf: bytes, pos: int) -> tuple[str, int] | None:
313
+ """Read a ``len32 + UTF-16LE`` string at ``pos``.
314
+
315
+ Returns ``(text, end)`` or ``None`` when the bytes at ``pos`` are not a string.
316
+ The sanity test is deliberately cheap and strict: every character must have a
317
+ zero (or ``0x01``) high byte — GUIDs, font tables and code bytes fail it.
318
+ """
319
+ if pos + 4 > len(buf):
320
+ return None
321
+ n_chars = struct.unpack_from("<I", buf, pos)[0]
322
+ if not (STRING_MIN_CHARS <= n_chars <= STRING_MAX_CHARS):
323
+ return None
324
+ end_of_chars = pos + 4 + 2 * n_chars
325
+ if end_of_chars > len(buf):
326
+ return None
327
+ raw = buf[pos + 4:end_of_chars]
328
+ for i in range(n_chars):
329
+ lo, hi = raw[2 * i], raw[2 * i + 1]
330
+ if hi == 0 and not (0x20 <= lo <= 0xFE or lo == 0x09):
331
+ return None # control byte: not text
332
+ if hi > 0x01:
333
+ return None # not a Latin-1-ish UTF-16 code unit
334
+ try:
335
+ text = raw.decode("utf-16-le")
336
+ except UnicodeDecodeError: # pragma: no cover - defensive
337
+ return None
338
+ end = end_of_chars
339
+ if n_chars % 2 == 1 and buf[end:end + 2] == b"\x00\x00":
340
+ # Alignment pad after an odd character count (rule 2). Without this the
341
+ # next length is read 16 bits too late and the rest of the design is lost.
342
+ end += 2
343
+ return text, end
344
+
345
+
346
+ def iter_string_events(buf: bytes) -> Iterator[tuple[int, str, int]]:
347
+ """Yield ``(offset, text, end)`` for every string event in ``buf``.
348
+
349
+ Byte-by-byte resynchronisation is mandatory: an event that starts inside the
350
+ padding of the previous one is not a 4-byte-aligned structure (rule 2).
351
+ """
352
+ out: list[tuple[int, str, int]] = []
353
+ pos = 0
354
+ limit = len(buf)
355
+ while pos < limit - 6:
356
+ got = _read_string(buf, pos)
357
+ if got is None:
358
+ pos += 1
359
+ continue
360
+ text, end = got
361
+ out.append((pos, text, end))
362
+ pos = end
363
+ yield from out
364
+
365
+
366
+ def _int_run(buf: bytes, start: int, end: int) -> tuple[int, ...]:
367
+ """The int32 array between two string events.
368
+
369
+ Unsigned on purpose: this reader exists to *sort* property arrays, and signed
370
+ reinterpretation would turn an embedded-picture byte pattern such as
371
+ ``0xFF000000`` into a plausible-looking ``Left``.
372
+ """
373
+ values = []
374
+ pos = start
375
+ while pos + 4 <= end:
376
+ values.append(struct.unpack_from("<I", buf, pos)[0])
377
+ pos += 4
378
+ return tuple(values)
379
+
380
+
381
+ # ---------------------------------------------------------------------------
382
+ # locating design records
383
+ # ---------------------------------------------------------------------------
384
+
385
+
386
+ def _class_offsets(buf: bytes) -> list[int]:
387
+ """Offsets of every occurrence of the DataReport class name."""
388
+ needle = DATAREPORT_CLASS.encode("ascii") + b"\x00"
389
+ out = []
390
+ pos = buf.find(needle)
391
+ while pos >= 0:
392
+ out.append(pos)
393
+ pos = buf.find(needle, pos + 1)
394
+ return out
395
+
396
+
397
+ def _head_at(buf: bytes, cls_pos: int) -> DesignHead | None:
398
+ """Validate the record header that must precede a class-name occurrence.
399
+
400
+ ``... <size:int32> <idx:uint8> <namelen:uint16> <name> 0x00 0xFF <len16><class>``
401
+ — every one of those fields is checked, because the class name alone also
402
+ occurs in the type-library region (rule 9).
403
+ """
404
+ name_len_pos = cls_pos - 4 # class byte at -3, len16 at -2, name NUL at -4
405
+ if name_len_pos < _RECORD_HDR_BYTES + 2:
406
+ return None
407
+ if buf[cls_pos - 3] != 0xFF:
408
+ return None
409
+ if struct.unpack_from("<H", buf, cls_pos - 2)[0] != len(DATAREPORT_CLASS):
410
+ return None
411
+ if buf[cls_pos - 4] != 0x00:
412
+ return None
413
+ # Walk back to the start of the ASCII name.
414
+ end_name = cls_pos - 4
415
+ start_name = end_name
416
+ while start_name > 0 and 0x20 <= buf[start_name - 1] < 0x7F:
417
+ start_name -= 1
418
+ if start_name == end_name:
419
+ return None
420
+ name_len_pos = start_name - 2
421
+ if name_len_pos < 0:
422
+ return None
423
+ if struct.unpack_from("<H", buf, name_len_pos)[0] != end_name - start_name:
424
+ return None
425
+ name = buf[start_name:end_name].decode("ascii", "replace")
426
+ size_pos = name_len_pos - _RECORD_HDR_BYTES
427
+ if size_pos < 0:
428
+ return None
429
+ size = struct.unpack_from("<I", buf, size_pos)[0]
430
+ # A design record is a few hundred bytes to a few hundred kilobytes; anything
431
+ # outside that is a coincidence in unrelated data.
432
+ if not (0x100 <= size <= 0x80000):
433
+ return None
434
+ if size_pos + size > len(buf):
435
+ return None
436
+ header_fields = struct.unpack_from("<II", buf, size_pos - 8) if size_pos >= 8 else (0, 0)
437
+ return DesignHead(
438
+ name=name,
439
+ va=0,
440
+ size=size,
441
+ offset=size_pos,
442
+ header_fields=header_fields,
443
+ )
444
+
445
+
446
+ def find_designs(path: str) -> list[DesignHead]:
447
+ """Locate every DataReport design record in one PE file.
448
+
449
+ Returns heads in file order with ``va`` filled in from the section table.
450
+ """
451
+ with open(path, "rb") as fh:
452
+ buf = fh.read()
453
+ image = pe.read(path)
454
+ heads = []
455
+ for cls_pos in _class_offsets(buf):
456
+ head = _head_at(buf, cls_pos)
457
+ if head is None:
458
+ continue
459
+ va = None
460
+ for sec in image.sections:
461
+ if sec.raw_offset <= head.offset < sec.raw_offset + max(sec.raw_size, 0):
462
+ va = image.image_base + sec.virtual_address + (head.offset - sec.raw_offset)
463
+ break
464
+ if va is None:
465
+ continue
466
+ # The whole record must live inside the same section.
467
+ end_off = head.offset + head.size
468
+ if not any(
469
+ sec.raw_offset <= end_off <= sec.raw_offset + max(sec.raw_size, 0)
470
+ for sec in image.sections
471
+ ):
472
+ continue
473
+ heads.append(
474
+ DesignHead(
475
+ name=head.name,
476
+ va=va,
477
+ size=head.size,
478
+ offset=head.offset,
479
+ header_fields=head.header_fields,
480
+ )
481
+ )
482
+ return heads
483
+
484
+
485
+ # ---------------------------------------------------------------------------
486
+ # parsing one design record
487
+ # ---------------------------------------------------------------------------
488
+
489
+
490
+ def _detail_top_mode(events: Sequence[tuple[int, str, int]], buf: bytes) -> int | None:
491
+ """Modal ``Top`` of this design's *complete* Detail records, or ``None``.
492
+
493
+ A complete record is one whose property array is not one of the two sparse
494
+ layouts (rule 6). Used only to fill ``Top`` where the design itself proves it
495
+ (rule 6); the measured mode on this target is 30 twips.
496
+ """
497
+ counts: Counter[int] = Counter()
498
+ section = ""
499
+ total = len(events)
500
+ for i, (_, text, end) in enumerate(events):
501
+ if SECTION_RE.match(text):
502
+ section = text
503
+ continue
504
+ if not CONTROL_NAME_RE.match(text) or section != DETAIL_SECTION:
505
+ continue
506
+ nxt = events[i + 1][0] if i + 1 < total else len(buf)
507
+ ints = _int_run(buf, end, nxt)
508
+ if len(ints) < 4:
509
+ continue
510
+ sparse = ints[3] in SMALL_ENUM and FONT_HEIGHT_MIN <= ints[2] <= FONT_HEIGHT_MAX
511
+ if sparse:
512
+ continue
513
+ top = ints[1]
514
+ if 0 <= top <= 2000:
515
+ counts[top] += 1
516
+ return counts.most_common(1)[0][0] if counts else None
517
+
518
+
519
+ def _classify_geometry(
520
+ ints: tuple[int, ...], section: str, detail_top: int | None
521
+ ) -> tuple[int | None, int | None, int | None, int | None, str, bool]:
522
+ """Interpret a control's property array.
523
+
524
+ Returns ``(left, top, width, height, geometry, top_inferred)``. ``None`` means
525
+ "not serialised by VB6", which is not the same as zero.
526
+ """
527
+ if not ints:
528
+ return None, None, None, None, "none", False
529
+ if len(ints) > MAX_PROPERTY_INTS:
530
+ # The string scanner lost a character; these int32s are payload.
531
+ return None, None, None, None, "unreliable", False
532
+ if len(ints) < 4:
533
+ # Too few int32s to be a property array at all; nothing is claimed.
534
+ return None, None, None, None, "partial", False
535
+
536
+ d0, d1, d2, d3 = ints[0], ints[1], ints[2], ints[3]
537
+
538
+ # Top is inferred only for Detail records, and only from Detail evidence: the
539
+ # modal Top of this design's complete Detail records is the topmost row of the
540
+ # detail band, which is where a Detail control with an unsaved Top sits. Using it
541
+ # for a PageHeader/PageFooter control would be a guess (header and detail rows sit
542
+ # at different heights), so those keep ``Top = None``.
543
+ inferred_top = detail_top if section == DETAIL_SECTION else None
544
+
545
+ if d3 in SMALL_ENUM and FONT_HEIGHT_MIN <= d2 <= FONT_HEIGHT_MAX:
546
+ # (L, W, H, X): Top is not serialised. Evidence on this target: a txt field
547
+ # at L=1816/W=1874 sits right of its header Label at W=1901, so the second
548
+ # dword is a width in the same stream position as the full layout's Width.
549
+ return d0, inferred_top, d1, d2, "sparse_lwhx", inferred_top is not None
550
+
551
+ if FONT_HEIGHT_MIN <= d1 <= FONT_HEIGHT_MAX and d2 in SMALL_ENUM:
552
+ # Double skip (L, H, X, Y): neither Top nor Width serialised. Measured on 62
553
+ # records, all Detail, d1 == 225 in 62/62.
554
+ return d0, inferred_top, None, d1, "sparse_lh", inferred_top is not None
555
+
556
+ return d0, d1, d2, d3, "full", False
557
+
558
+
559
+ def _make_record(
560
+ index: int,
561
+ kind: str,
562
+ text: str,
563
+ caption: str,
564
+ section: str,
565
+ ints: tuple[int, ...],
566
+ offset: int,
567
+ end: int,
568
+ detail_top: int | None,
569
+ ) -> Record:
570
+ common = dict(
571
+ index=index,
572
+ kind=kind,
573
+ name=text,
574
+ caption=caption,
575
+ section=section if kind == "control" else text,
576
+ section_role=SECTION_ROLE.get(section if kind == "control" else text, ""),
577
+ ints=ints,
578
+ offset=offset,
579
+ end=end,
580
+ )
581
+ if kind == "control":
582
+ left, top, width, height, note, inferred = _classify_geometry(ints, section, detail_top)
583
+ return Record(left=left, top=top, width=width, height=height,
584
+ geometry=note, top_inferred=inferred, **common)
585
+ if kind == "section":
586
+ # Section record: the first int32 is the section height in twips.
587
+ height = ints[0] if ints else None
588
+ return Record(height=height, geometry="section", **common)
589
+ return Record(geometry="none", **common)
590
+
591
+
592
+ def parse_design(head: DesignHead, buf: bytes, path: str) -> Design:
593
+ """Split one design record (``buf`` = the record's bytes) into records.
594
+
595
+ Classifying a string event (rules 3, 4, 7 of the module docstring):
596
+
597
+ * ``SectionN`` -> a section record; it becomes the owner of everything after it;
598
+ * a control-name pattern -> a control; the *next* string is its caption when that
599
+ string is neither a section nor another control name, or is the name itself
600
+ (VB6 writes a default/empty Caption as the name repeated);
601
+ * anything else -> an orphan value record, kept so no string is dropped silently.
602
+ """
603
+ events = list(iter_string_events(buf))
604
+ detail_top = _detail_top_mode(events, buf)
605
+ records: list[Record] = []
606
+ section = ""
607
+ i = 0
608
+ total = len(events)
609
+ while i < total:
610
+ offset, text, end = events[i]
611
+ next_offset = events[i + 1][0] if i + 1 < total else len(buf)
612
+ # The property array runs from the end of the name to the start of the next
613
+ # string event, whatever that event turns out to be.
614
+ ints = _int_run(buf, end, next_offset)
615
+ if SECTION_RE.match(text):
616
+ section = text
617
+ records.append(
618
+ _make_record(len(records) + 1, "section", text, "", section, ints,
619
+ offset, end, detail_top)
620
+ )
621
+ i += 1
622
+ continue
623
+ if CONTROL_NAME_RE.match(text):
624
+ caption = ""
625
+ rec_end = end
626
+ consumed = 1
627
+ if i + 1 < total:
628
+ following = events[i + 1][1]
629
+ if following == text or not (
630
+ SECTION_RE.match(following) or CONTROL_NAME_RE.match(following)
631
+ ):
632
+ # One record spans name + caption, so the caption string is not
633
+ # emitted again as a value record.
634
+ caption = following
635
+ rec_end = events[i + 1][2]
636
+ consumed = 2
637
+ records.append(
638
+ _make_record(len(records) + 1, "control", text, caption, section, ints,
639
+ offset, rec_end, detail_top)
640
+ )
641
+ i += consumed
642
+ continue
643
+ records.append(
644
+ _make_record(len(records) + 1, "value", text, "", section, ints,
645
+ offset, end, detail_top)
646
+ )
647
+ i += 1
648
+ return Design(
649
+ name=head.name,
650
+ path=path,
651
+ class_name=DATAREPORT_CLASS,
652
+ va=head.va,
653
+ size=head.size,
654
+ header_fields=head.header_fields,
655
+ records=tuple(records),
656
+ )
657
+
658
+
659
+ # ---------------------------------------------------------------------------
660
+ # public entry points
661
+ # ---------------------------------------------------------------------------
662
+
663
+
664
+ def read_designs(path: str) -> list[Design]:
665
+ """Every DataReport design in one file, in file order."""
666
+ with open(path, "rb") as fh:
667
+ buf = fh.read()
668
+ designs = []
669
+ for head in find_designs(path):
670
+ designs.append(parse_design(head, buf[head.offset:head.offset + head.size], path))
671
+ return designs
672
+
673
+
674
+ def read_paths(paths: Iterable[str]) -> list[Design]:
675
+ """Read a list of files, keeping every design (rule 10: no name-keyed dict)."""
676
+ designs: list[Design] = []
677
+ for path in paths:
678
+ designs.extend(read_designs(path))
679
+ return designs
680
+
681
+
682
+ def iter_files(targets: Iterable[str], patterns: Sequence[str] = ("*.dll", "*.ocx", "*.exe")) -> list[str]:
683
+ """Expand files and directories into a sorted file list."""
684
+ out: list[str] = []
685
+ for target in targets:
686
+ if os.path.isdir(target):
687
+ for pattern in patterns:
688
+ out.extend(
689
+ os.path.join(target, name)
690
+ for name in sorted(os.listdir(target))
691
+ if name.lower().endswith(pattern[1:].lower())
692
+ )
693
+ else:
694
+ out.append(target)
695
+ seen: set[str] = set()
696
+ unique = []
697
+ for path in out:
698
+ real = os.path.normcase(os.path.abspath(path))
699
+ if real not in seen:
700
+ seen.add(real)
701
+ unique.append(path)
702
+ return unique
703
+
704
+
705
+ def key(design: Design) -> tuple[str, str]:
706
+ """Composite join key ``(file name, design name)``."""
707
+ return design.key
708
+
709
+
710
+ def summary(designs: Sequence[Design]) -> dict:
711
+ """Coverage counts, always with the denominator they belong to."""
712
+ records = [r for d in designs for r in d.records]
713
+ controls = [r for r in records if r.kind == "control"]
714
+ notes = Counter(r.geometry for r in controls)
715
+ return {
716
+ "designs": len(designs),
717
+ "files": len({os.path.normcase(d.path) for d in designs}),
718
+ "records": len(records),
719
+ "sections": sum(1 for r in records if r.kind == "section"),
720
+ "controls": len(controls),
721
+ "values": sum(1 for r in records if r.kind == "value"),
722
+ "captions": sum(1 for r in controls if r.caption),
723
+ "default_captions": sum(1 for r in controls if r.caption_is_default),
724
+ "full_geometry": sum(1 for r in controls if r.has_full_geometry),
725
+ "top_unknown": sum(1 for r in controls if r.top is None),
726
+ "width_unknown": sum(1 for r in controls if r.width is None),
727
+ "top_inferred": sum(1 for r in controls if r.top_inferred),
728
+ "geometry": dict(sorted(notes.items())),
729
+ "designs_with_section": sum(1 for d in designs if d.sections),
730
+ "designs_with_controls": sum(1 for d in designs if d.controls),
731
+ "unreliable": notes.get("unreliable", 0),
732
+ }
733
+
734
+
735
+ def _pct(part: int, whole: int) -> str:
736
+ return f"{100.0 * part / whole:.1f}%" if whole else "n/a"
737
+
738
+
739
+ def validate(targets: Iterable[str], out=sys.stdout, per_file: bool = True) -> dict:
740
+ """Print the coverage gate for real files and return the counts.
741
+
742
+ There is no ``.dsr`` text available for a shipped report DLL, so this gate
743
+ reports coverage and internal consistency instead of a match rate: how many
744
+ designs were found per file, how many records they yielded, how much geometry
745
+ is actually stored, and how much is unknown.
746
+ """
747
+ files = iter_files(targets)
748
+ all_designs: list[Design] = []
749
+ rows = []
750
+ for path in files:
751
+ try:
752
+ designs = read_designs(path)
753
+ except (pe.PEError, OSError) as exc:
754
+ rows.append((path, f"skipped: {exc}"))
755
+ continue
756
+ all_designs.extend(designs)
757
+ if designs:
758
+ rows.append((path, designs))
759
+ stats = summary(all_designs)
760
+ if per_file:
761
+ print("file designs records sections controls unknownTop unknownW", file=out)
762
+ for path, value in rows:
763
+ if isinstance(value, str):
764
+ print(f"{os.path.basename(path):<32} {value}", file=out)
765
+ continue
766
+ s = summary(value)
767
+ print(
768
+ f"{os.path.basename(path):<32} {s['designs']:>7} {s['records']:>8} "
769
+ f"{s['sections']:>9} {s['controls']:>9} {s['top_unknown']:>11} {s['width_unknown']:>9}",
770
+ file=out,
771
+ )
772
+ print(
773
+ f"\nfiles with designs: {stats['files']} / {len(files)} read"
774
+ f" designs: {stats['designs']} records: {stats['records']}"
775
+ f" (sections {stats['sections']}, controls {stats['controls']},"
776
+ f" orphan values {stats['values']})",
777
+ file=out,
778
+ )
779
+ print(
780
+ f"captions on controls: {stats['captions']} / {stats['controls']}"
781
+ f" = {_pct(stats['captions'], stats['controls'])}"
782
+ f" of which the name repeated (= empty Caption in the .dsr):"
783
+ f" {stats['default_captions']}",
784
+ file=out,
785
+ )
786
+ print(
787
+ f"geometry stored in full (L,T,W,H, nothing inferred):"
788
+ f" {stats['full_geometry']} / {stats['controls']}"
789
+ f" = {_pct(stats['full_geometry'], stats['controls'])}",
790
+ file=out,
791
+ )
792
+ print(
793
+ f"Top unknown (not serialised, no design evidence either):"
794
+ f" {stats['top_unknown']} / {stats['controls']}"
795
+ f" = {_pct(stats['top_unknown'], stats['controls'])}",
796
+ file=out,
797
+ )
798
+ print(
799
+ f"Top inferred from the design's own Detail mode: {stats['top_inferred']}"
800
+ f" / {stats['controls']} = {_pct(stats['top_inferred'], stats['controls'])}",
801
+ file=out,
802
+ )
803
+ print(
804
+ f"Width unknown (double-skip layout): {stats['width_unknown']} / {stats['controls']}"
805
+ f" = {_pct(stats['width_unknown'], stats['controls'])}",
806
+ file=out,
807
+ )
808
+ print(f"property-array variants: {stats['geometry']}", file=out)
809
+ print(
810
+ f"designs with a section record: {stats['designs_with_section']} / {stats['designs']}"
811
+ f" with >=1 control: {stats['designs_with_controls']} / {stats['designs']}",
812
+ file=out,
813
+ )
814
+ # Internal consistency: record offsets must be strictly increasing inside a
815
+ # design and every record must sit inside the record extent that located it.
816
+ disorder = 0
817
+ outside = 0
818
+ for design in all_designs:
819
+ last = -1
820
+ for record in design.records:
821
+ if record.offset <= last:
822
+ disorder += 1
823
+ last = record.offset
824
+ if not (0 <= record.offset <= record.end <= design.size):
825
+ outside += 1
826
+ print(
827
+ f"self-check: out-of-order records {disorder} / {stats['records']},"
828
+ f" out-of-extent records {outside} / {stats['records']}",
829
+ file=out,
830
+ )
831
+ return stats
832
+
833
+
834
+ def _write_csv(designs: Sequence[Design], path: str) -> None:
835
+ with open(path, "w", newline="", encoding="utf-8-sig") as fh:
836
+ writer = csv.DictWriter(fh, fieldnames=["design", "dll"] + list(Record.__dataclass_fields__))
837
+ writer.writeheader()
838
+ for design in designs:
839
+ for record in design.records:
840
+ row = record.as_dict()
841
+ row.pop("int_count", None)
842
+ row["design"] = design.name
843
+ row["dll"] = os.path.basename(design.path)
844
+ row["ints"] = " ".join(str(v) for v in record.ints[:12])
845
+ writer.writerow({k: row.get(k, "") for k in writer.fieldnames})
846
+
847
+
848
+ def main(argv: Sequence[str] | None = None) -> int:
849
+ parser = argparse.ArgumentParser(
850
+ description="Read DataReport designs out of compiled VB6 binaries."
851
+ )
852
+ parser.add_argument("targets", nargs="+", help="files or directories to scan")
853
+ parser.add_argument(
854
+ "--validate",
855
+ action="store_true",
856
+ help="print the coverage gate (designs, records, stored vs unknown geometry)",
857
+ )
858
+ parser.add_argument("--csv", help="write one row per record to this CSV file")
859
+ parser.add_argument("--json", help="write the full design tree to this JSON file")
860
+ parser.add_argument("--quiet", action="store_true", help="only report totals")
861
+ args = parser.parse_args(argv)
862
+
863
+ if args.validate:
864
+ validate(args.targets, per_file=not args.quiet)
865
+ return 0
866
+
867
+ designs = read_paths(iter_files(args.targets))
868
+ stats = summary(designs)
869
+ print(
870
+ f"designs: {stats['designs']} in {stats['files']} files"
871
+ f" records: {stats['records']}"
872
+ f" (sections {stats['sections']}, controls {stats['controls']},"
873
+ f" orphan values {stats['values']})"
874
+ )
875
+ if args.csv:
876
+ _write_csv(designs, args.csv)
877
+ print(f"wrote {args.csv} ({stats['records']} rows)")
878
+ if args.json:
879
+ with open(args.json, "w", encoding="utf-8") as fh:
880
+ json.dump([d.as_dict() for d in designs], fh, ensure_ascii=False, indent=1)
881
+ print(f"wrote {args.json}")
882
+ return 0
883
+
884
+
885
+ if __name__ == "__main__": # pragma: no cover
886
+ sys.exit(main())