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:
MEMBERS-- a comma-separated list of field namesINIT[]-- an array of default values, one per fielddef()-- 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 macroThese 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
constblock 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 fromINIT[], calls the Constructor hook, and returns a referenceAction.copy(src)-- allocates a new object as a copy ofsrc, calls the CopyConstructor hookAction.delete(ref)-- calls the Destructor hook, frees the object, and setsrefto -1Action.copy_a_to_b(a, b)-- copies all fields from objectato objectbAction.init(ref)-- resets all fields to theirINIT[]valuesAction.repr(ref)-- returns a string representation for debuggingAction.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].timestampEach 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 functionThe 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 ifFor 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 functioncheck_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 macroVoice.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.