# Reflection and IPC for Dalamud plugins

Updated: 2026-08-17

Audience: Dalamud plugin authors, maintainers, reviewers, and software agents.

This guide explains how one plugin can communicate with or inspect another plugin. Prefer a documented public API or Dalamud IPC contract. Reflection is a version-fragile fallback for targets that expose no suitable contract. The Dropbox examples are a public case study; refresh the target source and runtime layout before relying on any reflected member.

## The decision in one minute

Use the narrowest stable boundary that solves the problem:

| Situation | Preferred boundary | Why |
| --- | --- | --- |
| Code is inside the same plugin | Direct method or service call | The compiler and normal tests protect the contract. |
| Another plugin publishes a matching operation or event | Dalamud IPC | The provider intentionally owns the public contract. |
| A shared library already models the operation | That library's public API | Avoids coupling either plugin to private runtime layout. |
| No public contract exists and a temporary integration is necessary | Reflection with explicit version guards | Can reach the behavior, but every member path is private and fragile. |
| The goal is to bypass permissions, conceal access, or modify unrelated sensitive state | Do not implement it | Reflection is not an authorization mechanism. |

The default order is: **direct/public API -> IPC -> reflection**.

## IPC and reflection compared

| Property | IPC | Reflection |
| --- | --- | --- |
| Contract owner | Provider intentionally registers it | Consumer infers private implementation details |
| Coupling | Tag, argument types, return type, and semantics | Assembly, type, member name, member kind, visibility, ownership, and semantics |
| Type safety | Strong once both sides use the same generic signature | Mostly runtime checked; casts and invocation can fail late |
| Discovery | Provider documentation, constants, or API package | Source inspection, decompilation when lawful, runtime inspection, and logs |
| Update tolerance | Good when versioned compatibly | Poor when private members move or change |
| Failure signal | Missing provider, signature mismatch, provider exception, or timeout policy | Missing type/member, inaccessible object, cast failure, invocation exception, or silent wrong-state write |
| Lifecycle | Register/unregister and subscribe/unsubscribe | Discover/invalidate caches and remove reflected event handlers |
| Performance | Cheap for normal calls; still avoid blocking callbacks | Member discovery is expensive; cached invocation is usually acceptable outside hot paths |
| Testing | Provider and consumer contract tests | Contract probes against each supported target version plus runtime validation |
| Maintenance | Provider and consumers negotiate changes | Consumer chases target internals after changes |

IPC is not automatically safe merely because it is public. A badly designed IPC callback can still block the framework thread, throw across plugin boundaries, leak mutable objects, or change meaning without a version bump.

## Part I: Dalamud IPC

### Mental model

An IPC gate is identified by two things:

1. an exact string tag, such as `Example.Math.Add.v1`; and
2. an exact generic delegate shape, such as two `int` inputs and one `int` result.

The provider and subscriber must agree on both. Treat the tag and signature as a public API. Put tags in shared constants or a small API package when multiple consumers exist.

### Provider: function, action, and event

```csharp
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;

public sealed class ExampleIpcProvider : IDisposable
{
    private readonly ICallGateProvider<int, int, int> add;
    private readonly ICallGateProvider<string> run;
    private readonly ICallGateProvider<string> statusChanged;

    public ExampleIpcProvider(IDalamudPluginInterface pluginInterface)
    {
        add = pluginInterface.GetIpcProvider<int, int, int>("Example.Math.Add.v1");
        run = pluginInterface.GetIpcProvider<string>("Example.Commands.Run.v1");
        statusChanged = pluginInterface.GetIpcProvider<string>("Example.Status.Changed.v1");

        add.RegisterFunc((left, right) => checked(left + right));
        run.RegisterAction(command => QueueValidatedCommand(command));
    }

    public void PublishStatus(string status) => statusChanged.SendMessage(status);

    public void Dispose()
    {
        run.UnregisterAction();
        add.UnregisterFunc();
    }

    private static void QueueValidatedCommand(string command)
    {
        // Validate and enqueue work. Do not run a long workflow inside the IPC call.
    }
}
```

