Using Classes

Learn how to use class wrappers for cleaner, object-oriented plugin APIs in C#.

Classes in Plugify provide an object-oriented way to work with plugin resources, offering automatic lifecycle management and cleaner APIs. Instead of manually managing handles and calling functions with explicit pointers, you can use intuitive class-based interfaces with the IDisposable pattern.

Why Use Classes?

Working with handle-based APIs in the traditional C-style can be verbose and error-prone:

Without Classes (C-style)
using test_keyvalues;

void ProcessConfig()
{
    // Create a KeyValues handle manually
    var kvHandle = test_keyvalues.Kv1Create("MyConfig");

    // Set properties using the handle
    test_keyvalues.Kv1SetName(kvHandle, "ServerSettings");

    // Find a subkey
    var subkeyHandle = test_keyvalues.Kv1FindKey(kvHandle, "Players");

    // Easy to forget cleanup!
    test_keyvalues.Kv1Destroy(subkeyHandle);
    test_keyvalues.Kv1Destroy(kvHandle);
}

With classes, the same code becomes much cleaner and safer:

With Classes (OOP-style)
using test_keyvalues;

void ProcessConfig()
{
    // Create using a constructor
    using var kv = new KeyValues("MyConfig");  // Automatic cleanup when scope ends

    // Use intuitive methods
    kv.SetName("ServerSettings");

    // Find a subkey - returns a KeyValues instance
    using var subkey = kv.FindKey("Players");

    // Automatic cleanup via using statement!
}

Benefits:

  • Cleaner syntax - Methods instead of functions with explicit handles
  • Automatic resource management - IDisposable pattern with deterministic cleanup
  • Using statement - Guarantees cleanup even if exceptions occur
  • Type safety - Better IDE autocomplete and compile-time checks
  • Thread-safe - SafeHandle uses DangerousAddRef/DangerousRelease for thread safety
  • Less error-prone - Harder to forget cleanup or use after disposal
  • Critical finalization - Finalizers run even during AppDomain unload
  • Exception safety - ObjectDisposedException when used after disposal

Important Pattern:

How Classes Work

When a plugin defines classes in its manifest, Plugify automatically generates C# class wrappers that:

  1. Inherit from SafeHandle - For classes with destructors, provides critical finalization
  2. Implement IDisposable - Enables using statement for deterministic cleanup
  3. Manage lifecycle - Automatically call destructor when disposed or finalized
  4. Provide utility methods - Get(), Release(), Reset(), IsValid for handle management
  5. Track ownership - Use Ownership enum to prevent double-free bugs
  6. Thread-safe operations - Use DangerousAddRef/DangerousRelease internally
  7. Validate handles - Throw ObjectDisposedException when used after disposal
  8. Critical finalizers - Run even during AppDomain unload

Defining Classes in Your Manifest

To create classes for your plugin, add a classes section to your manifest:

plugin.pplugin
{
  "name": "example_plugin",
  "version": "1.0.0",
  "language": "cpp",
  "methods": [
    {
      "name": "Kv1Create",
      "funcName": "Kv1Create",
      "paramTypes": [
        { "name": "setName", "type": "string" }
      ],
      "retType": { "type": "ptr64" }
    },
    {
      "name": "Kv1Destroy",
      "funcName": "Kv1Destroy",
      "paramTypes": [
        { "name": "kv", "type": "ptr64" }
      ],
      "retType": { "type": "void" }
    },
    {
      "name": "Kv1GetName",
      "funcName": "Kv1GetName",
      "paramTypes": [
        { "name": "kv", "type": "ptr64" }
      ],
      "retType": { "type": "string" }
    },
    {
      "name": "Kv1SetName",
      "funcName": "Kv1SetName",
      "paramTypes": [
        { "name": "kv", "type": "ptr64" },
        { "name": "name", "type": "string" }
      ],
      "retType": { "type": "void" }
    }
  ],
  "classes": [
    {
      "name": "KeyValues",
      "description": "RAII wrapper for KeyValues handle",
      "handleType": "ptr64",
      "invalidValue": "0",
      "constructors": ["Kv1Create"],
      "destructor": "Kv1Destroy",
      "bindings": [
        {
          "name": "GetName",
          "method": "Kv1GetName",
          "bindSelf": true
        },
        {
          "name": "SetName",
          "method": "Kv1SetName",
          "bindSelf": true
        }
      ]
    }
  ]
}

