RICCILAB
> blog/playground/eso-optional-depends-on-invisible

I Built a Typo Detector for Game Addons. Then the API Lied to Me.

_DEV_PROJECT

How a missing dependency feature turned into an undocumented API behavior discovery in Elder Scrolls Online.


If you’ve ever built tools that sit on top of someone else’s platform, you know the feeling. You design a feature, write clean code, deploy it, and… nothing happens. Not because your code is wrong. Because the platform silently omits data it promised to give you.

This is the story of how I found out that ESO’s addon API quietly ignores an entire category of dependency declarations — and how that discovery completely changed the scope of a feature I’d been building.

The Setup

I’m building AddOn Conflict Inspector (ACI), a diagnostic addon for Elder Scrolls Online. It loads last (the ZZZ_ prefix guarantees this), hooks into the addon manager, and produces a health report: orphaned libraries, event registration hotspots, SavedVariables conflicts, and dependency issues.

One of the Phase 2 features was missing dependency detection with typo hints. The idea: scan every addon’s declared dependencies, find the ones that don’t match any installed addon, and offer suggestions. Did you mean LibAddonMenu-2.0 instead of LibAddonManu-2.0? Levenshtein distance says yes.

The hint system has three tiers:

  1. Case mismatchlibFoo declared, LibFoo installed
  2. Version suffixLibFoo-2.0 declared, LibFoo installed (stripped 2.0 matches)
  3. Typo detection — Levenshtein distance <= 2 for names 8+ characters long Here’s the core of the Levenshtein implementation in Lua:
local function Levenshtein(a, b)
    if a == b then return 0 end
    local la, lb = #a, #b
    if la == 0 then return lb end
    if lb == 0 then return la end
    if math.abs(la - lb) > 2 then return 99 end  -- early exit
    local prev = {}
    for j = 0, lb do prev[j] = j end
    for i = 1, la do
        local curr = { [0] = i }
        for j = 1, lb do
            local cost = (a:sub(i, i) == b:sub(j, j)) and 0 or 1
            curr[j] = math.min(
                prev[j] + 1,
                curr[j - 1] + 1,
                prev[j - 1] + cost
            )
        end
        prev = curr
    end
    return prev[lb]
end

Nothing exotic. The math.abs(la - lb) > 2 early exit avoids wasting cycles on obviously different strings — important when you’re running this against 50+ addon names at game startup.

The detection pipeline is straightforward:

function ACI.FindMissingDependencies()
    local depIndex = ACI.BuildDependencyIndex()
    -- depIndex.reverse: depName -> [addons that depend on it]
    -- depIndex.byName:  name -> addon metadata
 
    for depName, users in pairs(depIndex.reverse) do
        if not depIndex.byName[depName] then
            -- Nobody installed with this name. Try hints:
            -- Tier 1: case match
            -- Tier 2: version-stripped match
            -- Tier 3: Levenshtein
        end
    end
end

Clean, testable, obvious. What could go wrong?

The Test Plan

ESO addons declare dependencies in a plain-text manifest file (.txt, not .json, not .xml). Each line starts with ##, which I'm omitting below for readability:

Title: AddOn Conflict Inspector
DependsOn: LibSomeLibrary
OptionalDependsOn: LibDebugLogger

Two flavors:

  • DependsOn — hard requirement. If the target isn’t installed, the addon won’t load.
  • OptionalDependsOn — soft requirement. The addon loads either way, but gets a load-order guarantee if the target is present.

My test plan: inject deliberate typos into ACI’s own manifest and see if /aci missing picks them up. Two test strings:

  • LibAddonManu-2.0 — Levenshtein distance 1 from LibAddonMenu-2.0 (Manu -> Menu)
  • LibDebuggLogger — Levenshtein distance 1 from LibDebugLogger (gg -> g) Simple, right?

Test 1: DependsOn (The Hard Way)

First attempt. I injected the typos as DependsOn:

## DependsOn: LibAddonManu-2.0 LibDebuggLogger

Deployed, restarted the game (ESO requires a full game restart to pick up Lua changes — /reloadui doesn’t reload addon code, another fun quirk).

Result: blank screen. No chat, no [ACI] v0.2.0 loaded, nothing. ACI didn’t load at all.

This makes sense in hindsight. DependsOn is a hard gate. If LibAddonManu-2.0 isn’t installed, ESO refuses to load the addon that requires it. My diagnostic tool — the thing that’s supposed to detect this exact problem — can’t load because it has the problem.

It’s like a spell checker that crashes when you give it a misspelled word.

Test 2: OptionalDependsOn (The Sneaky Way)

OK, switch to OptionalDependsOn. The addon should load regardless:

## OptionalDependsOn: LibAddonManu-2.0 LibDebuggLogger

Deployed, restarted.

