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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-gdb
3
- Version: 0.2.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
- read_lines,
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 = read_lines(self.path)
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`, built
433
- with one blob-chain walk and cached from then on.
434
- `iter_blobs`/`find_blob` themselves recommend this for
435
- anything beyond an occasional one-off lookup -- this class
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
- index: Dict[Tuple[int, int], BlobHeader] = {}
476
+ copies: Dict[Tuple[int, int], List[BlobHeader]] = {}
477
+ by_offset: Dict[int, BlobHeader] = {}
442
478
  for blob in iter_blobs(self.path):
443
- index[blob.line_channel(chans_max)] = blob
444
- self._blob_index = index
445
- self._calibrate_line_indices()
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
- See `find_line_table`'s and `read_lines`'s docstrings in
453
- `gdb_reader.py`. Checks, for a handful of small integer shifts,
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"`, `"users_max"`, `"page_size"`, and
447
- `"comp_level"` to its decoded int32 value. See
448
- docs/provenance/notes.md section 6.1 for the full table
449
- including the still-unknown offsets, and for why each
450
- confidence label was assigned.
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), ("users_max", 40),
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, and locating the table itself
717
- (`find_line_table` below) is a heuristic scan rather than the
718
- structurally-proven SUPER-anchor technique used for the channel
719
- table. See docs/spec.md section 3.2 and docs/provenance/notes.md
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) and no confirmed header
791
- field gives its start offset directly -- reconciling one with the
792
- header's capacity fields was tried and didn't cleanly round-trip
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, not yet fixed
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
- Heuristic (see `find_line_table`) -- less firmly established than
873
- `read_channels`. Since no confirmed header field gives the line
874
- table's slot capacity (the way `chans_max` does for the channel
875
- table), this reads forward from the located start until 8
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 -- a tolerance against one-off
878
- corruption/false-positive records, not a precisely-known table
879
- boundary.
880
-
881
- **`LineRecord.index` can be off by a small, fixed amount** on a
882
- file where `find_line_table`'s heuristic starts one or more slots
883
- late -- see that function's docstring. This makes `.name` still
884
- correct but `.index` (and therefore any blob_index lookup keyed on
885
- it) wrong. `GDB` (in `gdb.py`) corrects this against the actual
886
- blob chain before exposing lines by name; call it instead of this
887
- function directly when you need working (line, channel) data
888
- access, not just a list of names.
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: List[LineRecord] = []
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 is
1686
- simply one continuous compressed stream spanning the whole
1687
- `n_pages*page_size` span, not one independently-framed chunk per
1688
- page -- no special multi-page logic was actually needed once this
1689
- was verified, just reading the full span instead of one page.
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 and decompressing it as one stream (zlib.decompressobj()
1773
- # naturally stops at the real end of stream and reports the rest as
1774
- # padding; the LZRW1 chunk header's own decompressed_length/
1775
- # chunk_length fields already span the full compressed length
1776
- # regardless of how many pages it spilled into) is sufficient.
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
- chunk = _lzrw1.parse_chunk_header(raw_span, 0)
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. Each chunk (which may span several of the file's physical
19
- 1024-byte pages) is preceded by a 28-byte Geosoft-specific wrapper,
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 # file offset of the 16-byte magic sub-header
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
  ----------
@@ -46,7 +46,7 @@ dependencies = [
46
46
 
47
47
  [[package]]
48
48
  name = "pygdb-native"
49
- version = "0.2.0"
49
+ version = "0.3.0"
50
50
  dependencies = [
51
51
  "flate2",
52
52
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "pygdb-native"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  edition = "2021"
5
5
  description = "Optional Rust-accelerated backend for python-gdb (pygdb._native)"
6
6
  license = "MIT"
@@ -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