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