Wolfenstein Modloader Buy me a coffeeKaffee spendieren

Wiki / The New ColossusThe New Colossus

Modding Area: Wolfenstein II — The New Colossus

ReferenceNachschlagewerk Original page in the GitHub wikiOriginalseite im GitHub-Wiki

In plain wordsIn einfachen Worten

This page surveys all 24 archives of Wolfenstein II: The New Colossus and sorts the 174 kinds of game files into easy, harder and impossible to change. Most game settings are plain-text definitions that the project's parser reads and edits without errors. It lists the useful levers: difficulty, enemy health, weapon damage, starting gear, gore and perks. It also explains how much space an edit may take inside an archive and which questions are still open.

  • 24 archives, 178,077 entries, 174 types; 141 types are text, and 44,140 of 44,374 text files parse with 0 errors.
  • Edit test: 1,432 of 1,432 changed values were written and read back correctly.
  • Difficulty is seven tiny tables; 'Mein Leben' uses the same damage value as 'Nightmare' (1.75).
  • Edits can grow into zero-filled gaps after each entry; re-packed compression is byte-identical to the retail files.
  • The encrypted config files are now solved and used to unlock the console in a running game; which archive wins on duplicate names is still unmeasured.

Diese Seite untersucht alle 24 Archive von Wolfenstein II: The New Colossus und sortiert die 174 Dateiarten nach leicht, schwerer und unmöglich zu ändern. Die meisten Spielwerte sind Textdefinitionen, die der Parser des Projekts fehlerfrei liest und bearbeitet. Genannt werden die nützlichen Stellschrauben: Schwierigkeit, Gegnerleben, Waffenschaden, Startausrüstung, Gore und Perks. Außerdem erklärt die Seite, wie viel Platz eine Änderung im Archiv belegen darf und welche Fragen noch offen sind.

  • 24 Archive, 178.077 Einträge, 174 Typen; 141 Typen sind Text, 44.140 von 44.374 Textdateien werden mit 0 Fehlern gelesen.
  • Bearbeitungstest: 1.432 von 1.432 geänderten Werten wurden korrekt geschrieben und zurückgelesen.
  • Die Schwierigkeit steckt in sieben winzigen Tabellen; „Mein Leben“ nutzt denselben Schadenswert wie „Albtraum“ (1,75).
  • Änderungen dürfen in mit Nullen gefüllte Lücken hinter jedem Eintrag wachsen; neu gepackte Kompression ist byte-identisch zu den Originaldateien.
  • Die verschlüsselten Konfigurationsdateien sind gelöst und schalten im laufenden Spiel die Konsole frei; welches Archiv bei doppelten Namen gewinnt, ist noch nicht gemessen.

Technical detailsTechnische Details

Der technische Teil ist auf Englisch – so, wie er im Wiki steht. Unterstrichene Begriffe haben eine Erklärung auf Deutsch.

The counterpart to docs/moddingflaeche-tno.md, for Part 2. A map of what can be changed, what cannot, and how to recognize the difference. All numbers and field names below are measured, not estimated — from the unmodified Retail installation under <Steam library>\steamapps\common\Wolfenstein.II.The.New.Colossus\base\ (read-only). Reproducible with:

python tools/survey_tnc.py                     # full report
python tools/survey_tnc.py --types             # type survey
python tools/survey_tnc.py --parse             # decl.py against the dialect
python tools/survey_tnc.py --budget            # the cost question
python tools/survey_tnc.py --maps              # .entities and kiscule
python tools/survey_tnc.py --type weapon --samples 2
python tools/survey_tnc.py --grep "aiHealth"

Underlay: wolfsdk/idcl.py (archive format), wolfsdk/oodle.py (Kraken over the game DLL), tools/extract_tnc.py (unpacking). Proof that this is complete: tools/verify_extract_tnc.py.

Status: 24 Archives, 178 077 entries, 174 resource types, of which 167 694 unique (Type, Name) pairs. Compression: 146 927 Kraken (Mode 2), 31 125 uncompressed (Mode 0), 25 Kraken behind a 12-byte prefix (Mode 4). 3.40 GiB .resources plus 20.06 GiB .texdb (Virtual Textures, not handled here).


0. Summary

