Scope and authorization. Prefer a documented API or IPC surface. Reflection is a version-fragile fallback for a narrow, authorized integration; it is not a security bypass. The Dropbox member path below is a source-traced case study, not a promise about every current or future build.

01 / Decision

Choose the narrowest stable boundary.

SituationPreferReason
Code is inside one pluginDirect service or method callCompiler and ordinary tests own the contract.
Another plugin publishes the operationDalamud IPCThe provider intentionally owns a public boundary.
A shared library models the operationPublic library APIAvoids coupling consumers to plugin internals.
No contract exists; temporary integration is necessaryGuarded reflectionCan reach behavior, but private paths must be discovered, versioned, and tested.
Goal is to bypass permissions or inspect unrelated sensitive stateDo not implementReflection does not grant authorization.
Direct APIPublic IPCGuarded reflection

02 / Comparison

IPC publishes intent; reflection infers implementation.

PropertyIPCReflection
ContractExact tag, delegate shape, and documented semanticsAssembly, type, member name/kind, visibility, ownership, and inferred semantics
Type safetyStrong after both sides agree on the generic signatureRuntime lookup, casts, and invocation
Update toleranceGood when versioned compatiblyPoor when private members move or change
FailureProvider absent, signature mismatch, provider exceptionMissing type/member, stale object, cast failure, invocation exception, silent wrong-state write
LifecycleRegister/unregister; subscribe/unsubscribeDiscover, invalidate caches, and remove reflected handlers
TestingProvider and consumer contract testsContract probe for each supported target plus runtime verification
MaintenanceProvider and consumers negotiate changesConsumer chases target internals

IPC can still be poorly designed. A public callback must not block the framework thread, leak live mutable services, throw for routine absence, or silently change meaning without a version change.

03 / Dalamud IPC

A tag and generic signature form the wire contract.

A gate is identified by an exact tag such as Example.Math.Add.v1 and an exact delegate shape. Provider and subscriber must match both. Keep tags in shared constants or a small API package when several consumers exist.

Provider: function, action, and event

using Dalamud.Plugin.Ipc;

private readonly ICallGateProvider<int, int, int> add;
private readonly ICallGateProvider<string> run;
private readonly ICallGateProvider<string> statusChanged;

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

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

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

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

Use a function for a short request/response, an action for a short command, and an event message for notifications. Validate and enqueue long work; do not run a workflow inside the call.

Subscriber: invoke and subscribe

private readonly ICallGateSubscriber<int, int, int> add;
private readonly ICallGateSubscriber<string> run;
private readonly ICallGateSubscriber<string> statusChanged;

public ExampleClient(IDalamudPluginInterface pi)
{
    add = pi.GetIpcSubscriber<int, int, int>("Example.Math.Add.v1");
    run = pi.GetIpcSubscriber<string>("Example.Commands.Run.v1");
    statusChanged = pi.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);

Acquiring a subscriber does not prove that the provider is ready. Invocation is where absence, a signature mismatch, or a provider exception appears. Convert routine absence into a bounded feature-unavailable state; never retry every frame.

04 / IPC design

Design a small public protocol.

Namespace and version

Use a stable prefix and bump the tag when a breaking contract changes.

Small values

Prefer primitives, IDs, immutable records, and snapshots over live services.

Short calls

Validate and queue long work; expose completion through a query or event.

Symmetric cleanup

Every registration and subscription has an inverse lifecycle action.

Explicit idempotency

Document whether repetition is safe and use request IDs when necessary.

Capability query

Publish API major/minor and optional capabilities instead of guessing from plugin versions.

Common failures

FailureMeaningResponse
Provider not readyAbsent, disabled, loading, unloading, or not registeredDisable the feature and retry only on lifecycle change or bounded backoff.
Signature mismatchConsumer and provider shapes differTreat as an incompatible API version; log tag and expected shape.
Provider throwsPrecondition, provider defect, or downstream failurePreserve context and stop automatic repetition.
Event never arrivesTiming or missing completion pathPair events with queryable status and a timeout.
Duplicate workUnsafe retryAdd idempotency guards or correlation IDs.
Unload leakRegistration/subscription not removedMake ownership disposable and test reload.

05 / Reflection

Make the private contract explicit.

Use reflection only when no suitable public contract exists, the integration is authorized and narrow, exact target members can be discovered and tested, failure can disable the feature cleanly, and someone owns future maintenance.

ECommons discovery

At the ECommons snapshot observed on August 17, 2026, request the advanced module during initialization. TryGetDalamudPlugin returns the live instance as object.

using ECommons;
using ECommons.Reflection;

ECommonsMain.Init(pluginInterface, this, Module.DalamudReflector);

if (DalamudReflector.TryGetDalamudPlugin("Dropbox", out var instance))
{
    var liveType = instance.GetType();
    var liveAssembly = liveType.Assembly;
}

The reflector caches live instances and monitors installed-plugin changes. It still relies on non-public Dalamud internals; convenience does not make the path stable.

ReflectionHelper

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

GetFoP, SetFoP, GetStaticFoP, and Call shorten common operations. Feature-specific code must still validate target type, member meaning, overload, result type, and read-back.

06 / Live instance

Resolve the running object, not a second assembly.

Without ECommons, a common direct approach is to inspect loaded-plugin wrappers. This is intentionally shown as a fragile adapter: private Dalamud type names and the wrapper's instance field can change.

private static object? FindLoadedPluginInstance(string internalName)
{
    var assembly = typeof(IDalamudPluginInterface).Assembly;
    var service = assembly.GetType("Dalamud.Service`1");
    var managerType = assembly.GetType("Dalamud.Plugin.Internal.PluginManager");
    if (service is null || managerType is null) return null;

