chklib 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.
- chklib/__init__.py +47 -0
- chklib/chk.py +291 -0
- chklib/cli.py +312 -0
- chklib/diff.py +484 -0
- chklib/enums.py +321 -0
- chklib/inspect.py +433 -0
- chklib/mpq.py +622 -0
- chklib/pkware.py +221 -0
- chklib/py.typed +0 -0
- chklib/records.py +445 -0
- chklib/views.py +1201 -0
- chklib-0.1.0.dist-info/METADATA +527 -0
- chklib-0.1.0.dist-info/RECORD +16 -0
- chklib-0.1.0.dist-info/WHEEL +4 -0
- chklib-0.1.0.dist-info/entry_points.txt +2 -0
- chklib-0.1.0.dist-info/licenses/LICENSE +21 -0
chklib/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""chklib - a read/write library for StarCraft map data."""
|
|
2
|
+
|
|
3
|
+
from .chk import SECTION_HEADER_SIZE, Chk, Diagnostic, Section
|
|
4
|
+
from .records import (
|
|
5
|
+
Action,
|
|
6
|
+
Condition,
|
|
7
|
+
IsomRect,
|
|
8
|
+
Location,
|
|
9
|
+
Sprite,
|
|
10
|
+
Trigger,
|
|
11
|
+
Unit,
|
|
12
|
+
)
|
|
13
|
+
from .views import (
|
|
14
|
+
Forces,
|
|
15
|
+
Dimensions,
|
|
16
|
+
PlayerRaces,
|
|
17
|
+
PlayerSlots,
|
|
18
|
+
RecordArrayView,
|
|
19
|
+
ScenarioProperties,
|
|
20
|
+
FogGrid,
|
|
21
|
+
IsomGrid,
|
|
22
|
+
StringTable,
|
|
23
|
+
StringTableView,
|
|
24
|
+
TileGrid,
|
|
25
|
+
TilesetRef,
|
|
26
|
+
TriggerListView,
|
|
27
|
+
Version,
|
|
28
|
+
string_table_for,
|
|
29
|
+
isom_for,
|
|
30
|
+
terrain_for,
|
|
31
|
+
view_for,
|
|
32
|
+
TYPED_SECTIONS,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
# container
|
|
37
|
+
"Chk", "Section", "Diagnostic", "SECTION_HEADER_SIZE",
|
|
38
|
+
# records
|
|
39
|
+
"Unit", "Sprite", "Location", "Condition", "Action", "Trigger", "IsomRect",
|
|
40
|
+
# views
|
|
41
|
+
"Dimensions", "Version", "TilesetRef", "PlayerSlots", "PlayerRaces",
|
|
42
|
+
"ScenarioProperties", "Forces", "RecordArrayView", "TriggerListView",
|
|
43
|
+
"StringTableView", "StringTable", "TileGrid", "FogGrid",
|
|
44
|
+
"IsomGrid", "terrain_for", "isom_for", "string_table_for", "view_for",
|
|
45
|
+
"TYPED_SECTIONS",
|
|
46
|
+
]
|
|
47
|
+
__version__ = "0.1.0"
|
chklib/chk.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""The CHK section container.
|
|
2
|
+
|
|
3
|
+
A ``scenario.chk`` is a flat sequence of sections, each an 8-byte header -- a
|
|
4
|
+
4-byte name and a signed little-endian 32-bit length -- followed by that many
|
|
5
|
+
payload bytes.
|
|
6
|
+
|
|
7
|
+
The container below preserves three things that existing CHK readers throw
|
|
8
|
+
away, and that anything claiming to round-trip a map has to keep:
|
|
9
|
+
|
|
10
|
+
**Order.** StarCraft applies sections in file order, with later sections
|
|
11
|
+
overriding earlier ones of the same name. A reader that returns a mapping has
|
|
12
|
+
already destroyed the information needed to know which one wins.
|
|
13
|
+
|
|
14
|
+
**Duplicates.** Deliberately duplicated sections are a standard map-protection
|
|
15
|
+
technique. A ``dict[name] -> bytes`` silently keeps one and discards the rest.
|
|
16
|
+
|
|
17
|
+
**Raw bytes.** Sections this library does not understand -- and sections whose
|
|
18
|
+
declared length disagrees with reality -- survive a read/write cycle untouched.
|
|
19
|
+
|
|
20
|
+
Malformed input is reported as :class:`Diagnostic` values rather than raised.
|
|
21
|
+
A large share of interesting maps in the wild are malformed on purpose, and a
|
|
22
|
+
parser that refuses them is useless for exactly the maps people care about.
|
|
23
|
+
|
|
24
|
+
``Chk.from_bytes(raw).to_bytes() == raw`` holds for every input, including
|
|
25
|
+
truncated and protected files. That is guaranteed by construction: each section
|
|
26
|
+
re-emits its own verbatim header fields and payload, and anything the parser
|
|
27
|
+
could not interpret is preserved in :attr:`Chk.trailing`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import struct
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import Iterator, Sequence
|
|
35
|
+
|
|
36
|
+
__all__ = ["Chk", "Section", "Diagnostic", "SECTION_HEADER_SIZE"]
|
|
37
|
+
|
|
38
|
+
SECTION_HEADER_SIZE = 8
|
|
39
|
+
|
|
40
|
+
_NAME_STRUCT = struct.Struct("<4si")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class Diagnostic:
|
|
45
|
+
"""Something notable about the input, reported rather than raised."""
|
|
46
|
+
|
|
47
|
+
code: str
|
|
48
|
+
message: str
|
|
49
|
+
offset: int
|
|
50
|
+
severity: str = "warning"
|
|
51
|
+
|
|
52
|
+
def __str__(self) -> str:
|
|
53
|
+
return f"{self.severity}: {self.code} at 0x{self.offset:X}: {self.message}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class Section:
|
|
58
|
+
"""One CHK section, exactly as it appeared in the file."""
|
|
59
|
+
|
|
60
|
+
name: bytes
|
|
61
|
+
"""The 4 name bytes verbatim. Not necessarily printable ASCII."""
|
|
62
|
+
|
|
63
|
+
declared_size: int
|
|
64
|
+
"""The header's signed int32 length, verbatim -- may disagree with ``data``."""
|
|
65
|
+
|
|
66
|
+
data: bytes
|
|
67
|
+
"""The payload bytes actually present in the file."""
|
|
68
|
+
|
|
69
|
+
offset: int
|
|
70
|
+
"""Byte offset of this section's header within the source."""
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def label(self) -> str:
|
|
74
|
+
"""A printable name for display. Non-printable bytes become ``.``."""
|
|
75
|
+
return "".join(
|
|
76
|
+
chr(b) if 0x20 <= b < 0x7F else "." for b in self.name
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def key(self) -> bytes:
|
|
81
|
+
"""The name with trailing spaces stripped, e.g. ``b'VER'`` for ``b'VER '``."""
|
|
82
|
+
return self.name.rstrip(b" ")
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def is_truncated(self) -> bool:
|
|
86
|
+
"""True when the file ended before ``declared_size`` bytes were available."""
|
|
87
|
+
return len(self.data) != self.declared_size
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def header_bytes(self) -> bytes:
|
|
91
|
+
return _NAME_STRUCT.pack(self.name, self.declared_size)
|
|
92
|
+
|
|
93
|
+
def to_bytes(self) -> bytes:
|
|
94
|
+
return self.header_bytes + self.data
|
|
95
|
+
|
|
96
|
+
def __len__(self) -> int:
|
|
97
|
+
return len(self.data)
|
|
98
|
+
|
|
99
|
+
def __repr__(self) -> str:
|
|
100
|
+
trunc = (
|
|
101
|
+
f", truncated(declared={self.declared_size})" if self.is_truncated else ""
|
|
102
|
+
)
|
|
103
|
+
return (
|
|
104
|
+
f"Section({self.label!r}, {len(self.data)} bytes"
|
|
105
|
+
f"{trunc}, offset=0x{self.offset:X})"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _normalize(name: str | bytes) -> bytes:
|
|
110
|
+
"""Accept ``'VER'``, ``'VER '`` or ``b'VER '`` and return the 4-byte form."""
|
|
111
|
+
raw = name.encode("ascii") if isinstance(name, str) else bytes(name)
|
|
112
|
+
if len(raw) > 4:
|
|
113
|
+
raise ValueError(f"section name must be at most 4 bytes, got {raw!r}")
|
|
114
|
+
return raw.ljust(4, b" ")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(slots=True)
|
|
118
|
+
class Chk:
|
|
119
|
+
"""An ordered, duplicate-preserving sequence of CHK sections."""
|
|
120
|
+
|
|
121
|
+
sections: list[Section] = field(default_factory=list)
|
|
122
|
+
"""Every section, in file order, duplicates included."""
|
|
123
|
+
|
|
124
|
+
trailing: bytes = b""
|
|
125
|
+
"""Bytes after the last section the parser could interpret."""
|
|
126
|
+
|
|
127
|
+
diagnostics: list[Diagnostic] = field(default_factory=list)
|
|
128
|
+
"""Problems found while parsing. Never raised."""
|
|
129
|
+
|
|
130
|
+
# -- reading ----------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_bytes(cls, raw: bytes) -> "Chk":
|
|
134
|
+
"""Parse ``raw``. Never raises on malformed input."""
|
|
135
|
+
sections: list[Section] = []
|
|
136
|
+
diagnostics: list[Diagnostic] = []
|
|
137
|
+
trailing = b""
|
|
138
|
+
total = len(raw)
|
|
139
|
+
offset = 0
|
|
140
|
+
|
|
141
|
+
while offset + SECTION_HEADER_SIZE <= total:
|
|
142
|
+
name, declared = _NAME_STRUCT.unpack_from(raw, offset)
|
|
143
|
+
body = offset + SECTION_HEADER_SIZE
|
|
144
|
+
|
|
145
|
+
if declared < 0:
|
|
146
|
+
# A negative length is not a size; it is a backwards jump used
|
|
147
|
+
# by "jump section" protectors. Following it would mean
|
|
148
|
+
# emulating StarCraft's parser, including its overlaps. We stop
|
|
149
|
+
# and keep the remainder verbatim so the file still round-trips.
|
|
150
|
+
diagnostics.append(
|
|
151
|
+
Diagnostic(
|
|
152
|
+
"negative-section-length",
|
|
153
|
+
f"section {name!r} declares length {declared}; stopping and "
|
|
154
|
+
f"preserving the remaining {total - offset} bytes verbatim",
|
|
155
|
+
offset,
|
|
156
|
+
"error",
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
trailing = raw[offset:]
|
|
160
|
+
offset = total
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
end = body + declared
|
|
164
|
+
if end > total:
|
|
165
|
+
available = total - body
|
|
166
|
+
diagnostics.append(
|
|
167
|
+
Diagnostic(
|
|
168
|
+
"truncated-section",
|
|
169
|
+
f"section {name!r} declares {declared} bytes but only "
|
|
170
|
+
f"{available} remain",
|
|
171
|
+
offset,
|
|
172
|
+
"error",
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
sections.append(Section(name, declared, raw[body:], offset))
|
|
176
|
+
offset = total
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
sections.append(Section(name, declared, raw[body:end], offset))
|
|
180
|
+
offset = end
|
|
181
|
+
|
|
182
|
+
if offset < total and not trailing:
|
|
183
|
+
trailing = raw[offset:]
|
|
184
|
+
diagnostics.append(
|
|
185
|
+
Diagnostic(
|
|
186
|
+
"trailing-bytes",
|
|
187
|
+
f"{len(trailing)} bytes after the last section are too few to "
|
|
188
|
+
f"form a {SECTION_HEADER_SIZE}-byte header",
|
|
189
|
+
offset,
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
chk = cls(sections=sections, trailing=trailing, diagnostics=diagnostics)
|
|
194
|
+
chk._diagnose_names()
|
|
195
|
+
return chk
|
|
196
|
+
|
|
197
|
+
def _diagnose_names(self) -> None:
|
|
198
|
+
for section in self.sections:
|
|
199
|
+
if any(b < 0x20 or b >= 0x7F for b in section.name):
|
|
200
|
+
self.diagnostics.append(
|
|
201
|
+
Diagnostic(
|
|
202
|
+
"non-printable-section-name",
|
|
203
|
+
f"section name {section.name!r} is not printable ASCII",
|
|
204
|
+
section.offset,
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# -- writing ----------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def to_bytes(self) -> bytes:
|
|
211
|
+
"""Serialize. Round-trips byte-exactly if nothing has been modified."""
|
|
212
|
+
return b"".join(s.to_bytes() for s in self.sections) + self.trailing
|
|
213
|
+
|
|
214
|
+
def replace_section(self, name: str | bytes, data: bytes) -> Section:
|
|
215
|
+
"""Replace the payload of the *effective* section ``name``.
|
|
216
|
+
|
|
217
|
+
The effective section is the last one with that name, matching the
|
|
218
|
+
override order StarCraft applies. Earlier duplicates are left alone --
|
|
219
|
+
they are still what the file contained, and collapsing them would be a
|
|
220
|
+
silent edit.
|
|
221
|
+
|
|
222
|
+
Returns the new :class:`Section`. Raises :class:`KeyError` if absent.
|
|
223
|
+
"""
|
|
224
|
+
wanted = _normalize(name)
|
|
225
|
+
for index in range(len(self.sections) - 1, -1, -1):
|
|
226
|
+
section = self.sections[index]
|
|
227
|
+
if section.name == wanted:
|
|
228
|
+
replacement = Section(wanted, len(data), bytes(data), section.offset)
|
|
229
|
+
self.sections[index] = replacement
|
|
230
|
+
return replacement
|
|
231
|
+
raise KeyError(f"no {wanted!r} section to replace")
|
|
232
|
+
|
|
233
|
+
def add_section(self, name: str | bytes, data: bytes) -> Section:
|
|
234
|
+
"""Append a new section. Its offset is recorded as the current end."""
|
|
235
|
+
wanted = _normalize(name)
|
|
236
|
+
section = Section(wanted, len(data), bytes(data), len(self.to_bytes()))
|
|
237
|
+
self.sections.append(section)
|
|
238
|
+
return section
|
|
239
|
+
|
|
240
|
+
# -- lookup -----------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def find(self, name: str | bytes) -> list[Section]:
|
|
243
|
+
"""Every section with this name, in file order."""
|
|
244
|
+
wanted = _normalize(name)
|
|
245
|
+
return [s for s in self.sections if s.name == wanted]
|
|
246
|
+
|
|
247
|
+
def last(self, name: str | bytes) -> Section | None:
|
|
248
|
+
"""The section StarCraft would use: the last one with this name.
|
|
249
|
+
|
|
250
|
+
Later sections override earlier ones, so this is the effective value.
|
|
251
|
+
Returns ``None`` when absent.
|
|
252
|
+
"""
|
|
253
|
+
wanted = _normalize(name)
|
|
254
|
+
for section in reversed(self.sections):
|
|
255
|
+
if section.name == wanted:
|
|
256
|
+
return section
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
def __contains__(self, name: str | bytes) -> bool:
|
|
260
|
+
return self.last(name) is not None
|
|
261
|
+
|
|
262
|
+
def __iter__(self) -> Iterator[Section]:
|
|
263
|
+
return iter(self.sections)
|
|
264
|
+
|
|
265
|
+
def __len__(self) -> int:
|
|
266
|
+
return len(self.sections)
|
|
267
|
+
|
|
268
|
+
# -- summary ----------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def duplicated_names(self) -> list[bytes]:
|
|
272
|
+
"""Names appearing more than once, in first-appearance order."""
|
|
273
|
+
seen: dict[bytes, int] = {}
|
|
274
|
+
for section in self.sections:
|
|
275
|
+
seen[section.name] = seen.get(section.name, 0) + 1
|
|
276
|
+
return [name for name, count in seen.items() if count > 1]
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def has_errors(self) -> bool:
|
|
280
|
+
return any(d.severity == "error" for d in self.diagnostics)
|
|
281
|
+
|
|
282
|
+
def describe(self) -> str:
|
|
283
|
+
"""A short human-readable summary. Not the ``inspect`` output format."""
|
|
284
|
+
lines = [f"{len(self.sections)} sections, {len(self.to_bytes())} bytes"]
|
|
285
|
+
for section in self.sections:
|
|
286
|
+
lines.append(f" {section.label} {len(section.data):>8} bytes")
|
|
287
|
+
if self.trailing:
|
|
288
|
+
lines.append(f" <trailing> {len(self.trailing):>8} bytes")
|
|
289
|
+
for diagnostic in self.diagnostics:
|
|
290
|
+
lines.append(f" ! {diagnostic}")
|
|
291
|
+
return "\n".join(lines)
|
chklib/cli.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""``chkdiff`` -- command line entry point.
|
|
2
|
+
|
|
3
|
+
``inspect`` and ``diff`` are the tools; ``pack`` and ``unpack`` move a scenario
|
|
4
|
+
in and out of a map archive; ``textconv`` and ``install-textconv`` wire the whole
|
|
5
|
+
thing into ``git diff``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .chk import Chk
|
|
17
|
+
from .diff import diff
|
|
18
|
+
from .inspect import FORMAT_VERSION, render
|
|
19
|
+
from .mpq import (
|
|
20
|
+
MpqArchive,
|
|
21
|
+
MpqError,
|
|
22
|
+
SCENARIO_PATH,
|
|
23
|
+
looks_like_mpq,
|
|
24
|
+
write_scenario,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = ["main"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load(path: pathlib.Path) -> Chk:
|
|
31
|
+
"""Read a bare ``scenario.chk``, or pull one out of a ``.scm``/``.scx``.
|
|
32
|
+
|
|
33
|
+
Map files in a repository are MPQ archives, so accepting them directly is
|
|
34
|
+
what makes the git integration possible at all.
|
|
35
|
+
"""
|
|
36
|
+
raw = path.read_bytes()
|
|
37
|
+
if looks_like_mpq(raw):
|
|
38
|
+
try:
|
|
39
|
+
raw = MpqArchive(raw).read_file(SCENARIO_PATH)
|
|
40
|
+
except MpqError as exc:
|
|
41
|
+
raise SystemExit(f"{path}: {exc}") from exc
|
|
42
|
+
return Chk.from_bytes(raw)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _cmd_inspect(args: argparse.Namespace) -> int:
|
|
46
|
+
path = pathlib.Path(args.path)
|
|
47
|
+
if not path.is_file():
|
|
48
|
+
raise SystemExit(f"not a file: {path}")
|
|
49
|
+
chk = _load(path)
|
|
50
|
+
# --stable omits the source line: git's textconv passes a temporary filename
|
|
51
|
+
# that changes on every invocation and would appear as a spurious diff.
|
|
52
|
+
source = None if args.stable else str(path)
|
|
53
|
+
sys.stdout.write(render(chk, source=source))
|
|
54
|
+
if args.strict and chk.has_errors:
|
|
55
|
+
for diagnostic in chk.diagnostics:
|
|
56
|
+
if diagnostic.severity == "error":
|
|
57
|
+
print(f"error: {diagnostic}", file=sys.stderr)
|
|
58
|
+
return 1
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _cmd_diff(args: argparse.Namespace) -> int:
|
|
63
|
+
left, right = pathlib.Path(args.a), pathlib.Path(args.b)
|
|
64
|
+
for path in (left, right):
|
|
65
|
+
if not path.is_file():
|
|
66
|
+
raise SystemExit(f"not a file: {path}")
|
|
67
|
+
report = diff(_load(left), _load(right))
|
|
68
|
+
sys.stdout.write(report.to_json() if args.json else report.to_text())
|
|
69
|
+
# Exit codes follow diff(1): 0 identical, 1 differences found.
|
|
70
|
+
return 0 if report.is_empty else 1
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _cmd_unpack(args: argparse.Namespace) -> int:
|
|
74
|
+
"""Extract ``staredit\\scenario.chk`` from a map archive."""
|
|
75
|
+
source = pathlib.Path(args.map)
|
|
76
|
+
if not source.is_file():
|
|
77
|
+
raise SystemExit(f"not a file: {source}")
|
|
78
|
+
raw = source.read_bytes()
|
|
79
|
+
if not looks_like_mpq(raw):
|
|
80
|
+
raise SystemExit(f"{source} is not an MPQ archive")
|
|
81
|
+
try:
|
|
82
|
+
chk = MpqArchive(raw).read_file(SCENARIO_PATH)
|
|
83
|
+
except MpqError as exc:
|
|
84
|
+
raise SystemExit(f"{source}: {exc}") from exc
|
|
85
|
+
destination = pathlib.Path(args.out)
|
|
86
|
+
destination.write_bytes(chk)
|
|
87
|
+
print(f"wrote {destination} ({len(chk)} bytes)")
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _cmd_pack(args: argparse.Namespace) -> int:
|
|
92
|
+
"""Wrap a ``scenario.chk`` into a playable map archive."""
|
|
93
|
+
source = pathlib.Path(args.chk)
|
|
94
|
+
if not source.is_file():
|
|
95
|
+
raise SystemExit(f"not a file: {source}")
|
|
96
|
+
raw = source.read_bytes()
|
|
97
|
+
if looks_like_mpq(raw):
|
|
98
|
+
raise SystemExit(f"{source} is already an archive; did you mean unpack?")
|
|
99
|
+
|
|
100
|
+
# Refuse to emit something the reader cannot make sense of. A map that fails
|
|
101
|
+
# to parse here will fail in StarCraft too, and silently writing it wastes
|
|
102
|
+
# the user's time later rather than now.
|
|
103
|
+
chk = Chk.from_bytes(raw)
|
|
104
|
+
if chk.has_errors and not args.force:
|
|
105
|
+
for diagnostic in chk.diagnostics:
|
|
106
|
+
if diagnostic.severity == "error":
|
|
107
|
+
print(f"error: {diagnostic}", file=sys.stderr)
|
|
108
|
+
raise SystemExit(
|
|
109
|
+
f"{source} has parse errors; pass --force to pack it anyway"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
destination = pathlib.Path(args.out)
|
|
113
|
+
archive = write_scenario(raw, compress=args.compress)
|
|
114
|
+
destination.write_bytes(archive)
|
|
115
|
+
ratio = f", {len(archive) / len(raw):.0%} of the scenario" if raw else ""
|
|
116
|
+
print(f"wrote {destination} ({len(archive)} bytes{ratio})")
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _cmd_textconv(args: argparse.Namespace) -> int:
|
|
121
|
+
"""The git textconv driver. Must never fail.
|
|
122
|
+
|
|
123
|
+
Git runs this over every blob on both sides of a diff, including historical
|
|
124
|
+
ones that may be truncated, protected, or not maps at all. A driver that
|
|
125
|
+
exits non-zero or raises makes ``git diff`` fail outright, so anything
|
|
126
|
+
unreadable degrades to a short deterministic note instead.
|
|
127
|
+
|
|
128
|
+
The note is deliberately content-derived rather than error-derived: two
|
|
129
|
+
unreadable blobs with the same bytes must produce the same text, or the diff
|
|
130
|
+
churns.
|
|
131
|
+
"""
|
|
132
|
+
path = pathlib.Path(args.path)
|
|
133
|
+
try:
|
|
134
|
+
chk = _load(path)
|
|
135
|
+
except SystemExit as exc:
|
|
136
|
+
# _load raises SystemExit with a human message; keep the reason but not
|
|
137
|
+
# the path, which git randomises per invocation.
|
|
138
|
+
reason = str(exc).replace(str(path), "").lstrip(": ").strip()
|
|
139
|
+
sys.stdout.write(f"# chklib: unreadable ({reason})\n")
|
|
140
|
+
return 0
|
|
141
|
+
except Exception as exc: # noqa: BLE001 - a driver must not propagate
|
|
142
|
+
sys.stdout.write(f"# chklib: unreadable ({type(exc).__name__})\n")
|
|
143
|
+
return 0
|
|
144
|
+
sys.stdout.write(render(chk, source=None))
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
_GIT_SETUP = """\
|
|
149
|
+
# 1. Tell git how to render a map as text (once per machine, or --local):
|
|
150
|
+
git config --global diff.starcraft.textconv "chkdiff textconv"
|
|
151
|
+
git config --global diff.starcraft.binary false
|
|
152
|
+
|
|
153
|
+
# 2. Tell git which files that applies to. In .gitattributes:
|
|
154
|
+
*.scm diff=starcraft
|
|
155
|
+
*.scx diff=starcraft
|
|
156
|
+
*.chk diff=starcraft
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _cmd_install_textconv(args: argparse.Namespace) -> int:
|
|
161
|
+
scope = "--local" if args.local else "--global"
|
|
162
|
+
if not args.write:
|
|
163
|
+
sys.stdout.write(_GIT_SETUP)
|
|
164
|
+
sys.stdout.write(
|
|
165
|
+
"\nRe-run with --write to apply the git config, and --local to scope"
|
|
166
|
+
" it to this repository.\n"
|
|
167
|
+
)
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
import subprocess
|
|
171
|
+
|
|
172
|
+
for key, value in (
|
|
173
|
+
("diff.starcraft.textconv", "chkdiff textconv"),
|
|
174
|
+
("diff.starcraft.binary", "false"),
|
|
175
|
+
):
|
|
176
|
+
result = subprocess.run(
|
|
177
|
+
["git", "config", scope, key, value],
|
|
178
|
+
capture_output=True, text=True,
|
|
179
|
+
)
|
|
180
|
+
if result.returncode != 0:
|
|
181
|
+
raise SystemExit(f"git config failed: {result.stderr.strip()}")
|
|
182
|
+
print(f"set {key} = {value!r} ({scope})")
|
|
183
|
+
print("\nNow add these lines to .gitattributes:")
|
|
184
|
+
print(" *.scm diff=starcraft")
|
|
185
|
+
print(" *.scx diff=starcraft")
|
|
186
|
+
print(" *.chk diff=starcraft")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
191
|
+
parser = argparse.ArgumentParser(
|
|
192
|
+
prog="chkdiff",
|
|
193
|
+
description="Inspect and compare StarCraft scenario data.",
|
|
194
|
+
)
|
|
195
|
+
parser.add_argument(
|
|
196
|
+
"--version", action="version",
|
|
197
|
+
version=f"chkdiff {__version__} (inspect format v{FORMAT_VERSION})",
|
|
198
|
+
)
|
|
199
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
200
|
+
|
|
201
|
+
inspect = sub.add_parser(
|
|
202
|
+
"inspect",
|
|
203
|
+
help="print a stable, deterministic rendering of a scenario",
|
|
204
|
+
description=(
|
|
205
|
+
"Print a deterministic textual rendering of a scenario.chk. "
|
|
206
|
+
"Suitable as a git textconv driver, in which case pass --stable."
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
inspect.add_argument("path", help="path to a scenario.chk")
|
|
210
|
+
inspect.add_argument(
|
|
211
|
+
"--stable", action="store_true",
|
|
212
|
+
help="omit the source filename, for reproducible output (use for git textconv)",
|
|
213
|
+
)
|
|
214
|
+
inspect.add_argument(
|
|
215
|
+
"--strict", action="store_true",
|
|
216
|
+
help="exit non-zero if the file has parse errors",
|
|
217
|
+
)
|
|
218
|
+
inspect.set_defaults(func=_cmd_inspect)
|
|
219
|
+
|
|
220
|
+
compare = sub.add_parser(
|
|
221
|
+
"diff",
|
|
222
|
+
help="compare two scenarios semantically",
|
|
223
|
+
description=(
|
|
224
|
+
"Compare two scenario.chk files by meaning rather than by bytes. "
|
|
225
|
+
"Exit status follows diff(1): 0 when identical, 1 when they differ."
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
compare.add_argument("a", help="path to the first scenario.chk")
|
|
229
|
+
compare.add_argument("b", help="path to the second scenario.chk")
|
|
230
|
+
compare.add_argument(
|
|
231
|
+
"--json", action="store_true",
|
|
232
|
+
help="emit machine-readable JSON instead of text",
|
|
233
|
+
)
|
|
234
|
+
compare.set_defaults(func=_cmd_diff)
|
|
235
|
+
|
|
236
|
+
unpack = sub.add_parser(
|
|
237
|
+
"unpack", help="extract scenario.chk from a .scm/.scx map archive"
|
|
238
|
+
)
|
|
239
|
+
unpack.add_argument("map", help="path to a .scm or .scx")
|
|
240
|
+
unpack.add_argument("out", help="path to write scenario.chk to")
|
|
241
|
+
unpack.set_defaults(func=_cmd_unpack)
|
|
242
|
+
|
|
243
|
+
pack = sub.add_parser(
|
|
244
|
+
"pack",
|
|
245
|
+
help="wrap a scenario.chk into a .scm/.scx map archive",
|
|
246
|
+
description=(
|
|
247
|
+
"Wrap a scenario.chk into a map archive. Stores the scenario "
|
|
248
|
+
"uncompressed by default, which every reader accepts; --compress "
|
|
249
|
+
"uses zlib, which is what euddraft writes for production maps."
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
pack.add_argument("chk", help="path to a scenario.chk")
|
|
253
|
+
pack.add_argument("out", help="path to write the map archive to")
|
|
254
|
+
pack.add_argument(
|
|
255
|
+
"--compress", action="store_true", help="zlib-compress the scenario"
|
|
256
|
+
)
|
|
257
|
+
pack.add_argument(
|
|
258
|
+
"--force", action="store_true", help="pack even if the scenario has parse errors"
|
|
259
|
+
)
|
|
260
|
+
pack.set_defaults(func=_cmd_pack)
|
|
261
|
+
|
|
262
|
+
textconv = sub.add_parser(
|
|
263
|
+
"textconv",
|
|
264
|
+
help="render a map for git's diff.<driver>.textconv",
|
|
265
|
+
description=(
|
|
266
|
+
"Render a map as deterministic text for git. Always exits 0 and "
|
|
267
|
+
"always prints something, because a textconv driver that fails "
|
|
268
|
+
"makes git diff fail."
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
textconv.add_argument("path", help="path to a map or scenario.chk")
|
|
272
|
+
textconv.set_defaults(func=_cmd_textconv)
|
|
273
|
+
|
|
274
|
+
install = sub.add_parser(
|
|
275
|
+
"install-textconv",
|
|
276
|
+
help="print (or apply) the git configuration for map diffs",
|
|
277
|
+
)
|
|
278
|
+
install.add_argument(
|
|
279
|
+
"--write", action="store_true", help="run the git config commands"
|
|
280
|
+
)
|
|
281
|
+
install.add_argument(
|
|
282
|
+
"--local", action="store_true",
|
|
283
|
+
help="scope the config to this repository instead of the whole machine",
|
|
284
|
+
)
|
|
285
|
+
install.set_defaults(func=_cmd_install_textconv)
|
|
286
|
+
return parser
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def main(argv: list[str] | None = None) -> int:
|
|
290
|
+
args = build_parser().parse_args(argv)
|
|
291
|
+
try:
|
|
292
|
+
return args.func(args)
|
|
293
|
+
except BrokenPipeError:
|
|
294
|
+
# A downstream consumer closed the pipe -- `chkdiff diff x y | head` is
|
|
295
|
+
# completely normal usage. Python would otherwise flush stdout again at
|
|
296
|
+
# shutdown and print a second traceback to stderr, so point the fd at
|
|
297
|
+
# devnull first. 128 + SIGPIPE(13) is the conventional status.
|
|
298
|
+
devnull = None
|
|
299
|
+
try:
|
|
300
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
301
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
302
|
+
except (OSError, AttributeError, ValueError):
|
|
303
|
+
# stdout may not be a real file (a test double, or already closed).
|
|
304
|
+
# There is nothing useful left to do, and raising here would defeat
|
|
305
|
+
# the purpose of catching BrokenPipeError in the first place.
|
|
306
|
+
if devnull is not None:
|
|
307
|
+
os.close(devnull)
|
|
308
|
+
return 141
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
if __name__ == "__main__": # pragma: no cover
|
|
312
|
+
raise SystemExit(main())
|