Propset Architecture
- Version: 0.3.0
- Last Updated: 2026-08-01
- Status: Normative target architecture
Overview
The propset system is the profile-owned half of the object data architecture. Data schemas are defined in Smalls, giving ruleset authors control over game and rules data layout without engine recompilation. C++ owns object lifetime, native components, native ABI values and operations, serialization mechanics, and renderer-facing row storage. Runtime C++ must not reach into propset fields for game policy; profile scripts read propsets directly and communicate native side effects through explicit native functions.
The package-layer and cross-boundary rules in
conventions.md apply throughout this document. Profile root
resolution and schema bootstrap are defined in
profile-packages.md.
The dominant rules access pattern is discrete event processing: an attack or interaction resolves a small known set of object handles. Propset storage therefore supports direct handle lookup. System boundaries still use plural batch transforms; one event is the same path with a small row count. Table import, serialization, visual publication, and tool projections remain linear batch operations rather than repeated scalar bridge calls.
Normative Ownership Rule
[[native]], [[propset]], and a C++ object component describe three
different boundaries. They are not interchangeable storage annotations.
| Kind | Defined by | Owns | Smalls access |
|---|---|---|---|
| Native ABI function or value | core.* declaration plus C++ registration |
A copied value or invariant-preserving engine operation | Typed call or copied value |
| Native object component | C++ | Engine data required directly by native systems or protected by native ownership/lifetime invariants | Native ABI operations only |
| Profile propset | Selected profile's <root>.propsets module |
Object-local game schema and script policy state | get_propset! |
| Profile rules/config batch | Selected profile module | Shared interpretation, tuning, and policy tables | Ordinary Smalls values |
| Toolset projection | Selected toolset module | Replaceable editor state, labels, filters, and rows | RmlUi data-model binding |
Every production [[propset]] belongs to the selected profile's single
<root>.propsets module. core.* contains the native ABI and reusable
profile-neutral implementations, but no propsets, profile constants, table
interpretation, or profile imports. Propsets still under core.* or another
profile module are migration work, not precedent.
Qualified propset names are serialized protocol identifiers. Moving a propset
from core.item.ItemStats to nwn1.propsets.ItemStats changes the JSON section
key deliberately. Serialization reads and writes only the target names; older
archives must be reimported from their source format. There is no runtime alias,
fallback lookup, or duplicate schema.
A [[native]] value type may be stored as a field or array element inside a
profile propset when it is the exact copied value exchanged with C++. The field
type does not transfer ownership of the containing schema to core.*.
There is no "native propset" category. Engine-owned component storage remains
C++ data and is exposed through native operations. Profile-owned propset
storage remains a Smalls schema even though PropsetPoolManager allocates its
memory.
Classification Procedure
Classify a datum from its real readers, writers, frequency, and invariants:
- List every current reader and writer. Separate serialization/conversion from runtime consumption.
- If a native subsystem must consume the datum directly, or a write must preserve native ownership, spatial, renderer, inventory, or lifetime invariants, use a native component plus typed native operations.
- If the selected game defines the schema and normal reads and writes are rules-policy operations, use a profile propset.
- If C++ and Smalls exchange one copied fixed-shape value, define a native
value type in the relevant
core.*module. Do not create a component only to expose that value. - If the datum is shared immutable rules data, partition it by consumer:
native addressing/storage facts become native
Inforows and script policy becomes profileRulesrows. Join them by a stable scalar key. - If the datum exists only to display or interact with an editor, it is a toolset projection, not object storage.
Do not classify from names such as "visual," "stats," "rules," model prefixes, or source table columns. The consumer and required invariant decide the owner.
Reference Classifications
| Datum | Owner | Reason |
|---|---|---|
| Position, orientation, area membership | Native spatial component | Native spatial/pathing systems consume it and writes have side effects |
| Inventory ownership and equipment slots | Native inventory components | Ownership and occupancy must change atomically |
| Resolved model/light rows | Native visual component | Renderer-facing protocol, already resolved before consumption |
| Item model parts and color indices | Native item visual component | Fixed native layout with renderer invalidation invariants |
ItemProperty row value |
Native core.item value |
Exact copied row exchanged across the ABI |
| Item descriptor, stats, and property collection | nwn1.propsets |
NWN object schema and policy state |
| Base-item model/icon/layout facts | Native BaseItemInfo facts |
Native asset addressing and inventory geometry |
| Base-item combat and requirement policy | nwn1.item rules/config |
Moddable game behavior |
| Creature ability scores, feats, classes, and scripts | Profile propsets | Profile-defined object schema consumed by rules |
| Editor rows, filters, selected tab, and labels | Toolset Smalls | Replaceable authoring projection |
Cost Of Each Choice
The ownership choice determines where cost is paid:
| Owner | Runtime cost | Maintenance and migration cost |
|---|---|---|
| Native component | Native allocation/access and explicit component serialization | C++ rebuild, fixed layout/versioning, typed bridge and invariant tests |
| Profile propset | Handle lookup plus profile-script reads/writes; dynamic fields carry managed lifetime work | Qualified schema key, profile bootstrap, reimport when the schema name changes |
| Native ABI value/fact | Value conversion or copy across the VM boundary | Registered layout and explicit range/error contract |
Native Info plus profile Rules split |
One import pass per projection and a scalar-key join at use sites | Each source field must have exactly one runtime owner |
| Toolset projection | Rebuild on invalidation and viewport-bounded RmlUi materialization | No durable object migration; workflow remains replaceable |
No general performance ranking is implied. Use the simplest owner that serves the observed consumers and preserves the required invariant. If a proposed move is performance-motivated, measure the representative access pattern before and after; ownership alone is not evidence of speed.
Cross-Boundary Protocol
Native operations consume and produce copied values or homogeneous batches:
profile propsets + profile Rules + native Info
-> profile Smalls resolution
-> copied resolved row batch
-> invariant-preserving native component mutation
-> renderer, inventory, spatial, or other native consumer
The producer owns source data. Values crossing the ABI are copied; no pointer into a propset pool, VM arena, profile table, or C++ container crosses the boundary. The receiving native component owns accepted rows for the live object lifetime. Stable scalar IDs join native facts and profile policy.
Plural batch operations are the default. A singular operation is a thin
count = 1 use of the same validation and mutation path unless the target is
a true singleton such as the selected profile.
Invalid or stale object handles reject the operation. Invalid row counts, out-of-range values, duplicate destinations, and stale before-values reject the complete batch without partial mutation. A missing or invalid required profile schema fails profile initialization. Durable JSON with an obsolete or missing qualified propset/component section fails load; it does not select a fallback owner.
Two Worlds
Script Propsets
Game-logic data such as descriptors, appearance source data, ability scores,
feats, item stats, and object state belongs to the selected profile. Schemas
are defined in profile .smalls files using the
[[propset(ObjectType)]] annotation. Memory is managed by
PropsetPoolManager in slab-based pools keyed by ObjectHandle. Scripts
access propsets through the get_propset! intrinsic.
[[propset(Creature)]]
type CreatureStats {
abilities: int[6];
save_fort: int;
save_reflex: int;
save_will: int;
skills: array!(int);
feats: array!(int);
race: int;
gender: int;
good_evil: int;
lawful_chaotic: int;
ac_natural_bonus: int;
cr: float;
cr_adjust: int;
perception_range: int;
disarmable: int;
immortal: int;
interruptable: int;
lootable: int;
pc: int;
plot: int;
chunk_death: int;
bodybag: int;
special_abilities: array!(SpecialAbility);
};
The engine registers propset types, initializes them for object handles, and imports/exports them at serialization boundaries. Generic JSON reflection and profile legacy-format conversion may read or write propsets as data transforms. Runtime policy lives in Smalls. C++ runtime reads from propsets are temporary compatibility bridges or conversion code, not the long-term architecture.
The required <root>.propsets module is loaded once by the selected profile's
runtime bootstrap. It does not depend on the optional rules init module or an
editor import. Missing or invalid required schemas fail runtime initialization
before any object can be loaded.
Native Object Components
Engine-critical and engine-owned data — spatial state, local variables, geometry, visual rows, inventories, store inventories, item layout, vitals, and ability loadout rows — lives in ObjectComponentSystem. Components are C++ storage rows keyed by ObjectHandle. Some are contiguous vectors for common component data; inventories are lazily allocated because many objects do not carry one.
Scripts have no direct memory access to native components. They communicate through native functions that preserve C++ invariants. Equipping, inventory mutation, position changes, and visual row emission are engine transactions, not propset field writes.
Visual appearance follows the same rule: scripts emit explicit visual rows through bridge functions, and C++ stores those rows in object components for renderer/tooling consumption. The model/light/icon handoff is described in the Visual Asset Protocol.
The Boundary: Bridge Functions
All communication between the script world and the engine-owned data goes through native function signatures. Scripts call get_position(obj) — not get_propset(obj): Transform. This is deliberate:
- Engine systems protect their own invariants (
set_positioncan update spatial queries, trigger area transitions, notify pathfinding) - No shared memory or type system leakage in either direction
- The script-engine boundary is entirely legible as a set of function signatures
- No "native propset" concept needs to exist in the script type system at all
Native components are a C++ implementation pattern, not a script-visible abstraction.
Script Propset Schemas
Creature
Creature data is split by lifetime and access pattern. Cold descriptor and appearance source data stay separate from rules stats, health, level progression, and transient combat/cache rows.
[[propset(Creature)]]
type CreatureDescriptor {
on_attacked: ResRef;
on_blocked: ResRef;
on_conversation: ResRef;
// ... other event scripts ...
conversation: ResRef;
description: TextRef;
name_first: TextRef;
name_last: TextRef;
deity: string;
subrace: string;
soundset: int;
decay_time: int;
};
[[propset(Creature)]]
type CreatureAppearance {
appearance: int;
phenotype: int;
tail: int;
wings: int;
portrait: ResRef;
portrait_id: int;
color_hair: int;
color_skin: int;
color_tattoo1: int;
color_tattoo2: int;
body_part_belt: int;
// ... remaining body part fields ...
body_part_torso: int;
};
[[propset(Creature)]]
type CreatureStats {
abilities: int[6]; // STR=0, DEX=1, CON=2, INT=3, WIS=4, CHA=5
save_fort: int;
save_reflex: int;
save_will: int;
skills: array!(int); // indexed by Skill newtype value
feats: array!(int); // Feat newtype values
race: int;
gender: int;
good_evil: int; // 0=evil, 50=neutral, 100=good
lawful_chaotic: int; // 0=chaotic, 50=neutral, 100=lawful
ac_natural_bonus: int;
cr: float;
cr_adjust: int;
perception_range: int;
disarmable: int;
immortal: int;
interruptable: int;
lootable: int;
pc: int;
plot: int;
chunk_death: int;
bodybag: int;
special_abilities: array!(SpecialAbility);
};
[[propset(Creature)]]
type CreatureHealth {
hp: int;
hp_base_for_max: int;
hp_current: int;
hp_max: int;
hp_temp: int;
faction_id: int;
starting_package: int;
};
[[propset(Creature)]]
type CreatureLevels {
classes: int[8]; // Class IDs per slot
class_levels: int[8]; // Level count per slot
levelup_classes: array!(int);
xp: int;
walkrate: int;
};
[[transient, propset(Creature)]]
type CreatureCombat {
attack_current: int;
attacks_onhand: int;
attacks_offhand: int;
attacks_extra: int;
combat_mode: int;
ac_armor_base: int;
ac_shield_base: int;
size_ab_modifier: int;
size_ac_modifier: int;
target_distance_sq: float;
target_state: int;
hasted: int;
size: int;
// ... round/epoch/weapon cache fields ...
};
[[transient, propset(Creature)]]
type CreatureCombatCache {
// Cached combat modifier rows and epoch keys.
};
Common operation join cost:
| Operation | Propsets |
|---|---|
| Ability check | CreatureStats |
| Has feat | CreatureStats |
| Skill check | CreatureStats |
| Apply damage | CreatureHealth |
| Death check | CreatureHealth |
| Save roll | CreatureStats |
| BAB / attack roll | CreatureLevels + CreatureCombat |
| AC calculation | CreatureStats + CreatureCombat |
| Spell slot check | CreatureLevels + native ability loadout |
| Visual model resolution | CreatureAppearance + equipment/item propsets |
Most common rules operations touch one or two propsets. Complex combat paths may also consult equipment, item propsets, native ability loadout rows, or transient caches. Keep those joins explicit in Smalls instead of hiding them behind C++ object members.
[[transient]] propsets are runtime/cache rows. Component/propset JSON
initializes them from defaults on load and does not write them to durable
fixtures or saves.
Excluded from propsets (v1):
- Spell preparation/loadout rows — mutable runtime state owned by the native
ObjectAbilityLoadout component; legacy NWN1 SpellBook lists exist only
as GFF import/export compatibility.
- LevelHistory detail beyond class slots — feat/skill/ability choices remain
player-character compatibility data. CreatureLevels.levelup_classes carries
the class-slot projection used by current script rules.
- Equipped items and inventory ownership — native components, with Smalls hooks
for policy and visual/effect updates.
Item
// core.item: native ABI
[[native]]
type ItemProperty {
prop_type: int;
subtype: int;
cost_table: int;
cost_value: int;
param_table: int;
param_value: int;
tag: string;
};
// nwn1.propsets: selected profile persistence protocol
from core.types import { TextRef };
[[propset(Item)]]
type ItemDescriptor {
description: TextRef;
description_id: TextRef;
};
[[propset(Item)]]
type ItemStats {
base_item: int;
armor_id: int;
armor_dex_bonus: int;
armor_dex_bonus_valid: int;
armor_ac_bonus: int;
armor_ac_bonus_valid: int;
cost: int;
cost_additional: int;
stack_size: int;
charges: int;
cursed: int;
identified: int;
plot: int;
stolen: int;
};
core.item.ItemProperty is a native struct value stored in the native
ObjectItemPropertyState component. nwn1.item reads an ordered snapshot,
writes a whole replacement batch, and registers NWN1 property/effect row
constructors. core.item.process_item_properties performs the reusable batch
dispatch on equip/unequip. C++ owns only the bounded storage widths, snapshot
transfer, and effect application/removal boundary.
Item model parts and PLT colors live in the native ObjectItemVisualState
component. Smalls reads and mutates that fixed layout through core.item batch
functions so visual-change policy remains scriptable without reflecting storage
as a propset. Component/propset JSON requires the native
components.item_properties and components.item_visuals sections and writes
only native component rows. Archives with former propset representations are
rejected and must be reimported.
The visual component contract is one contiguous row per live item, owned by
ObjectComponentSystem for the item's lifetime:
model_colors: exactly 6 unsigned 8-bit values.model_parts: exactly 19 unsigned 16-bit values.part_colors: exactly 114 unsigned 8-bit values in part-major order;255means inherit the corresponding model color.
JSON load and Smalls mutation write whole batches into this layout. Wrong
counts, negative values, overflow, and duplicate batch destinations are
rejected without partial writes. Legacy GFF import converts valid part fields
into one complete row; negative or overflowing part values are dropped and
remain at the zero default. JSON with the former ItemVisuals propset and no
native component is rejected; reimport is the migration path.
The property component is one handle-indexed row with an ordered contiguous array. Each entry contains unsigned 16-bit type/subtype/cost-value fields, unsigned 8-bit table/parameter fields, and a string tag. Reads copy one snapshot in O(property count); writes validate and replace the complete batch without partial mutation. JSON rejects missing fields, signed values, and overflow. Legacy GFF import drops malformed property rows and imports every well-formed row in source order.
Door
[[propset(Door)]]
type DoorState {
hp: int;
hp_current: int;
hardness: int;
locked: int;
lock_dc: int;
bash_dc: int;
open_state: int;
plot: int;
interruptable: int;
};
Placeable
[[propset(Placeable)]]
type PlaceableState {
hp: int;
hp_current: int;
hardness: int;
locked: int;
plot: int;
useable: int;
has_inventory: int;
static: int;
appearance: int;
light_color: int;
};
Other Objects
These now have minimal object-type-scoped propsets:
| Object | Propset |
|---|---|
| Encounter | EncounterState, with array!(EncounterSpawn) |
| Sound | SoundState |
| Store | StoreState |
| Trigger | TriggerState |
| Waypoint | WaypointState |
Area and module data remain mostly C++/format-owned for now. Their contents reference sub-objects, components, and module-level resources rather than a single object-local rules state row.
Native Object Component System
Concept
C++ object components hold engine-owned data and explicit cross-system protocols. They are keyed by ObjectHandle, owned by ObjectComponentSystem, and accessed through native functions or C++ component APIs.
struct ObjectSpatialState {
ObjectHandle owner{};
ObjectID area = object_invalid;
glm::vec3 position{0.0f};
glm::vec3 orientation{0.0f};
glm::vec3 scale{1.0f};
glm::vec3 velocity{0.0f};
glm::vec3 angular_velocity{0.0f};
uint32_t flags = 0;
};
struct ObjectVisualState {
ObjectHandle owner{};
Vector<ObjectVisualModel> models;
Vector<ObjectVisualLight> lights;
Resref hold_animation;
PltColors base_plt_colors{};
uint32_t base_plt_color_mask = 0;
int32_t appearance = -1;
int32_t body_variant = 0;
};
Current native component families:
| Component | Owner / Reason |
|---|---|
| Spatial | position, orientation, area, velocity, future physics integration |
| Local data | dynamic script variables; sparse and mutable |
| Vitals | engine-facing current/max HP bridge |
| Geometry | triggers, encounters, spawn points, highlight bounds |
| Visual | script-emitted model/light rows for renderer/tooling |
| Item layout | inventory dimensions for items |
| Inventory / store inventory | ownership grids; lazily allocated |
| Ability loadout | known/slotted/unslotted ability rows |
The common case is not “all objects have all components.” Components are created when an object actually needs them. This keeps empty objects cheap and makes optional systems visible in code.
Script Access
Scripts access native component data exclusively through bridge functions. No get_propset equivalent exists for native components:
// core/object.smalls — bridge functions, not propset access
[[native]] fn get_position(obj: object): vec3;
[[native]] fn set_position(obj: object, pos: vec3);
[[native]] fn get_facing(obj: object): float;
[[native]] fn set_facing(obj: object, facing: float);
[[native]] fn get_area(obj: object): object;
The bridge function handles side effects such as spatial state updates, object transfer policy, pathing hooks, or renderer row invalidation. Scripts never touch component memory directly.
Visual rows use the same boundary:
[[native]] fn clear_visual(obj: object, appearance: int): bool;
[[native]] fn set_visual_hold_animation(obj: object, animation: ResRef): bool;
[[native]] fn add_visual_model_row(
obj: object,
model: ResRef,
plt_texture: ResRef,
attach_to: ResRef,
attach_from: ResRef,
kind: int,
slot: int,
part: int,
source_part: int,
model_part: int,
flags: int): bool;
Separation from Script Propsets
Native components and script propsets are separate storage systems:
| Script Propsets | Native Components | |
|---|---|---|
| Schema defined in | Smalls script | C++ struct |
| Memory managed by | PropsetPoolManager |
ObjectComponentSystem / component owner |
| GC integration | Direct heap fields are tracked as roots; unmanaged arrays are destroyed with the object row | Component-specific C++ ownership |
| Access from script | get_propset! intrinsic |
Bridge functions only |
| Runtime C++ policy access | Avoid; use Smalls or native rows | Direct C++ component API |
| Serialization | Generic propset JSON plus legacy GFF import/export policies | Fixed component JSON/GFF sections |
| Optimization target | Fast single-object fetch | Explicit C++ data protocol and optional component storage |
Current Implementation
PropsetPoolManager— slab pool, slot management, dirty tracking, unmanaged array support[[propset(ObjectType)]]annotation — parsed and validated byTypeResolver- Propset field validation — allows supported primitives, strings, object handles, native values, value structs, fixed arrays, and unmanaged arrays
get_propset!intrinsic — declared incore/prelude.smalls- Object-type propset registration at runtime startup
- GFF import/export through
PropsetGffImporterandPropsetGffExporter - Generic component/propset JSON through
object_to_component_propset_jsonandobject_from_component_propset_json - Durable propset JSON sections keyed by qualified Smalls type name
[[transient]]skip policy for durable JSON output- Object component JSON sections for native components such as spatial, local data, geometry, inventory, visuals, and ability loadout
- Script-side rules for combat, modifiers, item property processing, spell slot/known-spell logic, creature sizing, and visual row resolution
- Visual asset protocol from Smalls resolvers into
ObjectVisualState
All 16 persistent and transient NWN1 propsets live in the single
nwn1.propsets schema module. Shared core.* modules contain no propsets.
Key constraint: while a C++ mirror still has runtime consumers, remove or redirect those consumers before deleting the mirror. Do not add new runtime policy reads from propsets in C++; use profile Smalls functions, native components, or explicit row protocols as the boundary. Generic reflection, serialization, and profile format conversion are data transforms and may inspect registered schemas without owning their policy.
Refactoring Procedure
Apply this procedure to one object domain at a time:
- Observe the data. Record the source representation, representative counts and ranges, all runtime readers and writers, mutation frequency, serialization keys, and required side effects.
- Choose one owner per datum. Classify every field as native component, native ABI value/fact, profile propset/rules data, or toolset projection. A source column or object field cannot have two authoritative runtime representations.
- Define the boundary batch. Native reads return copied values or contiguous value batches. Native mutations accept homogeneous batches, validate the complete batch, and either apply every row or reject every row.
- Move policy before storage. Redirect runtime policy consumers to the profile API before deleting a C++ mirror. Redirect native subsystem consumers to an explicit resolved row/component before deleting their source bridge.
- Move persistence in the same slice. Update exact qualified propset names, native component sections, legacy import/export policy, fixtures, and round-trip tests together. Do not add compatibility aliases.
- Remove the old path. Delete duplicate storage, fallback reads, old qualified names, editor mirrors, and policy branches. A migration is not complete while both representations can load.
- Verify the common path. Test load, live mutation, save/reload, invalid/stale input rejection, and any renderer or ownership side effects. Measure only when the change makes a performance claim.
For every proposed native operation, write its concrete input and output and the invariant it preserves. If the only output is presentation state or a formatted editor row, the operation belongs in toolset Smalls instead.
For every proposed propset field, identify the profile policy that reads or writes it. If only a native subsystem consumes it, the datum belongs in an explicit native component or fact protocol instead.
Refactoring Completion Test
A domain migration is complete when:
- each persisted datum has one authoritative section;
- all propsets are declared and loaded from the selected profile's single
<root>.propsetsmodule; core.*contains only native ABI and reusable profile-neutral implementations for that domain, with no profile propsets, constants, or imports;- native components are mutated only through operations that preserve their invariants;
- runtime C++ contains no profile-policy propset reads;
- profile Smalls contains no renderer, RmlUi, or native storage knowledge;
- toolset SmallS/RML can replace the editor presentation without changing the data owner; and
- old JSON fails explicitly rather than selecting a compatibility path.
Deferred / Out of Scope (v1)
- Spell preparation/loadout in propsets — slot-per-level-per-class runtime rows
stay in the native
ObjectAbilityLoadoutcomponent for now; NWN1SpellBookremains only a legacy GFF list adapter. - Full
LevelHistoryin propsets — player-character compatibility detail; only the class-slot projection is currently inCreatureLevels - Persistent profile use of general
objecthandle fields — the language supports immediate object values, but durable identity and stale-reference policy remain unresolved - Area/module conversion to propsets — left mostly C++/format-owned until their object/subresource boundaries are clearer
- Schema migration policy beyond zero/default initialization for newly added fields
- Query system (iterate all objects with propset X) — not needed at current scale; revisit if simulation scope grows