Skip to content

Mod Integration API Reference

artriy edited this page Jul 27, 2026 · 6 revisions

API Reference

Supported namespace: PerfectComms.Api in PerfectComms.dll. Current API version: 1.2.

Back to Mod Integration

Only the types in PerfectComms.Api are the supported integration contract. Public implementation classes under VoiceChatPlugin.VoiceChat are not API.


Compatibility contract

API 1.2 preserves all API 1.0 and 1.1 public enum values, positional record constructors, and registration signatures. Existing integrations can keep their source and binaries unchanged. New context/result properties are init-only additions; new behavior uses separately named methods instead of ambiguous delegate overloads.

The original signatures remain:

RegisterVoiceRule(string, Func<VoiceRuleContext, VoiceRuleResult>);
RegisterGlobalGate(string, VoicePhaseKind, Func<bool>, string);
RegisterVoiceChannel(string, Func<VoiceRuleContext, VoiceChannelResult?>);
RegisterListenerOrigin(string, Func<PlayerControl, VoiceListenerResult?>);
RegisterListenerFilter(string, Func<PlayerControl, bool>);
RegisterHostOption(string, VoiceHostOption);
RegisterHostEnumOption(string, VoiceHostEnumOption);
RegisterModTab(string, string);
RegisterOverlayViewerRule(string, Func<VoiceOverlayViewerContext, VoiceOverlayViewerResult>);
RegisterOverlaySpeakerRule(string, Func<VoiceOverlaySpeakerContext, VoiceOverlaySpeakerResult>);
Unregister(string);

The runtime preserves the completed behavior behind those calls: per-speaker muffle, Lobby/dead/global gate enforcement, multiple and receive-only channels, speaker-position Proximity fallback, and LightRadius: -1 inheritance. API 1.2 adds listener sight-obscuration state without changing the existing filter constructor.


Runtime version and capabilities

public const string ApiVersion = "1.2";
public const string PluginId = "com.edgetel.perfectcomms";

public static string RuntimeApiVersion { get; }
public static VoiceApiCapability Capabilities { get; }
public static bool Supports(VoiceApiCapability capability);

ApiVersion is compiled into a consuming assembly and is not a runtime probe. RuntimeApiVersion is a property. Supports requires every requested flag and returns false for None.

[Flags]
public enum VoiceApiCapability
{
    None = 0,
    PerSpeakerMuffle = 1 << 0,
    GlobalReceiveGate = 1 << 1,
    DirectionalChannels = 1 << 2,
    MultipleChannels = 1 << 3,
    ContextualListeners = 1 << 4,
    PairRouting = 1 << 5,
    PlayerTraits = 1 << 6,
    PhaseObservers = 1 << 7,
    ConditionalHostOptions = 1 << 8,
    NumericHostOptions = 1 << 9,
    OverlayPrivacy = 1 << 10,
    ManagedTeamRadio = 1 << 11,
    PersistentHostOptions = 1 << 12,
    OverlayAppearance = 1 << 14,
    ListenerTaskGateBypass = 1 << 15,
    ListenerSightObscuration = 1 << 16,
}

An API 1.1 assembly does not expose the API 1.2 capability. A bridge supporting older builds must reflect for new properties before entering code that references new API members, or require Perfect Comms 4.1.7 or newer.


Phases and common option access

public enum VoicePhaseKind
{
    Lobby,
    Tasks,
    Meeting,
    Exile,
}

Callbacks receive the exact API phase. Meeting and Exile are distinct. Internal menu/lobby-like states map to Lobby; a phase observer reports API-level changes, not every internal scene transition. EndGame becomes a fresh global results-screen call: no per-player callback can be re-resolved after player objects disappear, so retained task/meeting mute, muffle, channel, and pair state is not reapplied.

Where present, these delegates take a bare key and scope it to the callback's registered modId:

Func<string, bool> GetOption;
Func<string, int> GetEnumOption;
Func<string, float> GetNumberOption;

Missing values return false, 0, and 0f.


Speaker rules and global gates

public enum VoiceVerdict
{
    Pass,
    Mute,
    Muffle,
}