Key fields explained:

  • name: The C# class name (PascalCase required)
  • handleType: The type of the underlying handle (usually ptr64 or ptr32)
  • invalidValue: What value represents an invalid handle (usually "0" or "-1")
  • constructors: Array of method names that create instances
  • destructor: Method name that cleans up resources (optional, enables SafeHandle)
  • bindings: Array of methods available on the class
    • bindSelf: If true, automatically passes the handle as the first parameter

Generated C# Code

When you define classes in your manifest, Plugify generates C# class wrappers with SafeHandle:

Generated Class (Conceptual)
using System;
using System.Runtime.InteropServices;

namespace test_keyvalues
{
    internal enum Ownership { Borrowed, Owned }

    /// <summary>
    /// RAII wrapper for KeyValues handle.
    /// </summary>
    internal sealed unsafe class KeyValues : SafeHandle
    {
        /// <summary>
        /// Creates a new KeyValues instance
        /// </summary>
        public KeyValues(string setName)
            : this(test_keyvalues.Kv1Create(setName), Ownership.Owned)
        {
        }

        /// <summary>
        /// Internal constructor for creating KeyValues from existing handle
        /// </summary>
        private KeyValues(nint handle, Ownership ownership)
            : base(handle, ownsHandle: ownership == Ownership.Owned)
        {
        }

        /// <summary>
        /// Releases the handle (called automatically by SafeHandle)
        /// </summary>
        protected override bool ReleaseHandle()
        {
            test_keyvalues.Kv1Destroy(handle);
            return true;
        }

        /// <summary>
        /// Checks if the KeyValues has a valid handle
        /// </summary>
        public override bool IsInvalid => handle == nint.Zero;

        /// <summary>
        /// Gets the underlying handle
        /// </summary>
        public nint Handle => (nint)handle;

        /// <summary>
        /// Checks if the handle is valid
        /// </summary>
        public bool IsValid => handle != nint.Zero;

        /// <summary>
        /// Gets the underlying handle
        /// </summary>
        public nint Get() => (nint)handle;

        /// <summary>
        /// Releases ownership of the handle and returns it
        /// </summary>
        public nint Release()
        {
            var h = handle;
            SetHandleAsInvalid();
            return h;
        }

        /// <summary>
        /// Disposes the handle
        /// </summary>
        public void Reset()
        {
            Dispose();
        }

        // Bound methods with thread-safe handle access
        /// <summary>
        /// Gets the section name of a KeyValues instance
        /// </summary>
        public string GetName()
        {
            ObjectDisposedException.ThrowIf(!IsValid, this);
            bool success = false;
            DangerousAddRef(ref success);
            try
            {
                return test_keyvalues.Kv1GetName(handle);
            }
            finally
            {
                if (success) DangerousRelease();
            }
        }

        /// <summary>
        /// Sets the section name of a KeyValues instance
        /// </summary>
        public void SetName(string name)
        {
            ObjectDisposedException.ThrowIf(!IsValid, this);
            bool success = false;
            DangerousAddRef(ref success);
            try
            {
                test_keyvalues.Kv1SetName(handle, name);
            }
            finally
            {
                if (success) DangerousRelease();
            }
        }

        /// <summary>
        /// Finds a key by name
        /// </summary>
        public KeyValues FindKey(string keyName)
        {
            ObjectDisposedException.ThrowIf(!IsValid, this);
            bool success = false;
            DangerousAddRef(ref success);
            try
            {
                return new KeyValues(test_keyvalues.Kv1FindKey(handle, keyName), Ownership.Borrowed);
            }
            finally
            {
                if (success) DangerousRelease();
            }
        }

        /// <summary>
        /// Adds a subkey to this KeyValues instance
        /// </summary>
        public void AddSubKey(KeyValues subKey)
        {
            ObjectDisposedException.ThrowIf(!IsValid, this);
            bool success = false;
            DangerousAddRef(ref success);
            try
            {
                test_keyvalues.Kv1AddSubKey(handle, subKey.Release());
            }
            finally
            {
                if (success) DangerousRelease();
            }
        }
    }
}