    var manager = service.MakeGenericType(managerType)
        .GetMethod("Get")?.Invoke(null, null);
    var installed = manager?.GetType().GetProperty("InstalledPlugins")
        ?.GetValue(manager) as System.Collections.IList;
    if (installed is null) return null;

    foreach (var wrapper in installed)
    {
        if (wrapper is null) continue;
        var found = wrapper.GetType().GetProperty("InternalName")
            ?.GetValue(wrapper)?.ToString();
        if (!string.Equals(found, 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;
}

Continue from pluginInstance.GetType() and its assembly. Loading another copy of the DLL can create type-identity failures across load contexts even when full type names match.

07 / Members

Resolve kind, ownership, and inheritance.

The resolver must account for fields/properties, public/non-public members, instance/static ownership, base types, and readable/writable accessors.

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;
}

A production result should distinguish “member missing,” “member found with null,” and “getter threw.” For writes, find the field or non-public setter, choose null for static ownership, validate the value type, write, and read back.

Method invocation

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

abort?.Invoke(taskManager, null);

Select overloads by exact parameter types and generic arity. Inspect TargetInvocationException.InnerException when the target throws. Reflected events also require an exactly compatible delegate retained for later removal.

08 / Approved case study

Dropbox proves why display-text guesses fail.

In the examined Dropbox layout, constructor and UI source established these internal static fields:

internal static Config C;
internal static Dropbox P;

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

The UI and consuming logic use C.Active.

03
Configuration

C is the static configuration container.

02
Plugin singleton

P is the static running plugin reference.

01
Discovery

Resolve the loaded Dropbox instance and start from its live type.

The real runtime path was Dropbox.Dropbox.C.Active. A plausible guess such as EnableAutoAcceptTrades would find nothing. The observed PermanentActive field described persistence separately from the live Active state.

Adaptable boolean write

foreach (var containerName in new[] { "C", "Config", "Configuration" })
{
    var config = GetMemberValue(instance.GetType(), instance, containerName);
    if (config is null) continue;

    if (TrySetMemberValue(config.GetType(), config, "Active", true) &&
        GetMemberValue(config.GetType(), config, "Active") is bool actual &&
        actual)
    {
        return true;
    }
}
return false;

Do not add guessed value names just to make a button report success. After read-back, verify the target UI and downstream behavior. Determine whether the plugin hot-reads the value, snapshots it, needs an apply/reload method, saves separately, or reconstructs the object.

09 / Cache and lifecycle

Cache metadata, not stale plugin ownership.

Cache validated FieldInfo, PropertyInfo, and MethodInfo outside hot paths. Avoid permanent strong references to another plugin. Invalidate on target unload/reload, installed-plugin changes, consumer reload, version/type validation failure, and stale-load-context invocation errors.

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

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

Remove reflected event handlers before either plugin unloads. A target event holding a delegate into an unloaded consumer can cause leaks or unload failures.

10 / Repeatable workflow

Trace, validate, mutate, verify, and plan for breakage.

  1. Confirm authorization and scope.Name the one behavior the integration needs.
  2. Search public APIs and IPC.Ask the provider for a supported contract when interoperability is long-lived.
  3. Resolve the live instance.A wrapper or manifest is not the target object.
  4. Use the live type and assembly.Avoid second-load type identity problems.
  5. Find the owner.Check singleton, config, service, task manager, scheduler, queue, or nested settings.
  6. Trace behavior.Follow constructor initialization, UI binding, and consuming logic.
  7. Classify the member.Field/property, static/instance, declaring type, accessors, and overload.
  8. Validate before mutation.Check target type/version, value type, range, and feature state.
  9. Write and read back.Then verify UI and downstream behavior separately.
  10. Handle apply and persistence.Do not confuse an in-memory write with a saved configuration.
  11. Add bounded diagnostics.Report resolved type/member without dumping unrelated state.
  12. Invalidate and dispose.Remove handlers and stale references on lifecycle change.
  13. Retest supported versions.Every reflected path is a private versioned contract.

11 / Troubleshooting

Let each failure identify the broken boundary.

SymptomLikely causeNext evidence
Plugin not foundWrong internal name, target absent, or discovery changedLog requested name and load state; refresh on plugin-list change.
Wrapper found, instance nullLoading/unloading or private wrapper field changedDefer and refresh; never cache null as success.
Config nullWrong owner, static not handled, different name, or legitimate nullInspect constructor/UI binding and distinguish missing from null-valued.
Member missingName, kind, declaring type, visibility, or version changedEnumerate bounded names/signatures in a development-only diagnostic.
Ambiguous methodMultiple overloadsResolve exact parameter types and generic arity.
Cast failsWrong member/type or second load contextLog assembly-qualified types and use the live assembly.
Write reads back, no behaviorWrong owner, snapshot semantics, or apply stepTrace consuming logic; verify UI and downstream effect.
Breaks after reloadStale instance or member metadataInvalidate on lifecycle change and rediscover.
Unload leakStrong reference or reflected event remainsUse weak references and symmetric removal.

12 / Safety and review

Require evidence at the private boundary.

  • Public API and IPC alternatives were evaluated first.
  • The target member contract has an observation/version boundary.
  • No credentials, account data, unrelated config, or sensitive state are inspected or logged.
  • The feature disables cleanly when the target is absent or incompatible.
  • Discovery is outside hot paths; lookup results are cached only after validation.
  • Every mutation validates type and allowed value before writing.
  • Every write has read-back plus separate behavior verification.
  • Apply, save, and persistence behavior are explicit.
  • Exceptions retain actionable context without dumping private data.
  • Reload invalidates instance and member caches.
  • Reflected events are removed on dispose and unload.
  • Runtime testing repeats after target or Dalamud updates.

13 / Sources

Refresh mutable APIs before implementation.

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.