All data here is measured against Retail files (Steam, id Tech 5, .index/.resources). Tool used: tools/entities.py. Reading occurs exclusively from base/_wolfsdk_backup/ — the installation is patched, and therefore pristine() from tools/build_gore_deep.py provides the originals. Statements without measurement are marked as unverified.
Read-only. This document describes no write operation. No mod was created, nothing applied, nothing reset. The cost accounting in Section 3 is purely theoretical: the modified card bytes were compressed and measured in memory, never written to disk.
1. What an .entities File Is
Each card lies as a file asset in the archive, uncompressed plaintext in Decl dialect:
Version 3
entity {
entityDef pick_up_prop_weapon_shotgun_1 {
inherit = "prop/weapon/shotgun";
class = "idItem";
expandInheritance = false;
edit = {
spawnPosition = { x = -23388.927734375; y = -396.16; z = 2403.78; }
}
}
}
Three fields carry the entire placement information:
inherit— what is there.ai/…an enemy,prop/weapon/…a weapon,prop/ammo/…ammunition,prop/health/…a medipack,prop/secret/…a collectible,player/starta spawn point.class— the engine class that instantiates the block (idAI,idItem,idLight,idTarget_Count, …).edit.spawnPosition— where. If an axis is missing, it does not appear in the file;tools/entities.pydisplays it as?. That the engine reads a missing axis as 0 is plausible but unverified.
Parsing with wolfsdk/decl.py — No Second Parser Needed
The dialect is identical to that of the entityDef decls which Decl already handles. Three places deviate, and Decl tolerates all three instead of aborting:
| Place | What Decl Makes From It |
|---|---|
Version 3 as first line |
a scalar Version = 3 |
renderModelInfo = ! { … } |
the ! becomes a scalar, the block following is flattened |
"float" gravity = 1066.0; |
typed keys shift around one token |
None of these places lie on the path to inherit, class, or spawnPosition, and the block boundaries remain intact because Decl delimits blocks by bracket depth, not grammar. Verification: count the line starts entity { in raw text and the blocks provided by the parser — identical across all 25 cards (--summary outputs the line "Block Count Verification"). The parser swallows nothing.
Cost: c01p1.entities (2,356,914 B) yields 70,683 paths in 3.4 s with ~78 MB peak memory. All 25 cards combined: 25 s. Anyone wishing to byte-exactly address the edit internals would need to extend Decl with ! and typed keys — not with a custom parser.
2. What Is in the Cards
python tools/entities.py --summary
| Map | Chunk | usize | csize | Entities | ai | Weapons | Ammo | Health | Secrets |
|---|---|---|---|---|---|---|---|---|---|
| c01p1 | chunk1 | 2 356 914 | 159 750 | 4 106 | 0 | 0 | 0 | 3 | 3 |
| c01p2 | chunk2 | 4 334 134 | 289 855 | 5 553 | 102 | 37 | 45 | 55 | 10 |
| c01p3 | chunk2 | 3 223 959 | 229 143 | 3 815 | 91 | 20 | 18 | 57 | 12 |
| c01p4 | chunk2 | 1 010 142 | 81 599 | 1 724 | 15 | 0 | 1 | 2 | 3 |
| c02p0 | chunk3 | 63 775 | 6 763 | 112 | 0 | 0 | 0 | 0 | 0 |
| c02p1 | chunk3 | 1 882 208 | 131 026 | 2 024 | 46 | 8 | 8 | 13 | 11 |
| c03p1 | chunk4 | 113 418 | 14 460 | 205 | 0 | 0 | 0 | 0 | 6 |
| c03p2 | chunk4 | 3 871 412 | 246 251 | 4 867 | 90 | 37 | 47 | 69 | 17 |
| c03p3 | chunk4 | 617 856 | 42 608 | 1 042 | 0 | 0 | 0 | 0 | 3 |
| c04p1 | chunk5 | 774 449 | 65 686 | 1 305 | 9 | 0 | 0 | 2 | 8 |
| c04p2_3 | chunk5 | 4 855 203 | 315 550 | 6 475 | 132 | 43 | 23 | 32 | 13 |
| c05_07_09 | chunk6 | 4 037 943 | 287 281 | 5 332 | 96 | 30 | 18 | 59 | 30 |
| c06p1 | chunk7 | 4 027 482 | 271 631 | 4 915 | 96 | 32 | 21 | 30 | 10 |
| c06p2 | chunk7 | 1 760 060 | 127 545 | 2 336 | 31 | 16 | 25 | 33 | 13 |
| c08p1_2 | chunk8 | 2 826 712 | 188 846 | 3 700 | 74 | 14 | 0 | 18 | 16 |
| c10p1 | chunk9 | 2 411 984 | 171 854 | 3 298 | 38 | 19 | 22 | 16 | 27 |
| c11p1 | chunk10 | 1 588 739 | 92 545 | 1 853 | 46 | 27 | 8 | 13 | 17 |
| c11p2 | chunk10 | 821 903 | 60 772 | 1 397 | 0 | 0 | 0 | 0 | 8 |
| c12p1 | chunk11 | 305 362 | 12 906 | 607 | 0 | 0 | 0 | 0 | 1 |
| c12p2 | chunk11 | 2 803 240 | 196 016 | 3 090 | 89 | 48 | 39 | 46 | 23 |
| c13p1 | chunk12 | 4 303 907 | 247 721 | 5 380 | 111 | 33 | 0 | 47 | 29 |
| c14p1 | chunk12 | 1 857 951 | 128 742 | 2 288 | 44 | 20 | 21 | 25 | 11 |
| c16p0 | chunk13 | 112 693 | 12 293 | 243 | 0 | 0 | 0 | 0 | 0 |
| c16p1 | chunk13 | 2 239 929 | 124 375 | 2 930 | 47 | 30 | 21 | 20 | 10 |
| c16p2 | chunk13 | 909 123 | 73 922 | 1 354 | 10 | 53 | 16 | 29 | 3 |
| 25 Maps | 53 110 498 | 3 579 140 | 69 951 | 1 167 | 467 | 333 | 569 | 284 |
The category sums match exactly with docs/moddingflaeche-tno.md
(1 167 / 467 / 333 / 569 / 284). The total number of blocks is 69 951,
not 69 952 — the difference is a counting error in the earlier survey, which
Section 1's cross-check confirms as 69 951.
Seven maps contain no enemies at all (c01p1, c02p0, c03p1, c03p3, c11p2, c12p1, c16p0). These are the intro, intermission, and transition sections. Anyone wanting to move enemies around is working on the remaining 18.
c16p2 is the exception in the other direction: 53 weapon spawn points with only 10 enemies — the final chapter dispenses supplies, no foes.
What a single map yields
python tools/entities.py --map c12p2 --stats
provides entity count, categories, the ten most frequent engine classes, and the complete species list for enemies, weapons, ammo, health, and secrets. Example c12p2:
Enemy species (89 items, 8 types)
ai/tungsten/c12/part_02/infantry_1960 32
ai/tungsten/c12/part_02/infantry_1960_snipertrains 31
ai/tungsten/c12/part_02/rocket_trooper 7
ai/tungsten/c12/part_02/supersoldat 6
...
Weapon spawn points (48 items, 7 types)
prop/weapon/handgrenadeemp 25
prop/weapon/assaultrifle1960 8
...
Finding individual entities
python tools/entities.py --map c06p1 --find prop/weapon
python tools/entities.py --map c16p2 --find "^ai/"
The pattern is a regex and runs against inherit, the entity name, and the
class. Output includes Name, inherit, the length of inherit, and
spawnPosition. The length column isn't there for show — it's the key to
Section 4.
3. The Cost Question
Installation.apply knows two ways to write back a modified asset (wolfsdk/patch.py):
- In-Place.
Archive.write_in_placecompresses the new content withdeflate_exactto exactly the oldcsizeand overwrites the slot. Offset andcsizeremain untouched; onlyusizein the index moves. Cost: oneseekand a few hundred thousand bytes. - Rebuild. If the new content does not fit into
csize,Archive.rebuildis called and the entirechunkN.resourcesis rewritten, all entries are realigned, and the index is refilled.
The boundary between the two is a single number:
free = csize − length(our own Deflate of the original)
As many compressed bytes the modified card may grow. If the number is exceeded, apply falls back to the Rebuild path.
python tools/entities.py --cost
| Card | Chunk | csize | Rebuild writes | free | Slot | +1 char raw→comp. | one Entity-Block raw→comp. |
|---|---|---|---|---|---|---|---|
| c01p1 | chunk1 | 159 750 | 73 MB | 428 | 438 | — | 234 → 71 |
| c01p2 | chunk2 | 289 855 | 368 MB | 727 | 728 | +11 → +2 | 580 → 194 |
| c01p3 | chunk2 | 229 143 | 368 MB | 279 | 288 | +42 → +12 | 9 589 → 2 647 |
| c01p4 | chunk2 | 81 599 | 368 MB | 34 | 35 | +11 → +52 | 1 074 → 247 |
| c02p0 | chunk3 | 6 763 | 121 MB | 0 | 5 | — | — |
| c02p1 | chunk3 | 131 026 | 121 MB | 711 | 725 | +31 → +4 | 9 533 → 2 811 |
| c03p1 | chunk4 | 14 460 | 159 MB | 0 | 4 | — | — |
| c03p2 | chunk4 | 246 251 | 159 MB | 1 071 | 1 076 | +20 → +0 | 10 055 → 2 751 |
| c03p3 | chunk4 | 42 608 | 159 MB | 54 | 54 | — | 151 → 42 |
| c04p1 | chunk5 | 65 686 | 233 MB | 61 | 71 | +1 → +0 | 9 821 → 2 559 |
| c04p2_3 | chunk5 | 315 550 | 233 MB | 774 | 776 | +24 → +4 | 6 350 → 1 910 |
| c05_07_09 | chunk6 | 287 281 | 278 MB | 393 | 408 | +31 → +3 | 376 → 91 |
| c06p1 | chunk7 | 271 631 | 204 MB | 476 | 477 | +51 → +12 | 6 210 → 1 740 |
| c06p2 | chunk7 | 127 545 | 204 MB | 372 | 379 | +7 → +5 | 933 → 276 |
| c08p1_2 | chunk8 | 188 846 | 161 MB | 506 | 508 | +30 → +2 | 657 → 204 |
| c10p1 | chunk9 | 171 854 | 204 MB | 436 | 438 | +6 → +1 | 9 419 → 2 792 |
| c11p1 | chunk10 | 92 545 | 172 MB | 281 | 296 | +6 → +1 | 9 266 → 2 881 |
| c11p2 | chunk10 | 60 772 | 172 MB | 24 | 36 | — | 151 → 40 |
| c12p1 | chunk11 | 12 906 | 140 MB | 0 | 6 | — | — |
| c12p2 | chunk11 | 196 016 | 140 MB | 567 | 567 | +32 → +7 | 355 → 134 |
| c13p1 | chunk12 | 247 721 | 466 MB | 1 185 | 1 192 | +71 → +9 | 10 148 → 2 679 |
| c14p1 | chunk12 | 128 742 | 466 MB | 383 | 393 | +12 → +2 | 10 138 → 2 792 |
| c16p0 | chunk13 | 12 293 | 208 MB | 0 | 11 | — | — |
| c16p1 | chunk13 | 124 375 | 208 MB | 676 | 685 | +30 → +2 | 448 → 143 |
| c16p2 | chunk13 | 73 922 | 208 MB | 77 | 91 | +7 → +6 | 7 317 → 2 337 |
Rebuild costs per Chunk — treating a single card as "rebuilding" means rewriting the entire file, and revert must fully restore it afterwards:
| Chunk | Size | affected cards |
|---|---|---|
| chunk12 | 466 MB | c13p1, c14p1 |
| chunk2 | 368 MB | c01p2, c01p3, c01p4 |
| chunk6 | 278 MB | c05_07_09 |
| chunk5 | 233 MB | c04p1, c04p2_3 |
| chunk13 | 208 MB | c16p0, c16p1, c16p2 |
| chunk7 | 204 MB | c06p1, c06p2 |
| chunk9 | 204 MB | c10p1 |
| chunk10 | 172 MB | c11p1, c11p2 |
| chunk8 | 161 MB | c08p1_2 |
| chunk4 | 159 MB | c03p1, c03p2, c03p3 |
| chunk11 | 140 MB | c12p1, c12p2 |
| chunk3 | 121 MB | c02p0, c02p1 |
| chunk1 | 73 MB | c01p1 |
What the numbers mean
The available space is better than "0–428 bytes" sounds — but only for the right kind of change. frei counts compressed bytes. Card text compresses extremely well because it consists of tens of thousands of nearly identical blocks. The "+1 character" column measures the exchange rate: if the most common enemy name becomes one character longer in all its occurrences, that costs 71 raw bytes on c13p1, but only 9 compressed. The rate varies by card between 1:1 and 15:1, with a median of about 6:1 — including an outlier in the opposite direction (c01p4, see below).
Given this, for a typical card with 400 bytes of available space: Text changes on the order of 2,000 characters fit without a rebuild. This is sufficient for dozens of reordered inherit targets or shifted coordinates. The number is a rule of thumb — for the specific change, continue to measure.
But a single additional enemy fits nowhere. The last column shows why. A real ai/ block from later cards is 6,000 to 10,000 bytes raw and 1,700 to 2,900 bytes compressed — these blocks drag entire edit sections with Scene Points, patrol paths, and animation bindings. Even the largest measured space (c13p1, 1,185 B) lies below that. Adding entities always means a rebuild. Only in enemy-free cards with small blocks (c01p1: 234 B/block) do additional entries fit — but there is nothing there one would want to add.
Four cards have exactly zero space: c02p0, c03p1, c12p1, c16p0. There, our Deflate hits the delivered csize byte-for-byte. Any change costing even a single byte forces a rebuild. All four are enemy-free and uninteresting for placement mods anyway.
The Slot column is a footnote. Archive.slot_size assigns each entry its csize plus up to 15 bytes of alignment padding. These 0–15 bytes would theoretically be additionally available, but Archive.write_in_place does not use them — it keeps csize intentionally constant because a gap in the data stream causes the engine to misaddress entries later (No address, error: 487). The column exists only to make visible that nothing can be retrieved there.
Ranking: which card is cheap?
Cheap means: lots of space and a small chunk if a rebuild does occur anyway.
- c02p1 — 711 B free, rebuild only 121 MB. The cheapest card with enemies at all. 46
ai/, 8 weapons. - c12p2 — 567 B free, 140 MB. 89 enemies, 48 weapon locations: the best ratio of content to cost in the entire game.
- c03p2 — 1,071 B free, 159 MB. 90 enemies, 37 weapons.
- c08p1_2 — 506 B free, 161 MB.
- c11p1 — 281 B free, 172 MB.
Expensive:
- c13p1 and c14p1 both lie in chunk12 — 466 MB per rebuild, the highest costs in the game, although
c13p1has the largest space at 1,185 B. Whoever can changec13p1in-place saves the most here. - c01p2, c01p3, c01p4 in chunk2 — 368 MB.
c01p4has only 34 B free for that. - c16p2 — 77 B free at 208 MB Chunk: 53 weapon locations, but little room to move.
4. The length-preserving change
The pattern is defined as _same_width() in tools/build_gore_deep.py: a number reformatted so that it keeps exactly the same number of characters. Translated to cards means swapping an enemy type against one of equal length.
Important: equal length guarantees nothing. Deflate depends on content, not just length — two texts of equal length compress differently. Equal length keeps the difference only small. Therefore, it was actually measured for each card: apply swap, re-compress, read the difference from the original.
tools/entities.py --cost searches for this by finding the most common ai/ type per card, taking another with an identical character count (different case of the same name does not count — that would not be a swap), replacing all occurrences, and measuring again.
| Card | Swap | Occurrence | Δ Deflate | Fits in free? |
|---|---|---|---|---|
| c01p2 | …/soldier_trenches_var2 → …_var3 |
11× | −2 B | yes (727) |
| c01p3 | c01/part_03/soldier → kamphund/kamphund46 |
42× | +56 B | yes (279) |
| c02p1 | …/drone/ranged_drone → …/pistol/commander60 |
5× | +5 B | yes (711) |
| c03p2 | …/blackguard → …/soldier_02 |
20× | +2 B | yes (1 071) |
| c04p1 | …/rifle/prisonguard01 → …/baton/prisonguard02 |
1× | −8 B | yes (61) |
| c04p2_3 | part_03/rifle/prisonguard01 → part_02/baton/… |
24× | +12 B | yes (774) |
| c05_07_09 | c15/part_01/blackguard → …/panzerhund |
26× | +8 B | yes (393) |
| c06p1 | …/drone/drone_moon → …/robot/guardrobot |
6× | +15 B | yes (476) |
| c06p2 | …/rifle/airforce_02 → …_03 |
2× | −2 B | yes (372) |
| c08p1_2 | …/camp_guard_reception_03 → …_02 |
4× | −4 B | yes (506) |
| c10p1 | …/rifle/infantry60_07 → …_01 |
4× | ±0 B | yes (436) |
| c11p1 | …/open_area_04 → …_02 |
6× | ±0 B | yes (281) |
| c14p1 | …/rifle/airforce_02 → …_03 |
5× | −2 B | yes (383) |
| c16p1 | …/fire/firetrooper → …/robot/guardrobot |
5× | +6 B | yes (676) |
| c16p2 | …/mecha_deathshead_stage2 → …_stage3 |
1× | +1 B | yes (77) |
15 of 25 cards can be changed this way without a rebuild. The difference lies between −8 and +56 bytes and remained within the margin in every single case. Notable: four swaps make the file even smaller.
The other ten fail not due to size, but due to the lack of a partner: seven cards have no ai/ spawns at all (c01p1, c02p0, c03p1, c03p3, c11p2, c12p1, c16p0), and in c01p4, c12p2, and c13p1 there are no two enemy types with the same character count among the 5 to 9 existing ones. The solution there is: use another enemy type from a different card with matching length — the inherit path is a global Decl name and not bound to the card. Whether the engine loads an enemy from Chapter 6 into Chapter 12 without the associated models and animations is unconfirmed.
The length column in --find is exactly for that purpose:
python tools/entities.py --map c16p2 --find "^ai/"
Name inherit len spawnPosition
cb_ai_mecha_deathshead_stage2 ai/…/mecha_deathshead_stage2 47 (? 1792 ?)
cb_ai_mecha_deathshead_stage3 ai/…/mecha_deathshead_stage3 47 (224 192 -512)
cb_ai_prototyperobot ai/…/prototyperobot 38 (-111.78 3886.07 ?)
cb_ai_scriptedanimweb_mdh_stagger ai/scriptedanimweb 18 (-192 -1472 -342)
Same number in len = swappable without growth.
What else works with length preservation
- Coordinates with the same digit count.
x = -23388.927734375has 17 digits; any other 17-digit number fits. That is exactly what_same_width()is written for — it sacrifices decimal places before letting the file grow. Moving a pickup a few meters costs zero bytes this way. - Weapons against weapons of equal length. Counted:
prop/weapon/shotgun↔prop/weapon/handgun(both 19 characters),prop/weapon/ar60marksman↔prop/weapon/kitchenknife(both 24).prop/weapon/knife(17),prop/weapon/handgrenadeemp(26), andprop/weapon/assaultrifle1960(28) stand alone. The lengths are listed in--find. - Ammo counts, provided they appear as a number within the block and the digit count remains the same (
5→9, not5→10).
What does not work
- Add or remove entities. Adding destroys every room (Section 3). Removing makes the file smaller — it always fits in
csize, becausedeflate_exactfills the remainder with a//comment. Whether the engine's Entities parser accepts//is unconfirmed and was not tested here; the Decl parser does. - Extend names. The 5:1 exchange rate works in both directions, but
c01p4shows the outlier: +11 raw bytes cost +52 compressed there — more than the 34 bytes of room. A well-compressed file can react to a small change with a large jump if it causes a Deflate block to be re-partitioned. Measure, not estimate.
5. Open Risks
- None of this has been tested in-game. The cost calculation is byte-exact and reproducible; whether the engine accepts a modified
.entitiesfile is not demonstrated. From particle experiences intools/build_gore_deep.py, it is known that streamed resources are silently rejected if something is wrong when their pre-calculated size does not match. For.entities, it is unconfirmed whether they follow the same streaming path. - The
//padding mechanism ofdeflate_exactdepends on the reader skipping comments. For Decls, this is confirmed; for.entities, it is unconfirmed. - Rebuild is slow and risky. Writing 466 MB anew, and
revertmust copy back the same amount. An interruption mid-way leaves a half-written.resourcesfile —Archive.rebuildwrites to a.wolfsdk_tmpfile and replaces it only at the end, which covers this case. - Enemies from foreign chapters (the solution for the ten maps without an equally long partner) assume that models, animations, and sounds are in the same chunk or loaded subsequently. Unconfirmed.
6. Tool
python tools/entities.py # Card list
python tools/entities.py --summary # all 25, ~25 s
python tools/entities.py --map c12p2 --stats # what is inside
python tools/entities.py --map c12p2 --find "prop/weapon" [--limit N]
python tools/entities.py --cost # Cost calculation, ~2 min
All read-only. The tool does not open any file for writing and creates no mods.