Skip to content

The Type System


When you build a complex instrument, you often need to manage collections of structured data at runtime -- active voices, linked list entries, modulation routing slots, action records. KSP has no native struct or object system, so IVLS provides one: a compile-time type system that generates pool-allocated objects with named fields, automatic CRUD operations, and reference safety checks.

If you've used Voice[ref].note or Voice[ref].vel in earlier chapters, you've already been using objects built on this system.

Defining a Type


Every type needs three things before it can be created:

  1. MEMBERS -- a comma-separated list of field names
  2. INIT[] -- an array of default values, one per field
  3. def() -- a function for any additional initialization (can be empty)

Here is a minimal type definition:

define Action.MEMBERS := type, timestamp, voice

declare Action.INIT[] := (0, 0, -1)

macro Action.def()
    { Additional setup, if needed }
end macro

These three prerequisites give the type system everything it needs to generate the infrastructure.

Creating the Pool


With the prerequisites defined, you create the type's memory pool:

cb ICB:
    type.Create(Action)

type.Create() allocates a pool with the default size (524,288 integers) and generates all the field accessors, allocation tracking, and internal data structures.

If you need a different pool size, use type.CreateCustomPool():

type.CreateCustomPool(Action, 1000000)

The pool size is specified in total integers, not object count. The system divides it by the block size (number of fields) to determine the maximum number of objects.

After this call, the type has:

  • A const block of field indices (Action.field.type, Action.field.timestamp, etc.)
  • A memory pool array (Action.pool)
  • An allocation tracker (Action.is_alloc)
  • A free-block queue (Action.Blocks)
  • Named property accessors for each field

Generating CRUD Operations


The type's functions are generated separately, in cb Functions:

cb Functions:
    type.Functions(Action)

type.Functions() generates:

  • Action.new() -- allocates a new object, initializes its fields from INIT[], calls the Constructor hook, and returns a reference
  • Action.copy(src) -- allocates a new object as a copy of src, calls the CopyConstructor hook
  • Action.delete(ref) -- calls the Destructor hook, frees the object, and sets ref to -1
  • Action.copy_a_to_b(a, b) -- copies all fields from object a to object b
  • Action.init(ref) -- resets all fields to their INIT[] values
  • Action.repr(ref) -- returns a string representation for debugging
  • Action.clear_pool() -- deletes all allocated objects

It also generates empty Constructor, CopyConstructor, and Destructor hooks that you can override for custom lifecycle behavior.

Property Access


Once a type is created, you access its fields using the familiar bracket-dot syntax:

declare act := Action.new()

{ Write a field }
Action[act].timestamp := ENGINE_UPTIME
Action[act].voice := self

{ Read a field }
declare t := Action[act].timestamp

Each field name becomes a KSP property with get and set functions. Under the hood, Action[act].timestamp computes a pool offset: Action.pool[act * Action.BLOCK + Action.field.timestamp].

You can also access fields by index using the raw accessor:

Action[act].access[Action.field.timestamp]

This is useful for iteration or when working with field indices dynamically.

Constructor and Destructor Overrides


The generated Constructor, CopyConstructor, and Destructor are empty by default. You can override them to add custom behavior:

function Action.Constructor(ref) override
    Action[ref].timestamp := ENGINE_UPTIME
end function

function Action.Destructor(ref) override
    { Clean up associated resources }
    lkh.ActionBuffer.remove(lkh.find_action_in_buffer(ref))
end function

The Constructor runs after field initialization in new(). The Destructor runs before deallocation in delete().

Local Variable Helpers


When processing an object, you often need all its fields as local variables. The type system provides macros for this:

type.Locals(Type, ref) -- declares a local variable for each field, prefixed with the type name, initialized from the object:

type.Locals(Action, act)
{ Now Action.type, Action.timestamp, Action.voice are local variables }

Action.timestamp := ENGINE_UPTIME + 1000
type.FillObj(Action, act)
{ Writes the modified locals back to the object }

type.FillLocals(Type, ref) -- refreshes existing locals from the object without redeclaring them.

type.FillObj(Type, ref) -- writes local variables back to the object.

These are useful inside processing loops where you read an object, modify several fields, then write them back in one batch.

Reference Safety


Every type reference is an index into the pool. A reference is valid only if the index is non-negative and the corresponding slot is marked as allocated. The check_ref() macro tests both conditions:

if check_ref(Action, act)
    { Safe to access Action[act] }
end if

For stricter validation inside functions, type.CheckRef() and type.CheckMethodRef() use assertions that halt with a named error:

function Action.process(ref)
    type.CheckMethodRef(Action, process, ref)
    { ref is guaranteed valid here }
end function

check_ref is a lightweight test suitable for flow control. type.CheckRef is a hard assertion suitable for catching bugs during development.

How Voice Uses the Type System


The Voice struct that you've used throughout this guide is built on this exact system. Its definition follows the same pattern:

define Voice.MEMBERS := Voice.BASE_MEMBERS, Voice.ADD_MEMBERS
define Voice.INIT_LIST := Voice.BASE_INIT, Voice.ADD_INIT

macro Voice.def()
    define Voice.BASE_MEMBERS := ...
        input, runtime, auto_release, ...
        vo_parent, vo_child, vo_left, vo_right, ...
        note, vel, midi_ch, ...
        event, volume, pan, tune, dyn_layer, rr

    declare Voice.BASE_INIT[] := (...
        -1, -1, TRUE, ...
        -1, -1, -1, -1, ...
        -1, -1, -1, ...
        -1, 0, 0, 0, 1, 0 ...
    )
end macro

Voice.BASE_MEMBERS provides the core fields that every IVLS instrument needs. Products extend Voice using vo_field declarations, which append to Voice.ADD_MEMBERS and Voice.ADD_INIT at compile time. The type system's pool allocation, property access, and CRUD operations all work the same way -- Voice is just a type with a larger member list.

When you write Voice[self].note or call Voice.new(), you're using the same generated infrastructure that any custom type gets from type.Create() and type.Functions().


The type system is the foundation for all structured data in IVLS. You won't need to create custom types for simple instruments, but understanding how it works explains why Voice, Entry, FIFO, NodeEnv, and other internal objects all share the same access patterns.