ACI loaded. Chat showed [ACI] v0.2.0 loaded.

Typed /aci missing.

[ACI] Missing Dependencies
[ACI] No missing dependencies

Zero results. The two deliberately misspelled dependencies — invisible.

Wait, What?

This is where it gets interesting. My code wasn’t broken. The dependency index was building correctly. The Levenshtein function works fine. The issue was upstream: GetAddOnDependencyInfo()** simply does not return OptionalDependsOn entries.**

Here’s how ACI collects dependency data:

local numDeps = manager:GetAddOnNumDependencies(i)
local deps = {}
for d = 1, numDeps do
    local depName, depActive = manager:GetAddOnDependencyInfo(i, d)
    table.insert(deps, { name = depName, active = depActive })
end

When the manifest says DependsOn: LibFoo, this loop returns {name = "LibFoo", active = true/false}. When the manifest says OptionalDependsOn: LibFoo, this loop returns… nothing. GetAddOnNumDependencies doesn’t count them. GetAddOnDependencyInfo doesn’t list them. They simply don’t exist as far as the API is concerned.

No error. No empty entry. No flag. Just silence.

The Full Picture

After both tests, the behavior matrix looks like this:

Manifest DirectiveGetAddOnDependencyInfo returns it?Addon loads?
DependsOn: InstalledLibYes, active=trueYes
DependsOn: MissingLibYes, active=falseNo
OptionalDependsOn: InstalledLibNoYes
OptionalDependsOn: MissingLibNoYes

Read that bottom-right cell carefully. An addon can declare OptionalDependsOn: CompletelyFakeLibrary and:

  • The addon loads fine
  • The API pretends the declaration doesn’t exist
  • No tool using the official API can detect this

The Catch-22

So here’s the trap:

  • DependsOn** typos are detectable** — GetAddOnDependencyInfo returns them with active=false. But the addon that declared them won’t load. It’s dead on arrival.
  • OptionalDependsOn** typos are undetectable** — the API doesn’t report them at all. But the addon loads fine, just without the optional feature. For a diagnostic tool, this means:
  • You can detect other addons’ hard dependency typos (they show up in the API even though those addons won’t load)
  • You cannot detect any addon’s optional dependency typos (they’re invisible)

My feature works. It just can’t catch the class of errors that are most likely to go unnoticed — the soft, silent ones.

The Workaround (and Why I Didn’t Use It)

There is a brute-force alternative: read every addon’s manifest .txt file directly from disk, parse the OptionalDependsOn lines yourself, and skip the API entirely. ESO’s Lua sandbox doesn’t allow arbitrary file I/O, but you could do it from an external tool.

I decided against it. The feature still provides value for its primary use case: when someone uninstalls a library and forgets that three other addons had DependsOn pointing at it. Those addons break silently (they just don’t load), and ACI can now tell you exactly which library is missing and what depends on it — with typo hints if the name is close to something installed.

Lessons for Platform Developers

If you’re building an API that exposes metadata:

  1. Don’t silently omit categories. If GetAddOnNumDependencies returns 3, I expect that to include all declared dependencies — hard and optional. If you’re going to filter, give me GetAddOnNumOptionalDependencies as a separate call, or a flag on each entry.
  2. Document the omission. ESO’s API documentation (what little exists) doesn’t mention that OptionalDependsOn is invisible to GetAddOnDependencyInfo. I had to discover this by injecting test data and observing the absence.
  3. Absence of data is the hardest bug to find. My code was correct. My test methodology was correct. The only problem was that the data I expected simply wasn’t there — and “nothing” looks exactly the same as “no results because everything is fine.”

Lessons for Tool Builders

If you’re building diagnostic tools on top of someone else’s platform:

  1. Test with positive controls. Don’t just test the happy path. Inject known-bad data and verify your tool catches it. If it doesn’t, you’ve found either a bug in your code or a limitation in the platform. Both are worth knowing.
  2. Assume the API is lying. Or more charitably, assume it’s incomplete. Check what it doesn’t return, not just what it does.
  3. Document platform quirks immediately. I wrote up this finding the same day, with the exact commands, manifest contents, and user reports. Six months from now, “the API doesn’t return optional deps” would be a vague memory. The test transcript is the proof.

Current Status

The feature is deployed and working for its supported scope (DependsOn detection). The 3-tier hint system (case → version-strip → Levenshtein) is ready for the day someone’s hard dependency has a typo.

And every time /aci missing shows No missing dependencies, I know there’s a whole category of problems hiding just out of reach. not because I can’t detect them, but because the platform chose not to tell me they exist.


This post is part of the development log for AddOn Conflict Inspector, an ESO addon diagnostic tool. The full test evidence and code are in the repository’s docs/ folder.

EOF — 2026-04-09
> comments