Rating What
Safe to go 141 text types, 44 126 text assets. decl.py parses 44 140 of 44 374 without error (0 DeclError), Roundtrip-Edit 1 432/1 432 correct. The levers: table:playerdamage/* (difficulty), entityDef.aiHealth (enemy health), damage.damageParms (weapon damage), devInvLoadout (starting equipment), gorewounds/goreBehavior (gore), upgrade/perks
Possible, but one layer deeper .entities maps (29 maps, 437 570 entity blocks, 397 MiB text, all 29 parse with decl.py), extkisclule (2 408 script files, 205 node types). Both are doubly compressed in the archive, see Section 5
Dialect deviation, measurable 2 242 Decls where decl.py inserts a punctuation mark as a value — lists without =. Affects almost only material (1 649), renderParm (296), table (189), env (88). Section 2
Solved since this survey cfile — the encryption key is now derived (not guessed) and round-trip proven; the twist is that this doesn't open strings/*.lang for editing, it opens the console. See the box after Section 6.
Unclear Whether patch_*.resources wins on load (Retail lays down 1 701 pairs twice, so must win something — the rule is unmeasured); particle (impossible in Part 1, here text and unverified); 25 entries in Mode 4

1. All Resource Types

Complete list: python tools/survey_tnc.py --types. The classification comes from four unpacked samples per type (the two smallest, one from the middle, the largest) and the proportion of printable bytes in the first 4 KiB.

Text — 141 types, 44 126 entries. The twenty largest:

n unique Type n unique Type
10 372 10 070 material 383 377 tutorialEvent
10 365 10 345 sound 351 338 missionobj
7 622 7 621 modelAsset 265 263 fx
3 307 3 198 voiceover 258 254 progressiondebug
1 661 1 661 renderParm 211 201 perks
1 461 1 422 entityDef 209 200 upgrade
1 456 1 422 md6Def 189 189 table
1 127 1 115 midnightScene 175 162 breakable
797 777 particle 156 154 projectileImpactEffect
383 383 gorewounds 154 151 damage

Binary — 32 types, 133,703 entries, uninteresting for Decl-modding: cm 50,699, image 32,182 (BIM, as in Part 1), model 17,983, voicetrack 6,875, modelstream 6,858, anim 4,963, renderProgResource 4,729, geistanim 2,808, extkisclule 2,492 (see Section 5 — these are only apparently binary), decalatlas 903, animEvent 624, prtmeshdist 483, baseModel 437, skeleton 391, geistidentity 248, destructionmodeldata 177, glassmodel 136, compfile 129 (also Section 5), staticStreamTree 110, lightatlas 82, transsortatlas 77, cfile 67, hknavmesh 45, hkcloth 40, cgr 37, pvs 30, staticShadowGeom 29, font 26, geistanimpara 24, mannequinmodel 10, staticParticleModel 7, fga 2.

Mixed — 1 type: file (248 entries, 148 unique) carries both. Endings: 32 .ambientsh, 31 .mapresources, 29 .tome, 29 .tome-metadata.json, 18 .bswf (Scaleform-UI), 6 .wl6 (the original data from Wolfenstein 3D, playable in the game), 2 .png, 1 .bin.

Which text types are Decls?

Practically all. Of the 141 text types, 140 begin with { and carry either edit = { … }, inherit = "…", or both — this is exactly the format that decl.py recognizes as id-tech-5-Decl. The only exception is json (true JSON behind an 8-byte length prefix; 30 entries, all devmapinfo.json).

Two sub-dialects, as in Part 1:

  • key = value; — the majority, including weapon, damage, entityDef, ammo, gorewounds, perks, upgrade, fx, sound.
  • key value without = and without ; — material ({ surfacetype 5.000000 }), renderParm ({ Vec 0 }), table ({ snap { 0, 0, 0, 1 } }), md6Def (init { inherit "…" }), articulatedFigure, visemeSet, env.

This second group is where the only measured weakness of decl.py lies, see below.


2. Does decl.py understand the Decls from Part 2?

Measured, three tests, because "no exception" alone proves nothing. python tools/survey_tnc.py --parse sends every text asset through Decl().

2.1 Does the parser crash? No.

Textassets: 44374   parsed cleanly: 44140   DeclError: 0   no UTF-8: 234

Zero DeclError over 44,140 Decls. The 234 failures are not a parser problem but lack of UTF-8: 219 file (the .tome, .ambientsh, and .mapresources binary files in the mixed type), 14 json (length prefix before the text) and one fx.

2.2 Does editing work? Yes, 1,432 of 1,432.

Per type up to 25 Decls: change a number with set() on 1234, render new text, parse anew, read the same path back. 1,432 correct, 0 incorrect. This is the test that truly counts — it checks not only if the parser runs through, but if the recorded byte spans match.

2.3 The Dialect Deviation: 2,242 Decls

decl.py writes a punctuation mark as a value into a path in 2,242 Decls. This is the signature of a misunderstanding: a list of multiple values behind a key without = is read as a chain of key/value pairs.

Decls Type what happens
1,649 material sssblurscale 2.5 1.0 0.6 0.1 → paths 2.500000:sssblurscale.1.000000 instead of a vector
296 renderParm { Vec 0 0 0 } → Vec.0, Vec.0#1 instead of three components
189 table {0:1, 0.69:1} → anchors land as keys
88 env colorCorrectionShadowsColor 0.72 1 0.8 → three nested paths
15 json no Decl, but JSON
3 articulatedFigure settings { 30 40 solid }
1 renderLayerDefinition comma-separated name list
1 entityDef (spline/path) a single case in editorVars

What this means: For the levers from Section 3, it doesn't matter — there, every value is listed as key = value;, and all 2,242 cases lie in types that are not touched by Balancing. However, if you want to generically rewrite material or renderParm, you need a vector mode in decl.py (summing multiple tokens after a key into one value as long as no {, =, or ; follows).

Negative finding as result: In Part 2, there is not a single construct at which decl.py stops. The id-tech-5-parser fully covers the id-tech-6 dialect; the difference lies only in resolution, not in validity.


3. The Levers

Part 1 had weapon, damage, entityDef, gamemodesp, faction. Part 2 has four of them — gamemodesp and difficultysetting do not exist; difficulty has moved elsewhere, to a place that is significantly easier to hit.

3.1 Difficulty levels: seven table single-lines · safe

entityDef:player (gameresources, 13,165 bytes) contains:

veryEasyTable = "playerdamage/veryeasy";
easyTable = "playerdamage/easy";
normalTable = "playerdamage/normal";
hardTable = "playerdamage/hard";
veryHardTable = "playerdamage/veryhard";
nightmareTable = "playerdamage/nightmare";
meinLebenTable = "playerdamage/meinleben";

These seven table Decls are each stored as a single line and uncompressed (Mode 0, csize == usize):

Decl Content usize
table:playerdamage/veryeasy { spline clamp min 0.1 max 0.1 left 0 right 1 {0:0, 1:1} } 58
table:playerdamage/easy min 0.4 max 0.4 58
table:playerdamage/normal min 0.5 max 0.5 58
table:playerdamage/hard min 0.75 max 0.75 60
table:playerdamage/veryhard min 1.25 max 1.25 60
table:playerdamage/nightmare min 1.75 max 1.75 60
table:playerdamage/meinleben min 1.75 max 1.75 60

The value is the multiplier for player damage. Side finding: "My Life" is computationally identical to "Nightmare" (both 1.75) — the difference lies solely in Permadeath, not in difficulty.

Immediately usable: a difficulty level mod that only changes seven numbers. min 0.5 max 0.5 → min 0.0 max 0.0 is Invulnerability on Normal, and since both strings are of equal length, this fits without any size calculation in csize.

Additionally in entityDef:player: autoSaveHealthDifficultyMultiplier[1..6] (1, 1, 0, 0, 0, 0 — no health gift on autosave from difficulty 3 onwards) and healthOverrides = { item[0] = "player/health_force_100"; item[1] = "player/armor_max_100"; }, which point to healthComponent Decls (edit.max, edit.cur, edit.component = "COMPONENT_ARMOR", edit.stepIncrement, edit.setRegenSteps).

3.2 Enemy health: entityDef.*Health.components · safe

Of 1,422 unique entityDefs, 52 have a health component: 49× aiHealth, 1× zeppelinHealth, 1× simpleHealth, 1× alarmHealth. Path: edit.aiHealth.components.components[0].max (and .cur).

Value entityDef
3,000 ai/zitadelle/zitadelle_base
2,400 ai/zerstorer/zerstorer_base
2,000 ai/panzerhund/panzerhund_base
800 player/playercontrolled/panzerhund
500 func/zeppelin
440 ai/supersoldat/supersoldat_base
300 ai/commander/uber/ubercommander_base
110 ai/elite/venus/elite_venus_* (light, heavy, sniper, shotgun)
90 ai/elite/elite_base
60 ai/soldier/venus/soldier_venus_*, ai/commander/venus/*
50 ai/kampfhund/kampfhund_base
45 ai/soldier/soldier_base
40 ai/commander/commander_base, .../scientist, .../engineer, .../robeman
25 ai/resistance/resistance_base
10 target/ai/newalarm

Inheritance makes the work small: setting ai/soldier/soldier_base to 5 hits every soldier variant that inherits from it.

aiGlobalSettings:default contains entityDifficultyMappings with defensiveModifiers.healthModifiers for each difficulty = "DIFFICULTY_NORMAL" — the location where enemy health would scale based on difficulty. In Retail, this block is empty (num = 2, only item[1] is set without a numeric value); unclear whether a populated block is evaluated.

3.3 Weapon Damage: the chain weapon → ammo → projectile → damage · safe

Four Decls, each with a single reference to the next one. Using the Pistol as an example, all four files read literally:

weapon:weapon/player/sp/handgun   →  initialAmmoDecl = "ammo/player/sp/handgun";
ammo:ammo/player/sp/handgun              →  projectileDecl  = "projectile/player/pistol";
                                            maxCount = 80; clipSize = 10; lowAmmoInClip = 3;
projectile:projectile/player/pistol      →  damageDecl = "damage/firearm/bullet_pistol";
                                            maxRangeBeforeExtraSpread = 20; tracers = 2;
damage:damage/firearm/bullet_pistol      →  minDamage = 20; maxDamage = 20;
                                            damageTypes = "DAMAGETYPE_HEALTH DAMAGETYPE_HANDGUN";
                                            declGoreBehavior = "standard/pistol";

The root is damage:damage/firearm/bullet_standard (minDamage = 15; maxDamage = 15; ragdollImpulseMag = 256; damageSource = "DAMAGESRC_BULLET"), from which 18 other damage Decls inherit. The Shotgun shows that the distance curve is also located here: damage/firearm/bullet_shotgun has minDamage = 1; maxDamage = 10; startFalloffDistance = 3; endFalloffDistance = 15;.

Field frequencies across all 154 damage Decls: damageParms.damageName 134, maxDamage 134, minDamage 133, damageTypes 82, radius 67, declGoreBehavior 63, damageSource 57, minKnockBack/maxKnockBack each 55, ragdollImpulseMag 37, selfDamageScale 30, radiusInner 27, isMelee 24, physicsExplosionParms 22, kickTime 15, ignoreArmor 13, kickAmplitude 13.

In weapon itself (96 Decls) lies the handling, not the damage: firingInterval = 85, initialBurstMode = "BURSTMODE_FULLAUTO", burstInfo.burstInfo[0].burstCount, maxOptimalRange = 15, dualwieldable = true, maxCount = 2, ironSightZoom.zoomedFOV = 85, heatInfo.heatIncrement / .heatDecrement, stressMod, weaponAutoSelectPrio, reticle, upgrades.item[0..3].

Targeting assistance is separate in aimAssist:aimassist/sp/default: targetAdhesionEnable = false, targetTrackingEnabled = false, targetSelection.maxDistance = 150 / .maxDegrees = 90 / .radius = 5, targetZoomSnap.optimalRadius = 2.5, targetFriction.optimalRadius = 2.5, targetDistanceModifier.optimalDistanceMin = 10 / .optimalDistanceMax = 35. One Decl for the entire game, referenced as declAimAssist = "aimassist/sp/default" in weapon/player/sp/base.

3.4 Starting Equipment: devInvLoadout · safe

57 Decls, 40 unique — one per chapter section, named devinvloadout/c03p1_devloadout and so on. Example c03p1 read literally:

startingInventory = { num = 16;
  item[0]  = { item = "weapon/player/sp/smg"; }
  item[1]  = { item = "ammo/player/sp/smg";   count = 180; forceCount = true; }
  item[4]  = { item = "weapon/player/sp/battlerifle"; equip = true; }
  item[6]  = { item = "weapon/player/sp/grenade_frag"; count = 3; }
  item[7]  = { item = "weapon/player/sp/lkw";  timelineFilter = "FERGUS"; }
  item[9]  = { item = "abilities/groundpound"; }
  item[13] = { item = "unlocks/upgradekit"; }
}

Fields: item, count, forceCount, equip, timelineFilter ("FERGUS" / "WYATT" — the two timelines).

Important: The name indicates "dev". It is linked via devmapInvLoadout = "devinvloadout/c01p1_devloadout"; in the world block of every .entities map — specifically within the Devmap path. Whether it functions during a normal campaign start is unclear and untested (the game has not been started). The campaign's player starting equipment is defined under entityDef:player with startingInventory = { num = 1; item[0] = { inventoryDecl = "player/perkitem"; } } — there is exactly one entry, and everything else is added via script.

3.5 Gore · safe

Three levels, all text, all editable with decl.py:

  • goreBehavior (41 Decls) — the roll table. Paths edit.probability.item[N].woundType and .probability. Actual values: woundType = "GORETYPE_HEAD_SHOT" (10×), "GORETYPE_FULL_BODY_GIBS", "GORETYPE_LIMBLOSS", "GORETYPE_SMALL_WOUND GORETYPE_BIG…", "GORETYPE_SIGNATURE_DEATH2"; probability = 0.5 / 0.8 / 0.400000006; falloff = "gore/falloff/grenade". inherit = "standard/heavy" / "standard/explosive". A mod that sets every probability to 1 is a ten-liner.
  • gorewounds (383 Decls) — what a wound does concretely. edit.woundType = "GORETYPE_DETACHABLE" (189×), edit.woundFlags = "WOUND_FLAGS_ALLOW_WHEN_ALIVE …" (208×) or "WOUND_FLAGS_ON_DEATH" (91×), edit.damageRequirements.damageGroups.item[0], edit.spawning.spawnEntities.item[0].entityDef (313×) and .fxTagJoint (336×), .linearVelocityBehavior.behaviors.item[0].type = "SPAWNED_VELOCITY_RANDOM", .randomMaxScale = 1.25, .behaviors.item[2].type = "SPAWNED_VELOCITY_WITH_DAMAGE", edit.visuals.disableMeshKits.item[0].slot = "MESH_KIT_GEAR", edit.behavior.disableHitGroups.
  • goreGraph 21, goreMannequin 10 (edit.model = "models/characters/goremannequin/…", lootDrop 19 (edit.goreDropId = "deathcard", edit.dropItemAtTag).

Attached via damage.damageParms.declGoreBehavior (63 of 154 damage Decls) — so: damage determines Gore, and both are text.

3.6 faction — Factions turn against each other · Format safe, effect untested

8 Decls. Schema identical in all eight:

edit.relationships.relationships.num = 8;
edit.relationships.relationships.item[0].otherFaction = "faction/regime";
edit.relationships.relationships.item[0].attitude     = "ATTITUDE_HATE";
edit.relationships.relationships.item[1].otherFaction = "faction/settlers";
edit.relationships.relationships.item[1].attitude     = "ATTITUDE_NEUTRAL";
…
edit.factionNameId = "#str_10025";

Measured attitudes: ATTITUDE_HATE (7 of 8 Decls in item[0]), ATTITUDE_NEUTRAL, ATTITUDE_DESPISE. Factions: faction/player, faction/regime, faction/settlers, faction/bandits, faction/muties, faction/no_team, faction/red_team, faction/yellow_team, faction/green_team. The names settlers/bandits/muties are inherited from RAGE and do not appear in Part 2 content-wise — a hint that the faction code is general, but no evidence that these factions actually do something in the game. aiGlobalSettings:default points to the factionGraph type (2 Decls) via factionGraph = "ai/default". entityDef:player carries faction.myFaction = "faction/player".

3.7 The TNC-exclusive level: perks and upgrade

Part 2 has an ability system that Part 1 did not have.

  • upgrade (209 Decls, 200 unique) — the actual modifier. edit.awardCondition = "UC_AUTOMATIC" (163×), edit.reUsable = true (160×), edit.applyTo = "weapon/player/sp/smg" (22×) / "weapon/player/sp/handgun" (21×), edit.skillPointCost, and the modifier lists: edit.modifiersAmmo.item[0].type = "AMT_AMMO_MAX_COUNT" (62×), edit.modifiersWeapon.item[0].type = "WMT_WEAPON_DAMAGE_IRON_SIGHT_DAMAGE_SCA…" (30×), edit.modifiersData.item[0].opType = "MOD_OPERATOR_SET" (60×) with .priority. upgrade:default is four lines long and uncompressed.
  • perks (211 Decls) — the unlock conditions for them. edit.upgrades.item[0..N] (173×), edit.criteria, edit.reward, edit.rewardScales.num = 5, edit.requiredStatForActivation.statName / .statIncrement (24×), edit.autoActivate = true, edit.descriptionFlags = "PKF_PLAYER_ABILITY", edit.presentationType = "Percent". Example: perk/player/sp/kobra_base.
  • Additionally: propItem 79 (edit.ammoCount 30×, edit.inventoryCount 42×, edit.inventoryDecl, edit.upgradeDecl) — what an ammo-gathering prerequisite yields; abilityInfo 40 (edit.abilityType = "AT_GROUNDPOUND"), abilityInventoryItem 24, inventoryItem 26, unlockable 96, medal 32, metric 46.

4. The Cost Question

For Part 1: "must fit in csize, otherwise Archive Rebuild." For IDCL the same problem applies — but the numbers are different, and there are two escape routes that Part 1 did not have. Everything here is verifiable with python tools/survey_tnc.py --budget.

4.1 Space an Entry Takes

An IDCL payload sits at offset, is csize bytes long, and behind it stands a gap padded with zeros until the next payload. Measured, across all entries sorted by offset:

Archive Entries Gap Median Total Gaps Overlaps
gameresources.resources 50 839 48 B 33.0 MiB 0
chunk_12.resources 19 969 650 B 25.6 MiB 0
patch_1.resources 676 63 B 0.6 MiB 0

All offsets are 16-byte aligned, but the gaps are larger than alignment dictates (median 48 instead of max. 15) and are demonstrably null bytes. Not a single overlap in 24 archives — no payload is shared by two entries. This means: whoever includes csize in the table may grow into the gap without shifting anything.

The code does this. idcl.plan_in_slot() first calls plan_in_place() — what fits in csize continues to be written byte-for-byte as before — and only then releases the gap; write_in_place() then subtracts csize from the entry table. Only upwards: a smaller csize would pull align_up(vprevious_end, k) under the next offset, and that continues via rebuild(). offset always stays put, as does file size. Each individual write operation measures three things instead of relying on the survey: that the gap bytes are truly 0x00, that no second entry begins on the same offset (136 do this, except four all with csize 0), and that the new length fits. The column "fits in csize + gap" is therefore no longer a calculation, but what plan_in_slot() traverses: tools/verify_idcl_gap.py measures it via exactly this code and arrives at the same numbers. Reliable this is not — the 10th percentile of the gap lies at 4 to 12 bytes —, but it is the difference between "almost never" and "almost always":

Testedit = every integer in the Decl multiplied by 1000 (so +3 characters per number), then repacked with Kraken again:

Type csize Median Gap Median Fits in csize Fits in csize + Gap
weapon 764 27 3/25 21/25
damage 366 32 0/12 12/12
entityDef 491 46 0/25 23/25
gorewounds 793 33 9/25 21/25
perks 502 27 0/18 16/18
upgrade 472 35 5/25 25/25
fx 852 48 4/25 22/25
particle 2 633 205 0/25 22/25

Kraken is so dense that there is hardly any reserve left within csize itself. The space comes from the gap.

4.2 The Mode Trick

compression is a table field, not a feature of the Payload. Of the 178,077 entries, 31,125 are uncompressed (Mode 0) — including 137 of 154 damage, 1,127 of 1,461 entityDef, 192 of 211 perks, 179 of 189 table, 52 of 57 ammo, all 10,365 sound, and all 8 healthComponent. Anyone editing such an entry and exceeding csize + Gap can switch it to Mode 2 and gain roughly half the space for larger Decls:

voiceover  stored 366 B  →  Kraken 170 B
entityDef  stored 250 B  →  Kraken 222 B
damage     stored 252 B  →  Kraken 224 B

For very small Decls, this backfires: renderParm 51 B stored → 53 B Kraken, table 75 → 77. Below approximately 150 bytes, Kraken costs more than it saves; there, one should stick with Mode 0.

4.3 New Compression is Bit-Identical to Retail

This is the most important single finding for the write path. oodle.compress(data) with Kraken at standard level generates byte-identical the same bytes that are in the Retail archive: 401/401 in a sample over weapon, damage, entityDef, gorewounds, perks, sound, material, and again 194/194 in the --budget run. A newly built archive can thus be compared byte-for-byte against the original — any deviation is then a real error and not compression noise.

4.4 What a New Build Would Cost

24 .resources combined 3,40 GiB
largest: gameresources.resources 670 MiB, 50,839 entries
.texdb alongside (untouched) 20.06 GiB
Oodle new compression throughput (measured) 26.9 MiB/s
⇒ gameresources completely repack ≈ 25 s
⇒ all 24 archives ≈ 2 min

Computation time is therefore not an obstacle. Disk space is: a backup copy of the .resources costs 3.4 GiB.

4.5 The Trap: Same Names in the Same Archive

3,869 (Type, Name) pairs occur multiple times within one and the same archive — exclusively renderProgResource, with 5 to 6 copies per name (422 affected names in gameresources, 429 in patch_4). version and flags are identical in all copies; the only field that distinguishes them is unknown_0x72 (32, 33, 34, 35, 47, 49 — looks like shader stage).

Archive.find(typ, name) in wolfsdk/idcl.py builds its index with setdefault and therefore delivers only the first of these copies. Anyone resolving shader resources by name hits one of six. For all Decl types from Section 3, this is inconsequential — there are no duplicate names within the same archive there.

4.6 The Cheap Way: A Separate Patch Archive

Retail itself stores 5,983 (Type, Name) pairs multiple times, and in 1,701 cases the data_hash values differ — so they are different contents under the same name, and one of them is the copy in a patch_*.resources. Additionally, there exist 1,013 pairs exclusively in Patch Archives (287 image, 265 material, 147 voicetrack, 92 missionobj, 36 anim, 33 sound, 32 medal, …).

Examples:

entityDef  player_c01_coma       chunk_1 242 B   →  patch_3 291 B
entityDef  player_young_bj       chunk_1 890 B   →  patch_3 939 B
skeleton   player_wtno.md6skl    chunk_1 12586 B →  patch_2 12666 B

For a patch to serve any purpose, the later copy must win. The string patch appears immediately next to chunk, chunkbase, and .texdb in the Resource-Cvars block (resource_debugLevel, resource_updatePakCacheTable, resource_trackLoadTimes) — consistent with a name-based composition of the archive list. The exact rule is unclear: there is no %s.resources format string in the Exe except for gameresources.resources / mp_gameresources.resources / snap_gameresources.resources, and the game was not launched.

If this is confirmed, a Mod costs its own archive of a few hundred KiB instead of a 670-MiB rebuild. This is the question that should be answered next.


5. .entities, Scripts, and the Second Compression Layer

Three types appear binary but are not: compfile (129), extkisclule (2 492), and cfile (67). They carry behind the archive compression a second layer:

0x00  i64  usize   negative = payload data is uncompressed, |usize| = length
0x08  u64  csize   Length of the Kraken frame, always len(payload) - 16
0x10  …    Kraken frame (starts with 0x8C) or raw text

Based on all 2 688 entries of these types: 129/129 compfile and 2 477/2 492 extkisclule succeed; the remaining 15 extkisclule carry a negative usize (d9ffffff… = −39) and are uncompressed text. cfile does not follow the schema — see Section 6. tools/survey_tnc.py --maps performs this layer.

5.1 The Maps: 29 Pieces, 437 570 Entity Blocks

29 .entities maps, 437570 entity blocks, 397.3 MiB text,
decl.py: 29 ok / 0 errors

The header of each map is Version 5 / HierarchyVersion 2, followed by numerous blocks of the form

entity {
    entityDef c01p1_vo_kiscule {
    inherit = "kiscule";
    class = "idKisculeEntity";
    expandInheritance = false;
    poolType = "POOLTYPE_NONE";
    edit = {
        spawnPosition = { x = -1.1000000238; y = 2.9000000954; }
        externalKisculeDecl = "maps/game/wolf/c01/c01p1_snd_vo/kiscules/c01p1_vo_kiscule.decl";
    }
}
}

The world block of the first map shows what everything depends on: globalAIsettings = "default", envSettings = "game/wolf/c01/c01p1_default", playerSwitchDefs (five player variants), devmapInvLoadout = "devinvloadout/c01p1_devloadout".

Most frequent inherit values across all 29 maps: func/staticinstance 323 106, decal_stamp 22 686, func/dynamic 15 467, light/point 6 858, ai/scenepoints/tactical 6 260, ai/scenepoints/combat 5 441, func/flare 4 535, prop/breakable 3 304, kiscule 2 443, func/emitter 2 194, ai/scenepoints/traversal 2 013, nav/region 1 714, soundportal 1 436, ai/scenepoints/roam 1 429, light/spot 1 351, prop/moveable 1 326, triggers/trigger 1 181.

Most frequent class: idStaticInstanceEntity 324 128, idDynamicStampEntity 22 686, idDynamicEntity 15 467, idScenePoint_Combat 14 922, idLight 8 329, idProp2 7 431, idLensFlare 4 535, idBreakable 3 313, idInfo 3 115, idKisculeEntity 2 443.

All 29 maps parse successfully with decl.py. Thus, enemy, light, and pickup switching is technically achievable. Practically, size is the problem: a map is up to ~2.6 MiB text, and every change incurs double recompression plus archive space overhead.

5.2 Kiscule: The Script Layer

2 408 unique extkisclule files (…/kiscules/*.decl), totaling 70 274 nodes in 205 node types — the id-tech-6 counterpart to the .script files from Part 1, but as a node graph instead of text VM. Format:

edit = { kiscule = { version = 6; nodes = { num = 3;
  item[0] = { name = "idKisculeNodeGetCustomEvent"; id = 1556281116;
              nodePos = { x = 220; y = 300; }
              parameters = { class = "idKiscule…" } } } } }

Most Frequent Nodes: idKisculeNodeEntityReference 13,850, idKisculeNodeGetCustomEvent 8,020, idKisculeNodeDelay 4,103, idKisculeNodeActionTriggerFx 3,532, idKisculeNodeActionSoundVoiceOver2 3,499, idKisculeNodeGate 3,195, idKisculeNodeSendCustomEvent 2,582, idKisculeNodeActionBoolControl 1,979, idKisculeNodeActionShowHide 1,837, idKisculeNodeRandomTime 1,808, idKisculeNodeActionSoundPostEvent 1,493, idKisculeNodePlayerEnt 1,473, idKisculeNodeLight 1,417, idKisculeNodeLog 1,325, idKisculeNodeNamedVariableBool 1,206.

Changing numeric values in existing nodes (delays, probabilities, Bool toggles) is purely decl.set(). Adding nodes requires understanding the id connections — unverified.

A second-pass catalogue corrects and extends the 205-type count. The node-type total (205 used, matching this survey) is unchanged and re-verified against RTTI, but the "additional classes exist, unused" figure above (113) is superseded: a corrected RTTI filter puts it at 105 genuinely unused node classes (not 113 — 15 entries in the naive count turned out to be parameter-payload classes, not node classes; neither pass's filter reproduces round 1's 113 exactly, left as an open, low-stakes discrepancy). A full, purpose-grouped reference with ports, parameters, and real wiring counts from all 2,408 scripts is now available: Kiscule Node Reference, built from re_probes/kisculeref/catalogue.json (Research-Report-R3-Kisculeref).

New capability, static only: the Command node is a no-patch route into the restricted console. idKisculeNodeCommand's Exec handler doesn't call the dispatcher — it queues its command string into the same buffer resourceExec feeds, drained against the full, unrestricted 234-command/3,256-Cvar catalog twice every engine frame. A trigger-fired Command node in a map script can therefore run God 1/Notarget 1/g_infiniteAmmo 1 with no resourceExec call and no console interaction — proven by full disassembly, not yet loaded in a custom map. See TNC Console Unlock §4 and Research-Report-R5-Kiscule-Command.

The 29 compfile/proc and 28 compfile/prockd entries also go away, but they contain Geometry (.mapProcKdFile0001 /* generated …), no Decl.


6. What is definitively not possible

cfile is encrypted, not just packed. There are 20 unique files: 10 .cfg and all 10 strings/*.lang (the complete localization, 389,360 bytes for English). All lie uncompressed in the archive (Mode 0), but carry their own header followed by noise:

strings/english.lang   d8441f00 f890ef59 964d5e45 fa3ea479 …
default_sp.cfg         fd180000 b708d259 b46bddd0 9dd39c64 …
default_mp.cfg         ffffffff 3450f557 927d60c0 edbf36ab …

The first 4 bytes are a length (0x1f44d8 = 2,049,240 for english.lang, significantly exceeding the 389,352 bytes of payload data — i.e., the length after unpacking), followed by the next 4 bytes as a Unix timestamp (0x59ef90f8 = October 24, 2017, within the game's build window). ffffffff as a length apparently marks "no decompression".

Oodle does not apply here: unpacking attempts from offset 4 and 8 for every plausible target size all fail, and oodle.raw_length() finds no valid frame length. Instead, NewColossus_x64vk.exe contains the symbols idSecureBlockCipher::Decrypt, idSecureBlockCipher::DecryptWithOSPadding, idSecureAEADCipher::Decrypt, and the Windows CNG call BCryptDecrypt. The content is encrypted, the key is unknown → localization is not moddable via this path.

Measured and expanded in docs/cfile.md (Tool tools/cfile.py): Handler is idEncryptedFileResource, AES in block mode with padding, a unique salt per file, the key is derived rather than stored. Three attack families are thoroughly refuted there — 33.8 million key windows from readable sections, 2.8 million string constants as KDF secret, 16,368 public derivations. The KDF itself resides in .text and is thus unreachable. The open path is different: Part 1 writes the same file as type file in plaintext, and whether a loose base/strings/english.lang beats the archive entry is a runtime question — see resource_loadLooseAssets and Experiment E2 in docs/ladepfade.md.

Incidental finding that partially mitigates this: default.cfg, default_sp.cfg, and build.cfg lie additionally as loose plaintext files in base\. CVar changes therefore do not need the archive at all.

Update — the key is solved, round-trip proven, and used in a running game. cfile is idEncryptedFileResource: 4 bytes declen, 12-byte salt, 16-byte IV, AES-128-CBC/PKCS7 ciphertext, a trailing 32-byte HMAC-SHA256 tag. The key derives from SHA256(salt || "swapTeam\n\x00" || resource name) — every input is offline-available, no runtime secret, no .text unpacking needed. Measured: 67/67 retail cfile payloads decrypt with a correct HMAC tag and valid PKCS7 padding, 67/67 re-encrypt byte-identically (round-trip), and 7/7 negative controls (wrong key, flipped bytes, wrong cipher mode) fail as intended. Full derivation: Research-Report-Cfilekey2; the loader's encoder lives in wolfsdk/tnccrypt.py.

This does not reopen strings/*.lang — the key derivation is per-resource-name, and nothing about it weakens the localization files specifically; replacing a .lang entry still means encrypting a full replacement under its exact name, untested for that use. What it does open, proven in a running game: the two retail-shipped, unused SnapMap configs snapdemo.cfg/snapedit.cfg are small cfile entries too — the loader replaces them with its own encrypted payload under the same name, and resourceExec <name> -s from the live console runs the lines inside at the unrestricted command/Cvar level. That is the entire mechanism behind TNC Console Unlock: non-exposed commands (God, Notarget) and Cvars (g_infiniteAmmo) confirmed executing in a real save game, no code patch.


7. Where uncertainty remains

  • Patch Priority. 1,701 double-stored pairs with different content prove that something wins—what rule? Unmeasured. This decides whether a Mod costs 300 KiB or 670 MiB. Highest priority.
  • devInvLoadout at Campaign Start. It is attached and visible only via devmapInvLoadout in the world block. Whether the normal campaign start reads the same Decl: unclear, untested.
  • particle. In Part 1, demonstrably impossible (pre-calculated resource block). In Part 2, all 797 entries are text, parse cleanly, and have ample space with a median gap of 205 bytes. Whether loading also works: not tested, the game was not started.
  • entityDifficultyMappings. The block in aiGlobalSettings:default is empty in Retail. Whether a filled one is evaluated: unclear.
  • Mode 4. 25 entries, all over 21 MB, all with the same 12-byte prefix. Extraction works, but what the prefix means is open—and thus also whether such an entry can be correctly written.
  • Gaps as Budget. That the gaps are null bytes and no entry overlaps is measured. That the engine has no second meaning for this area (Prefetch window, Streaming alignment) is not measured. Whoever writes into the gap should verify it individually.
  • Shader Modding is removed. renderProgResource (4,729 entries, 429 unique) is binary, and a full-text search over all 44,374 text assets for hlsl, SV_POSITION, and float4 finds no single asset with shader source text—the 14 hits are structure declarations in renderParm ({ struct { float3 pos; uint lightParms; float4 posShadow; … } }). Part 1 had HLSL sources still in renderProg; here they are pre-compiled.
  • The Vector Weakness of decl.py (Section 2.3) affects 2,242 Decls. As long as one does not automatically rewrite material, renderParm, table, and env, it is inconsequential—but it exists.
  • SpawnAI is not a registered command. An earlier pass of the command-dispatcher static table misread SpawnAI <entity-def-path> as registered (flags 0x0, same shape as killAI); a static re-check of the same table found this wrong — SpawnAI does not appear among the 234 registered commands, grouped with give/spawn/noclip. A data correction, not an in-game test result — see TNC Console Unlock §5 and Known Contradictions W21.
  • devgui 4 accepted with no visible window in one in-game test (a separate run, from the main menu, showed no error either, window state untested there). Whether this needs a key bind, or something else gates the draw call, is open — TNC Console Unlock §6.