python-gdb 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {python_gdb-0.2.0 → python_gdb-0.3.0}/PKG-INFO +2 -1
- {python_gdb-0.2.0 → python_gdb-0.3.0}/README.md +1 -0
- {python_gdb-0.2.0 → python_gdb-0.3.0}/pygdb/__init__.py +6 -0
- {python_gdb-0.2.0 → python_gdb-0.3.0}/pygdb/gdb.py +158 -15
- {python_gdb-0.2.0 → python_gdb-0.3.0}/pygdb/gdb_reader.py +305 -51
- {python_gdb-0.2.0 → python_gdb-0.3.0}/pygdb/lzrw1.py +147 -5
- {python_gdb-0.2.0 → python_gdb-0.3.0}/rust/Cargo.lock +1 -1
- {python_gdb-0.2.0 → python_gdb-0.3.0}/rust/Cargo.toml +1 -1
- {python_gdb-0.2.0 → python_gdb-0.3.0}/rust/src/lib.rs +170 -0
- {python_gdb-0.2.0 → python_gdb-0.3.0}/LICENSE +0 -0
- {python_gdb-0.2.0 → python_gdb-0.3.0}/pygdb/grd_reader.py +0 -0
- {python_gdb-0.2.0 → python_gdb-0.3.0}/pygdb/registry.py +0 -0
- {python_gdb-0.2.0 → python_gdb-0.3.0}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-gdb
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Intended Audience :: Science/Research
|
|
6
6
|
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
@@ -190,6 +190,7 @@ zensical serve
|
|
|
190
190
|
boundary on reverse engineering.
|
|
191
191
|
- [`docs/provenance/`](docs/provenance/index.md) — the original
|
|
192
192
|
research log and write-up this implementation was derived from.
|
|
193
|
+
- [`CHANGELOG.md`](CHANGELOG.md) — what changed in each release.
|
|
193
194
|
|
|
194
195
|
## Testing
|
|
195
196
|
|
|
@@ -152,6 +152,7 @@ zensical serve
|
|
|
152
152
|
boundary on reverse engineering.
|
|
153
153
|
- [`docs/provenance/`](docs/provenance/index.md) — the original
|
|
154
154
|
research log and write-up this implementation was derived from.
|
|
155
|
+
- [`CHANGELOG.md`](CHANGELOG.md) — what changed in each release.
|
|
155
156
|
|
|
156
157
|
## Testing
|
|
157
158
|
|
|
@@ -16,16 +16,19 @@ Reading only: writing/mutating `.gdb` or `.grd` files is out of scope.
|
|
|
16
16
|
|
|
17
17
|
from .gdb import GDB, CompressionInfo
|
|
18
18
|
from .gdb_reader import (
|
|
19
|
+
BlobDirectory,
|
|
19
20
|
BlobHeader,
|
|
20
21
|
ChannelRecord,
|
|
21
22
|
GDBParseWarning,
|
|
22
23
|
LineRecord,
|
|
23
24
|
check_magic,
|
|
25
|
+
exact_line_table_start,
|
|
24
26
|
find_blob,
|
|
25
27
|
find_channel_table,
|
|
26
28
|
find_line_table,
|
|
27
29
|
header_fields,
|
|
28
30
|
iter_blobs,
|
|
31
|
+
read_blob_directory,
|
|
29
32
|
read_blob_values,
|
|
30
33
|
read_channels,
|
|
31
34
|
read_lines,
|
|
@@ -36,6 +39,7 @@ from .registry import find_coordinate_systems
|
|
|
36
39
|
|
|
37
40
|
__all__ = [
|
|
38
41
|
"GDB",
|
|
42
|
+
"BlobDirectory",
|
|
39
43
|
"BlobHeader",
|
|
40
44
|
"ChannelRecord",
|
|
41
45
|
"CompressionInfo",
|
|
@@ -45,12 +49,14 @@ __all__ = [
|
|
|
45
49
|
"LZRW1DecodeError",
|
|
46
50
|
"LineRecord",
|
|
47
51
|
"check_magic",
|
|
52
|
+
"exact_line_table_start",
|
|
48
53
|
"find_blob",
|
|
49
54
|
"find_channel_table",
|
|
50
55
|
"find_coordinate_systems",
|
|
51
56
|
"find_line_table",
|
|
52
57
|
"header_fields",
|
|
53
58
|
"iter_blobs",
|
|
59
|
+
"read_blob_directory",
|
|
54
60
|
"read_blob_values",
|
|
55
61
|
"read_channels",
|
|
56
62
|
"read_grd",
|
|
@@ -16,7 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
import warnings
|
|
17
17
|
from dataclasses import dataclass
|
|
18
18
|
from pathlib import Path
|
|
19
|
-
from typing import Dict, Iterator, List, Optional, Tuple, Union
|
|
19
|
+
from typing import Dict, Iterator, List, Optional, Sequence, Tuple, Union
|
|
20
20
|
|
|
21
21
|
import numpy as np
|
|
22
22
|
|
|
@@ -30,9 +30,13 @@ from .gdb_reader import (
|
|
|
30
30
|
check_magic,
|
|
31
31
|
header_fields,
|
|
32
32
|
iter_blobs,
|
|
33
|
+
read_blob_directory,
|
|
33
34
|
read_blob_values,
|
|
34
35
|
read_channels,
|
|
35
|
-
|
|
36
|
+
_read_lines,
|
|
37
|
+
DIRECTORY_ABSENT,
|
|
38
|
+
DIRECTORY_INVALID,
|
|
39
|
+
DIRECTORY_LIVE,
|
|
36
40
|
)
|
|
37
41
|
from .registry import find_channel_roles, find_coordinate_systems
|
|
38
42
|
|
|
@@ -100,6 +104,15 @@ class GDB:
|
|
|
100
104
|
----------
|
|
101
105
|
path : str
|
|
102
106
|
Path to the `.gdb` file.
|
|
107
|
+
include_unlisted_blobs : bool, optional
|
|
108
|
+
A file that carries a blob directory (see Notes) lists exactly
|
|
109
|
+
one live blob per (line, channel). By default a blob the
|
|
110
|
+
directory does not list -- an all-zero slot -- is **skipped**, as
|
|
111
|
+
if it were absent, and one `GDBParseWarning` says how many
|
|
112
|
+
(line, channel) blobs in which channels were skipped. Pass
|
|
113
|
+
`True` to read them anyway (the last copy in chain order). A
|
|
114
|
+
file with no directory, or an all-zero one, is unaffected: every
|
|
115
|
+
blob in the chain is served.
|
|
103
116
|
|
|
104
117
|
Raises
|
|
105
118
|
------
|
|
@@ -137,10 +150,23 @@ class GDB:
|
|
|
137
150
|
the Rust-plan's M4 notes). Close it (`db.close()`, or use `GDB` as a
|
|
138
151
|
context manager) when done with it, or just let it get
|
|
139
152
|
garbage-collected -- `__del__` closes it too, as a safety net.
|
|
153
|
+
|
|
154
|
+
**Which copy of a blob is read.** The blob chain is append-only, so
|
|
155
|
+
a (line, channel) can have several blobs, an older stale one and
|
|
156
|
+
the current one, in either order (issue #2). The file's persisted
|
|
157
|
+
*blob directory* (docs/spec.md section 2.2) says which is current,
|
|
158
|
+
and it is the only thing consulted. A directory entry is used only
|
|
159
|
+
if its start page lands on a walked blob header whose `blob_index`
|
|
160
|
+
equals the slot and whose page count matches; if a non-zero entry
|
|
161
|
+
fails that check, the last copy in chain order is used and a
|
|
162
|
+
`GDBParseWarning` says so. Without a usable directory the last copy
|
|
163
|
+
in chain order is used, with a warning if a pair is duplicated: that
|
|
164
|
+
is a guess, and not always the current copy.
|
|
140
165
|
"""
|
|
141
166
|
|
|
142
|
-
def __init__(self, path: str):
|
|
167
|
+
def __init__(self, path: str, include_unlisted_blobs: bool = False):
|
|
143
168
|
self.path = path
|
|
169
|
+
self.include_unlisted_blobs = include_unlisted_blobs
|
|
144
170
|
self._file = open(path, "rb")
|
|
145
171
|
header = self._file.read(4096)
|
|
146
172
|
if not check_magic(header):
|
|
@@ -152,6 +178,7 @@ class GDB:
|
|
|
152
178
|
self._fields = header_fields(header)
|
|
153
179
|
self._channels: Optional[List[ChannelRecord]] = None
|
|
154
180
|
self._lines: Optional[List[LineRecord]] = None
|
|
181
|
+
self._lines_exact = False
|
|
155
182
|
self._channels_by_name: Optional[Dict[str, List[ChannelRecord]]] = None
|
|
156
183
|
self._lines_by_name: Optional[Dict[str, List[LineRecord]]] = None
|
|
157
184
|
self._blob_index: Optional[Dict[Tuple[int, int], BlobHeader]] = None
|
|
@@ -267,7 +294,7 @@ class GDB:
|
|
|
267
294
|
def lines(self) -> List[LineRecord]:
|
|
268
295
|
"""list of LineRecord: This file's line table, read once and cached."""
|
|
269
296
|
if self._lines is None:
|
|
270
|
-
self._lines =
|
|
297
|
+
self._lines, self._lines_exact = _read_lines(self.path)
|
|
271
298
|
self._lines_by_name = {}
|
|
272
299
|
for l in self._lines:
|
|
273
300
|
self._lines_by_name.setdefault(l.name, []).append(l)
|
|
@@ -429,28 +456,144 @@ class GDB:
|
|
|
429
456
|
Returns
|
|
430
457
|
-------
|
|
431
458
|
dict of {(int, int) : BlobHeader}
|
|
432
|
-
Maps `(line_slot, channel_slot)` to `BlobHeader
|
|
433
|
-
with one blob-chain walk and cached from
|
|
434
|
-
`iter_blobs`/`find_blob` themselves recommend this
|
|
435
|
-
anything beyond an occasional one-off lookup -- this
|
|
436
|
-
always wants line/channel listings and random-access
|
|
459
|
+
Maps `(line_slot, channel_slot)` to the `BlobHeader` of its
|
|
460
|
+
live blob, built with one blob-chain walk and cached from
|
|
461
|
+
then on. `iter_blobs`/`find_blob` themselves recommend this
|
|
462
|
+
for anything beyond an occasional one-off lookup -- this
|
|
463
|
+
class always wants line/channel listings and random-access
|
|
437
464
|
reads, so it always builds the index.
|
|
465
|
+
|
|
466
|
+
Warns
|
|
467
|
+
-----
|
|
468
|
+
GDBParseWarning
|
|
469
|
+
See `_select_live_blobs`: when blobs are skipped because the
|
|
470
|
+
file's blob directory does not list them, when a directory
|
|
471
|
+
entry fails validation, or when a pair is duplicated and
|
|
472
|
+
the file has no directory to say which copy is current.
|
|
438
473
|
"""
|
|
439
474
|
if self._blob_index is None:
|
|
440
475
|
chans_max = self.chans_max
|
|
441
|
-
|
|
476
|
+
copies: Dict[Tuple[int, int], List[BlobHeader]] = {}
|
|
477
|
+
by_offset: Dict[int, BlobHeader] = {}
|
|
442
478
|
for blob in iter_blobs(self.path):
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
self.
|
|
479
|
+
copies.setdefault(blob.line_channel(chans_max), []).append(blob)
|
|
480
|
+
by_offset[blob.offset] = blob
|
|
481
|
+
self._blob_index = {k: v[-1] for k, v in copies.items()}
|
|
482
|
+
if not self.lines:
|
|
483
|
+
return self._blob_index
|
|
484
|
+
if not self._lines_exact:
|
|
485
|
+
self._calibrate_line_indices()
|
|
486
|
+
self._select_live_blobs(copies, by_offset)
|
|
446
487
|
return self._blob_index
|
|
447
488
|
|
|
489
|
+
def _select_live_blobs(
|
|
490
|
+
self,
|
|
491
|
+
copies: Dict[Tuple[int, int], List[BlobHeader]],
|
|
492
|
+
by_offset: Dict[int, BlobHeader],
|
|
493
|
+
) -> None:
|
|
494
|
+
"""
|
|
495
|
+
Choose each real (line, channel)'s live blob using the blob directory.
|
|
496
|
+
|
|
497
|
+
Parameters
|
|
498
|
+
----------
|
|
499
|
+
copies : dict of {(int, int) : list of BlobHeader}
|
|
500
|
+
Every blob for each key, in chain order.
|
|
501
|
+
by_offset : dict of {int : BlobHeader}
|
|
502
|
+
Every blob of the chain, keyed by absolute offset.
|
|
503
|
+
|
|
504
|
+
Warns
|
|
505
|
+
-----
|
|
506
|
+
GDBParseWarning
|
|
507
|
+
Once per situation, naming counts and examples: (1) pairs
|
|
508
|
+
skipped because the directory has no live entry for them;
|
|
509
|
+
(2) pairs whose non-zero directory entry failed validation
|
|
510
|
+
(the last copy in chain order is used); (3) with no usable
|
|
511
|
+
directory at all, pairs that have more than one blob.
|
|
512
|
+
Administrative slots past the last real line (the REG/IPJ
|
|
513
|
+
registry, whose stale copies `pygdb.registry` handles
|
|
514
|
+
itself) are never judged or reported.
|
|
515
|
+
|
|
516
|
+
Notes
|
|
517
|
+
-----
|
|
518
|
+
Updates `self._blob_index` in place. See the class docstring for
|
|
519
|
+
the rules.
|
|
520
|
+
"""
|
|
521
|
+
index = self._blob_index
|
|
522
|
+
real_lines = {line.index for line in self.lines}
|
|
523
|
+
directory = read_blob_directory(self.path)
|
|
524
|
+
if directory is None:
|
|
525
|
+
duplicated = [k for k, v in copies.items() if len(v) > 1 and k[0] in real_lines]
|
|
526
|
+
if duplicated:
|
|
527
|
+
self._warn_pairs(
|
|
528
|
+
f"{len(duplicated)} (line, channel) pair(s) have more than one blob in the "
|
|
529
|
+
f"blob chain ({self._describe_pairs(duplicated)}) and the file has no blob "
|
|
530
|
+
f"directory to say which is current -- using the last one in chain order, "
|
|
531
|
+
f"which is not always the current copy. If a channel's rows look scrambled "
|
|
532
|
+
f"against the line's other channels, this is the likely cause (see issue #2)"
|
|
533
|
+
)
|
|
534
|
+
return
|
|
535
|
+
first_offset = min(by_offset)
|
|
536
|
+
skipped: List[Tuple[int, int]] = []
|
|
537
|
+
invalid: List[Tuple[int, int]] = []
|
|
538
|
+
for key in copies:
|
|
539
|
+
line_slot, channel_slot = key
|
|
540
|
+
if line_slot not in real_lines:
|
|
541
|
+
continue
|
|
542
|
+
status, blob = directory.resolve(
|
|
543
|
+
line_slot * self.chans_max + channel_slot, by_offset, first_offset, self.page_size,
|
|
544
|
+
)
|
|
545
|
+
if status == DIRECTORY_LIVE:
|
|
546
|
+
index[key] = blob
|
|
547
|
+
elif status == DIRECTORY_ABSENT:
|
|
548
|
+
skipped.append(key)
|
|
549
|
+
elif status == DIRECTORY_INVALID:
|
|
550
|
+
invalid.append(key)
|
|
551
|
+
if skipped and not self.include_unlisted_blobs:
|
|
552
|
+
for key in skipped:
|
|
553
|
+
del index[key]
|
|
554
|
+
channel_names = {c.index: c.name for c in self.channels}
|
|
555
|
+
by_channel: Dict[int, int] = {}
|
|
556
|
+
for _, channel_slot in skipped:
|
|
557
|
+
by_channel[channel_slot] = by_channel.get(channel_slot, 0) + 1
|
|
558
|
+
listing = ", ".join(
|
|
559
|
+
f"{channel_names.get(cs, f'#{cs}')!r} ({n})" for cs, n in sorted(by_channel.items())[:5]
|
|
560
|
+
)
|
|
561
|
+
more = f" and {len(by_channel) - 5} more channel(s)" if len(by_channel) > 5 else ""
|
|
562
|
+
self._warn_pairs(
|
|
563
|
+
f"skipped {len(skipped)} (line, channel) blob(s) in {len(by_channel)} channel(s) "
|
|
564
|
+
f"[{listing}{more}] that the file's blob directory does not list as live. Pass "
|
|
565
|
+
f"include_unlisted_blobs=True to read them anyway"
|
|
566
|
+
)
|
|
567
|
+
if invalid:
|
|
568
|
+
self._warn_pairs(
|
|
569
|
+
f"{len(invalid)} (line, channel) pair(s) have a blob-directory entry that does not "
|
|
570
|
+
f"point at a blob with the right index and size ({self._describe_pairs(invalid)}) "
|
|
571
|
+
f"-- using the last copy in chain order for them"
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
def _describe_pairs(self, keys: Sequence[Tuple[int, int]], limit: int = 3) -> str:
|
|
575
|
+
line_names = {line.index: line.name for line in self.lines}
|
|
576
|
+
channel_names = {c.index: c.name for c in self.channels}
|
|
577
|
+
named = [
|
|
578
|
+
f"line {line_names[ls]!r} channel {channel_names.get(cs, f'#{cs}')!r}"
|
|
579
|
+
for ls, cs in keys if ls in line_names
|
|
580
|
+
]
|
|
581
|
+
more = f" and {len(named) - limit} more" if len(named) > limit else ""
|
|
582
|
+
return ", ".join(named[:limit]) + more
|
|
583
|
+
|
|
584
|
+
def _warn_pairs(self, message: str) -> None:
|
|
585
|
+
# stacklevel: here -> _select_live_blobs -> _ensure_blob_index -> public method -> caller
|
|
586
|
+
warnings.warn(f"{self.path}: {message}", GDBParseWarning, stacklevel=5)
|
|
587
|
+
|
|
448
588
|
def _calibrate_line_indices(self) -> None:
|
|
449
589
|
"""
|
|
450
590
|
Correct a possible small, fixed off-by-N in every line's index.
|
|
451
591
|
|
|
452
|
-
|
|
453
|
-
`
|
|
592
|
+
Only used when `read_lines` had to fall back to the
|
|
593
|
+
`find_line_table` heuristic (`self._lines_exact` is False): a
|
|
594
|
+
file whose line table is located exactly already has true
|
|
595
|
+
indices. See `find_line_table`'s and `read_lines`'s docstrings
|
|
596
|
+
in `gdb_reader.py`. Checks, for a handful of small integer shifts,
|
|
454
597
|
which one makes the most already-found lines actually have at
|
|
455
598
|
least one real data blob on disk for *some* channel -- then
|
|
456
599
|
applies the winning shift to every `LineRecord.index` in
|
|
@@ -443,11 +443,15 @@ def header_fields(data: bytes) -> dict:
|
|
|
443
443
|
Returns
|
|
444
444
|
-------
|
|
445
445
|
dict
|
|
446
|
-
Maps each of `"chans_max"
|
|
447
|
-
`"
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
446
|
+
Maps each of `"chans_max"` (word 24), `"blobs_max"` (28),
|
|
447
|
+
`"lines_max"` (36), `"users_max"` (40), `"index_slots"` (44,
|
|
448
|
+
the total number of blob-directory slots), `"data_slots"` (48,
|
|
449
|
+
the number of those that address (line, channel) data blobs),
|
|
450
|
+
`"page_size"` (100), and `"comp_level"` (120) to its decoded
|
|
451
|
+
int32 value. See docs/spec.md section 2 for the full table
|
|
452
|
+
with a confidence rating per word, and
|
|
453
|
+
docs/provenance/notes.md section 6.1/6.1b for the derivations
|
|
454
|
+
and the still-unknown offsets.
|
|
451
455
|
|
|
452
456
|
Warns
|
|
453
457
|
-----
|
|
@@ -466,7 +470,8 @@ def header_fields(data: bytes) -> dict:
|
|
|
466
470
|
(`DB_COMP_SIZE`) IS confirmed real zlib.
|
|
467
471
|
"""
|
|
468
472
|
result = {}
|
|
469
|
-
for name, offset in (("chans_max", 24), ("
|
|
473
|
+
for name, offset in (("chans_max", 24), ("blobs_max", 28), ("lines_max", 36),
|
|
474
|
+
("users_max", 40), ("index_slots", 44), ("data_slots", 48),
|
|
470
475
|
("page_size", 100), ("comp_level", 120)):
|
|
471
476
|
try:
|
|
472
477
|
result[name] = struct.unpack_from("<i", data, offset)[0]
|
|
@@ -713,10 +718,10 @@ class LineRecord:
|
|
|
713
718
|
-----
|
|
714
719
|
**[LIKELY]/[UNKNOWN]** -- much less firmly established than
|
|
715
720
|
`ChannelRecord`: only the name (relative +32) and category code
|
|
716
|
-
(relative +108) fields are decoded
|
|
717
|
-
(`
|
|
718
|
-
|
|
719
|
-
|
|
721
|
+
(relative +108) fields are decoded. The table is located exactly
|
|
722
|
+
(`exact_line_table_start`, **[CONFIRMED]** on the corpus) and only
|
|
723
|
+
falls back to the `find_line_table` heuristic scan when that
|
|
724
|
+
cannot be validated. See docs/spec.md section 3.2 and docs/provenance/notes.md
|
|
720
725
|
section 6.3.
|
|
721
726
|
|
|
722
727
|
`eq=False` keeps the default identity-based `__eq__`/`__hash__`
|
|
@@ -785,23 +790,25 @@ def find_line_table(data: bytes, search_window: Tuple[int, Optional[int]] = (128
|
|
|
785
790
|
|
|
786
791
|
Notes
|
|
787
792
|
-----
|
|
793
|
+
**Prefer `exact_line_table_start`** (via `read_lines`): the line
|
|
794
|
+
table's start follows exactly from `lines_max` and the channel
|
|
795
|
+
table's position (docs/spec.md section 2.1), which this heuristic
|
|
796
|
+
predates. This scan is now only the fallback for a file where that
|
|
797
|
+
arithmetic cannot be validated.
|
|
798
|
+
|
|
788
799
|
Unlike `find_channel_table`, there's no known default-name anchor
|
|
789
800
|
(the line table has nothing analogous to the channel table's
|
|
790
|
-
"SUPER" user record immediately after it)
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
(docs/provenance/log.md Session 1 section 1.16,
|
|
794
|
-
docs/provenance/notes.md section 6.3). This is therefore a
|
|
795
|
-
heuristic **[LIKELY]** scan, not the structurally-proven technique
|
|
796
|
-
used for the channel table: it looks for a run of 128-byte records
|
|
801
|
+
"SUPER" user record immediately after it), so this is a
|
|
802
|
+
heuristic **[LIKELY]** scan, not a structurally-proven technique:
|
|
803
|
+
it looks for a run of 128-byte records
|
|
797
804
|
whose relative +32 field looks like a clean, NUL-terminated,
|
|
798
805
|
printable line name and whose relative +108 category field
|
|
799
806
|
matches one of the two confirmed real values (100=NORMAL/FLIGHT,
|
|
800
807
|
200=GROUP), then returns the earliest such record in the run with
|
|
801
808
|
the most hits at a consistent 128-byte phase.
|
|
802
809
|
|
|
803
|
-
**Known limitation, found by real-file testing
|
|
804
|
-
here:** if a table's true first slot(s) don't carry a category
|
|
810
|
+
**Known limitation, found by real-file testing (fixed by using
|
|
811
|
+
`exact_line_table_start` instead, not here):** if a table's true first slot(s) don't carry a category
|
|
805
812
|
code in {100, 200}, this returns a start that's one or more slots
|
|
806
813
|
too late -- every subsequent `LineRecord.index` is then off by
|
|
807
814
|
that same fixed amount, which breaks blob_index lookups by line
|
|
@@ -847,6 +854,60 @@ def find_line_table(data: bytes, search_window: Tuple[int, Optional[int]] = (128
|
|
|
847
854
|
return min(phase_hits[best_phase])
|
|
848
855
|
|
|
849
856
|
|
|
857
|
+
_LINE_TABLE_GAP = 24 # [CONFIRMED] bytes between the end of the line table and the
|
|
858
|
+
# channel table -- docs/spec.md section 2.1
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def exact_line_table_start(data: bytes, lines_max: Optional[int]) -> Optional[int]:
|
|
862
|
+
"""
|
|
863
|
+
Compute the line table's start from the header and the channel table.
|
|
864
|
+
|
|
865
|
+
Parameters
|
|
866
|
+
----------
|
|
867
|
+
data : bytes
|
|
868
|
+
The file's bytes up to at least the end of the channel table
|
|
869
|
+
(`read_lines` passes everything before the blob region).
|
|
870
|
+
lines_max : int or None
|
|
871
|
+
The line-table capacity (header word 36); `None` or non-positive
|
|
872
|
+
means unknown.
|
|
873
|
+
|
|
874
|
+
Returns
|
|
875
|
+
-------
|
|
876
|
+
int or None
|
|
877
|
+
Byte offset of the line table's first record, or `None` if the
|
|
878
|
+
arithmetic cannot be validated (unknown `lines_max`, no
|
|
879
|
+
locatable channel table, a start before the header ends, or no
|
|
880
|
+
line-shaped record anywhere in the computed table) -- the
|
|
881
|
+
caller then falls back to `find_line_table`.
|
|
882
|
+
|
|
883
|
+
Notes
|
|
884
|
+
-----
|
|
885
|
+
The line table is `lines_max` 128-byte slots followed by a 24-byte
|
|
886
|
+
gap and then the channel table, so its start is
|
|
887
|
+
`find_channel_table(data) - 24 - lines_max * 128`. **[CONFIRMED]**
|
|
888
|
+
on all 23 real files examined (docs/spec.md section 2.1;
|
|
889
|
+
docs/provenance/notes.md section 6.1b): the computed start is
|
|
890
|
+
always a slot boundary that matches the heuristic's start, or is an
|
|
891
|
+
earlier slot the heuristic missed. The at-least-one-line-record
|
|
892
|
+
check only guards a file whose layout does not follow this
|
|
893
|
+
arithmetic; it is not needed for any real file seen.
|
|
894
|
+
"""
|
|
895
|
+
if not lines_max or lines_max <= 0:
|
|
896
|
+
return None
|
|
897
|
+
try:
|
|
898
|
+
channel_start = find_channel_table(data)
|
|
899
|
+
except ValueError:
|
|
900
|
+
return None
|
|
901
|
+
start = channel_start - _LINE_TABLE_GAP - lines_max * SYMBOL_RECORD_SIZE
|
|
902
|
+
if start < 256:
|
|
903
|
+
return None
|
|
904
|
+
for i in range(lines_max):
|
|
905
|
+
rec = _parse_line_record(data, start + i * SYMBOL_RECORD_SIZE, i)
|
|
906
|
+
if rec.name and rec.name_is_clean and rec.category_code in DB_CATEGORY_LINE_NAMES:
|
|
907
|
+
return start
|
|
908
|
+
return None
|
|
909
|
+
|
|
910
|
+
|
|
850
911
|
def read_lines(path: str) -> List[LineRecord]:
|
|
851
912
|
"""
|
|
852
913
|
Decode the line symbol table.
|
|
@@ -869,30 +930,59 @@ def read_lines(path: str) -> List[LineRecord]:
|
|
|
869
930
|
|
|
870
931
|
Notes
|
|
871
932
|
-----
|
|
872
|
-
|
|
873
|
-
`
|
|
874
|
-
|
|
875
|
-
|
|
933
|
+
The table is located **exactly** whenever possible (see
|
|
934
|
+
`exact_line_table_start`): it holds `lines_max` (header word 36)
|
|
935
|
+
128-byte slots and ends 24 bytes before the channel table, so its
|
|
936
|
+
start is `channel_table_start - 24 - lines_max * 128`
|
|
937
|
+
(**[CONFIRMED]** on every real file in the corpus, docs/spec.md
|
|
938
|
+
section 2.1). Every one of the `lines_max` slots is then examined,
|
|
939
|
+
and `LineRecord.index` is the true slot number, so
|
|
940
|
+
`LineRecord.index` is exact and blob_index lookups keyed on it
|
|
941
|
+
are right.
|
|
942
|
+
|
|
943
|
+
Only when that arithmetic cannot be validated (a header without
|
|
944
|
+
`lines_max`, or no line-shaped record at the computed position) does
|
|
945
|
+
this fall back to the older heuristic (see `find_line_table`):
|
|
946
|
+
**[LIKELY]**, less firmly established, reading forward until 8
|
|
876
947
|
consecutive records fail to look like either a populated line
|
|
877
|
-
record or clean unused capacity
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
948
|
+
record or clean unused capacity. In that fallback
|
|
949
|
+
**`LineRecord.index` can be off by a small, fixed amount** when the
|
|
950
|
+
scan starts one or more slots late -- `.name` is still correct but
|
|
951
|
+
`.index` is wrong. `GDB` (in `gdb.py`) corrects this against the
|
|
952
|
+
actual blob chain only in that case.
|
|
953
|
+
|
|
954
|
+
A populated slot whose category is neither `100` nor `200` (for
|
|
955
|
+
example the `65636` slot 0 of one 1991 file) is not returned, as
|
|
956
|
+
before -- but, unlike the fallback, it no longer shifts the
|
|
957
|
+
indices of the lines after it.
|
|
958
|
+
"""
|
|
959
|
+
return _read_lines(path)[0]
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _read_lines(path: str) -> Tuple[List[LineRecord], bool]:
|
|
963
|
+
"""
|
|
964
|
+
Decode the line symbol table, also reporting how it was located.
|
|
965
|
+
|
|
966
|
+
Parameters
|
|
967
|
+
----------
|
|
968
|
+
path : str
|
|
969
|
+
Path to the `.gdb` file.
|
|
970
|
+
|
|
971
|
+
Returns
|
|
972
|
+
-------
|
|
973
|
+
lines : list of LineRecord
|
|
974
|
+
As `read_lines`.
|
|
975
|
+
exact : bool
|
|
976
|
+
True if the table was located by `exact_line_table_start`
|
|
977
|
+
(indices are exact); False if the heuristic fallback was used
|
|
978
|
+
(indices may need `GDB`'s blob-chain calibration).
|
|
889
979
|
"""
|
|
890
980
|
with open(path, "rb") as f:
|
|
891
981
|
header = f.read(4096)
|
|
892
982
|
if not check_magic(header):
|
|
893
983
|
_warn(f"{path}: does not start with the expected '!CBD' magic -- "
|
|
894
984
|
f"not a recognized .gdb file, returning no lines")
|
|
895
|
-
return []
|
|
985
|
+
return [], False
|
|
896
986
|
blob_start = blob_region_start(header)
|
|
897
987
|
f.seek(0, 2)
|
|
898
988
|
size = f.tell()
|
|
@@ -900,14 +990,24 @@ def read_lines(path: str) -> List[LineRecord]:
|
|
|
900
990
|
read_size = blob_start if (blob_start is not None and 0 < blob_start <= size) else min(size, 20_000_000)
|
|
901
991
|
data = f.read(read_size)
|
|
902
992
|
|
|
993
|
+
lines_max = header_fields(header).get("lines_max")
|
|
994
|
+
exact_start = exact_line_table_start(data, lines_max)
|
|
995
|
+
if exact_start is not None:
|
|
996
|
+
lines = []
|
|
997
|
+
for i in range(lines_max):
|
|
998
|
+
rec = _parse_line_record(data, exact_start + i * SYMBOL_RECORD_SIZE, i)
|
|
999
|
+
if rec.name and rec.name_is_clean and rec.category_code in DB_CATEGORY_LINE_NAMES:
|
|
1000
|
+
lines.append(rec)
|
|
1001
|
+
return lines, True
|
|
1002
|
+
|
|
903
1003
|
try:
|
|
904
1004
|
table_start = find_line_table(data, search_window=(128, len(data)))
|
|
905
1005
|
except ValueError as e:
|
|
906
1006
|
_warn(f"{path}: could not locate the line symbol table ({e}) -- "
|
|
907
1007
|
f"returning no lines")
|
|
908
|
-
return []
|
|
1008
|
+
return [], False
|
|
909
1009
|
|
|
910
|
-
lines
|
|
1010
|
+
lines = []
|
|
911
1011
|
consecutive_bad = 0
|
|
912
1012
|
i = 0
|
|
913
1013
|
while True:
|
|
@@ -929,7 +1029,7 @@ def read_lines(path: str) -> List[LineRecord]:
|
|
|
929
1029
|
consecutive_bad += 1
|
|
930
1030
|
if consecutive_bad >= 8:
|
|
931
1031
|
break
|
|
932
|
-
return lines
|
|
1032
|
+
return lines, False
|
|
933
1033
|
|
|
934
1034
|
|
|
935
1035
|
BLOB_MAGIC = b"\xcc\xcc\x00\xff"
|
|
@@ -1301,6 +1401,145 @@ def find_blob(path: str, line_slot: int, channel_slot: int, chans_max: Optional[
|
|
|
1301
1401
|
return None
|
|
1302
1402
|
|
|
1303
1403
|
|
|
1404
|
+
DIRECTORY_OFFSET = 280 # [CONFIRMED] first blob-directory slot -- docs/spec.md section 2.2
|
|
1405
|
+
_DIRECTORY_SLOT_DTYPE = np.dtype([("word", "<u4"), ("n_pages", "<u2")]) # 6 bytes, unaligned
|
|
1406
|
+
_DIRECTORY_LIVE_FLAG = 0x8 # top nibble of a live (line, channel) entry's 32-bit word
|
|
1407
|
+
|
|
1408
|
+
DIRECTORY_LIVE = "live"
|
|
1409
|
+
DIRECTORY_ABSENT = "absent"
|
|
1410
|
+
DIRECTORY_INVALID = "invalid"
|
|
1411
|
+
DIRECTORY_OUTSIDE = "outside"
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
@dataclass(eq=False)
|
|
1415
|
+
class BlobDirectory:
|
|
1416
|
+
"""
|
|
1417
|
+
The persisted blob directory: which blob is the live one for each slot.
|
|
1418
|
+
|
|
1419
|
+
Attributes
|
|
1420
|
+
----------
|
|
1421
|
+
data_slots : int
|
|
1422
|
+
Number of directory slots that address (line, channel) data
|
|
1423
|
+
blobs (header word 48, `lines_max * chans_max`); slot `i` is
|
|
1424
|
+
the blob whose `blob_index` is `i`.
|
|
1425
|
+
entries : dict of {int : (int, int)}
|
|
1426
|
+
Every **non-zero** data slot as `(32-bit word, n_pages)`, keyed
|
|
1427
|
+
by slot. A zero slot is simply absent from this dict.
|
|
1428
|
+
|
|
1429
|
+
Notes
|
|
1430
|
+
-----
|
|
1431
|
+
**[CONFIRMED]** layout, **[LIKELY]** interpretation
|
|
1432
|
+
(docs/spec.md section 2.2; docs/provenance/notes.md section 6.1c).
|
|
1433
|
+
The directory is an array of 6-byte slots starting at file offset
|
|
1434
|
+
280. A live entry is `(0x80000000 | start page, n_pages)` where
|
|
1435
|
+
the start page is relative to the first blob (`(offset - blob
|
|
1436
|
+
region start) / page_size`). Across the real corpus it addressed
|
|
1437
|
+
100% of the real (line, channel) blobs of 20 of 22 files, and for
|
|
1438
|
+
every duplicated pair with an independent oracle (13 of 13) it
|
|
1439
|
+
pointed at the correct copy. A slot that is all-zero belongs to a
|
|
1440
|
+
blob the file does not list as live.
|
|
1441
|
+
"""
|
|
1442
|
+
|
|
1443
|
+
data_slots: int
|
|
1444
|
+
entries: dict
|
|
1445
|
+
|
|
1446
|
+
def resolve(self, blob_index: int, blobs_by_offset: dict, first_offset: int, page_size: int):
|
|
1447
|
+
"""
|
|
1448
|
+
Look up the live blob for `blob_index`.
|
|
1449
|
+
|
|
1450
|
+
Parameters
|
|
1451
|
+
----------
|
|
1452
|
+
blob_index : int
|
|
1453
|
+
The slot, `line_slot * chans_max + channel_slot`.
|
|
1454
|
+
blobs_by_offset : dict of {int : BlobHeader}
|
|
1455
|
+
Every blob of the chain walk, keyed by absolute `offset`.
|
|
1456
|
+
first_offset : int
|
|
1457
|
+
Absolute offset of the first blob (`blob_region_start`).
|
|
1458
|
+
page_size : int
|
|
1459
|
+
The file's page size.
|
|
1460
|
+
|
|
1461
|
+
Returns
|
|
1462
|
+
-------
|
|
1463
|
+
status : str
|
|
1464
|
+
`"live"`: the entry's start page lands on a walked blob
|
|
1465
|
+
header whose `blob_index` equals `blob_index` and whose
|
|
1466
|
+
`n_pages` equals the entry's page count (the strict
|
|
1467
|
+
check). `"absent"`: the slot is all-zero. `"invalid"`: the
|
|
1468
|
+
slot is non-zero but fails the strict check. `"outside"`:
|
|
1469
|
+
`blob_index` is not a data slot at all (an administrative
|
|
1470
|
+
slot), so the directory says nothing about it.
|
|
1471
|
+
blob : BlobHeader or None
|
|
1472
|
+
The live blob for `"live"`, otherwise `None`.
|
|
1473
|
+
"""
|
|
1474
|
+
if not 0 <= blob_index < self.data_slots:
|
|
1475
|
+
return DIRECTORY_OUTSIDE, None
|
|
1476
|
+
entry = self.entries.get(blob_index)
|
|
1477
|
+
if entry is None:
|
|
1478
|
+
return DIRECTORY_ABSENT, None
|
|
1479
|
+
word, n_pages = entry
|
|
1480
|
+
if word >> 28 != _DIRECTORY_LIVE_FLAG:
|
|
1481
|
+
return DIRECTORY_INVALID, None
|
|
1482
|
+
blob = blobs_by_offset.get(first_offset + (word & 0x7FFFFFFF) * page_size)
|
|
1483
|
+
if blob is None or blob.blob_index != blob_index or blob.n_pages != n_pages:
|
|
1484
|
+
return DIRECTORY_INVALID, None
|
|
1485
|
+
return DIRECTORY_LIVE, blob
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def read_blob_directory(path: str) -> Optional[BlobDirectory]:
|
|
1489
|
+
"""
|
|
1490
|
+
Read the persisted blob directory.
|
|
1491
|
+
|
|
1492
|
+
Parameters
|
|
1493
|
+
----------
|
|
1494
|
+
path : str
|
|
1495
|
+
Path to the `.gdb` file.
|
|
1496
|
+
|
|
1497
|
+
Returns
|
|
1498
|
+
-------
|
|
1499
|
+
BlobDirectory or None
|
|
1500
|
+
The directory, or `None` when the file has none to trust: a bad
|
|
1501
|
+
magic or truncated header, header words that are inconsistent
|
|
1502
|
+
with the layout (`data_slots != lines_max * chans_max`, or the
|
|
1503
|
+
data slots would run past the blob region), or a directory
|
|
1504
|
+
whose every data slot is zero. Absence is not an anomaly, so
|
|
1505
|
+
nothing is warned.
|
|
1506
|
+
|
|
1507
|
+
Notes
|
|
1508
|
+
-----
|
|
1509
|
+
See `BlobDirectory`. Only the data slots (`0 <= slot <
|
|
1510
|
+
data_slots`) are read; the registry blob-symbol slots and the
|
|
1511
|
+
cache slots after them are not used by the reader.
|
|
1512
|
+
"""
|
|
1513
|
+
with open(path, "rb") as f:
|
|
1514
|
+
header = f.read(4096)
|
|
1515
|
+
if not check_magic(header):
|
|
1516
|
+
return None
|
|
1517
|
+
fields = header_fields(header)
|
|
1518
|
+
chans_max, lines_max, data_slots, page_size = (
|
|
1519
|
+
fields["chans_max"], fields["lines_max"], fields["data_slots"], fields["page_size"],
|
|
1520
|
+
)
|
|
1521
|
+
if None in (chans_max, lines_max, data_slots, page_size):
|
|
1522
|
+
return None
|
|
1523
|
+
blob_start = blob_region_start(header)
|
|
1524
|
+
if (
|
|
1525
|
+
blob_start is None or data_slots <= 0 or data_slots != lines_max * chans_max
|
|
1526
|
+
or DIRECTORY_OFFSET + data_slots * _DIRECTORY_SLOT_DTYPE.itemsize > blob_start
|
|
1527
|
+
):
|
|
1528
|
+
return None
|
|
1529
|
+
f.seek(DIRECTORY_OFFSET)
|
|
1530
|
+
raw = f.read(data_slots * _DIRECTORY_SLOT_DTYPE.itemsize)
|
|
1531
|
+
if len(raw) != data_slots * _DIRECTORY_SLOT_DTYPE.itemsize:
|
|
1532
|
+
return None
|
|
1533
|
+
slots = np.frombuffer(raw, dtype=_DIRECTORY_SLOT_DTYPE)
|
|
1534
|
+
nonzero = np.nonzero((slots["word"] != 0) | (slots["n_pages"] != 0))[0]
|
|
1535
|
+
if len(nonzero) == 0:
|
|
1536
|
+
return None
|
|
1537
|
+
entries = {
|
|
1538
|
+
int(i): (int(slots["word"][i]), int(slots["n_pages"][i])) for i in nonzero
|
|
1539
|
+
}
|
|
1540
|
+
return BlobDirectory(data_slots=data_slots, entries=entries)
|
|
1541
|
+
|
|
1542
|
+
|
|
1304
1543
|
def _element_width(channel: ChannelRecord) -> Optional[int]:
|
|
1305
1544
|
"""
|
|
1306
1545
|
Byte width of one element of `channel`'s data.
|
|
@@ -1682,11 +1921,16 @@ def read_blob_values(path: str, blob: BlobHeader, channel: ChannelRecord,
|
|
|
1682
1921
|
established ground truth exactly) and for both single- and
|
|
1683
1922
|
multi-page DB_COMP_SPEED (a real 2-page `Northing_AMGz55` blob
|
|
1684
1923
|
decodes to sane real coordinates with real `rDUMMY` sentinels).
|
|
1685
|
-
See docs/provenance/notes.md section 6.6d: a multi-page blob
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1924
|
+
See docs/provenance/notes.md section 6.6d: a multi-page blob has no
|
|
1925
|
+
per-page re-framing -- just read the full `n_pages*page_size` span
|
|
1926
|
+
instead of one page. **A DB_COMP_SPEED blob is, however, a chain of
|
|
1927
|
+
chunks of at most 16368 decompressed bytes each, not one chunk**
|
|
1928
|
+
(docs/provenance/notes.md section 6.6e): only the first carries the
|
|
1929
|
+
16-byte magic, later ones are a bare 12-byte sub-header plus
|
|
1930
|
+
payload, and the blob header's `+24` field is the total
|
|
1931
|
+
decompressed size across the chain. Decoding only the first chunk
|
|
1932
|
+
-- as this function once did -- silently truncated any channel
|
|
1933
|
+
longer than 2046 float64 values on a line.
|
|
1690
1934
|
|
|
1691
1935
|
**A real third on-disk variant, auto-detected here rather than
|
|
1692
1936
|
assumed away (docs/provenance/notes.md section 6.6b):** even
|
|
@@ -1769,11 +2013,11 @@ def read_blob_values(path: str, blob: BlobHeader, channel: ChannelRecord,
|
|
|
1769
2013
|
# to be a SINGLE continuous compressed stream spanning the whole
|
|
1770
2014
|
# n_pages*page_size span -- NOT one independently-framed chunk per
|
|
1771
2015
|
# page. There is no per-page re-framing to handle: reading the full
|
|
1772
|
-
# span
|
|
1773
|
-
#
|
|
1774
|
-
#
|
|
1775
|
-
#
|
|
1776
|
-
#
|
|
2016
|
+
# span is sufficient for zlib (zlib.decompressobj() naturally stops
|
|
2017
|
+
# at the real end of stream and reports the rest as padding, and its
|
|
2018
|
+
# single stream always matches the blob header's `+24` total). LZRW1
|
|
2019
|
+
# is different: the span holds a chain of chunks, not one -- see the
|
|
2020
|
+
# DB_COMP_SPEED branch below.
|
|
1777
2021
|
if page_size is None:
|
|
1778
2022
|
with _file_handle(path, file) as f:
|
|
1779
2023
|
header = f.read(128)
|
|
@@ -1838,9 +2082,19 @@ def read_blob_values(path: str, blob: BlobHeader, channel: ChannelRecord,
|
|
|
1838
2082
|
)
|
|
1839
2083
|
return np.array([])
|
|
1840
2084
|
elif subtype == _lzrw1.DB_COMP_SPEED:
|
|
2085
|
+
# A blob is a chain of chunks of at most 16368 decompressed bytes
|
|
2086
|
+
# each (docs/spec.md section 7.3); the blob header's `+24` field
|
|
2087
|
+
# is the total across all of them, and is what tells the decoder
|
|
2088
|
+
# where the chain ends (the bytes after the last chunk are page
|
|
2089
|
+
# padding, not a reliable terminator).
|
|
2090
|
+
with _file_handle(path, file) as f:
|
|
2091
|
+
f.seek(blob.offset + 24)
|
|
2092
|
+
total_field = f.read(4)
|
|
2093
|
+
total_decompressed = (
|
|
2094
|
+
struct.unpack("<i", total_field)[0] if len(total_field) == 4 else 0
|
|
2095
|
+
)
|
|
1841
2096
|
try:
|
|
1842
|
-
|
|
1843
|
-
decompressed = _lzrw1.decode_speed_chunk(raw_span, chunk)
|
|
2097
|
+
decompressed = _lzrw1.decode_speed_blob(raw_span, total_decompressed)
|
|
1844
2098
|
except _lzrw1.LZRW1DecodeError as e:
|
|
1845
2099
|
_warn(
|
|
1846
2100
|
f"blob_index={blob.blob_index}: LZRW1 chunk decode failed ({e}) "
|
|
@@ -15,9 +15,10 @@ reference C wrapper:
|
|
|
15
15
|
1. No 4-byte FLAG_BYTES prefix (the reference C code's own
|
|
16
16
|
FLAG_COMPRESS/FLAG_COPY byte + 3 padding bytes) -- the control word
|
|
17
17
|
starts immediately for a compressed chunk.
|
|
18
|
-
2.
|
|
19
|
-
1024-byte pages) is preceded by a 28-byte Geosoft-specific
|
|
20
|
-
not part of LZRW1 itself
|
|
18
|
+
2. The first chunk of a blob (which may span several of the file's
|
|
19
|
+
physical 1024-byte pages) is preceded by a 28-byte Geosoft-specific
|
|
20
|
+
wrapper, not part of LZRW1 itself (see point 4 for what follows
|
|
21
|
+
it):
|
|
21
22
|
- 16 bytes: the magic sub-header shared with the `.grd` sibling
|
|
22
23
|
format and with `.gdb`'s DB_COMP_SIZE (zlib) mode:
|
|
23
24
|
`0f 0e ff fe 12 34 56 78 <subtype int32> <reserved int32>`
|
|
@@ -49,6 +50,21 @@ reference C wrapper:
|
|
|
49
50
|
values). Every real marker value found across all 10 real
|
|
50
51
|
Speed files was one of these two constants -- zero exceptions,
|
|
51
52
|
zero unrecognized third values.
|
|
53
|
+
4. **A blob is a chain of chunks, not one chunk** ([CONFIRMED] on
|
|
54
|
+
every Speed blob checked -- see docs/spec.md section 7.3/7.4). A
|
|
55
|
+
chunk decompresses to at most 16368 bytes (2046 float64 values);
|
|
56
|
+
a channel holding more data than that on one line is split across
|
|
57
|
+
several chunks stored back to back. Only the *first* chunk of a
|
|
58
|
+
blob carries the 16-byte magic; each later one is just its own
|
|
59
|
+
bare 12-byte `<decompressed_length> <chunk_length> <marker>`
|
|
60
|
+
sub-header immediately followed by its payload, starting
|
|
61
|
+
`chunk_length` bytes after the previous sub-header began. The
|
|
62
|
+
blob header (docs/spec.md section 7.4) records the total
|
|
63
|
+
decompressed size at `+24`, which is how a reader knows when to
|
|
64
|
+
stop -- the bytes after the last chunk are page padding, not
|
|
65
|
+
zeros, so they can't be relied on as a terminator. Every chunk
|
|
66
|
+
decoded independently (LZRW1 back-references never reach across
|
|
67
|
+
a chunk boundary). See `decode_speed_blob`.
|
|
52
68
|
|
|
53
69
|
Validated exactly (not just "plausibly") against **all 10 real**
|
|
54
70
|
DB_COMP_SPEED files now in this project's sample set (the original 4
|
|
@@ -72,6 +88,7 @@ from __future__ import annotations
|
|
|
72
88
|
import struct
|
|
73
89
|
import warnings
|
|
74
90
|
from dataclasses import dataclass
|
|
91
|
+
from typing import List, Optional
|
|
75
92
|
|
|
76
93
|
try:
|
|
77
94
|
from . import _native as _native_ext
|
|
@@ -208,7 +225,8 @@ def _lzrw1_decompress_py(data: bytes, start: int, decompressed_length: int) -> b
|
|
|
208
225
|
|
|
209
226
|
@dataclass
|
|
210
227
|
class SpeedChunk:
|
|
211
|
-
magic_offset: int
|
|
228
|
+
magic_offset: Optional[int] # offset of the 16-byte magic sub-header; None for a
|
|
229
|
+
# continuation chunk, which has no magic of its own
|
|
212
230
|
subtype: int # 1 = DB_COMP_SPEED, 2 = DB_COMP_SIZE
|
|
213
231
|
decompressed_length: int
|
|
214
232
|
chunk_length: int # includes the 12-byte length sub-header
|
|
@@ -361,11 +379,135 @@ def decode_speed_chunk(data: bytes, chunk: SpeedChunk):
|
|
|
361
379
|
) from e
|
|
362
380
|
|
|
363
381
|
|
|
382
|
+
def decode_speed_blob(data: bytes, total_decompressed_length: int = 0):
|
|
383
|
+
"""
|
|
384
|
+
Decode every chunk of a DB_COMP_SPEED blob, in order.
|
|
385
|
+
|
|
386
|
+
Parameters
|
|
387
|
+
----------
|
|
388
|
+
data : bytes or bytearray
|
|
389
|
+
The blob's compressed span, starting at the 16-byte magic of its
|
|
390
|
+
first chunk (i.e. everything after the blob header,
|
|
391
|
+
docs/spec.md section 7.4).
|
|
392
|
+
total_decompressed_length : int, optional
|
|
393
|
+
The blob's total decompressed size in bytes, from its header
|
|
394
|
+
(`+24`, docs/spec.md section 7.4). Decoding continues chunk by
|
|
395
|
+
chunk until this many bytes have been produced. If not positive
|
|
396
|
+
(a header that doesn't carry it, as in some hand-built
|
|
397
|
+
fixtures), only the first chunk is decoded.
|
|
398
|
+
|
|
399
|
+
Returns
|
|
400
|
+
-------
|
|
401
|
+
bytearray or bytes
|
|
402
|
+
The concatenated output of every chunk. A single-chunk blob
|
|
403
|
+
returns exactly what `decode_speed_chunk` does for it (no extra
|
|
404
|
+
copy); a multi-chunk one is a new, writable `bytearray`.
|
|
405
|
+
|
|
406
|
+
Raises
|
|
407
|
+
------
|
|
408
|
+
LZRW1DecodeError
|
|
409
|
+
If any chunk fails to decode (see `decode_speed_chunk`), the
|
|
410
|
+
chain runs off the end of `data`, or the chunks don't add up
|
|
411
|
+
to exactly `total_decompressed_length`.
|
|
412
|
+
|
|
413
|
+
Notes
|
|
414
|
+
-----
|
|
415
|
+
A blob is a chain of chunks of at most 16368 decompressed bytes
|
|
416
|
+
each, not a single chunk (module docstring point 4): only the first
|
|
417
|
+
carries the 16-byte magic, and every later one is a bare 12-byte
|
|
418
|
+
sub-header plus payload starting `chunk_length` bytes after the
|
|
419
|
+
previous sub-header began. This reader used to decode only the first
|
|
420
|
+
chunk, silently truncating any channel longer than 2046 float64
|
|
421
|
+
values on a line to exactly that length.
|
|
422
|
+
|
|
423
|
+
Dispatches to the compiled `pygdb._native` extension when it's
|
|
424
|
+
available and `data` is `bytes` (same algorithm, ported to Rust --
|
|
425
|
+
see `rust/src/lib.rs`), falling back to the pure-Python
|
|
426
|
+
`_decode_speed_blob_py` below otherwise. The native version raises
|
|
427
|
+
`ValueError`/`IndexError` for the same conditions; both are turned
|
|
428
|
+
into `LZRW1DecodeError` here, so callers never see which backend
|
|
429
|
+
produced a failure.
|
|
430
|
+
"""
|
|
431
|
+
if _native_ext is not None and isinstance(data, bytes):
|
|
432
|
+
try:
|
|
433
|
+
return _native_ext.decode_speed_blob(data, total_decompressed_length)
|
|
434
|
+
except (ValueError, IndexError) as e:
|
|
435
|
+
raise LZRW1DecodeError(str(e)) from e
|
|
436
|
+
return _decode_speed_blob_py(data, total_decompressed_length)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _decode_speed_blob_py(data: bytes, total_decompressed_length: int = 0):
|
|
440
|
+
"""
|
|
441
|
+
Pure-Python reference implementation of `decode_speed_blob`.
|
|
442
|
+
|
|
443
|
+
Parameters
|
|
444
|
+
----------
|
|
445
|
+
data : bytes or bytearray
|
|
446
|
+
See `decode_speed_blob`.
|
|
447
|
+
total_decompressed_length : int, optional
|
|
448
|
+
See `decode_speed_blob`.
|
|
449
|
+
|
|
450
|
+
Returns
|
|
451
|
+
-------
|
|
452
|
+
bytearray or bytes
|
|
453
|
+
See `decode_speed_blob`.
|
|
454
|
+
|
|
455
|
+
Raises
|
|
456
|
+
------
|
|
457
|
+
LZRW1DecodeError
|
|
458
|
+
See `decode_speed_blob`.
|
|
459
|
+
"""
|
|
460
|
+
first = parse_chunk_header(data, 0)
|
|
461
|
+
out = decode_speed_chunk(data, first)
|
|
462
|
+
if total_decompressed_length <= len(out):
|
|
463
|
+
return out
|
|
464
|
+
|
|
465
|
+
parts: List[bytes] = [out]
|
|
466
|
+
produced = len(out)
|
|
467
|
+
header_start = first.payload_offset - 12 # where this chunk's sub-header began
|
|
468
|
+
chunk_length = first.chunk_length
|
|
469
|
+
while produced < total_decompressed_length:
|
|
470
|
+
if chunk_length < 12:
|
|
471
|
+
raise LZRW1DecodeError(
|
|
472
|
+
f"implausible chunk_length={chunk_length} -- corrupt chunk chain"
|
|
473
|
+
)
|
|
474
|
+
header_start += chunk_length
|
|
475
|
+
try:
|
|
476
|
+
decompressed_length, chunk_length, marker = struct.unpack_from(
|
|
477
|
+
"<iii", data, header_start
|
|
478
|
+
)
|
|
479
|
+
except struct.error as e:
|
|
480
|
+
raise LZRW1DecodeError(
|
|
481
|
+
f"chunk chain runs off the end of the data after {produced} of "
|
|
482
|
+
f"{total_decompressed_length} byte(s) -- truncated data"
|
|
483
|
+
) from e
|
|
484
|
+
chunk = SpeedChunk(
|
|
485
|
+
magic_offset=None,
|
|
486
|
+
subtype=DB_COMP_SPEED,
|
|
487
|
+
decompressed_length=decompressed_length,
|
|
488
|
+
chunk_length=chunk_length,
|
|
489
|
+
marker=marker,
|
|
490
|
+
payload_offset=header_start + 12,
|
|
491
|
+
)
|
|
492
|
+
parts.append(decode_speed_chunk(data, chunk))
|
|
493
|
+
produced += decompressed_length
|
|
494
|
+
|
|
495
|
+
if produced != total_decompressed_length:
|
|
496
|
+
raise LZRW1DecodeError(
|
|
497
|
+
f"chunks decode to {produced} byte(s) but the blob header declares "
|
|
498
|
+
f"{total_decompressed_length}"
|
|
499
|
+
)
|
|
500
|
+
return bytearray().join(parts)
|
|
501
|
+
|
|
502
|
+
|
|
364
503
|
def find_speed_chunks(data: bytes):
|
|
365
504
|
"""
|
|
366
505
|
Yield every DB_COMP_SPEED (subtype==1) chunk found in `data`.
|
|
367
506
|
|
|
368
|
-
Scans for the shared 16-byte magic byte-by-byte.
|
|
507
|
+
Scans for the shared 16-byte magic byte-by-byte. Since only the
|
|
508
|
+
*first* chunk of a blob carries that magic (see `decode_speed_blob`),
|
|
509
|
+
this finds one chunk per blob, not every chunk -- it's a scanning
|
|
510
|
+
helper for locating blobs, not a way to decode them.
|
|
369
511
|
|
|
370
512
|
Parameters
|
|
371
513
|
----------
|
|
@@ -148,6 +148,175 @@ fn lzrw1_decompress_impl(data: &[u8], start: usize, out: &mut [u8]) -> PyResult<
|
|
|
148
148
|
Ok(())
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
const DB_COMP_SPEED: i32 = 1;
|
|
152
|
+
const MARKER_COMPRESSED: i32 = -186263865; // 0xF4E5D6C7 -- payload is real LZRW1
|
|
153
|
+
const MARKER_STORED_RAW: i32 = -253635901; // 0xF0E1D2C3 -- payload is stored verbatim
|
|
154
|
+
|
|
155
|
+
/// A chunk's 12-byte `<decompressed_length> <chunk_length> <marker>`
|
|
156
|
+
/// sub-header, read from `data[header_start..]`.
|
|
157
|
+
fn read_chunk_subheader(data: &[u8], header_start: usize) -> PyResult<(i32, i32, i32)> {
|
|
158
|
+
let bytes = header_start
|
|
159
|
+
.checked_add(12)
|
|
160
|
+
.and_then(|end| data.get(header_start..end))
|
|
161
|
+
.ok_or_else(|| {
|
|
162
|
+
PyIndexError::new_err(
|
|
163
|
+
"decode_speed_blob: chunk chain runs off the end of the data -- truncated",
|
|
164
|
+
)
|
|
165
|
+
})?;
|
|
166
|
+
let field = |i: usize| i32::from_le_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]);
|
|
167
|
+
Ok((field(0), field(4), field(8)))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// Decode one chunk's payload into `out` (exactly its `decompressed_length`
|
|
171
|
+
/// bytes) -- the Rust counterpart of `pygdb.lzrw1.decode_speed_chunk`,
|
|
172
|
+
/// with the same checks in the same order.
|
|
173
|
+
fn decode_speed_chunk_into(
|
|
174
|
+
data: &[u8],
|
|
175
|
+
header_start: usize,
|
|
176
|
+
chunk_length: i32,
|
|
177
|
+
marker: i32,
|
|
178
|
+
out: &mut [u8],
|
|
179
|
+
) -> PyResult<()> {
|
|
180
|
+
let payload_offset = header_start + 12;
|
|
181
|
+
match marker {
|
|
182
|
+
MARKER_STORED_RAW => {
|
|
183
|
+
if chunk_length as i64 - 12 != out.len() as i64 {
|
|
184
|
+
return Err(PyValueError::new_err(format!(
|
|
185
|
+
"decode_speed_blob: stored-raw chunk should have chunk_length-12 == \
|
|
186
|
+
decompressed_length (got chunk_length-12={}, decompressed_length={})",
|
|
187
|
+
chunk_length as i64 - 12,
|
|
188
|
+
out.len(),
|
|
189
|
+
)));
|
|
190
|
+
}
|
|
191
|
+
let payload = data
|
|
192
|
+
.get(payload_offset..payload_offset + out.len())
|
|
193
|
+
.ok_or_else(|| {
|
|
194
|
+
PyIndexError::new_err(
|
|
195
|
+
"decode_speed_blob: truncated stored-raw payload -- file cut off mid-chunk?",
|
|
196
|
+
)
|
|
197
|
+
})?;
|
|
198
|
+
out.copy_from_slice(payload);
|
|
199
|
+
Ok(())
|
|
200
|
+
}
|
|
201
|
+
MARKER_COMPRESSED => lzrw1_decompress_impl(data, payload_offset, out),
|
|
202
|
+
other => Err(PyValueError::new_err(format!(
|
|
203
|
+
"decode_speed_blob: unrecognized marker value: {other}"
|
|
204
|
+
))),
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/// Walk a whole `DB_COMP_SPEED` blob's chain of chunks into `out`.
|
|
209
|
+
///
|
|
210
|
+
/// `out.len()` is either the first chunk's `decompressed_length` (a
|
|
211
|
+
/// single-chunk blob, or no usable total) or the blob's declared total; the
|
|
212
|
+
/// loop stops as soon as `out` is full, and errors if a chunk would
|
|
213
|
+
/// overshoot it -- matching `pygdb.lzrw1._decode_speed_blob_py`.
|
|
214
|
+
fn decode_speed_chain_into(data: &[u8], out: &mut [u8]) -> PyResult<()> {
|
|
215
|
+
let mut written = 0usize;
|
|
216
|
+
let mut header_start = 16usize; // just past the first chunk's 16-byte magic
|
|
217
|
+
loop {
|
|
218
|
+
let (decompressed_length, chunk_length, marker) = read_chunk_subheader(data, header_start)?;
|
|
219
|
+
if !(0 < decompressed_length && decompressed_length < 200_000_000) {
|
|
220
|
+
return Err(PyValueError::new_err(format!(
|
|
221
|
+
"decode_speed_blob: implausible decompressed_length={decompressed_length} -- \
|
|
222
|
+
likely a misaligned or corrupt chunk header"
|
|
223
|
+
)));
|
|
224
|
+
}
|
|
225
|
+
let end = written + decompressed_length as usize;
|
|
226
|
+
if end > out.len() {
|
|
227
|
+
return Err(PyValueError::new_err(
|
|
228
|
+
"decode_speed_blob: chunks decode to more bytes than the blob header declares",
|
|
229
|
+
));
|
|
230
|
+
}
|
|
231
|
+
decode_speed_chunk_into(
|
|
232
|
+
data,
|
|
233
|
+
header_start,
|
|
234
|
+
chunk_length,
|
|
235
|
+
marker,
|
|
236
|
+
&mut out[written..end],
|
|
237
|
+
)?;
|
|
238
|
+
written = end;
|
|
239
|
+
if written == out.len() {
|
|
240
|
+
return Ok(());
|
|
241
|
+
}
|
|
242
|
+
if chunk_length < 12 {
|
|
243
|
+
return Err(PyValueError::new_err(format!(
|
|
244
|
+
"decode_speed_blob: implausible chunk_length={chunk_length} -- corrupt chunk chain"
|
|
245
|
+
)));
|
|
246
|
+
}
|
|
247
|
+
header_start += chunk_length as usize;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/// Decode every chunk of a `DB_COMP_SPEED` blob into one writable Python
|
|
252
|
+
/// `bytearray`.
|
|
253
|
+
///
|
|
254
|
+
/// Rust port of `pygdb.lzrw1.decode_speed_blob` -- see that module's
|
|
255
|
+
/// docstring (point 4) and docs/spec.md section 7.3/7.4 for the format:
|
|
256
|
+
/// a blob is a chain of chunks of at most 16368 decompressed bytes each,
|
|
257
|
+
/// only the first preceded by the 16-byte magic; `data` starts at that
|
|
258
|
+
/// magic, and `total_decompressed_length` is the blob header's `+24`
|
|
259
|
+
/// field (how the decoder knows the chain has ended -- the bytes after
|
|
260
|
+
/// the last chunk are page padding, not zeros). A total that isn't
|
|
261
|
+
/// larger than the first chunk (including 0 or negative, "no usable
|
|
262
|
+
/// total") decodes just the first chunk.
|
|
263
|
+
///
|
|
264
|
+
/// Raises `ValueError` for a malformed chunk or chain (bad marker,
|
|
265
|
+
/// implausible lengths, a total the chain doesn't add up to) and
|
|
266
|
+
/// `IndexError` for truncated data -- `pygdb.lzrw1.decode_speed_blob`
|
|
267
|
+
/// turns both into `LZRW1DecodeError`, so callers never see which
|
|
268
|
+
/// backend produced the failure.
|
|
269
|
+
///
|
|
270
|
+
/// The output buffer is sized once, up front, from the first chunk's
|
|
271
|
+
/// header and the declared total, then filled in place via
|
|
272
|
+
/// `PyByteArray::new_with` under `Python::detach` -- the same single-
|
|
273
|
+
/// allocation technique and the same GIL-release argument as
|
|
274
|
+
/// `lzrw1_decompress` (`data` is `&[u8]`, so an immutable `bytes`; the
|
|
275
|
+
/// target `bytearray` isn't Python-visible until this returns). A
|
|
276
|
+
/// declared total larger than any real chain could produce from `data`
|
|
277
|
+
/// (LZRW1 expands at most 8x, plus the 12-byte sub-headers) is rejected
|
|
278
|
+
/// before allocating, so a corrupt header can't request a huge buffer.
|
|
279
|
+
#[pyfunction]
|
|
280
|
+
fn decode_speed_blob<'py>(
|
|
281
|
+
py: Python<'py>,
|
|
282
|
+
data: &[u8],
|
|
283
|
+
total_decompressed_length: i64,
|
|
284
|
+
) -> PyResult<Bound<'py, PyByteArray>> {
|
|
285
|
+
let subtype = data
|
|
286
|
+
.get(8..12)
|
|
287
|
+
.map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
|
288
|
+
.ok_or_else(|| PyIndexError::new_err("decode_speed_blob: truncated -- no chunk header"))?;
|
|
289
|
+
if subtype != DB_COMP_SPEED {
|
|
290
|
+
return Err(PyValueError::new_err(format!(
|
|
291
|
+
"decode_speed_blob: not a Speed chunk (subtype={subtype})"
|
|
292
|
+
)));
|
|
293
|
+
}
|
|
294
|
+
let (first_length, _, _) = read_chunk_subheader(data, 16)?;
|
|
295
|
+
if !(0 < first_length && first_length < 200_000_000) {
|
|
296
|
+
return Err(PyValueError::new_err(format!(
|
|
297
|
+
"decode_speed_blob: implausible decompressed_length={first_length} -- \
|
|
298
|
+
likely a misaligned or corrupt chunk header"
|
|
299
|
+
)));
|
|
300
|
+
}
|
|
301
|
+
let first_length = first_length as usize;
|
|
302
|
+
let out_len = if total_decompressed_length > first_length as i64 {
|
|
303
|
+
let total = total_decompressed_length as u64;
|
|
304
|
+
if total > (data.len() as u64).saturating_mul(9) {
|
|
305
|
+
return Err(PyValueError::new_err(format!(
|
|
306
|
+
"decode_speed_blob: blob header declares {total} decompressed byte(s), \
|
|
307
|
+
more than {} byte(s) of chunk data could possibly produce",
|
|
308
|
+
data.len(),
|
|
309
|
+
)));
|
|
310
|
+
}
|
|
311
|
+
total as usize
|
|
312
|
+
} else {
|
|
313
|
+
first_length
|
|
314
|
+
};
|
|
315
|
+
PyByteArray::new_with(py, out_len, |buf| {
|
|
316
|
+
py.detach(|| decode_speed_chain_into(data, buf))
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
151
320
|
/// Decompress a `DB_COMP_SIZE` blob's raw zlib/DEFLATE stream into a
|
|
152
321
|
/// writable Python `bytearray`.
|
|
153
322
|
///
|
|
@@ -483,6 +652,7 @@ fn decode_fixed_width_strings_ucs4<'py>(
|
|
|
483
652
|
fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
484
653
|
m.add_function(wrap_pyfunction!(ping, m)?)?;
|
|
485
654
|
m.add_function(wrap_pyfunction!(lzrw1_decompress, m)?)?;
|
|
655
|
+
m.add_function(wrap_pyfunction!(decode_speed_blob, m)?)?;
|
|
486
656
|
m.add_function(wrap_pyfunction!(zlib_decompress, m)?)?;
|
|
487
657
|
m.add_function(wrap_pyfunction!(decompress_grd_blocks, m)?)?;
|
|
488
658
|
m.add_function(wrap_pyfunction!(decode_fixed_width_strings_ucs4, m)?)?;
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|