Use a function for a short request/response, an action for a short command, and `SendMessage` for notifications. The event gate does not register a function or action; subscribers attach handlers to it.

### Subscriber: invoke and subscribe

```csharp
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;

public sealed class ExampleIpcClient : IDisposable
{
    private readonly ICallGateSubscriber<int, int, int> add;
    private readonly ICallGateSubscriber<string> run;
    private readonly ICallGateSubscriber<string> statusChanged;

    public ExampleIpcClient(IDalamudPluginInterface pluginInterface)
    {
        add = pluginInterface.GetIpcSubscriber<int, int, int>("Example.Math.Add.v1");
        run = pluginInterface.GetIpcSubscriber<string>("Example.Commands.Run.v1");
        statusChanged = pluginInterface.GetIpcSubscriber<string>("Example.Status.Changed.v1");
        statusChanged.Subscribe(OnStatusChanged);
    }

    public int Add(int left, int right) => add.InvokeFunc(left, right);

    public void Run(string command) => run.InvokeAction(command);

    public void Dispose() => statusChanged.Unsubscribe(OnStatusChanged);

    private static void OnStatusChanged(string status)
    {
        // Copy what is needed and return quickly.
    }
}
```

Acquiring a subscriber does not prove that a provider is ready. Invocation is the point at which a missing provider, wrong signature, or provider-side exception becomes observable. Convert those failures into a bounded feature-unavailable state; do not retry every frame or hide the failure.

### Design a durable IPC contract

- **Namespace tags.** Use a stable plugin or API prefix and include a version when a breaking change is possible.
- **Prefer small serializable values.** Primitive values, immutable records, IDs, and snapshots are safer than exposing live mutable services.
- **Document direction.** State who calls whom, which thread invokes the callback, and whether the result is a snapshot.
- **Keep calls short.** Validate and enqueue long work, then expose status or completion through another query/event.
- **Make cleanup symmetric.** Every registration and subscription needs an unregister or unsubscribe path.
- **Separate availability from success.** “Provider is loaded” and “requested operation completed” are different states.
- **Define idempotency.** State whether repeating a command is safe and how callers supply correlation IDs when it is not.
- **Version semantics, not only types.** The same `bool` signature can become incompatible if the meaning changes.
- **Never throw for routine absence.** Missing optional dependencies should disable a feature cleanly; programmer and invariant failures should remain visible.

### Capability and status pattern

For a larger surface, start with a small version/capability query:

```csharp
public sealed record ApiInfo(int Major, int Minor, IReadOnlySet<string> Capabilities);

// Provider tag: Example.Api.GetInfo.v1
// Consumer checks Major before acquiring or invoking optional operations.
```

This prevents a consumer from guessing support from plugin version text alone. It also lets a provider add optional capabilities without breaking older consumers.

### IPC failure matrix

| Failure | What it usually means | Consumer response |
| --- | --- | --- |
| Provider not ready | Plugin absent, disabled, loading, unloading, or registration not complete | Mark unavailable, back off, and retry only on lifecycle change or a bounded schedule |
| Signature mismatch | Provider and consumer generic shapes differ | Treat as an incompatible API version; log the tag and expected shape |
| Provider throws | Contract precondition, provider defect, or downstream failure | Preserve the inner failure context and stop automatic repetition |
| Event never arrives | Subscription timing, provider state, or missing completion path | Pair events with a queryable status and timeout |
| Duplicate work | Consumer retried a non-idempotent action | Add request IDs, state checks, or an explicit idempotency contract |
| Unload crash or leak | Registration/subscription was not removed | Make lifecycle ownership disposable and test reload paths |

## Part II: Reflection

### Use reflection only when the tradeoff is explicit

Reflection is appropriate when all of the following are true:

- no supported public API or IPC surface provides the required operation;
- the integration is authorized and narrowly scoped;
- the exact target version or member contract can be discovered and tested;
- failure can disable the optional feature without destabilizing either plugin; and
- someone owns the maintenance burden after target updates.

Avoid it for security boundaries, hot per-frame loops, silent writes to unknown state, core startup requirements, or integrations that can be solved by asking the provider to publish IPC.

### ECommons `DalamudReflector`

At the ECommons source snapshot observed on 2026-08-17, the reflection module must be requested during initialization and `TryGetDalamudPlugin` returns the live plugin instance as `object`:

```csharp
using ECommons;
using ECommons.Reflection;

public Plugin(IDalamudPluginInterface pluginInterface)
{
    ECommonsMain.Init(pluginInterface, this, Module.DalamudReflector);
}

if (DalamudReflector.TryGetDalamudPlugin("Dropbox", out var pluginInstance))
{
    var pluginType = pluginInstance.GetType();
    var liveAssembly = pluginType.Assembly;
}
```

`DalamudReflector` caches discovered instances unless cache use is disabled and monitors installed-plugin changes to invalidate that cache. The module is still reflecting over private Dalamud internals; convenience does not turn that path into a public contract.

### ECommons `ReflectionHelper`

Current ECommons helpers include unified field/property access and method calls:

```csharp
using static ECommons.Reflection.ReflectionHelper;

var taskManager = pluginInstance.GetFoP("TaskManager");
var isBusy = taskManager?.GetFoP<bool>("IsBusy") ?? false;
taskManager?.Call("Abort", Array.Empty<object>());
```

`GetFoP`, `SetFoP`, `GetStaticFoP`, and `Call` reduce boilerplate. They do not verify that a member still carries the same meaning, and ambiguous overloads may require exact argument-type matching. Wrap them in feature-specific validation and read-back.

### Direct .NET discovery of the live instance

When ECommons is not available, the common direct approach is to inspect Dalamud's loaded-plugin wrappers and extract the live plugin object. This relies on non-public Dalamud types and fields and can break after a Dalamud update.

```csharp
using Dalamud.Plugin;
using System.Collections;
using System.Reflection;

private static object? FindLoadedPluginInstance(string internalName)
{
    var dalamudAssembly = typeof(IDalamudPluginInterface).Assembly;
    var serviceType = dalamudAssembly.GetType("Dalamud.Service`1");
    var pluginManagerType = dalamudAssembly.GetType("Dalamud.Plugin.Internal.PluginManager");

    if (serviceType is null || pluginManagerType is null)
        return null;

    var pluginManager = serviceType
        .MakeGenericType(pluginManagerType)
        .GetMethod("Get")?
        .Invoke(null, null);

    var installed = pluginManager?.GetType()
        .GetProperty("InstalledPlugins")?
        .GetValue(pluginManager) as IList;

    if (installed is null)
        return null;

    foreach (var wrapper in installed)
    {
        if (wrapper is null)
            continue;

        var discoveredName = wrapper.GetType()
            .GetProperty("InternalName")?
            .GetValue(wrapper)?
            .ToString();

        if (!string.Equals(discoveredName, internalName, StringComparison.Ordinal))
            continue;

        var wrapperType = wrapper.GetType().Name == "LocalDevPlugin"
            ? wrapper.GetType().BaseType
            : wrapper.GetType();

        return wrapperType?
            .GetField("instance", BindingFlags.NonPublic | BindingFlags.Instance)?
            .GetValue(wrapper);
    }

    return null;
}
```

Start all further work from `pluginInstance.GetType()` and `pluginInstance.GetType().Assembly`. Do not load a second copy of the target DLL into the running process: types from separate load contexts may look identical by name and still be incompatible.

### Resolve fields and properties correctly

The most common failure is not `MethodInfo.Invoke`; it is choosing the wrong object, ownership, or member kind. A useful resolver checks:

- field and property;
- public and non-public;
- instance and static;
- base types;
- readable/writable accessors; and
- the actual declaring member used by the target behavior.

```csharp
private const BindingFlags AllFlags =
    BindingFlags.Public |
    BindingFlags.NonPublic |
    BindingFlags.Instance |
    BindingFlags.Static |
    BindingFlags.DeclaredOnly;

