Details for Nerds
A technical deep-dive into how Dynamic Leveled Lists works under the hood, covering the merge algorithm, ESP binary parsing, cycle detection, overflow handling, 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. There are no in-game notifications — all output goes to the log file.
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 LVLI:
| 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 LVLI, 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 leveled list 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 list from disk (binary ESP parsing) and compares it against the base. The comparison uses multiplicity-based diffing:
Entry Identity
Each entry is identified by a triple: (formID, level, count). Two entries are "the same" only if all three fields match. This matters because leveled lists legitimately use duplicate entries to weight drop probabilities. Three copies of Iron Bow at level 1 means 3x the drop chance.
Entry Bags
Both the base and loser versions are converted into an entry bag (a multiset): a map from (formID, level, count) to how many times that triple appears.
Base bag: {(IronBow, 1, 1): 3, (SteelSword, 6, 1): 1}
Loser bag: {(IronBow, 1, 1): 3, (SteelSword, 6, 1): 1, (MyWeapon, 1, 1): 2}
Delta Calculation
- Addition: Entry has higher multiplicity in the loser than the base. Delta =
loserMult - baseMult. - Removal: Entry has higher multiplicity in the base than the loser. Delta =
baseMult - loserMult.
Additions: {(MyWeapon, 1, 1): 2} <- loser added 2 copies
Removals: {} <- loser didn't remove anything
Cross-Loser Aggregation
When multiple losers add the same entry, the largest delta wins. If Mod A adds 3 copies and Mod B adds 2, the merged result adds 3 (not 5). This prevents snowballing because each mod independently decided "this item should appear N times," and the largest intent is honored.
List-Level Properties
The plugin also tracks changes to the list's flags (UseAll, CalculateFromAllLevels, etc.) and chanceNone. Last-loser-wins for property conflicts. If the winner already changed a property from the base value, the losers' changes are skipped (winner's explicit choice is respected).
Phase 2: Conflict Resolution
If the same (formID, level, count) triple appears in both the additions and removals maps (one mod added it, another deliberately removed it), the plugin must pick a side.
Rule: Removal intent wins. If any mod explicitly removed an item, that decision is respected even if another mod added it. This is critical for mods like Open World Loot and Morrowloot Ultimate that carefully curate loot tables.
The conflicting entry is dropped from the additions map and a warning is logged naming both mods.
Phase 3: Apply Deltas to the Winner
The winner's current entry list (what the engine has in memory) is the starting point.
3a. Removals
For each removal delta, search the winner's entries for matching (formID, level, count) triples and remove up to multiplicity instances.
3b. Additions
For each addition delta, check how many copies already exist in the winner versus the base. Only add copies that the winner is missing:
toAdd = max(0, delta.multiplicity - (winnerMult - baseMult))
This prevents double-adding entries the winner already has from its own override. New entries are created as LEVELED_OBJECT structs with the form pointer, level, and count set.
3c. Property Changes
If a loser changed flags or chanceNone and the winner didn't already change them from the base, the loser's values are applied.
3d. Overflow Check
If the final entry count exceeds 255, the list is split into overflow sublists before being written back (see Overflow Sublists below).
3e. Memory Write
The final entry array is written directly into the engine's SimpleArray<LEVELED_OBJECT> via RE::malloc/RE::free. The old allocation is freed, a new block is allocated with the correct size, entries are copied in, and numEntries 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 "LVLI" (all leveled item records)
LVLI record
EDID subrecord -> Editor ID ("LItemWeaponGreatSword")
LVLD subrecord -> Chance None (1 byte)
LVLF subrecord -> Flags (1 byte)
LVLO subrecord -> Entry: level(2) + pad(2) + formID(4) + count(2) [10 bytes]
LVLO subrecord -> (repeats for each entry)
LVLI record
...
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 LVLI group (label
0x494C564C) - Within the LVLI group, read each record header (24 bytes: type, size, flags, formID)
- If the record is compressed (flag
0x00040000), decompress with zlib - Parse subrecords: EDID, LVLO, LVLF, LVLD
- 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
Caching
Parsed results are cached per filename. If multiple leveled lists reference the same plugin as a loser, it's only parsed once. The cache is cleared after all merges complete.
Overflow Sublists (255 Entry Limit)
Skyrim's TESLeveledList::numEntries is a uint8_t, so the max is 255. When many mods add items to popular lists, the merged result easily exceeds this. The plugin handles it by creating overflow sublists at runtime, which no other Skyrim mod does.
How Sublists Are Created
The engine's IFormFactory::Create<RE::TESLevItem>() allocates a new leveled list form with a dynamic formID in the 0xFF000000+ range. These forms exist only in memory and don't persist in saves, which is fine since the plugin re-merges every launch.
Strategy: UseAll Lists
When a list has the kUseAll flag (every entry produces an item):
- Keep as many direct entries in the parent as possible
- Move the overflow into sublists of up to 255 entries each, all marked
kUseAll - Add those sublists as entries in the parent
Parent (255 entries max): [direct entry 1] [direct entry 2] ... [direct entry 253] [overflow sublist A (255 entries, kUseAll)] [overflow sublist B (remaining entries, kUseAll)]
The formula: numSublists = ceil((total - 255) / 254), directCount = 255 - numSublists.
Strategy: Random Pick Lists
When a list does NOT have kUseAll (engine picks one random entry), probability must be preserved. The solution: move ALL entries into sublists and distribute evenly.
Parent (N entries): [sublist A (200 entries)] [sublist B (200 entries)] Engine picks 1 of 2 sublists (50%), then sublist picks 1 of 200 (0.5%) Each item: 50% x 0.5% = 0.25% = 1/400. Equal probability preserved.
The formula: numSublists = ceil(total / 255), entries distributed as evenly as possible.
If IFormFactory::Create returns null because the engine ran out of dynamic formIDs (extremely unlikely), the plugin falls back to truncating at 255 entries and logs an error.
Cycle Detection and Breaking
Leveled lists can reference other leveled lists (nested lists). Automatic merging can create circular references that didn't exist before. For example, List A gets an entry pointing to List B from one mod, and List B gets an entry pointing to List A from another. If the engine tries to resolve this, it recurses infinitely and crashes.
Detection: Iterative DFS
After all merges complete, the plugin builds a directed graph of every LVLI-to-LVLI reference in the game and runs iterative depth-first search with three-color marking:
- White (0): Unvisited
- Gray (1): In the current DFS path (on the stack)
- Black (2): Fully explored
A back-edge (gray -> gray) means a cycle. The full cycle path is logged:
Circular leveled list reference detected: 00035319 -> 000A1234 -> 00035319
Breaking: Remove the Back-Edge
For each detected cycle, the plugin removes the entry that creates the back-edge from its parent list. This is the minimal change needed to break the cycle. The parent's entry array is rebuilt without the offending entry.
Each break is logged with instructions for a permanent xEdit fix:
Broke circular reference: removed SomeSublist [LVLI:000A1234] from MainList [LVLI:00035319] To permanently fix: open MyMod.esp in xEdit, find MainList [LVLI:00035319], and remove the entry pointing to SomeSublist [LVLI:000A1234]
Cycle warnings are always logged regardless of enablelogs because they indicate crash risk. The EditorID cache is built even with logging disabled specifically for readable cycle warnings.
Memory Layout: How Entries Are Written
Skyrim stores leveled list entries in a SimpleArray<LEVELED_OBJECT>. This is a custom container with a heap-allocated buffer:
Heap layout: [size_t count] [LEVELED_OBJECT[0]] [LEVELED_OBJECT[1]] ... [LEVELED_OBJECT[N-1]] ^ ^ header _data pointer (what SimpleArray stores)
The plugin's RebuildEntries() function:
- Steps back from
_datato thesize_theader and callsRE::free() - Allocates a new block:
RE::malloc(sizeof(size_t) + sizeof(LEVELED_OBJECT) * count) - Writes the count into the header, copies entries via
memcpy - Updates the SimpleArray's
_datapointer andnumEntries
This directly manipulates engine memory. The engine sees the updated list immediately without any hooks, patches, or detours.
Comparison: Dynamic Leveled Lists vs. Wrye Bash
Wrye Bash's Bashed Patch includes leveled list merging via its ListsMerger patcher. Both tools solve the same problem but take fundamentally different approaches.
| Aspect | Dynamic Leveled Lists | 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 and removals 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 | Multiplicity-based. Compares entry bags (multisets) between base and loser. Tracks exact counts of duplicate entries. Largest delta across losers wins | Set-based. Compares entry keys between master and mod. Tracks add/remove/change operations per mod. Applies deltas sequentially in load order |
| 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 |
| Duplicate weighting | Fully tracked. Entry identity is (formID, level, count). Three copies of Iron Bow = 3x weight, and that multiplicity is preserved through merges |
Tracked via linear search and key matching. Duplicates are generally preserved but behavior depends on tag configuration |
| 255 entry limit | Creates overflow sublists at runtime via IFormFactory::Create. Distributes excess entries into nested leveled lists. Preserves probability for random-pick lists. No data loss |
Hard truncation at 255 entries with a warning message. Excess entries are dropped. User must fix manually |
| Circular references | Full cycle detection via iterative DFS across the entire LVLI graph. Automatically breaks cycles by removing back-edges. Logs xEdit fix instructions | Empty sublist cascade removal (removes empty lists and propagates). Does not detect actual A->B->A cycles |
| Scope | LVLI records only | LVLI, LVLN (NPCs), LVSP (spells), plus many non-leveled-list record types (containers, stats, names, etc.) |
| Performance cost | Adds a few seconds 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 Leveled Lists 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 have lists exceeding 255 entries (overflow sublists vs. truncation)
- You want automatic circular reference detection and breaking
- You change your load order frequently and don't want to rebuild patches
Wrye Bash is better when:
- You need to merge record types beyond leveled lists (containers, 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 Leveled Lists 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
- Missing ESP file: Logs a warning, skips that mod's changes. Other mods still merge correctly
- 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
- Sublist creation failure: Falls back to 255 truncation
- 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:
=== DynamicLeveledLists: Merge complete === Total leveled lists: 6651 With overrides (base + winner only): 1470 With conflicts (3+ sources): 110 Actually merged: 29 Total entries added: 76 Total entries removed: 7 Overflow sublists created: 3 No circular references detected
| Stat | Meaning |
|---|---|
| Total leveled lists | Every LVLI form loaded in the game |
| With overrides | Lists touched by exactly 2 plugins (base + winner). No merge needed |
| With conflicts | Lists touched by 3+ plugins. These are candidates for merging |
| Actually merged | How many lists had real changes to apply (some conflicts result in identical entries) |
| Total entries added | Sum of all individual items added across all merged lists |
| Total entries removed | Sum of all individual items removed across all merged lists |
| Overflow sublists created | How many runtime sublists were created to handle the 255 limit. Only shown when > 0 |
| Circular references broken | How many cycles were detected and broken. Shown as "No circular references detected" when 0 |
