chklib 0.1.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.
- chklib-0.1.0/.gitignore +32 -0
- chklib-0.1.0/LICENSE +21 -0
- chklib-0.1.0/PKG-INFO +527 -0
- chklib-0.1.0/README.md +494 -0
- chklib-0.1.0/docs/contribution-targets.md +157 -0
- chklib-0.1.0/docs/first-consumer-scope.md +135 -0
- chklib-0.1.0/docs/reference-builds.md +161 -0
- chklib-0.1.0/pyproject.toml +67 -0
- chklib-0.1.0/src/chklib/__init__.py +47 -0
- chklib-0.1.0/src/chklib/chk.py +291 -0
- chklib-0.1.0/src/chklib/cli.py +312 -0
- chklib-0.1.0/src/chklib/diff.py +484 -0
- chklib-0.1.0/src/chklib/enums.py +321 -0
- chklib-0.1.0/src/chklib/inspect.py +433 -0
- chklib-0.1.0/src/chklib/mpq.py +622 -0
- chklib-0.1.0/src/chklib/pkware.py +221 -0
- chklib-0.1.0/src/chklib/py.typed +0 -0
- chklib-0.1.0/src/chklib/records.py +445 -0
- chklib-0.1.0/src/chklib/views.py +1201 -0
- chklib-0.1.0/tests/conftest.py +70 -0
- chklib-0.1.0/tests/test_chk.py +203 -0
- chklib-0.1.0/tests/test_corpus.py +83 -0
- chklib-0.1.0/tests/test_corpus_typed.py +337 -0
- chklib-0.1.0/tests/test_diff.py +410 -0
- chklib-0.1.0/tests/test_git_textconv.py +239 -0
- chklib-0.1.0/tests/test_inspect.py +300 -0
- chklib-0.1.0/tests/test_isom.py +287 -0
- chklib-0.1.0/tests/test_mpq.py +484 -0
- chklib-0.1.0/tests/test_records.py +226 -0
- chklib-0.1.0/tests/test_strings.py +435 -0
- chklib-0.1.0/tests/test_terrain.py +530 -0
- chklib-0.1.0/tests/test_views.py +320 -0
- chklib-0.1.0/tools/extract_fixtures.py +100 -0
chklib-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# External reference clones - cloned, not vendored
|
|
2
|
+
reference/
|
|
3
|
+
|
|
4
|
+
# Build output
|
|
5
|
+
out/
|
|
6
|
+
build/
|
|
7
|
+
_build/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
__pycache__/
|
|
11
|
+
*.pyc
|
|
12
|
+
|
|
13
|
+
# Tooling / session artifacts
|
|
14
|
+
.rstk/
|
|
15
|
+
**/.sf/
|
|
16
|
+
**/.sfdx/
|
|
17
|
+
**/node_modules/
|
|
18
|
+
vcpkg_installed/
|
|
19
|
+
|
|
20
|
+
# OS
|
|
21
|
+
.DS_Store
|
|
22
|
+
Thumbs.db
|
|
23
|
+
desktop.ini
|
|
24
|
+
|
|
25
|
+
# Test fixtures extracted from real (copyrighted) maps
|
|
26
|
+
tests/fixtures/
|
|
27
|
+
|
|
28
|
+
# Research scratch for the typed-views spec
|
|
29
|
+
.research/
|
|
30
|
+
|
|
31
|
+
# Build artifacts
|
|
32
|
+
dist/
|
chklib-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 chklib contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
chklib-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: chklib
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read/write library for StarCraft map data (CHK), with a diff-oriented CLI
|
|
5
|
+
Project-URL: Homepage, https://github.com/dmang-dev/chklib
|
|
6
|
+
Project-URL: Source, https://github.com/dmang-dev/chklib
|
|
7
|
+
Project-URL: Issues, https://github.com/dmang-dev/chklib/issues
|
|
8
|
+
Author-email: dmang-dev <282426319+dmang-dev@users.noreply.github.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: brood-war,chk,diff,map-editor,mpq,scenario,scm,scx,starcraft,textconv
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
23
|
+
Classifier: Topic :: Games/Entertainment :: Real Time Strategy
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Topic :: System :: Archiving
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
30
|
+
Provides-Extra: fixtures
|
|
31
|
+
Requires-Dist: eudplib<0.81,>=0.80; extra == 'fixtures'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# chklib
|
|
35
|
+
|
|
36
|
+
A read/write library for StarCraft map data, and `chkdiff` — a CHK-aware diff tool
|
|
37
|
+
that makes `git diff` say something useful about a `.scx` file.
|
|
38
|
+
|
|
39
|
+
**Status: working end to end.** Container, typed views, terrain (including ISOM), string-table editing
|
|
40
|
+
(`STR` and `STRx`), MPQ reading *and* writing, `chkdiff inspect`, `chkdiff diff`, `pack`/`unpack`, and the git
|
|
41
|
+
integration are all implemented and tested. A map can be opened, edited — including its
|
|
42
|
+
name, description and any trigger text — and saved back to a playable archive.
|
|
43
|
+
|
|
44
|
+
## Why
|
|
45
|
+
|
|
46
|
+
Everything that can read a StarCraft map today is one of three things:
|
|
47
|
+
|
|
48
|
+
- **read-only** — [`bw-chk`](https://github.com/ShieldBattery/bw-chk) parses `scenario.chk`
|
|
49
|
+
in JavaScript, triggers included, but has no writer at all and does not parse locations.
|
|
50
|
+
- **a section bag** — [`eudplib`](https://github.com/armoha/eudplib) models a CHK as
|
|
51
|
+
`dict[bytes, bytes]`. It collapses duplicate sections and its writer injects a junk
|
|
52
|
+
`ISOM` section, so it does not round-trip.
|
|
53
|
+
- **welded into a Windows GUI** — [Chkdraft](https://github.com/TheNitesWhoSay/Chkdraft)
|
|
54
|
+
has the only complete read/write CHK engine that exists, including a trigger text
|
|
55
|
+
compiler, but it is C++ inside a Win32 application with no bindings.
|
|
56
|
+
|
|
57
|
+
There is no library anywhere that reads *and* writes a CHK losslessly with a typed
|
|
58
|
+
trigger model. That is the gap this fills.
|
|
59
|
+
|
|
60
|
+
See [docs/first-consumer-scope.md](docs/first-consumer-scope.md) for the scope and the
|
|
61
|
+
acceptance gate, and [docs/contribution-targets.md](docs/contribution-targets.md) for a
|
|
62
|
+
survey of the surrounding ecosystem.
|
|
63
|
+
|
|
64
|
+
## The container
|
|
65
|
+
|
|
66
|
+
A `scenario.chk` is a flat sequence of sections: a 4-byte name, a signed 32-bit
|
|
67
|
+
little-endian length, then that many payload bytes. `Chk` preserves three things other
|
|
68
|
+
readers discard:
|
|
69
|
+
|
|
70
|
+
- **Order** — StarCraft applies sections in file order, later overriding earlier. A
|
|
71
|
+
mapping has already lost the information needed to know which wins.
|
|
72
|
+
- **Duplicates** — deliberately duplicated sections are a standard protection technique.
|
|
73
|
+
- **Raw bytes** — unknown sections, and sections whose declared length disagrees with
|
|
74
|
+
reality, survive untouched.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from chklib import Chk
|
|
78
|
+
|
|
79
|
+
chk = Chk.from_bytes(open("scenario.chk", "rb").read())
|
|
80
|
+
|
|
81
|
+
len(chk) # 37
|
|
82
|
+
chk.last("DIM").data # b'@\x00@\x00' - the section StarCraft would use
|
|
83
|
+
chk.find("VER") # every section with that name, in file order
|
|
84
|
+
chk.duplicated_names # [b'VER '] if the map is protected that way
|
|
85
|
+
chk.diagnostics # problems, reported rather than raised
|
|
86
|
+
|
|
87
|
+
chk.to_bytes() == raw # True, always
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Malformed input is never raised on. Half the interesting maps in the wild are malformed
|
|
91
|
+
on purpose, and a parser that refuses them is useless for exactly the maps people care
|
|
92
|
+
about.
|
|
93
|
+
|
|
94
|
+
## Typed views
|
|
95
|
+
|
|
96
|
+
Views are layered *over* the raw bytes, never in place of them, so a read/write cycle
|
|
97
|
+
reproduces the input exactly — including short sections, undocumented flag bits, and the
|
|
98
|
+
fields the format sources only call `unused`.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from chklib import Chk
|
|
102
|
+
from chklib.views import view_for
|
|
103
|
+
from chklib.enums import Tileset, ActionType
|
|
104
|
+
|
|
105
|
+
chk = Chk.from_bytes(raw)
|
|
106
|
+
|
|
107
|
+
view_for(chk, "DIM") # Dimensions(64x64), .pixel_width, .tile_count
|
|
108
|
+
view_for(chk, "VER").name # 'Hybrid (Brood War compatible)'
|
|
109
|
+
Tileset(view_for(chk, "ERA").value).name # 'Badlands'
|
|
110
|
+
|
|
111
|
+
strings = view_for(chk, "STR")
|
|
112
|
+
sprp = view_for(chk, "SPRP")
|
|
113
|
+
strings.text(sprp.name_string_id) # 'Tutorial 1'
|
|
114
|
+
|
|
115
|
+
for unit in view_for(chk, "UNIT"): # 36-byte records
|
|
116
|
+
unit.owner, unit.xc, unit.yc, unit.type
|
|
117
|
+
|
|
118
|
+
for trigger in view_for(chk, "TRIG"): # 2400-byte records
|
|
119
|
+
for action in trigger.used_actions():
|
|
120
|
+
ActionType(action.action_type) # CreateUnitWithProperties, ...
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Covered: `DIM VER ERA OWNR IOWN SIDE SPRP FORC UNIT THG2 MRGN TRIG MBRF STR STRx MTXM
|
|
124
|
+
TILE MASK ISOM`. Everything else — `VCOD`, `PUNI`, `UPGR`, `WAV`, `UNIS` and the rest — is
|
|
125
|
+
reachable as raw bytes through the container and round-trips untouched.
|
|
126
|
+
|
|
127
|
+
Three behaviours worth knowing, each of which a naive implementation gets wrong:
|
|
128
|
+
|
|
129
|
+
- **`TRIG` and `MBRF` share a byte layout but not an action id space.** Ids 0–9 mean
|
|
130
|
+
entirely different things in each, so a view carries `is_briefing`.
|
|
131
|
+
- **String ids are 1-based, and strings end at the next NUL** — never at the following
|
|
132
|
+
slot's offset. In 30 of the 65 corpus maps the string data is not in ascending id
|
|
133
|
+
order, so offset-differencing yields negative lengths.
|
|
134
|
+
- **Location ids are 1-based too**: `Anywhere` is id 64, which is file record 63.
|
|
135
|
+
|
|
136
|
+
The format work behind this is in `.research/SPEC.md` (gitignored, regenerable): six
|
|
137
|
+
independent implementations cross-checked against each other and validated against the
|
|
138
|
+
corpus, with every claim carrying a confidence tier and every unresolved disagreement
|
|
139
|
+
listed rather than guessed at.
|
|
140
|
+
|
|
141
|
+
## `chkdiff inspect`
|
|
142
|
+
|
|
143
|
+
A deterministic textual rendering of a scenario, designed so that `git diff` can use it
|
|
144
|
+
as a `textconv` driver.
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
chkdiff inspect scenario.chk
|
|
148
|
+
chkdiff inspect --stable scenario.chk # omit the filename, for git
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The point is that a small change to a map becomes a small change in the text. Moving one
|
|
152
|
+
unit and dropping its hitpoints — 3 changed bytes in the binary — reads as:
|
|
153
|
+
|
|
154
|
+
```diff
|
|
155
|
+
-p12 type=188 at=(224,448) hp=100% resources=5000
|
|
156
|
+
+p12 type=188 at=(288,448) hp=55% resources=5000
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Three rules make that work, and they are what the tests pin down:
|
|
160
|
+
|
|
161
|
+
- **Determinism.** The same bytes always produce the same characters — no paths, no
|
|
162
|
+
timestamps, no iteration-order accidents. Verified over all 65 corpus maps.
|
|
163
|
+
- **One fact per line.** A moved unit is one changed line, not a reflowed block.
|
|
164
|
+
- **Canonical ordering where order carries no meaning.** Units and sprites are sorted by
|
|
165
|
+
content and carry no index, so inserting one produces a constant-size diff regardless
|
|
166
|
+
of how many units the map has. Triggers, locations and strings keep file order, because
|
|
167
|
+
for them the index *is* the identity.
|
|
168
|
+
|
|
169
|
+
Nothing is invented: unit and sprite types print as numbers, because naming them needs
|
|
170
|
+
`units.dat` from a StarCraft installation, which this library does not read.
|
|
171
|
+
|
|
172
|
+
## `chkdiff diff`
|
|
173
|
+
|
|
174
|
+
Compares two scenarios by meaning rather than by bytes. Exit status follows `diff(1)`:
|
|
175
|
+
0 when identical, 1 when they differ.
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
chkdiff diff a.chk b.chk
|
|
179
|
+
chkdiff diff --json a.chk b.chk
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
~ map name
|
|
184
|
+
- #1 "Z6) The Dark Templar"
|
|
185
|
+
+ #1 "Z8) The Dark Templar"
|
|
186
|
+
~ players p6 race
|
|
187
|
+
- 2 Protoss
|
|
188
|
+
+ 0 Zerg
|
|
189
|
+
~ forces force2 members
|
|
190
|
+
- p2,p3
|
|
191
|
+
+ p2
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
A byte diff can't do this — inserting one string shifts every offset after it, and one
|
|
195
|
+
new trigger moves 2400 bytes of everything downstream. So each section is compared using
|
|
196
|
+
whatever notion of identity it actually has:
|
|
197
|
+
|
|
198
|
+
| Kind | Sections | How |
|
|
199
|
+
|---|---|---|
|
|
200
|
+
| Identity by index | strings, locations | position by position — ids are referenced from elsewhere in the map |
|
|
201
|
+
| No identity | units, sprites | multiset, then pair leftovers by `(owner, type)` so a move reads as a change |
|
|
202
|
+
| Content **and** position | triggers | LCS alignment over content hashes |
|
|
203
|
+
|
|
204
|
+
Triggers are the hard case: no ids, but their order *is* execution order, so they can't
|
|
205
|
+
be treated as a set either. A positional comparison reports every later trigger as
|
|
206
|
+
modified the moment you insert one at the top.
|
|
207
|
+
|
|
208
|
+
The fix is an LCS alignment (`difflib.SequenceMatcher`) over per-trigger content hashes,
|
|
209
|
+
with survivors inside each replace-block paired greedily by similarity. Inserting a
|
|
210
|
+
trigger at position 0 of a 20-trigger map reports **one addition**. Insert one *and* edit
|
|
211
|
+
a later one, and you get:
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
+ TRIG trigger 0
|
|
215
|
+
+ owners=[18] | flags=0 | if Bring(...) | do CreateUnit(...)
|
|
216
|
+
~ TRIG trigger 6->7
|
|
217
|
+
- do RemoveUnit(group=17, type=101, flags=0x14)
|
|
218
|
+
+ do RemoveUnit(group=17, time=12345, type=101, flags=0x14)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The `6->7` records that the trigger both moved and changed. It degrades gracefully: below
|
|
222
|
+
the similarity threshold a replacement is reported as an add plus a remove, which is
|
|
223
|
+
correct, just less informative.
|
|
224
|
+
|
|
225
|
+
## Editing
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
from chklib import Chk, StringTable
|
|
229
|
+
from chklib.mpq import MpqArchive, write_scenario, SCENARIO_PATH
|
|
230
|
+
from chklib.views import view_for
|
|
231
|
+
|
|
232
|
+
chk = Chk.from_bytes(MpqArchive(open("map.scm", "rb").read()).read_file(SCENARIO_PATH))
|
|
233
|
+
|
|
234
|
+
# terrain, players, units, triggers - edit the typed view, write it back
|
|
235
|
+
view_for(chk, "ERA").value = 4
|
|
236
|
+
chk.replace_section("ERA", view_for(chk, "ERA").to_bytes())
|
|
237
|
+
|
|
238
|
+
# strings - the map name, description, location names and all trigger text
|
|
239
|
+
strings = StringTable.from_view(view_for(chk, "STR"))
|
|
240
|
+
strings[view_for(chk, "SPRP").name_string_id] = "Blood Bath (Remix)"
|
|
241
|
+
new_id = strings.add("a string that did not exist before")
|
|
242
|
+
chk.replace_section("STR", strings.to_bytes())
|
|
243
|
+
|
|
244
|
+
open("edited.scx", "wb").write(write_scenario(chk.to_bytes(), compress=True))
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`StringTableView` reads; `StringTable` writes. Two things about it are load-bearing:
|
|
248
|
+
|
|
249
|
+
**String ids are positional and gaps are preserved.** Id 7 is referenced as 7 from
|
|
250
|
+
`SPRP`, `MRGN`, `FORC` and every trigger, so compacting the table would silently repoint
|
|
251
|
+
every reference in the map without touching a single trigger.
|
|
252
|
+
|
|
253
|
+
**Offsets are 16-bit, and the limit is enforced honestly.** Chkdraft's own guard sums
|
|
254
|
+
string lengths *without* the terminating NUL its writer then emits, so it accepts
|
|
255
|
+
payloads a little over 64 KB and writes offsets that wrap modulo 65536 — a corrupt map
|
|
256
|
+
with no error raised. This counts the NULs and raises instead. The id ceiling (32766) is
|
|
257
|
+
likewise derived from the offset table filling the addressable space, and named as such
|
|
258
|
+
rather than inherited: the sources give four different ceilings across five orders of
|
|
259
|
+
magnitude, none of which is a format limit.
|
|
260
|
+
|
|
261
|
+
Rebuilding the string table of all 65 corpus maps preserves every string at its own id,
|
|
262
|
+
and a rename leaves every location name still resolving.
|
|
263
|
+
|
|
264
|
+
### Terrain
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
from chklib.views import terrain_for
|
|
268
|
+
|
|
269
|
+
game = terrain_for(chk, "MTXM") # what StarCraft reads
|
|
270
|
+
editor = terrain_for(chk, "TILE") # the editor's ISOM-derived layer
|
|
271
|
+
fog = terrain_for(chk, "MASK")
|
|
272
|
+
|
|
273
|
+
game[5, 42] = 0x0864 # (x, y), row-major
|
|
274
|
+
chk.replace_section("MTXM", game.to_bytes())
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Three things here corrupt terrain silently rather than raising, so each is pinned by a
|
|
278
|
+
test:
|
|
279
|
+
|
|
280
|
+
**Indexing is row-major**, `y * width + x`. Chkdraft's own header comments declare these
|
|
281
|
+
arrays column-major — and the same wrong comment appears verbatim on MTXM, TILE, ISOM and
|
|
282
|
+
MASK — while every accessor in that codebase uses row-major. On a square map the two are
|
|
283
|
+
indistinguishable, so the tests use non-square maps.
|
|
284
|
+
|
|
285
|
+
**MTXM and TILE are distinct layers, never aliases.** They are byte-identical in only
|
|
286
|
+
**1 of 65** corpus maps and differ in the other 64 by a mean of 4.8% of tiles, worst 13.2%.
|
|
287
|
+
|
|
288
|
+
**A set bit in MASK means the tile *is* fogged** for that player, bit 0 being player 1.
|
|
289
|
+
|
|
290
|
+
Short, long and odd-length sections are read rather than refused — the game itself
|
|
291
|
+
tolerates them, and `blackvrice`'s hard error would reject real protected maps. `to_bytes()`
|
|
292
|
+
preserves the original length so an unmodified short section stays short; `normalize=True`
|
|
293
|
+
emits the full grid, which is what Chkdraft always does.
|
|
294
|
+
|
|
295
|
+
### `ISOM`
|
|
296
|
+
|
|
297
|
+
The editor's isometric terrain, on its own grid: `isom_width = tileWidth // 2 + 1`,
|
|
298
|
+
`isom_height = tileHeight + 1`, 8 bytes per record.
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
from chklib.views import isom_for
|
|
302
|
+
|
|
303
|
+
grid = isom_for(chk) # 33x65 records for a 64x64 map
|
|
304
|
+
grid[1, 2].values() # the four sides, editor flags masked off
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Its **bit layout is the least corroborated thing in the format** — Chkdraft is the only
|
|
308
|
+
witness — so each side stays a raw `u16` and the accessors are an interpretation over it.
|
|
309
|
+
Measuring 488 real maps supports the layout well:
|
|
310
|
+
|
|
311
|
+
| Claim | Evidence |
|
|
312
|
+
|---|---|
|
|
313
|
+
| framing `(w/2+1)*(h+1)*8` | 455 exact, 7 short, 0 long |
|
|
314
|
+
| value is 11 bits at 14–4 | observed range 0–2014, ceiling 2047 |
|
|
315
|
+
| edge flags are 3 bits at 3–1 | every observed value even, all 8 seen |
|
|
316
|
+
| editor flags reach the file | **21 `Visited` and 50 `Modified` sides found** |
|
|
317
|
+
|
|
318
|
+
That last row matters: the layout says Chkdraft merely *clears* those bits by convention
|
|
319
|
+
and nothing guarantees it. Real maps carry them, so masking on read is necessary rather
|
|
320
|
+
than defensive.
|
|
321
|
+
|
|
322
|
+
Two traps avoided. Chkdraft's `scenario.cpp` pads with `expected - actual` computed in
|
|
323
|
+
`size_t` after testing `!=`, so an *oversized* ISOM underflows into an astronomical
|
|
324
|
+
insert; padding here is short-only. And eudplib writes a decoy ISOM with a length past
|
|
325
|
+
`0x80000000` as a protection marker — the container already stops at a negative section
|
|
326
|
+
length, so such a map simply has no ISOM rather than a fabricated one.
|
|
327
|
+
|
|
328
|
+
### `STRx`
|
|
329
|
+
|
|
330
|
+
Remastered maps use `STRx` — exactly `STR` with the count and every offset widened from
|
|
331
|
+
`u16` to `u32`, nothing else changed. `string_table_for(chk)` returns whichever table a
|
|
332
|
+
map's references actually resolve against, because **`STRx` supersedes `STR` in either
|
|
333
|
+
file order** and that rule is not expressible as a section lookup. Chkdraft, bw-chk and
|
|
334
|
+
eudplib agree on it; blackvrice abstains when both are present and so fails to open
|
|
335
|
+
ordinary Remastered maps that kept a legacy `STR`.
|
|
336
|
+
|
|
337
|
+
Verified against the 24 `STRx` maps in a real installation: every scenario name and
|
|
338
|
+
description resolves, and rebuilding each table preserves every string at its id. Reading
|
|
339
|
+
one of those sections at the wrong width yields empty strings rather than an error — which
|
|
340
|
+
is why the width is decided by the section name and never sniffed.
|
|
341
|
+
|
|
342
|
+
An empirical note the format sources don't carry: of 423 installed maps, 24 use `STRx` and
|
|
343
|
+
**none uses both**, so the precedence rule is real but unexercised by those maps.
|
|
344
|
+
|
|
345
|
+
## Reading map files
|
|
346
|
+
|
|
347
|
+
`.scm`/`.scx` maps are MPQ archives, so the library reads those directly — that is what
|
|
348
|
+
makes `chkdiff` usable on files as they actually exist in a repository.
|
|
349
|
+
|
|
350
|
+
```python
|
|
351
|
+
from chklib.mpq import MpqArchive, SCENARIO_PATH
|
|
352
|
+
|
|
353
|
+
chk_bytes = MpqArchive(open("(4)Blood Bath.scm", "rb").read()).read_file(SCENARIO_PATH)
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Reading is deliberately partial: MPQ v1 with encrypted hash/block tables, multi-sector
|
|
357
|
+
and single-unit files, `FIX_KEY`, and the compressions maps actually use. MPQ v2 is
|
|
358
|
+
**refused rather than guessed at**, because a v2 archive parsed as v1 produces
|
|
359
|
+
plausible-looking wrong bytes.
|
|
360
|
+
|
|
361
|
+
### Saving
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
from chklib.mpq import write_scenario
|
|
365
|
+
|
|
366
|
+
open("edited.scx", "wb").write(write_scenario(chk_bytes, compress=True))
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
```bash
|
|
370
|
+
chkdiff unpack "(4)Blood Bath.scm" scenario.chk
|
|
371
|
+
chkdiff pack --compress scenario.chk rebuilt.scx
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Writing produces a plainly laid out v1 archive and encrypts nothing — encryption exists
|
|
375
|
+
to make files hard to extract, which buys a map editor nothing.
|
|
376
|
+
|
|
377
|
+
On compression, two tools whose maps demonstrably load in StarCraft disagree, and both
|
|
378
|
+
are fine: **euddraft** writes zlib (`MPQ_COMPRESSION_ZLIB`), while **sc64-maps** stores
|
|
379
|
+
everything plainly. So the default is *stored* — the option nothing can refuse — and
|
|
380
|
+
`--compress` opts into zlib on the strength of euddraft's production use. Writing PKWARE
|
|
381
|
+
implode, what Blizzard's own maps use, would need a compressor; only the decompressor is
|
|
382
|
+
implemented here.
|
|
383
|
+
|
|
384
|
+
Round-tripping every installed map through the writer and back out via StormLib:
|
|
385
|
+
|
|
386
|
+
| Check | Result |
|
|
387
|
+
|---|---|
|
|
388
|
+
| 423 maps rewritten, reopened by StormLib | **423/423 identical** |
|
|
389
|
+
| 65 sc64 scenarios, stored and zlib | **65/65 identical** in both modes |
|
|
390
|
+
| Size of our zlib archives vs Blizzard's | 44% |
|
|
391
|
+
|
|
392
|
+
`chkdiff pack` refuses a scenario that fails to parse, since it would fail in StarCraft
|
|
393
|
+
too; `--force` overrides.
|
|
394
|
+
|
|
395
|
+
A permissive off-the-shelf reader wasn't an option: `mpyq` (BSD) cannot open a single
|
|
396
|
+
genuine Blizzard map — it has no decryption, and no PKWARE implode, which is compression
|
|
397
|
+
method 0x08 and what Blizzard maps overwhelmingly use. So both the archive layer and a
|
|
398
|
+
PKWARE exploder are implemented here from the format description.
|
|
399
|
+
|
|
400
|
+
**Verification is by ground truth, not inspection.** Every scenario is compared
|
|
401
|
+
byte-for-byte against the same file extracted by StormLib:
|
|
402
|
+
|
|
403
|
+
| Corpus | Result |
|
|
404
|
+
|---|---|
|
|
405
|
+
| 423 maps in a StarCraft 1.16.1 install | **423 byte-identical**, 0 mismatched, 0 errors |
|
|
406
|
+
| 65 StarCraft 64 scenarios | **65 byte-identical** |
|
|
407
|
+
| PKWARE sectors decompressed | 22,308, all exact, all three dictionary sizes |
|
|
408
|
+
| Archives with encrypted blocks | 312 |
|
|
409
|
+
|
|
410
|
+
Eight of those maps are deliberately protected — seven declare a `hashTableSize` of
|
|
411
|
+
`0x10000400` instead of `0x400`, and one hides its hash entry away from its home slot.
|
|
412
|
+
Both are handled (the declared size is clamped to what the file can hold; a failed probe
|
|
413
|
+
falls back to a full scan), and both are *recorded* on the archive object rather than
|
|
414
|
+
silently absorbed, so a caller can tell a protected map from a clean one.
|
|
415
|
+
|
|
416
|
+
One honest gap: the literal-mode byte was 0 in all 488 maps, so the 256-symbol Huffman
|
|
417
|
+
table for coded literals has never been exercised against ground truth. It is marked
|
|
418
|
+
unverified in the source.
|
|
419
|
+
|
|
420
|
+
## Git integration
|
|
421
|
+
|
|
422
|
+
`git diff` on a map file says this today:
|
|
423
|
+
|
|
424
|
+
```
|
|
425
|
+
Binary files a/map.scx and b/map.scx differ
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
Two lines of setup replace that with the actual change:
|
|
429
|
+
|
|
430
|
+
```bash
|
|
431
|
+
chkdiff install-textconv # prints the commands; --write applies them
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
```bash
|
|
435
|
+
git config --global diff.starcraft.textconv "chkdiff textconv"
|
|
436
|
+
git config --global diff.starcraft.binary false
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
plus, in `.gitattributes`:
|
|
440
|
+
|
|
441
|
+
```
|
|
442
|
+
*.scm diff=starcraft
|
|
443
|
+
*.scx diff=starcraft
|
|
444
|
+
*.chk diff=starcraft
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
After which the same commit reads:
|
|
448
|
+
|
|
449
|
+
```diff
|
|
450
|
+
[map]
|
|
451
|
+
version 205 Brood War
|
|
452
|
+
tileset 5 Desert
|
|
453
|
+
-dimensions 128x96 tiles (4096x3072 px)
|
|
454
|
+
-name #1 "Dust Bowl"
|
|
455
|
+
+dimensions 128x128 tiles (4096x4096 px)
|
|
456
|
+
+name #1 "Hot Zone"
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
`chkdiff textconv` exists as its own subcommand rather than being an alias for
|
|
460
|
+
`inspect --stable`, because a textconv driver has a requirement ordinary commands don't:
|
|
461
|
+
**it must never fail.** Git runs it over every blob on both sides of a diff, including
|
|
462
|
+
historical ones that may be truncated, protected, or not maps at all — and a driver that
|
|
463
|
+
exits non-zero makes `git diff` fail outright, which is worse than no driver. So it always
|
|
464
|
+
exits 0, always prints something, and degrades unreadable input to a short deterministic
|
|
465
|
+
note that never contains the randomised temp path git passes in.
|
|
466
|
+
|
|
467
|
+
The tests drive a real git repository rather than mocking it, and assert the status quo
|
|
468
|
+
(`Binary files ... differ`) as well as the improvement.
|
|
469
|
+
|
|
470
|
+
## Development
|
|
471
|
+
|
|
472
|
+
```bash
|
|
473
|
+
py -3.13 -m venv .venv
|
|
474
|
+
.venv/Scripts/python -m pip install -e ".[dev]"
|
|
475
|
+
.venv/Scripts/python -m pytest
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
That runs everything except the tests that need **real maps** — and those are the ones
|
|
479
|
+
that found most of the bugs here, so they are worth enabling. Point two environment
|
|
480
|
+
variables at map corpora you already have:
|
|
481
|
+
|
|
482
|
+
```bash
|
|
483
|
+
export CHKLIB_SC_MAPS="/path/to/StarCraft/Maps" # a StarCraft installation
|
|
484
|
+
export CHKLIB_SC64_MAPS="/path/to/sc64-maps/gamedata" # optional, see below
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
`CHKLIB_SC_MAPS` is the valuable one. A StarCraft install ships hundreds of maps including
|
|
488
|
+
protected ones, Remastered `STRx` maps and current ladder maps — the corpus that turned up
|
|
489
|
+
duplicate `MTXM`, short and odd terrain sections, and every MPQ edge case the reader
|
|
490
|
+
handles.
|
|
491
|
+
|
|
492
|
+
`CHKLIB_SC64_MAPS` points at output from [sc64-maps](https://github.com/dmang-dev/sc64-maps),
|
|
493
|
+
scenarios extracted from a StarCraft 64 cartridge. They are useful precisely because
|
|
494
|
+
StarEdit never touched them, so they catch assumptions a PC-only corpus cannot.
|
|
495
|
+
|
|
496
|
+
Some tests additionally need extracted `scenario.chk` fixtures, which are gitignored
|
|
497
|
+
because map archives are copyrighted and are not redistributed here:
|
|
498
|
+
|
|
499
|
+
```bash
|
|
500
|
+
pip install -e ".[fixtures]" # adds eudplib, Python 3.10-3.13 only
|
|
501
|
+
python tools/extract_fixtures.py "path/to/maps/*.scm"
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
Fixtures are produced with eudplib's MPQ reader rather than our own, deliberately: a gate
|
|
505
|
+
generated by an independent implementation cannot pass by agreeing with our own bugs.
|
|
506
|
+
|
|
507
|
+
**Everything is optional.** Anything not found makes the tests that need it skip, never
|
|
508
|
+
fail, so a clean clone with no StarCraft installed still has a green suite.
|
|
509
|
+
|
|
510
|
+
## The name
|
|
511
|
+
|
|
512
|
+
Named after the **CHK file format**, following what the rest of this ecosystem does:
|
|
513
|
+
Chkdraft, ChkForge and bw-chk all name the format, and StormLib and CascLib name a
|
|
514
|
+
technology. None of them names a Blizzard product.
|
|
515
|
+
|
|
516
|
+
This project was briefly called `openstaredit`, which was a mistake worth not repeating:
|
|
517
|
+
`StarEdit.exe` is a Blizzard product that ships with the game, and prefixing a product
|
|
518
|
+
name with `open` reads as "the open replacement for it" — exactly the association to
|
|
519
|
+
avoid. A file format's four-character code carries no such baggage.
|
|
520
|
+
|
|
521
|
+
## License
|
|
522
|
+
|
|
523
|
+
MIT — see [LICENSE](LICENSE).
|
|
524
|
+
|
|
525
|
+
StarCraft is a trademark of Blizzard Entertainment, Inc. This project is not
|
|
526
|
+
affiliated with or endorsed by Blizzard Entertainment, and redistributes none of
|
|
527
|
+
its data: the test corpora are read from your own installation and are gitignored.
|