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.
@@ -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.