01 / Decision
Choose the narrowest stable boundary.
| Situation | Prefer | Reason |
|---|---|---|
| Code is inside one plugin | Direct service or method call | Compiler and ordinary tests own the contract. |
| Another plugin publishes the operation | Dalamud IPC | The provider intentionally owns a public boundary. |
| A shared library models the operation | Public library API | Avoids coupling consumers to plugin internals. |
| No contract exists; temporary integration is necessary | Guarded reflection | Can reach behavior, but private paths must be discovered, versioned, and tested. |
| Goal is to bypass permissions or inspect unrelated sensitive state | Do not implement | Reflection does not grant authorization. |
02 / Comparison
IPC publishes intent; reflection infers implementation.
| Property | IPC | Reflection |
|---|---|---|
| Contract | Exact tag, delegate shape, and documented semantics | Assembly, type, member name/kind, visibility, ownership, and inferred semantics |
| Type safety | Strong after both sides agree on the generic signature | Runtime lookup, casts, and invocation |
| Update tolerance | Good when versioned compatibly | Poor when private members move or change |
| Failure | Provider absent, signature mismatch, provider exception | Missing type/member, stale object, cast failure, invocation exception, silent wrong-state write |
| Lifecycle | Register/unregister; subscribe/unsubscribe | Discover, invalidate caches, and remove reflected handlers |
| Testing | Provider and consumer contract tests | Contract probe for each supported target plus runtime verification |
| Maintenance | Provider and consumers negotiate changes | Consumer 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.
Use a stable prefix and bump the tag when a breaking contract changes.
Prefer primitives, IDs, immutable records, and snapshots over live services.
Validate and queue long work; expose completion through a query or event.
Every registration and subscription has an inverse lifecycle action.
Document whether repetition is safe and use request IDs when necessary.
Publish API major/minor and optional capabilities instead of guessing from plugin versions.
Common failures
| Failure | Meaning | Response |
|---|---|---|
| Provider not ready | Absent, disabled, loading, unloading, or not registered | Disable the feature and retry only on lifecycle change or bounded backoff. |
| Signature mismatch | Consumer and provider shapes differ | Treat as an incompatible API version; log tag and expected shape. |
| Provider throws | Precondition, provider defect, or downstream failure | Preserve context and stop automatic repetition. |
| Event never arrives | Timing or missing completion path | Pair events with queryable status and a timeout. |
| Duplicate work | Unsafe retry | Add idempotency guards or correlation IDs. |
| Unload leak | Registration/subscription not removed | Make 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);The UI and consuming logic use C.Active.
C is the static configuration container.
P is the static running plugin reference.
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.
- Confirm authorization and scope.Name the one behavior the integration needs.
- Search public APIs and IPC.Ask the provider for a supported contract when interoperability is long-lived.
- Resolve the live instance.A wrapper or manifest is not the target object.
- Use the live type and assembly.Avoid second-load type identity problems.
- Find the owner.Check singleton, config, service, task manager, scheduler, queue, or nested settings.
- Trace behavior.Follow constructor initialization, UI binding, and consuming logic.
- Classify the member.Field/property, static/instance, declaring type, accessors, and overload.
- Validate before mutation.Check target type/version, value type, range, and feature state.
- Write and read back.Then verify UI and downstream behavior separately.
- Handle apply and persistence.Do not confuse an in-memory write with a saved configuration.
- Add bounded diagnostics.Report resolved type/member without dumping unrelated state.
- Invalidate and dispose.Remove handlers and stale references on lifecycle change.
- Retest supported versions.Every reflected path is a private versioned contract.
11 / Troubleshooting
Let each failure identify the broken boundary.
| Symptom | Likely cause | Next evidence |
|---|---|---|
| Plugin not found | Wrong internal name, target absent, or discovery changed | Log requested name and load state; refresh on plugin-list change. |
| Wrapper found, instance null | Loading/unloading or private wrapper field changed | Defer and refresh; never cache null as success. |
| Config null | Wrong owner, static not handled, different name, or legitimate null | Inspect constructor/UI binding and distinguish missing from null-valued. |
| Member missing | Name, kind, declaring type, visibility, or version changed | Enumerate bounded names/signatures in a development-only diagnostic. |
| Ambiguous method | Multiple overloads | Resolve exact parameter types and generic arity. |
| Cast fails | Wrong member/type or second load context | Log assembly-qualified types and use the live assembly. |
| Write reads back, no behavior | Wrong owner, snapshot semantics, or apply step | Trace consuming logic; verify UI and downstream effect. |
| Breaks after reload | Stale instance or member metadata | Invalidate on lifecycle change and rediscover. |
| Unload leak | Strong reference or reflected event remains | Use 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