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, includingweapon,damage,entityDef,ammo,gorewounds,perks,upgrade,fx,sound.key valuewithout=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. Pathsedit.probability.item[N].woundTypeand.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 everyprobabilityto 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.goreGraph21,goreMannequin10 (edit.model = "models/characters/goremannequin/…",lootDrop19 (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:defaultis 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:
propItem79 (edit.ammoCount30×,edit.inventoryCount42×,edit.inventoryDecl,edit.upgradeDecl) — what an ammo-gathering prerequisite yields;abilityInfo40 (edit.abilityType = "AT_GROUNDPOUND"),abilityInventoryItem24,inventoryItem26,unlockable96,medal32,metric46.
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 callsplan_in_place()— what fits incsizecontinues to be written byte-for-byte as before — and only then releases the gap;write_in_place()then subtractscsizefrom the entry table. Only upwards: a smallercsizewould pullalign_up(vprevious_end, k)under the next offset, and that continues viarebuild().offsetalways 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 withcsize0), and that the new length fits. The column "fits incsize+ gap" is therefore no longer a calculation, but whatplan_in_slot()traverses:tools/verify_idcl_gap.pymeasures 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(Tooltools/cfile.py): Handler isidEncryptedFileResource, 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.textand is thus unreachable. The open path is different: Part 1 writes the same file as typefilein plaintext, and whether a loosebase/strings/english.langbeats the archive entry is a runtime question — seeresource_loadLooseAssetsand Experiment E2 indocs/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.
cfileisidEncryptedFileResource: 4 bytesdeclen, 12-byte salt, 16-byte IV, AES-128-CBC/PKCS7 ciphertext, a trailing 32-byte HMAC-SHA256 tag. The key derives fromSHA256(salt || "swapTeam\n\x00" || resource name)— every input is offline-available, no runtime secret, no.textunpacking needed. Measured: 67/67 retailcfilepayloads 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 inwolfsdk/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.langentry 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 configssnapdemo.cfg/snapedit.cfgare smallcfileentries too — the loader replaces them with its own encrypted payload under the same name, andresourceExec <name> -sfrom 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.
devInvLoadoutat Campaign Start. It is attached and visible only viadevmapInvLoadoutin theworldblock. 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 inaiGlobalSettings:defaultis 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 forhlsl,SV_POSITION, andfloat4finds no single asset with shader source text—the 14 hits are structure declarations inrenderParm({ struct { float3 pos; uint lightParms; float4 posShadow; … } }). Part 1 had HLSL sources still inrenderProg; 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 rewritematerial,renderParm,table, andenv, it is inconsequential—but it exists. SpawnAIis not a registered command. An earlier pass of the command-dispatcher static table misreadSpawnAI <entity-def-path>as registered (flags0x0, same shape askillAI); a static re-check of the same table found this wrong —SpawnAIdoes not appear among the 234 registered commands, grouped withgive/spawn/noclip. A data correction, not an in-game test result — see TNC Console Unlock §5 and Known Contradictions W21.devgui 4accepted 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.