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.

SKSE loads the DLL. SKSEPlugin_Load registers a message listener with the SKSE messaging interface.
Engine fires kDataLoaded. All ESPs/ESMs/ESLs have been loaded into memory. Every leveled list form is now accessible through RE::TESDataHandler.
Settings loaded from INI. Reads enablelogs. Auto-generates the INI with defaults if missing.
MergeAllLeveledLists() iterates every LVLI form in the game. For each one with 3+ source plugins, it runs the three-phase merge. Afterwards, it runs cycle detection across the entire leveled list graph.

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):

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

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

  1. Resolve the file path (handles MO2's virtual filesystem, relative paths, fallback directories)
  2. Open as binary stream, skip the TES4 header
  3. Scan top-level GRUPs for the LVLI group (label 0x494C564C)
  4. Within the LVLI group, read each record header (24 bytes: type, size, flags, formID)
  5. If the record is compressed (flag 0x00040000), decompress with zlib
  6. Parse subrecords: EDID, LVLO, LVLF, LVLD
  7. 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:

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):

  1. Keep as many direct entries in the parent as possible
  2. Move the overflow into sublists of up to 255 entries each, all marked kUseAll
  3. 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:

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:

  1. Steps back from _data to the size_t header and calls RE::free()
  2. Allocates a new block: RE::malloc(sizeof(size_t) + sizeof(LEVELED_OBJECT) * count)
  3. Writes the count into the header, copies entries via memcpy
  4. Updates the SimpleArray's _data pointer and numEntries

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:

Wrye Bash is better when:

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

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
StatMeaning
Total leveled listsEvery LVLI form loaded in the game
With overridesLists touched by exactly 2 plugins (base + winner). No merge needed
With conflictsLists touched by 3+ plugins. These are candidates for merging
Actually mergedHow many lists had real changes to apply (some conflicts result in identical entries)
Total entries addedSum of all individual items added across all merged lists
Total entries removedSum of all individual items removed across all merged lists
Overflow sublists createdHow many runtime sublists were created to handle the 255 limit. Only shown when > 0
Circular references brokenHow many cycles were detected and broken. Shown as "No circular references detected" when 0
Support me on Ko-fi