private static object? GetMemberValue(Type type, object? instance, string name)
{
    for (var current = type; current is not null; current = current.BaseType)
    {
        var field = current.GetField(name, AllFlags);
        if (field is not null)
            return field.GetValue(field.IsStatic ? null : instance);

        var property = current.GetProperty(name, AllFlags);
        var getter = property?.GetGetMethod(nonPublic: true);
        if (getter is not null && property!.GetIndexParameters().Length == 0)
            return property.GetValue(getter.IsStatic ? null : instance);
    }

    return null;
}

private static bool TrySetMemberValue(
    Type type,
    object? instance,
    string name,
    object? value)
{
    for (var current = type; current is not null; current = current.BaseType)
    {
        var field = current.GetField(name, AllFlags);
        if (field is not null)
        {
            field.SetValue(field.IsStatic ? null : instance, value);
            return true;
        }

        var property = current.GetProperty(name, AllFlags);
        var setter = property?.GetSetMethod(nonPublic: true);
        if (setter is not null && property!.GetIndexParameters().Length == 0)
        {
            property.SetValue(setter.IsStatic ? null : instance, value);
            return true;
        }
    }

    return false;
}
```

Do not treat `null` as proof that the member is missing; the member may exist and legitimately contain `null`. A production resolver should return a result object that distinguishes “not found,” “found with null value,” and “getter threw.”

### Invoke methods deliberately

```csharp
var abort = taskManager.GetType().GetMethod(
    "Abort",
    BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
    binder: null,
    types: Type.EmptyTypes,
    modifiers: null);

abort?.Invoke(taskManager, null);

var enqueue = taskManager.GetType().GetMethod(
    "Enqueue",
    BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
    binder: null,
    types: new[] { typeof(Func<bool?>), typeof(string) },
    modifiers: null);

enqueue?.Invoke(taskManager, new object[]
{
    new Func<bool?>(() => true),
    "Example task"
});
```

Select overloads by parameter types. Check optional parameters, `ref`/`out` arguments, generic arity, return type, and whether the method is static. `TargetInvocationException` wraps an exception thrown by the target; preserve its inner exception in diagnostics.

For a generic method:

```csharp
var definition = instance.GetType()
    .GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
    .Single(method =>
        method.Name == "Resolve" &&
        method.IsGenericMethodDefinition &&
        method.GetGenericArguments().Length == 1);

var closed = definition.MakeGenericMethod(typeof(string));
var result = closed.Invoke(instance, new object[] { "key" });
```

### Subscribe to reflected events safely

Event reflection requires an exact compatible delegate. Keep the created delegate so it can be removed later.

```csharp
var eventInfo = pluginType.GetEvent(
    "StateChanged",
    BindingFlags.Public | BindingFlags.NonPublic |
    BindingFlags.Instance | BindingFlags.Static);

var handlerMethod = typeof(ConsumerPlugin).GetMethod(
    nameof(OnTargetStateChanged),
    BindingFlags.NonPublic | BindingFlags.Static);

if (eventInfo?.EventHandlerType is not null && handlerMethod is not null)
{
    reflectedHandler = Delegate.CreateDelegate(eventInfo.EventHandlerType, handlerMethod);
    eventInfo.AddEventHandler(eventInfo.AddMethod?.IsStatic == true ? null : pluginInstance, reflectedHandler);
}

// During dispose or target unload:
eventInfo?.RemoveEventHandler(
    eventInfo.RemoveMethod?.IsStatic == true ? null : pluginInstance,
    reflectedHandler);