Built-in Utility Methods and Properties

Every generated class includes several utility methods for handle management:

IsValid - Check Handle Validity

Returns true if the handle is valid (not equal to invalidValue):

Using IsValid
using test_keyvalues;

void CheckHandle()
{
    using var kv = new KeyValues("Config");

    if (kv.IsValid)
    {
        kv.SetName("ServerConfig");
        Console.WriteLine("Handle is valid");
    }
    else
    {
        Console.WriteLine("Handle is invalid");
    }

    // After release, handle becomes invalid
    var handle = kv.Release();
    Console.WriteLine(kv.IsValid);  // false
}

Get() / Handle - Access Raw Handle

Returns the underlying handle value. Use this when you need to pass the raw handle to C-style functions:

Using Get()
using test_keyvalues;

void GetRawHandle()
{
    using var kv = new KeyValues("Config");

    // Get the raw handle
    nint rawHandle = kv.Get();
    // or
    nint handle = kv.Handle;

    Console.WriteLine($"Handle value: {rawHandle}");

    // Pass to a function expecting a raw handle
    SomeCFunction(kv.Get());
}

Release() - Transfer Ownership

Releases ownership of the handle and returns it. After calling Release(), the class instance becomes invalid and will not call the destructor:

Using Release()
using test_keyvalues;

nint CreateAndRelease()
{
    var kv = new KeyValues("Config");
    kv.SetName("ServerConfig");

    // Transfer ownership out
    nint handle = kv.Release();

    // kv is now invalid, won't clean up
    Console.WriteLine(kv.IsValid);  // false

    // No using needed - we released ownership
    return handle;
}

void UseReleased()
{
    // We now own the handle and must clean it up manually
    nint rawHandle = CreateAndRelease();
    // ... use rawHandle ...
    test_keyvalues.Kv1Destroy(rawHandle);  // Manual cleanup required!
}

Use Release() when you need to:

  • Transfer ownership to another system
  • Store the handle in a long-lived data structure
  • Interface with C-style code that takes ownership

Reset() / Dispose() - Manual Cleanup

Explicitly disposes the handle. Reset() calls Dispose():

Using Reset() and Dispose()
using test_keyvalues;

void ManualCleanup()
{
    var kv = new KeyValues("Config");
    kv.SetName("ServerConfig");

    // Option 1: Explicitly reset
    kv.Reset();

    // Option 2: Explicitly dispose
    var kv2 = new KeyValues("Config2");
    kv2.Dispose();

    // Handle is now invalid
    Console.WriteLine(kv.IsValid);  // false

    // Methods will throw ObjectDisposedException
    try
    {
        kv.SetName("Test");
    }
    catch (ObjectDisposedException ex)
    {
        Console.WriteLine($"Error: {ex.Message}");
    }
}

The Dispose() method is:

  • Idempotent - Safe to call multiple times
  • Called automatically - By using statement and finalizer
  • Ownership-aware - Only calls destructor if object owns the handle
  • Thread-safe - Uses SafeHandle's thread-safe disposal

Resource Management

C# classes generated by Plugify support deterministic cleanup via the using statement:

The using statement provides deterministic cleanup and is the recommended pattern:

Using Statement (Recommended)
using test_keyvalues;

void ProcessConfig()
{
    using var kv = new KeyValues("Config");  // C# 8.0+ using declaration
    kv.SetName("ServerConfig");
    kv.SetString("hostname", "My Server");
    // ... use kv ...

    // Automatically disposed when leaving scope
}

// Or traditional using block
void ProcessConfigTraditional()
{
    using (var kv = new KeyValues("Config"))
    {
        kv.SetName("ServerConfig");
        // ... use kv ...
    } // Disposed here
}

This is the recommended pattern because:

  • Guaranteed cleanup even if exceptions occur
  • Clear scope of resource lifetime
  • Deterministic cleanup timing
  • Idiomatic C# code
  • Works with SafeHandle's critical finalizer

Critical Finalization (Non-Deterministic Fallback)

SafeHandle provides critical finalization that runs even during AppDomain unload:

Critical Finalization
using test_keyvalues;

