Details for Nerds
A technical deep-dive into how Dynamic Container Loot works under the hood, covering the merge algorithm, ESP binary parsing, FormID resolution, memory safety, and how it all compares to Wrye Bash's Bashed Patch.
Runtime Lifecycle
The plugin is a DLL loaded by SKSE. It runs entirely in-process with the Skyrim engine and never touches files on disk.
SKSEPlugin_Load registers a message listener with the SKSE messaging interface.RE::TESDataHandler.enablelogs. Auto-generates the INI with defaults if missing.Everything happens once, during the loading screen before the main menu. The merge results live in engine memory for the rest of the session. Nothing is written to disk. Nothing touches your save file. Uninstalling the plugin simply means the merges don't happen next launch.
How It Decides What to Merge
The engine exposes a source file array for every form. This array lists every plugin that defines or overrides that record, in load order. The plugin uses this to classify every CONT:
| Source Count | Meaning | Action |
|---|---|---|
1 |
Only defined in one plugin (the base). No conflict. | Skip |
2 |
Base + one override. The override is the winner. The game already has the right version. | Skip |
3+ |
Base + one winner + one or more losers. The losers' changes are invisible to the engine. This is a conflict. | Merge |
No bash tags, no user intervention, no opt-in. If 3+ plugins touch the same CONT, it gets merged automatically.
Identifying Base, Winners, and Losers
From the source file array (in load order):
- First = base (the original definition, usually from
Skyrim.esmor a DLC) - Last = winner (what the engine currently has in memory)
- Everything in between = losers (their changes were overwritten)
The merge algorithm recovers the losers' changes and folds them into the winner.
The Three-Phase Merge Algorithm
Each conflicting container goes through three phases. The goal: figure out what every loser mod intended to change, then apply those changes to the winner.
Phase 1: Compute Deltas
For each loser mod, the plugin reads the loser's version of the container from disk (binary ESP parsing) and compares it against the base.
Entry Identity
Each container entry is identified by its formID. The plugin builds a map of formID -> count for both the base and the loser, then compares them:
- Addition: Item exists in the loser but not in the base. The loser added it.
- Removal: Item exists in the base but not in the loser. The loser deleted it.
- Count change: Item exists in both but count differs. The loser changed the quantity.
Base items: {IronSword: 1, Bread: 2}
Loser items: {IronSword: 1, Bread: 2, Apple: 3}
Additions: {Apple: 3} <- loser added 3 apples
Removals: {} <- loser didn't remove anything
Count changes: {} <- same counts on shared items
Cross-Loser Aggregation
Deltas from all losers are merged into a single set of maps. Last-loser-wins: if multiple losers modify the same item, the last one in load order takes precedence. Its delta overwrites earlier ones.
Phase 2: Conflict Resolution
If the same item appears in both the additions and removals maps (one loser added it, another removed it), the plugin has to pick a side.
Rule: Removal intent wins. If any mod explicitly removed an item, that decision is respected even if another mod added it. A deliberate deletion is a stronger intent signal than a quantity tweak.
Conflicting entries are dropped from the count changes map.
Phase 3: Apply Deltas to the Winner
The winner's current in-memory container is snapshotted before any modifications. Then deltas are applied in order:
3a. Removals
For each removal, check if the item exists in the winner. If it does, remove it from the container array. If the winner already removed it independently, skip.
3b. Count Changes
For each count change, check if the item exists in the winner AND the winner's count still matches the base count. If so, apply the loser's new count. If the winner independently changed the count (doesn't match base), skip. The winner's explicit choice is respected.
3c. Additions
For each addition, check if the item is NOT already in the winner. If absent, add it. If the winner already has it, skip to avoid duplicates.
3d. Memory Write
Container entries are stored in a ContainerObject** array. The plugin reallocates this array directly using RE::calloc/RE::free (Skyrim's own allocator). The old array is freed, a new one is allocated, entries are copied in, and numContainerObjects is updated.
ESP Binary Parsing
To reconstruct what each loser mod intended, the plugin reads the raw ESP/ESM/ESL files from disk. This is necessary because the engine only keeps the winning version in memory. Loser data is gone.
File Format
ESP files are structured as nested records and groups:
TES4 header (file metadata, master list)
GRUP "CONT" (all container records)
CONT record
EDID subrecord -> Editor ID ("BarrelFood01")
CNTO subrecord -> Entry: formID(4) + count(4)
CNTO subrecord -> (repeats for each item)
CONT record
...
Binary Layout
Record Header (24 bytes) GRUP Header (24 bytes) Subrecord Header (6 bytes)
+-----------+ +-----------+ +-----------+
| type [4] | "CONT" | type [4] | "GRUP" | type [4] | "CNTO"
| size [4] | data bytes | size [4] | total bytes | size [2] | data bytes
| flags [4] | compressed? | label [4] | "CONT" +-----------+
| formID[4] | | gType [4] | 0=top-level
| misc [8] | | misc [8] | CNTO Data (8 bytes)
+-----------+ +-----------+ +-----------+
| formID[4] | item reference
| count [4] | stack size
+-----------+
Parsing Flow
- Resolve the file path (handles MO2's virtual filesystem, relative paths, fallback directories)
- Open as binary stream, skip the TES4 header
- Scan top-level GRUPs for the CONT group (label
0x544E4F43) - Within the CONT group, read each record header (24 bytes: type, size, flags, formID)
- If the record is compressed (flag
0x00040000), decompress with zlib - Parse subrecords: EDID (editor ID string), CNTO (item formID + count)
- Resolve formIDs from local plugin indices to runtime IDs using the master file list
FormID Resolution
Raw formIDs in ESP files use local indices. The high byte references a position in the file's master list, not the actual load order index. The parser resolves these to runtime formIDs:
- Normal plugins (ESP/ESM):
(compileIndex << 24) | basePart - Light plugins (ESL):
0xFE000000 | (smallIndex << 12) | basePart
The parser checks sourceFile->IsLight() and picks the right formula. This covers full ESMs, regular ESPs, ESL-flagged ESPs, and compact-form ESLs.
Caching
Parsed results are cached per filename. If multiple containers reference the same plugin as a loser, it's only parsed once. The cache is cleared after all merges complete.
Memory Safety: The ContainerItemExtra Problem
This is the trickiest implementation detail. The engine's built-in AddObjectToContainer always allocates a ContainerItemExtra struct, even when passing null for the owner. But items loaded normally from ESP files that lack a COED subrecord have itemExtra = nullptr.
The crash: Other systems (Papyrus VM, EngineFixes, etc.) check if (itemExtra) and then dereference itemExtra->owner without a secondary null check on the owner field. An allocated-but-empty ContainerItemExtra passes the first check but crashes on the second.
Dynamic Container Loot avoids this entirely by bypassing the engine's add function:
- Creates a
ContainerObjectusing the 2-argument constructor (object + count), which leavesitemExtra = nullptr - Copies the existing container array into a
std::vector, appends the new entry - Allocates a new array using Skyrim's own allocator (
RE::calloc), copies entries in, and frees the old array withRE::free
This matches exactly how the engine stores items loaded from ESP files with no COED data. No spurious allocations, no dangling pointers, no crashes.
Why Skyrim's Allocator?
Skyrim uses its own memory pools. If we allocated with new/malloc and the engine later tried to RE::free our pointer (or vice versa), it would corrupt the heap. Using RE::calloc/RE::free keeps our arrays in the same memory pool as engine-created ones.
EditorID Cache (Logging Only)
Skyrim strips EditorIDs from most form types at runtime to save memory. Leveled item lists (LVLI) are among the affected types. When detailed logging is enabled, items like LItemFoodInn would show up as just "LVLI 00035319", which isn't useful for debugging.
To fix this, the plugin scans every loaded mod's LVLI GRUP from disk, extracts the EDID subrecord, and builds a FormID-to-name cache. This is only done when enablelogs=true because it reads every mod file and adds noticeable startup time.
Comparison: Dynamic Container Loot vs. Wrye Bash
The merge logic draws from the same principles as Wrye Bash's Bashed Patch, specifically its leveled list merging (Delev/Relev) and record conflict resolution. Both tools solve the same category of problem but take fundamentally different approaches.
| Aspect | Dynamic Container Loot | Wrye Bash (Bashed Patch) |
|---|---|---|
| When it runs | Runtime, every game launch, during the loading screen before the main menu | Build-time, manually triggered by the user. Produces an ESP that the game loads normally |
| Output | In-memory changes only. No files written to disk. Nothing in your save | A new ESP file (Bashed Patch, 0.esp) that must be active in your load order |
| User intervention | None. Install and forget. Merges everything automatically | Requires rebuilding the patch after any load order change. May require Bash Tags on plugins for correct behavior |
| Bash Tags | Not used. The plugin detects additions, removals, and count changes automatically by comparing each mod against the base record | Critical. Tags like Delev (remove items) and Relev (relevel items) control what operations are allowed. Without tags, some changes are silently skipped |
| Delta calculation | FormID-keyed maps. Each loser is compared against the base. Tracks additions, removals, and count changes as separate delta maps. Last-loser-wins for conflicts | Set-based. Compares entry keys between master and mod. Applies deltas sequentially in load order via mergeWith() |
| Conflict: add vs. remove | Removal wins. If any mod removed an entry, it stays removed even if another mod added it | Depends on tags. Without Delev, removals may not be detected. With it, removals win |
| Winner independence | If the winner independently changed an item's count, the loser's count change is skipped. The winner's explicit choice is respected | No per-field winner checks. Merge is additive, combining everything into one combined list |
| Entry attributes | Tracks FormID + count only (CNTO subrecords only store these two values) | Tracks rich attributes: minimum level, count, owner, global variable, conditions. Uses gen_coed_key() for unique entry identity |
| Scope | CONT records only | LVLI, LVLN (NPCs), LVSP (spells), plus many non-leveled-list record types (stats, names, graphics, etc.) |
| Performance cost | Adds a small amount of time to the loading screen (ESP parsing + merge). Happens once per launch, before the main menu | Zero runtime cost (the ESP loads like any other plugin). Build time varies |
Where Each Tool Wins
Dynamic Container Loot is better when:
- You just want to install mods and play without thinking about patching
- Mod authors haven't applied Bash Tags to their plugins
- You change your load order frequently and don't want to rebuild patches
- You want removals detected automatically without tagging mods with
Delev
Wrye Bash is better when:
- You need to merge record types beyond containers (leveled lists, NPC stats, names, etc.)
- You want fine-grained control over exactly what gets merged
- You prefer zero runtime overhead
- You're building a carefully curated load order with explicit tag control
Using Both Together
They don't conflict. Wrye Bash produces an ESP that becomes another plugin in the load order. Dynamic Container Loot sees it like any other mod and incorporates its changes. If both fix the same conflict, the result is the same. There's no double-counting because the delta calculation compares against the base, not against zero. Keeping existing patches is harmless.
Error Handling
- Corrupt or unreadable record: Skipped with a warning. The merge continues with whatever data was parseable
- Decompression failure: Records compressed with zlib that fail to decompress are skipped (safety cap: 64 MB max uncompressed size)
- Unresolvable formID: If a loser references a form that doesn't exist at runtime, that entry is skipped during addition
- Uncaught exception: Caught at the top level. The plugin logs the error and continues running. It will not crash SKSE or the game
Stats Glossary
The log summary after every merge:
=== DynamicContainerLoot: Merge complete === Total containers: 1247 With overrides (base + winner only): 89 With conflicts (3+ sources): 34 Actually merged: 12 Total items added: 27 Total items removed: 3 Total items count changed: 2
| Stat | Meaning |
|---|---|
| Total containers | Every CONT form loaded in the game |
| With overrides | Containers touched by exactly 2 plugins (base + winner). No merge needed |
| With conflicts | Containers touched by 3+ plugins. These are candidates for merging |
| Actually merged | How many containers had real changes to apply (some conflicts result in identical entries) |
| Total items added | Sum of all individual items added across all merged containers |
| Total items removed | Sum of all individual items removed across all merged containers |
| Total items count changed | Sum of all count modifications. Only shown when > 0 |
BBCode (Nexus Description)
Simplified version of this page for Nexus mod descriptions.