```

If the signature changes, delegate creation should fail the feature cleanly. Never leave a target event holding a delegate into an unloaded consumer.

## Dropbox case study: trace behavior, not display text

The approved case study demonstrates why guessing member names fails. At the examined Dropbox source layout, the plugin initialized two internal static fields:

```csharp
internal static Config C;
internal static Dropbox P;
```

Its settings UI bound the “Enable auto-accept trades” checkbox to:

```csharp
ImGui.Checkbox("Enable auto-accept trades", ref C.Active);
```

That evidence established the actual runtime path as `Dropbox.Dropbox.C.Active`:

- `P` is the static plugin singleton;
- `C` is the static configuration container;
- `Active` controls the live behavior; and
- `PermanentActive` separately describes persistence across restarts in that observed layout.

A display-label-derived guess such as `EnableAutoAcceptTrades` would compile in consumer code yet find nothing at runtime. Constructor initialization, UI bindings, and the logic that consumes the value are stronger evidence than a plausible name.

### Generic boolean-setting adapter

```csharp
public static bool TrySetBooleanConfigFlag(
    object pluginInstance,
    IReadOnlyList<string> configCandidates,
    IReadOnlyList<string> valueCandidates,
    bool newValue,
    out string message)
{
    object? config = null;

    foreach (var candidate in configCandidates)
    {
        config = GetMemberValue(pluginInstance.GetType(), pluginInstance, candidate);
        if (config is not null)
            break;
    }

    if (config is null)
    {
        message = "Configuration container was not resolved.";
        return false;
    }

    foreach (var candidate in valueCandidates)
    {
        if (!TrySetMemberValue(config.GetType(), config, candidate, newValue))
            continue;

        var readBack = GetMemberValue(config.GetType(), config, candidate);
        if (readBack is bool actual && actual == newValue)
        {
            message = $"Set {candidate}={actual} and verified the read-back.";
            return true;
        }

        message = $"Wrote {candidate}, but read-back did not match.";
        return false;
    }

    message = "No candidate value member was resolved.";
    return false;
}
```

Dropbox-specific use at the examined layout would provide `C`, `Config`, and `Configuration` as container candidates and `Active` as the value candidate. Do not add guessed fallbacks merely to make a control appear to work. A wrong boolean can be more dangerous than a missing one.

### Live state and persistence are different

After any reflected write, determine whether the target:

- reads the value continuously;
- snapshots it only when a workflow starts;
- requires `Apply`, `Refresh`, `Rebuild`, or another method;
- requires an explicit save call;
- maintains a second persistence flag; or
- reconstructs the object and discards the reference later.

Read-back proves only that the member contains the written value. It does not prove downstream behavior or persistence. Verify the target UI and the actual behavior separately.

## Caching without stale objects

Cache `FieldInfo`, `PropertyInfo`, and `MethodInfo` only after the target type and version have been validated. Avoid permanent strong references to plugin instances.

```csharp
private WeakReference<object>? cachedPlugin;
private MethodInfo? cachedAbort;