void ProcessConfig()
{
    var kv = new KeyValues("Config");
    kv.SetName("ServerConfig");
    // ... use kv ...
    // NO using - relies on finalization (NOT RECOMMENDED!)
}

// kv will eventually be finalized and cleaned up
// but timing is unpredictable - USE 'using' INSTEAD!

No Destructor Case

If a class doesn't have a destructor defined in the manifest, it's generated as a simple wrapper class without SafeHandle:

Wrapper Without Destructor
using test_keyvalues;

void UseWrapper()
{
    // Class with no destructor - simple wrapper, no IDisposable
    var wrapper = new SomeWrapper();

    // Still has utility methods
    if (wrapper.IsValid)
    {
        nint handle = wrapper.Get();
        // ... use handle ...
    }

    // No automatic cleanup - handle persists
    // Useful for stateless wrappers or global resources
}

Working with Ownership

Some methods transfer ownership of resources. The manifest specifies this with the owner field in paramAliases and retAlias.

Taking Ownership (Method Parameters)

When a method takes ownership of a resource, it calls Release() automatically:

Ownership Transfer
using test_keyvalues;

void TransferOwnership()
{
    using var parent = new KeyValues("Parent");
    var child = new KeyValues("Child");
    // DON'T use 'using' with child - parent will take ownership

    // AddSubKey takes ownership of child
    parent.AddSubKey(child);

    // child is now owned by parent
    // child.IsValid is now false (Release() was called internally)
    // parent will handle cleanup
}

In the manifest, this is defined as:

Manifest Ownership
{
  "name": "AddSubKey",
  "method": "Kv1AddSubKey",
  "bindSelf": true,
  "paramAliases": [
    {
      "name": "subKey",
      "owner": true  // This parameter takes ownership
    }
  ]
}

Best practice: After transferring ownership, the object is automatically released:

Proper Ownership Handling
using test_keyvalues;

void ProperOwnership()
{
    using var parent = new KeyValues("Parent");
    var child = new KeyValues("Child");

    // Transfer ownership - child.Release() called internally
    parent.AddSubKey(child);

    // child.IsValid is now false
    // Only use through parent
    using var found = parent.FindKey("Child");
}

Returning Ownership (Return Values)

When a method returns a new resource with ownership:

Return Ownership
using test_keyvalues;

void ReturnOwnership()
{
    using var parent = new KeyValues("Parent");

    // FindKey returns a NEW KeyValues that we own
    using var child = parent.FindKey("Settings");

    if (child.IsValid)
    {
        child.SetName("UpdatedSettings");
        // We're responsible for child's lifecycle
        // Disposed automatically by using
    }
}

In the manifest:

Manifest Return Ownership
{
  "name": "FindKey",
  "method": "Kv1FindKey",
  "bindSelf": true,
  "retAlias": {
    "name": "KeyValues",
    "owner": true  // Caller owns the returned object
  }
}

Non-Owning References

When owner: false, the method returns a reference without transferring ownership:

Non-Owning Reference
using test_keyvalues;

void NonOwningRef()
{
    using var parent = new KeyValues("Parent");

    // GetFirstSubKey returns a reference, parent still owns it
    var childRef = parent.GetFirstSubKey();

    if (childRef.IsValid)
    {
        // Use the reference, but DON'T dispose or release it
        string name = childRef.GetName();
        // childRef will be cleaned up by parent
        // DON'T use 'using' with childRef
    }
}

Plugin Lifecycle and Fields

When using classes in plugins, you must be careful about disposal during plugin unload:

Managing Fields

If you store class instances in fields, you must dispose them properly:

plugin.cs
using Plugify;
using test_keyvalues;

public class MyPlugin : Plugin
{
    // Field - must be disposed!
    private KeyValues? _config;

    public override void PluginStart()
    {
        // Create field instance
        _config = new KeyValues("GlobalConfig");
        _config.SetName("ServerSettings");
        Log("Plugin started with global config");
    }

    public override void PluginEnd()
    {
        // CRITICAL: Dispose fields before plugin unload
        _config?.Dispose();
        _config = null;
        Log("Plugin ended, global config disposed");
    }
}

Safe Patterns

✅ Safe: Local scope with using