public sealed record VoiceRuleContext(
    PlayerControl Player,
    VoicePhaseKind Phase,
    bool IsLocal,
    bool IsDead)
{
    public PlayerControl? LocalPlayer { get; init; }
    public bool LocalIsDead { get; init; }
    public Func<string, bool> GetOption { get; init; }
    public Func<string, int> GetEnumOption { get; init; }
    public Func<string, float> GetNumberOption { get; init; }
}

public sealed record VoiceRuleResult(VoiceVerdict Verdict, string Reason)
{
    public static readonly VoiceRuleResult Pass;
    public static VoiceRuleResult Mute(string reason);
    public static VoiceRuleResult Muffle(string reason);
}

Player is the speaker being resolved. LocalPlayer is this client's listener when available. IsDead and LocalIsDead include contributed VoiceDead/Spectator traits for normal rule evaluation.

Mute silences the speaker in Lobby, Tasks, Meeting, and Exile, including voice-dead routes, and blocks the local transmit path when the local player is affected. Muffle keeps only that speaker audible through the low-pass filter. Mute wins over Muffle; otherwise muffle is sticky. Null or throwing rules become Pass.

public sealed record VoiceGlobalGateContext(
    PlayerControl? LocalPlayer,
    VoicePhaseKind Phase,
    bool LocalIsDead)
{
    public Func<string, bool> GetOption { get; init; }
    public Func<string, int> GetEnumOption { get; init; }
    public Func<string, float> GetNumberOption { get; init; }
}

public static void RegisterGlobalGate(
    string modId,
    VoicePhaseKind phase,
    Func<bool> isActive,
    string reason);

public static void RegisterContextualGlobalGate(
    string modId,
    VoicePhaseKind phase,
    Func<VoiceGlobalGateContext, bool> isActive,
    string reason);

Both forms are phase-exact and receiver-enforced as well as transmit-enforced. The contextual form can read options and local listener state. A throwing predicate is inactive.

See Gate.


Player traits

[Flags]
public enum VoicePlayerTraits
{
    None = 0,
    ImpostorVoice = 1 << 0,
    VoiceDead = 1 << 1,
    Spectator = 1 << 2,
}

public static void RegisterVoicePlayerTraits(
    string modId,
    Func<VoiceRuleContext, VoicePlayerTraits> traits);

Traits compose by bitwise OR across registrations and cannot remove a base classification. Spectator implies VoiceDead. Unknown bits and throwing callbacks are ignored. ImpostorVoice contributes the same vent, ghost-hearing, Team Radio, and viewer classification used by built-in impostor voice. Trait callbacks receive the player's base dead state; subsequent rules receive the effective voice-dead state.


Listener-speaker pair rules

public enum VoicePairVerdict
{
    Pass,
    Mute,
    Muffle,
    Route,
}

public enum VoicePairRouteShape
{
    Proximity,
    Radio,
    Ghost,
}

public sealed record VoicePairContext(
    PlayerControl Listener,
    PlayerControl Speaker,
    VoicePhaseKind Phase,
    bool ListenerIsDead,
    bool SpeakerIsDead)
{
    public Func<string, bool> GetOption { get; init; }
    public Func<string, int> GetEnumOption { get; init; }
    public Func<string, float> GetNumberOption { get; init; }
}

public sealed record VoicePairResult(VoicePairVerdict Verdict, string Reason)
{
    public VoicePairRouteShape Shape { get; init; }
    public float Volume { get; init; }
    public Vector2? SpeakerOrigin { get; init; }
    public Vector2? ListenerOrigin { get; init; }

    public static readonly VoicePairResult Pass;
    public static VoicePairResult Mute(string reason);
    public static VoicePairResult Muffle(string reason);
    public static VoicePairResult Route(
        VoicePairRouteShape shape,
        float volume = 1f,
        Vector2? speakerOrigin = null,
        Vector2? listenerOrigin = null,
        string reason = "Mod Route");
}

public static void RegisterVoicePairRule(
    string modId,
    Func<VoicePairContext, VoicePairResult> rule);

The listener is always local. Pair Mute wins immediately. Pair Muffle is retained and applies to whichever route is otherwise selected. The first valid Route wins, but later rules are still inspected for restrictive mute/muffle results.

