36. Value::NativeObject: one opaque object type per module, dispatched through the existing method-lookup path
- Status: Accepted
- Date: 2026-09-05
Context
Korrin has no mechanism today for "a native object with private Rust state
and its own methods." The closest existing thing, BoundNative
(ADR 0016), binds a receiver to a &'static
Rust function for exactly three Value variants — Str, List, Map —
via a compile-time-closed 3-arm match in methods::lookup
(crates/korrin/src/interpreter/methods.rs). Every stdlib function today
(math.sqrt, io.read_file, ...) is a flat, stateless Value::Native —
nothing in the codebase constructs an object carrying opaque Rust-side
state (a file handle, a buffer, an open window) that Korrin methods then
operate on.
Milestone 7's Window needs exactly that: a minifb::Window plus a pixel
buffer, held behind methods like poll_events/draw_rect/present. A
third-party type cannot simply become a new Value enum variant — Value
lives in the published crates/korrin library, and a module author's own
type is defined outside it.
Decision
One new Value variant, NativeObject, carrying opaque state plus a
method table the module itself supplies — not a fully general,
multi-type, inheritance-capable object-type registry.
pub struct NativeObject {
pub type_name: &'static str, // what type() reports — the module's own choice
state: RefCell<Box<dyn Any>>, // opaque to Korrin and to the interpreter alike
pub methods: &'static [NativeMethod],
}
impl NativeObject {
pub fn downcast_ref<T: 'static>(&self) -> Option<Ref<'_, T>>;
pub fn downcast_mut<T: 'static>(&self) -> Option<RefMut<'_, T>>;
}
impl Value {
pub fn native_object(type_name: &'static str, state: impl Any, methods: &'static [NativeMethod]) -> Value;
}
methods::lookup gains exactly one new arm:
Value::NativeObject(obj) => obj.methods,
Everything downstream — BoundNative construction in get_attribute, the
arity check, the call dispatch in both engines — is unchanged. A module's
method table reuses the existing NativeMethod/NativeMethodFn/Arity
types verbatim, built with the same const fn method(...) helper
methods.rs already uses for str/list/map. A NativeMethodFn
downcasts its receiver internally
(recv.as_native_object().unwrap().downcast_mut::<WindowState>().unwrap())
— the same "dispatch already guarantees the variant; a mismatch would be a
bug in lookup, not a Korrin program's fault" invariant recv_str/
recv_list/recv_map already rely on.
type_name(), repr()/Debug, and values_equal each gain the forced new
arm: type_name() returns obj.type_name (like Instance returning its
class name, not a fixed string); repr()/Debug render <native {type_name}>, never the opaque state; equality is Rc::ptr_eq only — the
same identity-only stance already taken for Instance/Class/Function.
Consequences
- Ships the smallest real thing that actually unblocks GameKorr, matching this project's own established discipline (M1's tree-walker before M3's VM; M5's leaf-only packages before a real dependency graph).
- One object type per module, not several. A module wanting two
distinct native object kinds (say, a
Windowand aFont) gets two separateValue::native_object(...)call sites with two separate&'static [NativeMethod]tables — there is no registry keyed by type name, no way for Korrin code to ask "what native types exist," and no inheritance between native object types. This is a real, deliberate limitation, not an oversight — a fully general registry is named and rejected below. downcast_mut/downcast_refreturningNoneon a type mismatch is only ever a bug in the native module itself (its own method table pointing a method at the wrong constructor's state) — never a condition a Korrin program can trigger, sincelookuponly ever returns a method from the table already attached to the receiver. Native code is expected to.expect()this, not propagate it as a catchable Korrin exception.get_attribute,BoundNative, and both engines' call-dispatch code need zero changes — the entire cost of this decision is one newValuevariant, its forced exhaustive-match arms, and one new line inmethods::lookup.
Alternatives considered
- A fully general type registry — multiple native types per module,
real extensibility hooks (a name-to-table
HashMappopulated at runtime, method resolution across a type hierarchy). Closer to a real object system for native types, and more future-proof. Rejected for v1: real upfront interpreter-surface design and risk (how do two modules' types interact? what doestype()report for a registry-dispatched value? does it need its own diagnostics?) that nothing in this milestone — or in GameKorr's own stated shape — actually needs yet. Revisit once a second native module with its own object type exists and the one-type- per-module limit is felt for real, not hypothetically. - Representing native state as an
Instance(Korrin's existing class/object mechanism) with a special "native" class flag. Rejected:Instance's fields are aHashMap<String, Value>— Korrin-level values set byself.x = ...— with no slot for opaque, non-ValueRust data (aminifb::Windowcannot be aValue). RetrofittingInstanceto carry an optional native payload would complicate a type every existing class-related code path already handles, for a case that needs none ofInstance's actual class machinery (inheritance,super, user-definedinit).