Safe Pattern
public void OnCommand(string[] args)
{
    // Local object with using - automatically disposed
    using var kv = new KeyValues("TempConfig");
    kv.SetName("CommandConfig");
    // ... use kv ...
    // Automatically disposed when leaving scope - safe!
}

✅ Safe: Method-local variables

Safe Pattern
public void ProcessData()
{
    using var kv = new KeyValues("TempConfig");
    kv.SetName("CommandConfig");
    // ... use kv ...
    // Disposed by using - safe!
}

✅ Safe: Field with disposal

Safe Pattern
public class MyPlugin : Plugin
{
    private KeyValues? _pluginConfig;

    public override void PluginStart()
    {
        _pluginConfig = new KeyValues("PluginConfig");
        _pluginConfig.SetName("Settings");
    }

    public override void PluginEnd()
    {
        // Dispose fields
        _pluginConfig?.Dispose();
        _pluginConfig = null;
    }
}

❌ Unsafe: Field without disposal

Unsafe Pattern
public class MyPlugin : Plugin
{
    // DANGEROUS: Field without disposal
    private KeyValues _config = new KeyValues("GlobalConfig");

    public override void PluginStart()
    {
        _config.SetName("ServerSettings");
    }

    public override void PluginEnd()
    {
        // MISSING: No disposal!
        // Relies on finalizer - not ideal!
    }
}

❌ Unsafe: Static field without disposal

Unsafe Pattern
public class MyPlugin : Plugin
{
    // DANGEROUS: Static field
    private static Dictionary<string, KeyValues> _cache = new();

    public void CacheConfig(string name)
    {
        _cache[name] = new KeyValues(name);
    }

    public override void PluginEnd()
    {
        // MISSING: No cache cleanup!
        // Should dispose all cached objects!
    }
}

Cleanup Checklist

Before your plugin unloads (PluginEnd()), ensure:

  1. ✅ All field instances are explicitly disposed or set to null
  2. ✅ All collections (Dictionary, List, etc.) containing class instances are cleared and disposed
  3. ✅ Static fields are disposed if they contain class instances
  4. ✅ You've called Dispose() or used using for all owned objects

Complete Example: Configuration System

Here's a complete example showing how to use classes for a configuration system:

ConfigManager.cs
using System;
using test_keyvalues;

public class ConfigManager : IDisposable
{
    private KeyValues? _root;

    public ConfigManager(string configName)
    {
        _root = new KeyValues(configName);

        if (!_root.IsValid)
        {
            throw new InvalidOperationException($"Failed to create config: {configName}");
        }
    }

    public void Dispose()
    {
        _root?.Dispose();
        _root = null;
    }

    public void CreateSection(string sectionName)
    {
        if (_root == null || !_root.IsValid)
            throw new ObjectDisposedException(nameof(ConfigManager));

        var section = new KeyValues(sectionName);

        if (!section.IsValid)
        {
            throw new InvalidOperationException($"Failed to create section: {sectionName}");
        }

        _root.AddSubKey(section);
    }

    public KeyValues? GetSection(string sectionName)
    {
        if (_root == null || !_root.IsValid)
            throw new ObjectDisposedException(nameof(ConfigManager));

        return _root.FindKey(sectionName);
    }

    public void SetValue(string sectionName, string key, string value)
    {
        using var section = GetSection(sectionName);
        if (section == null || !section.IsValid)
            throw new InvalidOperationException($"Section not found: {sectionName}");

        section.SetString(key, value);
    }

    public string GetValue(string sectionName, string key, string defaultValue = "")
    {
        using var section = GetSection(sectionName);
        if (section == null || !section.IsValid)
            return defaultValue;

        return section.GetString(key, defaultValue);
    }

    public void Save(string filename)
    {
        if (_root == null || !_root.IsValid)
            throw new ObjectDisposedException(nameof(ConfigManager));

        _root.SaveToFile(filename);
    }

    public void Load(string filename)
    {
        if (_root == null || !_root.IsValid)
            throw new ObjectDisposedException(nameof(ConfigManager));

        _root.LoadFromFile(filename);
    }
}