Radio is flat. Proximity and Ghost use normal host distance/falloff and pan. Omitted SpeakerOrigin and ListenerOrigin use the resolved speaker and listener positions. Volume is clamped to 0..1. Invalid shape, non-finite volume/origins, null, and exceptions are neutral.

An explicit pair route replaces ordinary routing for that pair. Speaker/global mutes and Tasks OnlyMeetingOrLobby remain authoritative. In Tasks, the pair route runs before OnlyGhostsCanTalk and Comms-sabotage blocking to support Medium-style exceptions; in Meeting/Exile those host restrictions run first. Channels stay below those restrictions.

See Channels.


Channels

public enum VoiceAudioShape
{
    Proximity,
    Radio,
    Muffle,
}

public sealed record VoiceChannelResult(
    string Key,
    bool TwoWay = true,
    VoiceAudioShape Shape = VoiceAudioShape.Radio,
    float Volume = 1f,
    Vector2? Origin = null);

public static void RegisterVoiceChannel(
    string modId,
    Func<VoiceRuleContext, VoiceChannelResult?> channel);

Every non-null result with a non-empty key is retained, so one player can hold several memberships from several callbacks. Keys are scoped internally to the registering modId; the encoding is not part of the public contract. When several shared memberships produce routes, the loudest valid route is used.

For a local listener to hear a target, both need the same namespaced key and the target membership must have TwoWay: true. A false membership is receive-only: it can hear a transmitting member but cannot be heard back through that membership. The target membership supplies shape, volume, and origin.

Radio and Muffle are flat. Proximity uses the target's Origin when finite and otherwise the target's resolved body position. It spatializes in Lobby, Tasks, Meeting, and Exile whenever a listener position is available. Volume is clamped; an invalid shape falls back to Radio, and a non-finite origin falls back to the speaker.

See Channels.

Managed Team Radio

public sealed record VoiceManagedRadioChannelResult(
    string Key,
    string Label,
    string Badge);

public static void RegisterManagedRadioChannel(
    string modId,
    Func<VoiceRuleContext, VoiceManagedRadioChannelResult?> channel);

Return one current membership or null for each resolved player. Perfect Comms namespaces Key by modId; players whose callbacks return the same key share a selectable private radio. Label is used in tooltips and Badge on compact/touch UI. Empty/control-character keys and oversized wire keys are ignored; duplicate keys for one player collapse to one choice.

This primitive uses the existing Team Radio master and Tasks/Meeting policy. Perfect Comms adds eligible memberships after built-in selector choices, opens capture while its radio control is held (including in Push To Talk mode), synchronizes the selected key, applies the radio filter to matching members, and mutes living non-members before pair/general-channel routing. A claimed/stale key that is absent from the transmitting player's resolved memberships is muted. Dead listeners retain the existing ghost fallthrough.

Use general RegisterVoiceChannel when your mod owns its own transmit state or wants a nonexclusive route. Use RegisterManagedRadioChannel when Perfect Comms should own selection, PTT, wire state, and private routing end to end.


Listener origin, filter, and phase observer

public enum VoiceListenerMode
{
    Replace,
    Additive,
}

public sealed record VoiceListenerResult(
    Vector2 Origin,
    float LightRadius,
    VoiceListenerMode Mode)
{
    public bool BypassTaskVoiceGates { get; init; }
}

public sealed record VoiceListenerContext(
    PlayerControl Listener,
    VoicePhaseKind Phase,
    bool IsDead)
{
    public Func<string, bool> GetOption { get; init; }
    public Func<string, int> GetEnumOption { get; init; }
    public Func<string, float> GetNumberOption { get; init; }
}

public sealed record VoiceListenerFilterResult(bool Muffle)
{
    public bool SightObscured { get; init; }
}
public static void RegisterListenerOrigin(
    string modId,
    Func<PlayerControl, VoiceListenerResult?> origin);

public static void RegisterContextualListenerOrigin(
    string modId,
    Func<VoiceListenerContext, VoiceListenerResult?> origin);

public static void RegisterListenerFilter(
    string modId,
    Func<PlayerControl, bool> shouldMuffle);