private void InvalidateTargetCache()
{
    cachedPlugin = null;
    cachedAbort = null;
}
```

Invalidate on target unload/reload, installed-plugin changes, consumer reload, failed type/version validation, and invocation errors that indicate a stale load context. Reflection discovery belongs in setup or refresh paths, not every framework update.

## Repeatable reflection workflow

1. **Confirm authorization and scope.** Name the one behavior the integration needs.
2. **Search for public APIs and IPC first.** Ask the provider to publish a contract when long-term interoperability matters.
3. **Resolve the live plugin instance.** Metadata or a wrapper is not the target object.
4. **Use the live type and assembly.** Avoid second-load type identity problems.
5. **Find the authoritative owner.** Check plugin singleton, config, service, task manager, scheduler, queue, or nested settings object.
6. **Trace the exact behavior.** Follow constructor initialization, settings UI bindings, and consuming logic.
7. **Classify the member.** Field or property; instance or static; declaring type; read/write access; exact method overload.
8. **Validate before mutation.** Check target version/type, current value type, allowed range, and feature state.
9. **Write and read back.** Then verify the UI or downstream behavior separately.
10. **Handle apply/save semantics.** Do not assume a successful write is persistent.
11. **Add diagnostics.** Record the resolved type and member path without dumping sensitive state.
12. **Invalidate and dispose.** Remove event handlers and stale references on plugin changes.
13. **Test each supported version.** Treat every reflection path as a private, versioned contract.

## Troubleshooting

| Symptom | Likely cause | Diagnostic |
| --- | --- | --- |
| Plugin not found | Wrong internal name, target not loaded, or discovery internals changed | Log the requested internal name and target load state; refresh on plugin-list change |
| Wrapper found but instance is null | Target is still loading/unloading or wrapper field changed | Defer and refresh; do not cache null as success |
| Config is null | Wrong owner, static member not handled, different candidate name, or legitimate null state | Inspect constructor and UI binding; distinguish missing from null-valued |
| Member not found | Name, declaring type, visibility, field/property kind, or version changed | Enumerate names and signatures in a development-only diagnostic |
| Ambiguous method | Multiple overloads | Resolve by exact parameter types and generic arity |
| Cast fails | Wrong member, changed type, or second assembly/load context | Log assembly-qualified types and use the live target assembly |
| Invocation wraps an exception | Target code threw | Inspect `TargetInvocationException.InnerException` and stop repetition |
| Write reads back but behavior does not change | Wrong owner, snapshot semantics, or apply/reload step required | Trace the consuming code and verify target UI plus behavior |
| Works until plugin reload | Cached object or `MemberInfo` belongs to an old load context | Invalidate on lifecycle change and rediscover |
| Growing memory or unload failure | Strong cached reference or reflected event not removed | Use weak references and symmetric event cleanup |

## Safety and review checklist

- Public API and IPC alternatives were evaluated first.
- Target plugin and member contract are documented with an observation/version boundary.
- No credentials, account data, unrelated configuration, or sensitive state are inspected or logged.
- The feature disables cleanly when the target is absent or incompatible.
- Reflection is not executed in a hot path unless lookup results are cached and measured.
- Every mutation validates type and allowed value before writing.
- Every write has read-back plus separate behavior verification.
- Apply/save/persistence behavior is explicit.
- Exceptions retain actionable context without dumping private data.
- Target reload invalidates instance and member caches.
- Reflected events are removed during dispose and target unload.
- Runtime testing is repeated after target or Dalamud updates.
- The maintenance owner knows that private contracts can break without notice.

## ECommons or direct reflection?

Use ECommons when the project already depends on it and its helpers match the required operation. `DalamudReflector` handles live-instance discovery and cache invalidation; `ReflectionHelper` shortens common field/property/method operations. Use direct .NET reflection when avoiding the dependency is important or when the resolver needs stricter result types, inheritance rules, overload selection, diagnostics, or validation.

Both approaches remain reflection. Neither makes the target's private implementation stable.

## Further learning

- [ECommons Education](./ecommons-education.html) - initialization, modules, task management, UI helpers, IPC helpers, throttling, configuration, events, hooks, and adoption guidance.
- [ECommons source](https://github.com/NightmareXIV/ECommons) - refresh exact APIs before implementation.
- [ECommons source snapshot used for this guide](https://github.com/NightmareXIV/ECommons/tree/e6be8f0fd7786a9e1781db2e71cb5b9146f04980) - observed 2026-08-17; repository commit dated 2026-08-08.
- [Dalamud IPC API reference](https://dalamud.dev/api/Dalamud.Plugin.Ipc/) - current provider/subscriber interfaces.
- [.NET reflection overview](https://learn.microsoft.com/dotnet/fundamentals/reflection/reflection) - runtime type inspection fundamentals.
- [BindingFlags reference](https://learn.microsoft.com/dotnet/api/system.reflection.bindingflags) - member-selection flags.

## Final rule

Design IPC as a small public protocol. Treat reflection as a private adapter with a version check, read-back, diagnostics, cache invalidation, and an exit plan.