// Usage
void UseConfigManager()
{
    using var config = new ConfigManager("ServerConfig");

    config.CreateSection("Server");
    config.SetValue("Server", "hostname", "My Server");
    config.SetValue("Server", "maxplayers", "32");

    config.CreateSection("Game");
    config.SetValue("Game", "mode", "competitive");

    config.Save("config.kv");
}

Best Practices

  1. Always use using - Use using var (C# 8.0+) or using blocks for all IDisposable objects
  2. Dispose fields in PluginEnd() - Critical: Explicitly dispose all field instances before plugin unload
  3. Check null and IsValid - Always check validity before using objects
  4. Don't using non-owned references - Don't use using with objects returned by methods with owner: false
  5. Prefer local scope - Keep class instances in local scope with using when possible
  6. Handle ObjectDisposedException - Be prepared to catch exceptions from disposed objects
  7. Respect ownership - Don't use objects after transferring ownership (they're automatically released)
  8. Use nullable reference types - Enable nullable reference types (C# 8.0+) for better null safety
  9. Implement IDisposable pattern - If creating wrappers, follow the IDisposable pattern correctly
  10. Trust SafeHandle - SafeHandle provides thread-safe and critical finalization, but still use using
  11. Check for null returns - Methods might return null on failure

When NOT to Use Classes

Classes are designed for resources that need lifecycle management. Don't define classes for:

  • Stateless utility functions - Simple functions that don't manage resources
  • Functions that return primitive values - No need to wrap simple getters
  • One-off operations - Operations that don't maintain state
  • Global singletons - Resources that live for the entire program lifetime (but still consider IDisposable)

For these cases, continue using the regular function-based API.

Troubleshooting

ObjectDisposedException

If you get ObjectDisposedException, you're using an object after it's been disposed:

Handle Disposed Object
using test_keyvalues;

void HandleDisposed()
{
    var kv = new KeyValues("Config");
    kv.Dispose();

    // This throws ObjectDisposedException
    try
    {
        kv.SetName("Test");
    }
    catch (ObjectDisposedException ex)
    {
        Console.WriteLine($"Error: {ex.Message}");
    }

    // Check IsValid first to avoid exceptions
    if (kv.IsValid)
    {
        kv.SetName("Test");
    }
    else
    {
        Console.WriteLine("Handle is disposed!");
    }
}

Invalid Handle on Creation

If constructor returns invalid handle, check the underlying C function:

Handle Constructor Failure
using test_keyvalues;

void HandleFailure()
{
    var kv = new KeyValues("Config");

    if (!kv.IsValid)
    {
        Console.WriteLine("Failed to create KeyValues");
        kv.Dispose();
        return;
    }

    using (kv)
    {
        // ... use kv ...
    }
}

Dangling References

Be careful with non-owning references when the owner is disposed:

Dangling Reference Problem
using test_keyvalues;

KeyValues GetChildRef()
{
    using var parent = new KeyValues("Parent");
    var child = parent.GetFirstSubKey();  // Non-owning reference
    return child;  // BAD: parent will be disposed!
}

// This is dangerous!
void UseDanglingRef()
{
    var childRef = GetChildRef();
    // childRef now points to disposed memory
}

// Better approach:
KeyValues GetChildOwned()
{
    var parent = new KeyValues("Parent");

    var child = parent.FindKey("Child");  // Returns owned instance
    if (child == null || !child.IsValid)
    {
        parent.Dispose();
        return null;
    }

    // Caller must dispose both parent and child
    // Or keep parent alive
    return child;
}

Disposal During Plugin Unload

If you're relying on finalizers:

Proper Disposal
using Plugify;
using test_keyvalues;

public class MyPlugin : Plugin
{
    private KeyValues? _config;
    private Dictionary<string, KeyValues> _cache = new();

    public override void PluginStart()
    {
        _config = new KeyValues("Config");
        _cache["main"] = new KeyValues("Main");
    }

    public override void PluginEnd()
    {
        // Dispose field
        _config?.Dispose();
        _config = null;

        // Dispose cached objects
        foreach (var kv in _cache.Values)
        {
            kv.Dispose();
        }
        _cache.Clear();

        Log("All resources disposed safely");
    }
}

See Also

  • Learn how to export your own classes
  • Learn about importing functions
  • Plugin manifest reference