RFC-0048 — Self-describing argument marshalling for type-blind push sites

  • Status: Accepted — implemented for C via _Generic (2026-06-14, issue #83)
  • Author: Mark Truluck mark.truluck@cogiton.com
  • Created: 2026-06-14
  • Builds on: RFC-0020 (runtime kernel), RFC-0008 (modal stack / pop$), #81 (C heap-boxed doubles)
  • Relates to: the C argument marshalling in framec/src/frame_c/compiler/codegen/frame_expansion/{transition,pop_transition}.rs and framec/src/frame_c/compiler/codegen/runtime/c.rs

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in RFC 2119.

Summary

Frame passes state/enter/exit arguments through per-backend containers. On self-describing backends — dynamic languages (a Python float is a real float object) and type-erased managed languages (std::any, Object, Any, interface{}, Box<dyn Any>) — the value carries its own runtime type, so the reader recovers it without the writer needing to know the declared type.

C is the lone backend whose container slot (void*) is not self-describing. A void* carries no type tag, and the correct representation of a value depends on its type: a double MUST be heap-boxed (#81 — it neither fits nor round-trips as an integer), while ints, strings, and pointers travel directly in the slot. The C reader ($> / <$ / handler-param binding) chooses its unmarshal from the declared parameter type, so the writer MUST produce the matching representation — which means the writer needs the type too.

For a normal transition -> $State(args) the target state is statically known, so framec looks up the declared param types and marshals each value correctly (the state_param_types / state_enter_param_types plumbing from #81). But for a pop$ transition the popped target state is whatever sits on the runtime modal stack — statically unknowable. The type-blind push there fell back to (void*)(intptr_t)(value), which is correct for ints/strings/pointers but truncates a double (and the typed reader then dereferences the truncated integer as if it were a heap box — a crash). This is issue #83.

This RFC establishes the general principle and its C realization.

Rationale: issue #83

-> (3.25) pop$ followed by a restored $>(x: float):

  • Before: the pop site pushed (void*)(intptr_t)(3.25) → the slot holds the integer 3 cast to a pointer. The handler reads unpack_double(slot), which dereferences (double*)3segfault / garbage. Floats on pop$ were effectively unsupported on C (every other backend handled them fine).
  • After: the value round-trips exactly (3.25), and the output compiles under -std=c11 -fno-exceptions (so it remains usable as a Godot-web GDExtension, cf. #86).

Only C, only pop$, only floating-point args were affected — because C is the only non-self-describing slot, pop$ is the only push whose target type is unknown, and double/float is the only supported type whose representation diverges from the intptr_t/pointer default (structs-by-value also diverge but are already unsupported as C args).

Principle

At a push site where the declared target type is statically unavailable, the writer MUST NOT guess a representation. Instead it MUST defer representation-selection to a mechanism that knows the value’s type — either the target language’s own static type dispatch over the value, or a self-describing (tagged) slot. The reader’s representation is thereby always matched without the writer knowing the declared type.

This generalizes beyond C: any future low-level backend whose argument container is not self-describing (an older C dialect, an embedded or register-level target, a language with only monomorphic typed containers) will hit the same wall and SHOULD adopt the same principle.

C design (implemented): _Generic value dispatch

C11 _Generic selects an expression by the static type of a value. framec emits a per-system macro that dispatches the push on the value’s type — exactly the type information the type-blind site lacks about the target — and the value and the declared param agree in any correct program, so the representation the macro produces is the one the reader expects:

#define Sys_ARG_IS_FLOAT(v) _Generic((v), double:1, float:1, long double:1, default:0)
#define Sys_ARG_DBL(v)      _Generic((v), double:(v), float:(v), long double:(double)0, default:(double)0)
#define Sys_ARG_WORD(v)     _Generic((v), double:(void*)0, float:(void*)0, long double:(void*)0, default:(void*)(intptr_t)(v))
#define Sys_ARG_PUSH(vec, v) ( Sys_ARG_IS_FLOAT(v) \
    ? Sys_FrameVec_push_owned((vec), Sys_pack_double(Sys_ARG_DBL(v))) \
    : Sys_FrameVec_push((vec), Sys_ARG_WORD(v)) )

Correctness notes:

  • Floating-point → owned heap box (pack_double, #81): the vec frees it on destroy and deep-copies it on compartment copy. Everything else → the intptr_t/pointer slot, stored un-owned (the caller owns strings; ints aren’t pointers).
  • Every _Generic branch is a valid expression for any argument type. The float branches use the raw value (v) (never an invalid (double)pointer cast); the default casts through intptr_t, valid for both ints and pointers. This sidesteps the _Generic gotcha that all branches — not just the selected one — are type-checked.
  • The argument is evaluated exactly once. A _Generic controlling expression is not evaluated (only its type is used), and the compile-time-constant ternary drops the dead arm, so the live value expression is evaluated once in the selected branch.
  • No representation change, no reader change. The macro produces the same bare void* representation the typed marshal already produces for that type, so the existing $>/<$ readers (which unmarshal by declared type) are untouched, and a slot pushed by pop$ is indistinguishable from one pushed by a normal transition.

Applied at the two type-blind C sites in pop_transition.rs: pop$ enter-args (-> (args) pop$) and pop$ exit-args ((args) -> pop$).

Alternatives considered

  • Tagged-union slot (struct { uint8_t tag; union {...}; }) — a hand-rolled std::any for C. More general (works for languages without _Generic) but a cross-cutting representation change rippling through every read/copy/persist site. Rejected for C as unnecessary: _Generic achieves the same result with zero representation change. Retained as the recommended path for a future non-_Generic low-level backend.
  • Type inference in framec — infer the value’s type for the common cases (field refs, literals) and marshal directly. Partial (fails on arbitrary native expressions), and redundant with what the C compiler already knows via _Generic. Rejected.
  • Diagnostic only (warn/error on float pop$ args) — the honest stopgap, but strictly worse than a fix that makes the feature work. Superseded.

Future work

_Generic value-dispatch could also replace the state_param_types / state_enter_param_types lookup plumbing for normal transitions (#81): since the macro derives the marshal from the value, framec would no longer need to thread declared types to the C push sites at all. Out of scope here (the typed path works and is well-tested); noted as a consolidation opportunity.

Drawbacks

  • C11-specific. A pre-C11 target would need the tagged-union alternative. framec’s C output already requires C11 (e.g. the _Static-free Sys_strdup_ shim and the -std=c11 compile gates), so this is not a new constraint.
  • Slightly larger emitted runtime (four macro lines per system).