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}.rsandframec/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 integer3cast to a pointer. The handler readsunpack_double(slot), which dereferences(double*)3→ segfault / garbage. Floats onpop$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 → theintptr_t/pointer slot, stored un-owned (the caller owns strings; ints aren’t pointers). - Every
_Genericbranch is a valid expression for any argument type. The float branches use the raw value(v)(never an invalid(double)pointercast); the default casts throughintptr_t, valid for both ints and pointers. This sidesteps the_Genericgotcha that all branches — not just the selected one — are type-checked. - The argument is evaluated exactly once. A
_Genericcontrolling 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 bypop$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-rolledstd::anyfor 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:_Genericachieves the same result with zero representation change. Retained as the recommended path for a future non-_Genericlow-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-freeSys_strdup_shim and the-std=c11compile gates), so this is not a new constraint. - Slightly larger emitted runtime (four macro lines per system).