public static void RegisterContextualListenerFilter(
    string modId,
    Func<VoiceListenerContext, VoiceListenerFilterResult> filter);

Original and contextual origins share one registration order; the first finite, non-null origin wins. Replace hears only from the override during Tasks; Additive compares body and override routes and keeps the louder result per speaker.

Any negative LightRadius, including -1, inherits the local resolved light radius. 0 disables vision-radius limiting at the override. A positive value supplies an explicit radius; non-finite values normalize to inheritance. BypassTaskVoiceGates can bypass only Tasks-wide OnlyGhostsCanTalk and Comms-sabotage receive gates; speaker mutes, phase policy, vent privacy, sight, distance, walls, and channel membership remain authoritative.

Original and contextual filters also share one list. Results compose restrictively. Any Muffle result applies the listener low-pass filter to all audible incoming audio. Any SightObscured result restricts sight-based hearing as temporarily blinded/obscured without revealing the source mod's private state. Failures are neutral for both fields.

public sealed record VoicePhaseChangedContext(
    VoicePhaseKind PreviousPhase,
    VoicePhaseKind Phase,
    PlayerControl? LocalPlayer)
{
    public Func<string, bool> GetOption { get; init; }
    public Func<string, int> GetEnumOption { get; init; }
    public Func<string, float> GetNumberOption { get; init; }
}

public static void RegisterVoicePhaseObserver(
    string modId,
    Action<VoicePhaseChangedContext> observer);

The first observed phase initializes the tracker without firing. Later API phase changes fire once before the new phase's player callbacks. Observer exceptions are ignored.

See Listener Origin & Filter.


Overlay privacy

Viewer and speaker contexts expose all three option accessors. Viewer results compose HideAll > DimAll > Pass. Speaker results compose HideAll > HideSource > Alias > Pass; conflicting, missing, sentinel, or unsafe aliases become HideSource.

Viewer exceptions fail to HideAll; speaker exceptions fail to HideSource. Overlay rules affect identity-bearing Perfect Comms UI only, not audio or transmission.

public enum VoiceOverlayViewerVerdict
{
    Pass,
    DimAll,
    HideAll,
}

public readonly record struct VoiceOverlayViewerResult(
    VoiceOverlayViewerVerdict Verdict)
{
    public static readonly VoiceOverlayViewerResult Pass;
    public static readonly VoiceOverlayViewerResult DimAll;
    public static readonly VoiceOverlayViewerResult HideAll;
}

public enum VoiceOverlaySpeakerVerdict
{
    Pass,
    Alias,
    HideSource,
    HideAll,
}

public readonly record struct VoiceOverlaySpeakerResult(
    VoiceOverlaySpeakerVerdict Verdict,
    byte? AliasPlayerId = null)
{
    public static readonly VoiceOverlaySpeakerResult Pass;
    public static readonly VoiceOverlaySpeakerResult HideSource;
    public static readonly VoiceOverlaySpeakerResult HideAll;
    public static VoiceOverlaySpeakerResult Alias(byte targetPlayerId);
}

public sealed record VoiceOverlayViewerContext(
    PlayerControl Viewer,
    VoicePhaseKind Phase,
    bool IsDead)
{
    public Func<string, bool> GetOption { get; init; }
    public Func<string, int> GetEnumOption { get; init; }
    public Func<string, float> GetNumberOption { get; init; }
}

public sealed record VoiceOverlaySpeakerContext(
    PlayerControl Viewer,
    PlayerControl Speaker,
    VoicePhaseKind Phase,
    bool ViewerIsDead,
    bool SpeakerIsDead)
{
    public Func<string, bool> GetOption { get; init; }
    public Func<string, int> GetEnumOption { get; init; }
    public Func<string, float> GetNumberOption { get; init; }
}

public static void RegisterOverlayViewerRule(
    string modId,
    Func<VoiceOverlayViewerContext, VoiceOverlayViewerResult> rule);

public static void RegisterOverlaySpeakerRule(
    string modId,
    Func<VoiceOverlaySpeakerContext, VoiceOverlaySpeakerResult> rule);

See Overlay Privacy for the exact result records and safe-alias rules.

Overlay appearance

public static void RegisterAnimatedColorRule(
    string modId,
    Func<int, bool> isAnimatedColor);

