xgparse 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xgparse/__init__.py +0 -0
- xgparse/extractxgdata.py +101 -0
- xgparse/xgimport.py +214 -0
- xgparse/xgstruct.py +964 -0
- xgparse/xgutils.py +95 -0
- xgparse/xgzarc.py +256 -0
- xgparse-0.1.0.dist-info/METADATA +521 -0
- xgparse-0.1.0.dist-info/RECORD +9 -0
- xgparse-0.1.0.dist-info/WHEEL +4 -0
xgparse/__init__.py
ADDED
|
File without changes
|
xgparse/extractxgdata.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#
|
|
2
|
+
# extractxgdata.py - Simple XG data extraction tool
|
|
3
|
+
# Copyright (C) 2013,2014 Michael Petch <mpetch@gnubg.org>
|
|
4
|
+
# <mpetch@capp-sysware.com>
|
|
5
|
+
#
|
|
6
|
+
# This program is free software: you can redistribute it and/or modify
|
|
7
|
+
# it under the terms of the GNU Lesser General Public License as published by
|
|
8
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
9
|
+
# (at your option) any later version.
|
|
10
|
+
#
|
|
11
|
+
# This program is distributed in the hope that it will be useful,
|
|
12
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
13
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
14
|
+
# GNU Lesser General Public License for more details.
|
|
15
|
+
#
|
|
16
|
+
# You should have received a copy of the GNU Lesser General Public License
|
|
17
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
18
|
+
#
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import pprint
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import xgimport
|
|
27
|
+
import xgstruct
|
|
28
|
+
import xgzarc
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _valid_directory(parser: argparse.ArgumentParser, path: str) -> Path:
|
|
32
|
+
p = Path(path)
|
|
33
|
+
if not p.is_dir():
|
|
34
|
+
parser.error(f"Directory '{path}' does not exist")
|
|
35
|
+
return p
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main() -> None:
|
|
39
|
+
parser = argparse.ArgumentParser(
|
|
40
|
+
description="XG data extraction utility",
|
|
41
|
+
formatter_class=argparse.RawTextHelpFormatter,
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"-d",
|
|
45
|
+
metavar="DIR",
|
|
46
|
+
dest="outdir",
|
|
47
|
+
help="Directory to write segments to (default: same directory as the import file)\n",
|
|
48
|
+
type=lambda p: _valid_directory(parser, p),
|
|
49
|
+
default=None,
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"files",
|
|
53
|
+
metavar="FILE",
|
|
54
|
+
nargs="+",
|
|
55
|
+
help="One or more .xg files to process",
|
|
56
|
+
)
|
|
57
|
+
args = parser.parse_args()
|
|
58
|
+
|
|
59
|
+
for xg_path_str in args.files:
|
|
60
|
+
xg_path = Path(xg_path_str)
|
|
61
|
+
out_dir = args.outdir if args.outdir is not None else xg_path.parent
|
|
62
|
+
stem = xg_path.stem
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
importer = xgimport.Import(xg_path)
|
|
66
|
+
print(f"Processing file: {xg_path}")
|
|
67
|
+
file_version = -1
|
|
68
|
+
|
|
69
|
+
for segment in importer.get_file_segments():
|
|
70
|
+
if segment.extension is not None:
|
|
71
|
+
dest = (out_dir / stem).with_suffix("")
|
|
72
|
+
dest = out_dir / (stem + segment.extension)
|
|
73
|
+
segment.copy_to(dest)
|
|
74
|
+
|
|
75
|
+
if segment.seg_type is xgimport.SegmentType.XG_GAMEFILE:
|
|
76
|
+
segment.fd.seek(0)
|
|
77
|
+
while True:
|
|
78
|
+
rec = xgstruct.read_game_record(
|
|
79
|
+
segment.fd, version=file_version
|
|
80
|
+
)
|
|
81
|
+
if rec is None:
|
|
82
|
+
break
|
|
83
|
+
if isinstance(rec, xgstruct.HeaderMatchEntry):
|
|
84
|
+
file_version = rec.version
|
|
85
|
+
if not isinstance(rec, xgstruct.UnimplementedEntry):
|
|
86
|
+
pprint.pprint(rec, width=160)
|
|
87
|
+
|
|
88
|
+
elif segment.seg_type is xgimport.SegmentType.XG_ROLLOUTS:
|
|
89
|
+
segment.fd.seek(0)
|
|
90
|
+
while True:
|
|
91
|
+
rec = xgstruct.read_rollout_record(segment.fd)
|
|
92
|
+
if rec is None:
|
|
93
|
+
break
|
|
94
|
+
pprint.pprint(rec, width=160)
|
|
95
|
+
|
|
96
|
+
except (xgimport.Error, xgzarc.Error) as exc:
|
|
97
|
+
print(exc)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
main()
|
xgparse/xgimport.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#
|
|
2
|
+
# xgimport.py - XG import module
|
|
3
|
+
# Copyright (C) 2013,2014 Michael Petch <mpetch@gnubg.org>
|
|
4
|
+
# <mpetch@capp-sysware.com>
|
|
5
|
+
#
|
|
6
|
+
# This program is free software: you can redistribute it and/or modify
|
|
7
|
+
# it under the terms of the GNU Lesser General Public License as published by
|
|
8
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
9
|
+
# (at your option) any later version.
|
|
10
|
+
#
|
|
11
|
+
# This program is distributed in the hope that it will be useful,
|
|
12
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
13
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
14
|
+
# GNU Lesser General Public License for more details.
|
|
15
|
+
#
|
|
16
|
+
# You should have received a copy of the GNU Lesser General Public License
|
|
17
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
18
|
+
#
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import tempfile
|
|
25
|
+
from collections.abc import Generator
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from enum import IntEnum
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import BinaryIO
|
|
30
|
+
|
|
31
|
+
import xgstruct
|
|
32
|
+
import xgzarc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Public exceptions
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Error(Exception):
|
|
41
|
+
"""Raised when a file cannot be parsed as a valid XG game data file."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, filename: str | Path) -> None:
|
|
44
|
+
self.message = message
|
|
45
|
+
self.filename = Path(filename)
|
|
46
|
+
super().__init__(f"XG import error processing '{filename}': {message}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Segment types and metadata
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SegmentType(IntEnum):
|
|
55
|
+
GDF_HDR = 0
|
|
56
|
+
GDF_IMAGE = 1
|
|
57
|
+
XG_GAMEHDR = 2
|
|
58
|
+
XG_GAMEFILE = 3
|
|
59
|
+
XG_ROLLOUTS = 4
|
|
60
|
+
XG_COMMENT = 5
|
|
61
|
+
ZLIBARC_IDX = 6
|
|
62
|
+
XG_UNKNOWN = 7
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_EXTENSIONS: dict[SegmentType, str | None] = {
|
|
66
|
+
SegmentType.GDF_HDR: "_gdh.bin",
|
|
67
|
+
SegmentType.GDF_IMAGE: ".jpg",
|
|
68
|
+
SegmentType.XG_GAMEHDR: "_gamehdr.bin",
|
|
69
|
+
SegmentType.XG_GAMEFILE: "_gamefile.bin",
|
|
70
|
+
SegmentType.XG_ROLLOUTS: "_rollouts.bin",
|
|
71
|
+
SegmentType.XG_COMMENT: "_comments.bin",
|
|
72
|
+
SegmentType.ZLIBARC_IDX: "_idx.bin",
|
|
73
|
+
SegmentType.XG_UNKNOWN: None,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_FILEMAP: dict[str, SegmentType] = {
|
|
77
|
+
"temp.xgi": SegmentType.XG_GAMEHDR,
|
|
78
|
+
"temp.xgr": SegmentType.XG_ROLLOUTS,
|
|
79
|
+
"temp.xgc": SegmentType.XG_COMMENT,
|
|
80
|
+
"temp.xg": SegmentType.XG_GAMEFILE,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Byte offset of the magic string "DMLI" inside temp.xg.
|
|
84
|
+
_XG_GAMEHDR_LEN = 556
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Segment object
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Segment:
|
|
94
|
+
"""One logical chunk extracted from an XG file.
|
|
95
|
+
|
|
96
|
+
Attributes:
|
|
97
|
+
seg_type: Which part of the XG file this segment represents.
|
|
98
|
+
filename: Path to the backing temp file on disk (may be ``None``
|
|
99
|
+
before :meth:`create_temp_file` is called).
|
|
100
|
+
fd: Open file object for the backing temp file.
|
|
101
|
+
extension: File extension used when saving this segment to disk.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
seg_type: SegmentType
|
|
105
|
+
filename: Path | None = field(default=None, repr=False)
|
|
106
|
+
fd: BinaryIO | None = field(default=None, repr=False)
|
|
107
|
+
_autodelete: bool = field(default=True, repr=False)
|
|
108
|
+
_prefix: str = field(default="tmpXGI", repr=False)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def extension(self) -> str | None:
|
|
112
|
+
return _EXTENSIONS.get(self.seg_type)
|
|
113
|
+
|
|
114
|
+
# Context-manager support for auto-creating/deleting temp files.
|
|
115
|
+
def __enter__(self) -> "Segment":
|
|
116
|
+
self._create_temp_file()
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def __exit__(self, *_: object) -> None:
|
|
120
|
+
self._close()
|
|
121
|
+
|
|
122
|
+
def _create_temp_file(self, mode: str = "w+b") -> None:
|
|
123
|
+
raw_fd, path_str = tempfile.mkstemp(prefix=self._prefix)
|
|
124
|
+
self.filename = Path(path_str)
|
|
125
|
+
self.fd = os.fdopen(raw_fd, mode)
|
|
126
|
+
|
|
127
|
+
def _close(self) -> None:
|
|
128
|
+
if self.fd is not None:
|
|
129
|
+
try:
|
|
130
|
+
self.fd.close()
|
|
131
|
+
finally:
|
|
132
|
+
self.fd = None
|
|
133
|
+
if self._autodelete and self.filename is not None:
|
|
134
|
+
self.filename.unlink(missing_ok=True)
|
|
135
|
+
self.filename = None
|
|
136
|
+
|
|
137
|
+
def copy_to(self, dest: str | Path) -> None:
|
|
138
|
+
"""Copy the backing temp file to *dest*."""
|
|
139
|
+
if self.filename is None:
|
|
140
|
+
raise RuntimeError("Segment has no backing file to copy.")
|
|
141
|
+
shutil.copy(self.filename, dest)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Top-level importer
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Import:
|
|
150
|
+
"""Parse an eXtreme Gammon ``.xg`` file and iterate over its segments."""
|
|
151
|
+
|
|
152
|
+
def __init__(self, filename: str | Path) -> None:
|
|
153
|
+
self.filename = Path(filename)
|
|
154
|
+
|
|
155
|
+
def get_file_segments(self) -> Generator[Segment, None, None]:
|
|
156
|
+
"""Yield each :class:`Segment` found in the XG file in order.
|
|
157
|
+
|
|
158
|
+
Segments are:
|
|
159
|
+
|
|
160
|
+
1. The GDF header binary blob (always present).
|
|
161
|
+
2. The thumbnail JPEG (present only when a thumbnail was saved).
|
|
162
|
+
3. The game header (``temp.xgi``).
|
|
163
|
+
4. The full game file (``temp.xg``).
|
|
164
|
+
5. The rollout data (``temp.xgr``).
|
|
165
|
+
6. The comments (``temp.xgc``).
|
|
166
|
+
|
|
167
|
+
Each segment's backing temp file is valid for the duration of a single
|
|
168
|
+
loop iteration. Do **not** hold on to segment references across
|
|
169
|
+
iterations — the file will be deleted.
|
|
170
|
+
"""
|
|
171
|
+
with self.filename.open("rb") as xginfile:
|
|
172
|
+
# --- GDF / RichGame outer header ---
|
|
173
|
+
gdf_header = xgstruct.GameDataFormatHdrRecord.from_stream(xginfile)
|
|
174
|
+
if gdf_header is None:
|
|
175
|
+
raise Error("Not a game data format file", self.filename)
|
|
176
|
+
|
|
177
|
+
with Segment(SegmentType.GDF_HDR) as seg:
|
|
178
|
+
xginfile.seek(0)
|
|
179
|
+
seg.fd.write(xginfile.read(gdf_header.header_size)) # type: ignore[union-attr]
|
|
180
|
+
seg.fd.flush()
|
|
181
|
+
yield seg
|
|
182
|
+
|
|
183
|
+
# --- Embedded thumbnail JPEG (optional) ---
|
|
184
|
+
if gdf_header.thumbnail_size > 0:
|
|
185
|
+
with Segment(SegmentType.GDF_IMAGE) as seg:
|
|
186
|
+
xginfile.seek(gdf_header.thumbnail_offset, os.SEEK_CUR)
|
|
187
|
+
seg.fd.write(xginfile.read(gdf_header.thumbnail_size)) # type: ignore[union-attr]
|
|
188
|
+
seg.fd.flush()
|
|
189
|
+
yield seg
|
|
190
|
+
|
|
191
|
+
# --- Zlib archive containing the four inner XG files ---
|
|
192
|
+
archive = xgzarc.ZlibArchive(stream=xginfile)
|
|
193
|
+
for file_rec in archive.arcregistry:
|
|
194
|
+
seg_file, seg_path = archive.getarchivefile(file_rec)
|
|
195
|
+
seg_type = _FILEMAP[file_rec.name]
|
|
196
|
+
seg = Segment(
|
|
197
|
+
seg_type=seg_type,
|
|
198
|
+
filename=seg_path,
|
|
199
|
+
fd=seg_file,
|
|
200
|
+
_autodelete=False,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if seg_type is SegmentType.XG_GAMEFILE:
|
|
204
|
+
seg_file.seek(_XG_GAMEHDR_LEN)
|
|
205
|
+
magic = seg_file.read(4).decode("ascii", errors="replace")
|
|
206
|
+
if magic != "DMLI":
|
|
207
|
+
seg_file.close()
|
|
208
|
+
seg_path.unlink(missing_ok=True)
|
|
209
|
+
raise Error("Not a valid XG gamefile", self.filename)
|
|
210
|
+
|
|
211
|
+
yield seg
|
|
212
|
+
|
|
213
|
+
seg_file.close()
|
|
214
|
+
seg_path.unlink(missing_ok=True)
|