Animated-color classifiers compose additively: the first true marks that color id for Perfect Comms' animated rainbow speaking-avatar material. Exceptions are treated as false. Source mods register their own classifiers; Perfect Comms does not reflect mod-specific color state.


Host options and tabs

public sealed record VoiceHostOption(string Key, string Label, bool Default)
{
    public string Description { get; init; }
    public Func<VoiceHostOptionContext, bool>? Visible { get; init; }
}

public sealed record VoiceHostEnumOption(
    string Key,
    string Label,
    int Default,
    string[] Choices)
{
    public string Description { get; init; }
    public Func<VoiceHostOptionContext, bool>? Visible { get; init; }
}

public sealed record VoiceHostNumberOption(
    string Key,
    string Label,
    float Default,
    float Min,
    float Max,
    float Step,
    string Format = "0.0")
{
    public string Description { get; init; }
    public Func<VoiceHostOptionContext, bool>? Visible { get; init; }
}

public sealed record VoiceHostOptionContext
{
    public Func<string, bool> GetOption { get; init; }
    public Func<string, int> GetEnumOption { get; init; }
    public Func<string, float> GetNumberOption { get; init; }
}

VoiceHostOptionContext exposes the three scoped accessors. A visibility callback exception shows the row rather than hiding it.

Every option key must be non-empty and unique across bool, enum, and number options for that exact modId. Enum choices must be non-empty; enum defaults, local changes, and received values clamp to the declared choices. Numeric declarations require finite bounds/default/step, Max >= Min, and Step > 0; invalid declarations are ignored. Numeric values clamp to the range and round to the nearest step relative to Min, and an invalid display format falls back to 0.0.

public static void RegisterHostOption(string modId, VoiceHostOption option);
public static void RegisterHostEnumOption(string modId, VoiceHostEnumOption option);
public static void RegisterHostNumberOption(string modId, VoiceHostNumberOption option);
public static void RegisterModTab(string modId, string tabLabel);

One exact mod id gets one tab; its first label wins. Rows render toggles, then enums, then numbers, preserving registration order inside each group. Local host values persist in Perfect Comms' global BepInEx config under encoded mod/key definitions; the connected host's values are synchronized as lobby overrides and never overwrite a client's stored local-host choices.

The snapshot holds at most 256 mod-option values and identifies each scoped key/type with a 32-bit wire hash (numbers use a separate type salt); unknown hashes are ignored and collisions are not detected.

See Host Options & Tabs.


Registration, cadence, and failures

An empty modId or null registration callback/option is ignored. Host options receive the validation above and the inventory is capped at 256 synced values. Registrations accumulate; only the exact mod tab is deduplicated.

Unregister(modId) removes that id's rules, traits, pair rules, general/managed channels, listener callbacks, observers, overlay rules, animated-color rules, integration claims, gates, tab, option declarations, and active option values. Persisted local-host option entries remain available if the same mod/key registers again. There is no individual unregister method.

The callback collection currently being evaluated is snapshotted. A callback may register or unregister safely without invalidating that pass; a new callback in the same collection begins on its next evaluation. Do normal cross-primitive registration outside callbacks rather than depending on same-frame timing between rules, gates, channels, and listeners.

Callback Failure result
Speaker rule Pass
Global gate inactive
Player traits None
Pair rule Pass
Channel null
Managed radio null
Listener origin null
Listener filter not muffled or sight-obscured
Phase observer ignored exception
Option visibility visible
Overlay viewer HideAll
Overlay speaker HideSource
Animated color false

Audio callbacks run at snapshot cadence, roughly 20 times per second per applicable player. Listener filters and overlay rules may run once per rendered frame. Do not rely on an exact cadence.


Current status / limitations

Currently broken: None of the documented API 1.2 primitives on this page.

  • Perfect Comms synchronizes registered host-option values and persists local-host choices. It does not own your role/modifier state, targets, pairings, lifecycle history, or role RPCs. Managed Team Radio owns its existing selector/input/capture/wire path, not gameplay membership.
  • Host-option snapshots and local callback evaluation coordinate cooperative clients. They are not hostile-client authentication or enforcement.

Clone this wiki locally