~bigbes/tarantool

tarantool-protobuf

e67a90f2085f492aee03f56856200a4ed97d85d2 — Eugene Blikh 2 months ago 5e8f5f6
c_runtime: decode singular scalars (ra6 3c)

New entry pb.c_runtime.decode(plan, bytes) -> table, symmetric in
scope with 3b: int32/64, uint32/64, sint32/64, bool, fixed32/64,
sfixed32/64, float, double, enum, string, bytes. Repeated, map,
message-typed, and unknown tags skip by wire type — 3d/3e/3i/wyp
will extend later.

Result shape mirrors mode=full pure-Lua decode byte-for-byte: int64
family (int64/uint64/sint64/fixed64/sfixed64) push Tarantool cdata
via luaL_pushint64 / luaL_pushuint64; everything else lands as Lua
number/string/boolean. Pre-sized via lua_createtable(0, n_fields).
Field lookup is a linear scan over plan->fields by field_number; a
tag-keyed dispatch table is a future optimization.

Tests: 26 cases per codegen mode (full + runtime), covering the
bd-mz6 acceptance (round-trip of bd-y1n's Person payload matches
mode=full Person_decode shape-for-shape) plus per-kind coverage,
proto3-optional presence, fixed64 cdata, -0.0 sign preservation
(built via cdata to dodge LuaJIT literal-folding to +0.0),
skip-by-wire-type for out-of-scope shapes, unknown-tag skip, WKT
override rejection, and truncated input. Suite: 832/832 with
PB_ENABLE_C=1, 748/748 + 84 skipped without.

Closes bd-mz6
3 files changed, 455 insertions(+), 1 deletions(-)

M .beads/issues.jsonl
M runtime/pb/c/c_runtime.c
A test/c_runtime_decode_test.lua
M .beads/issues.jsonl => .beads/issues.jsonl +1 -1
@@ 2,7 2,7 @@
{"_type":"issue","id":"tarantool-protobuf-jc9","title":"ra6 3e: repeated/packed scalar encode/decode","description":"Repeated scalar fields: encode-time, walk the Lua array via cached stack idx; decode-time, lazy-create the array on first hit and append via lua_rawseti with a cached stack idx (NOT lazy lua_getfield per element — that's 2x slower at 100KB per the spike). Packed repeated: length-prefixed varint blob; tight loop in C. Covers int32/sint32/uint32/int64/uint64/fixed32/fixed64/double/bool/enum. Depends on 3b + 3c (need the scalar primitives). Acceptance: Person.lucky_numbers (packed int32) round-trips byte-equal; a fixture with both packed and unpacked repeated scalars round-trips against mode=full at 10/100/1000-element counts.","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:33Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:33Z","dependencies":[{"issue_id":"tarantool-protobuf-jc9","depends_on_id":"tarantool-protobuf-mz6","type":"blocks","created_at":"2026-05-18T23:22:07Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-jc9","depends_on_id":"tarantool-protobuf-y1n","type":"blocks","created_at":"2026-05-18T23:22:07Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-hwe","title":"ra6 3d: sub-message encode/decode (recursion)","description":"Nested-message support. Encode: open a sub-buffer, recurse with the submsg plan, prefix parent buffer with tag + length. Decode: bound the byte range, recurse, lua_setfield the resulting table. Sub-buffer stack-backed (4KB) and malloc-promoted, same pattern as the outer buffer. Recursion is by C function call into the same encode/decode routine with a different plan, so depth is bounded by C stack. Depends on 3b + 3c. Acceptance: Person.address (1-level) round-trips; deeper nesting via a 5-level test fixture round-trips byte-equal to mode=full.","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:24Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:24Z","dependencies":[{"issue_id":"tarantool-protobuf-hwe","depends_on_id":"tarantool-protobuf-mz6","type":"blocks","created_at":"2026-05-18T23:22:06Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-hwe","depends_on_id":"tarantool-protobuf-y1n","type":"blocks","created_at":"2026-05-18T23:22:05Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":2,"dependent_count":4,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-awv","title":"ra6 3l: 64-bit cdata fidelity","description":"int64/uint64/fixed64/sfixed64 must round-trip as LuaJIT cdata in both encode and decode — never narrowed to a Lua double. Same convention as msgpackffi, net.box, box.tuple, the built-in protobuf. Use luaT_pushuint64/luaT_pushint64 from Tarantool's module.h for the C side. Independent transverse concern — can land in parallel with 3b/3c since it's a per-kind fix rather than a new pipeline stage. Acceptance: a value \u003e 2^53 round-trips byte-equal and remains cdata after decode; luatest test/protobuf_test.lua int64-cdata cases pass with PB_ENABLE_C=1.","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:10Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:10Z","dependencies":[{"issue_id":"tarantool-protobuf-awv","depends_on_id":"tarantool-protobuf-mq7","type":"blocks","created_at":"2026-05-18T23:21:55Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-mz6","title":"ra6 3c: decode scalars","description":"C-side decode for scalar wire types. Pre-sized result table via lua_createtable(0, n_fields) using descriptor stats; per-field plan-driven dispatch on wire type and kind; cached luaL_ref-based lua_setfield. Wire→table output must equal mode=full Lua output for the same input bytes. Depends on 3a. Acceptance: Person decode round-trip works for the bytes produced by 3b; the decoded table is shape-identical to pure-Lua decode (verified by running test/interop tests with PB_ENABLE_C=1).","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:06Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:06Z","dependencies":[{"issue_id":"tarantool-protobuf-mz6","depends_on_id":"tarantool-protobuf-mq7","type":"blocks","created_at":"2026-05-18T23:21:54Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-mz6","title":"ra6 3c: decode scalars","description":"C-side decode for scalar wire types. Pre-sized result table via lua_createtable(0, n_fields) using descriptor stats; per-field plan-driven dispatch on wire type and kind; cached luaL_ref-based lua_setfield. Wire→table output must equal mode=full Lua output for the same input bytes. Depends on 3a. Acceptance: Person decode round-trip works for the bytes produced by 3b; the decoded table is shape-identical to pure-Lua decode (verified by running test/interop tests with PB_ENABLE_C=1).","status":"closed","priority":1,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:06Z","created_by":"Eugene Blikh","updated_at":"2026-05-23T10:00:26Z","started_at":"2026-05-23T09:54:01Z","closed_at":"2026-05-23T10:00:26Z","close_reason":"C-side singular scalar/enum/string/bytes decode. pb.c_runtime.decode(plan, bytes) -\u003e table mirrors mode=full output: int64 family pushes cdata via luaL_pushint64/pushuint64, others Lua number/string/boolean. Linear field_number scan over plan-\u003efields per tag; repeated/map/message and unknown tags skipped by wire type — 3d/3e/3i extend later. 26 tests pass (full + runtime modes); full suite 832/832 with PB_ENABLE_C=1, 748/748+84 skipped without.","dependencies":[{"issue_id":"tarantool-protobuf-mz6","depends_on_id":"tarantool-protobuf-mq7","type":"blocks","created_at":"2026-05-18T23:21:54Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-y1n","title":"ra6 3b: encode scalars (varint, fixed32/64, double, bool, enum, sint)","description":"C-side encode for scalar wire types into the message's output buffer. Reads field values via cached luaL_ref + lua_rawgeti, dispatches on plan field kind, writes varint/fixed32/fixed64/double/bool/enum/sint into the 4KB stack-backed buffer with malloc-promotion on overflow. Wire-byte output must be byte-equal to mode=full Lua output. Depends on 3a (needs the plan). Acceptance: Person encode round-trip works for {name='x', age=42, balance=-7, user_id=0xDEADBEEFCAFEBABEULL, weight_kg=3.14} with byte-equal output to mode=full pure Lua.","status":"closed","priority":1,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:03Z","created_by":"Eugene Blikh","updated_at":"2026-05-23T09:41:31Z","started_at":"2026-05-23T09:32:14Z","closed_at":"2026-05-23T09:41:31Z","close_reason":"C-side singular-scalar encode landed in runtime/pb/c/c_runtime.c. New entry pb.c_runtime.encode(plan, msg) -\u003e string. Buffer strategy: 4KB stack scratch promoted to lua_newuserdata on overflow — mid-encode luaL_error doesn't leak (userdata GC'd at unwind). Covered kinds: int32/64, uint32/64, sint32/64, bool, fixed32/64, sfixed32/64, float, double, enum (number/string lookup via field-\u003eenum_ref's by_name), string, bytes. Repeated/map/message silently skipped (3d/3e scope). Proto3 zero-suppression mirrors mode=full exactly: empty strings, zero ints/fixed, +0.0 double skipped; -0.0 emitted via type-pun byte equality (matches Lua's 1/v == -math.huge guard); proto3-optional fields bypass suppression. WKT override plans rejected (3k/bd-rmf scope). New test test/c_runtime_encode_test.lua: 16 cases per mode (full + runtime) = 32 tests covering acceptance (Person {name='x', age=42, balance=-7, user_id=0xDEADBEEFCAFEBABEULL, weight_kg=3.14} byte-equal to mode=full), per-kind sweep, edge cases (proto3-optional empty string, -0.0 double, enum-string lookup + unknown-name error, cdata uint64 fixed64, heap-buffer grow at 8KB string, repeated/message-skip silent). Full suite: 806/806 with PB_ENABLE_C=1, 748/748 + 58 skipped without (silent fallback intact). Unblocks 3d/3e/3g/3i/3j.","dependencies":[{"issue_id":"tarantool-protobuf-y1n","depends_on_id":"tarantool-protobuf-mq7","type":"blocks","created_at":"2026-05-18T23:21:53Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-mq7","title":"ra6 3a: descriptor → C plan compiler","description":"Compile finalized Lua descriptors into a C-side plan userdata, stashed on desc.c_plan. The plan carries per-field records (field number, wire type, kind tag, presence offset, default index), pre-encoded tag bytes, luaL_ref slots for cached field names, sub-descriptor pointers, oneof grouping metadata (parallel array, matches desc.oneofs_list), extension range hooks, and WKT override pointers (desc.encode/desc.decode passthrough). Rebuilt at pb.finalize_message time when PB_ENABLE_C=1. Not serialized, not shared across processes. Foundation for all other ra6 sub-issues — no other 3* issue can start until this lands. Acceptance: pb.c_runtime.compile_plan(desc) returns a userdata; plan-\u003en_fields and plan-\u003efields[i].tag are readable from a C-level smoke test; plans for hello.Person and Address build without error.","status":"closed","priority":1,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:19:58Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T04:46:39Z","started_at":"2026-05-19T04:26:53Z","closed_at":"2026-05-19T04:46:39Z","close_reason":"Descriptor -\u003e C plan compiler landed in runtime/pb/c/c_runtime.c. Compiles a finalized Lua descriptor into an opaque pb_plan userdata with: per-field records (field_number, wire_type, kind, repeated/packed/optional, pre-encoded tag bytes, sub_plan_idx, oneof_idx, enum_ref), oneof grouping (parallel array with member indices + field oneof_idx back-pointers), WKT override pointers (has_override + override_encode_ref/decode_ref skipping field-walk), extension range hooks (proto2 scaffolding), field-name luaL_ref cache, sub-plan table for cycle-safe recursion. Self-references (Person.friends -\u003e Person) handled by stashing c_plan on desc BEFORE recursing, so resolve_sub_plan returns the same userdata. Idempotent: re-compiling returns cached plan. Build via 'just build-c'. Smoke test at test/c_runtime_plan_test.lua: 26 assertions across both codegen modes (full + runtime) verify module surface, scalar/enum/message/map/repeated/packed shapes, sub-plan resolution, self-reference cycle break, oneof grouping (Result.outcome), idempotency, WKT override detection (Timestamp). Full suite: 771/771 with PB_ENABLE_C=1, 745+26 skipped without (silent fallback verified). Unblocks 3b/3c/3l/3k (y1n, mz6, awv, rmf).","dependencies":[{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-47e","type":"blocks","created_at":"2026-05-18T23:21:52Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-pf6","type":"blocks","created_at":"2026-05-18T23:21:52Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-z7x","type":"blocks","created_at":"2026-05-18T23:21:53Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":3,"dependent_count":5,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-1eq","title":"C accel spike: generic one-call C codec variant (strategy 3)","description":"Sub-issue of tarantool-protobuf-04c. Implement strategy 3: a generic C codec that gets a descriptor + Lua table once per top-level encode/decode call and owns the inner loop. Internally dispatches per-field on descriptor kind (the work pf6 / ra6 ends up shipping in production). Measure with bench/c_accel/spike_bench.lua. Acceptance: numbers in bench/c_accel/README.md showing the gap to strategy 4 (hand-written, no dispatch).","notes":"Phase A measured strategy 4 ceiling at 3-10x over pure Lua. The 4 vs 3 gap is the key signal for pf6 architecture: \u003c20-40% gap means generic C runtime (ra6) alone is sufficient; \u003e40% gap means we need codegen-emitted C (c0i). For the spike, dispatch can be simple (switch on protoreflect.Kind); no need to design the compiled-plan format here.","status":"closed","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T19:28:08Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T19:44:07Z","closed_at":"2026-05-18T19:44:07Z","close_reason":"Measured as part of the 04c spike; numbers and architecture conclusion captured in 04c close, bench/c_accel/README.md, and bd memory c-accel-spike-04c-final-2026-05-18.","dependencies":[{"issue_id":"tarantool-protobuf-1eq","depends_on_id":"tarantool-protobuf-04c","type":"blocks","created_at":"2026-05-18T22:28:15Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}

M runtime/pb/c/c_runtime.c => runtime/pb/c/c_runtime.c +276 -0
@@ 1144,6 1144,281 @@ encode_lua(lua_State *L)
}

/* ---------------------------------------------------------------- *
 *  Decode (bd-mz6 / ra6 3c): singular scalar fields.                *
 *                                                                  *
 *  Symmetric scope with 3b. Repeated/map/message-typed fields are  *
 *  skipped over by wire-type — 3d/3e/3i will overwrite this with   *
 *  real handling. Unknown tags are skipped silently too; bd-wyp    *
 *  will add round-trip capture.                                     *
 *                                                                  *
 *  Result-table shape must match mode=full pure-Lua decode. That   *
 *  pins per-kind value types: int64/uint64/sint64/fixed64/sfixed64 *
 *  push Tarantool int64_t/uint64_t cdata via luaL_pushint64 /      *
 *  luaL_pushuint64; everything else pushes a Lua number, boolean,  *
 *  or string. See runtime/pb/wire.lua decoder comments.             *
 * ---------------------------------------------------------------- */

typedef struct dec_ctx {
	lua_State    *L;
	const uint8_t *buf;
	size_t        len;
	size_t        pos;
} dec_ctx;

static uint64_t
dec_varint(dec_ctx *c)
{
	uint64_t v = 0;
	int shift = 0;
	for (int i = 0; i < 10; i++) {
		if (c->pos >= c->len)
			luaL_error(c->L, "truncated varint at offset %d",
			           (int)c->pos);
		uint8_t b = c->buf[c->pos++];
		v |= ((uint64_t)(b & 0x7f)) << shift;
		if ((b & 0x80) == 0)
			return v;
		shift += 7;
	}
	luaL_error(c->L, "varint exceeds 10 bytes at offset %d",
	           (int)c->pos);
	return 0;
}

static uint32_t
dec_fixed32(dec_ctx *c)
{
	if (c->len - c->pos < 4)
		luaL_error(c->L, "truncated fixed32 at offset %d",
		           (int)c->pos);
	uint32_t v = (uint32_t)c->buf[c->pos]
	           | ((uint32_t)c->buf[c->pos + 1] << 8)
	           | ((uint32_t)c->buf[c->pos + 2] << 16)
	           | ((uint32_t)c->buf[c->pos + 3] << 24);
	c->pos += 4;
	return v;
}

static uint64_t
dec_fixed64(dec_ctx *c)
{
	if (c->len - c->pos < 8)
		luaL_error(c->L, "truncated fixed64 at offset %d",
		           (int)c->pos);
	uint64_t v = 0;
	for (int i = 0; i < 8; i++)
		v |= ((uint64_t)c->buf[c->pos + i]) << (i * 8);
	c->pos += 8;
	return v;
}

static void
dec_skip(dec_ctx *c, uint8_t wt)
{
	switch (wt) {
	case PB_WIRE_VARINT:
		(void)dec_varint(c);
		break;
	case PB_WIRE_I32:
		(void)dec_fixed32(c);
		break;
	case PB_WIRE_I64:
		(void)dec_fixed64(c);
		break;
	case PB_WIRE_LEN: {
		uint64_t plen = dec_varint(c);
		if (c->len - c->pos < plen)
			luaL_error(c->L, "truncated length-delimited payload");
		c->pos += (size_t)plen;
		break;
	}
	default:
		luaL_error(c->L, "unsupported wire type %d for skip", (int)wt);
	}
}

static inline int32_t
zigzag32_dec(uint32_t u)
{
	return (int32_t)((u >> 1) ^ (~(u & 1) + 1));
}

static inline int64_t
zigzag64_dec(uint64_t u)
{
	return (int64_t)((u >> 1) ^ (~(u & 1) + 1));
}

/* Decode a single value for field `f` and push it onto the Lua stack. */
static void
dec_push_one(dec_ctx *c, pb_plan_field *f)
{
	switch (f->kind) {
	case PB_KIND_INT32: {
		uint64_t u = dec_varint(c);
		int32_t v = (int32_t)(uint32_t)u;
		lua_pushinteger(c->L, v);
		return;
	}
	case PB_KIND_INT64: {
		uint64_t u = dec_varint(c);
		luaL_pushint64(c->L, (int64_t)u);
		return;
	}
	case PB_KIND_UINT32: {
		uint64_t u = dec_varint(c);
		uint32_t v = (uint32_t)u;
		lua_pushnumber(c->L, (double)v);
		return;
	}
	case PB_KIND_UINT64: {
		uint64_t u = dec_varint(c);
		luaL_pushuint64(c->L, u);
		return;
	}
	case PB_KIND_SINT32: {
		uint64_t u = dec_varint(c);
		int32_t v = zigzag32_dec((uint32_t)u);
		lua_pushinteger(c->L, v);
		return;
	}
	case PB_KIND_SINT64: {
		uint64_t u = dec_varint(c);
		luaL_pushint64(c->L, zigzag64_dec(u));
		return;
	}
	case PB_KIND_BOOL: {
		uint64_t u = dec_varint(c);
		lua_pushboolean(c->L, u != 0);
		return;
	}
	case PB_KIND_ENUM: {
		uint64_t u = dec_varint(c);
		int32_t v = (int32_t)(uint32_t)u;
		lua_pushinteger(c->L, v);
		return;
	}
	case PB_KIND_FIXED32: {
		uint32_t u = dec_fixed32(c);
		lua_pushnumber(c->L, (double)u);
		return;
	}
	case PB_KIND_SFIXED32: {
		uint32_t u = dec_fixed32(c);
		lua_pushinteger(c->L, (int32_t)u);
		return;
	}
	case PB_KIND_FLOAT: {
		uint32_t u = dec_fixed32(c);
		union { uint32_t u; float f; } pun;
		pun.u = u;
		lua_pushnumber(c->L, (double)pun.f);
		return;
	}
	case PB_KIND_FIXED64: {
		uint64_t u = dec_fixed64(c);
		luaL_pushuint64(c->L, u);
		return;
	}
	case PB_KIND_SFIXED64: {
		uint64_t u = dec_fixed64(c);
		luaL_pushint64(c->L, (int64_t)u);
		return;
	}
	case PB_KIND_DOUBLE: {
		uint64_t u = dec_fixed64(c);
		union { uint64_t u; double d; } pun;
		pun.u = u;
		lua_pushnumber(c->L, pun.d);
		return;
	}
	case PB_KIND_STRING:
	case PB_KIND_BYTES: {
		uint64_t plen = dec_varint(c);
		if (c->len - c->pos < plen)
			luaL_error(c->L, "truncated string/bytes payload");
		lua_pushlstring(c->L, (const char *)(c->buf + c->pos),
		                (size_t)plen);
		c->pos += (size_t)plen;
		return;
	}
	default:
		luaL_error(c->L, "dec_push_one: unsupported kind %d",
		           (int)f->kind);
	}
}

static int
decode_lua(lua_State *L)
{
	pb_plan *plan = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
	size_t buf_len;
	const char *buf = luaL_checklstring(L, 2, &buf_len);

	if (plan->has_override) {
		return luaL_error(L,
			"C decode on plan with override is unsupported (bd-rmf)");
	}

	/* Pre-size the result table: at most n_fields singular entries. */
	lua_createtable(L, 0, plan->n_fields);
	int result_idx = lua_gettop(L);

	/* Pin field-names so the loop can rawgeti by 1-based field index. */
	if (plan->field_names_ref != LUA_NOREF)
		lua_rawgeti(L, LUA_REGISTRYINDEX, plan->field_names_ref);
	else
		lua_pushnil(L);
	int names_idx = lua_gettop(L);

	dec_ctx c;
	c.L   = L;
	c.buf = (const uint8_t *)buf;
	c.len = buf_len;
	c.pos = 0;

	while (c.pos < c.len) {
		uint64_t tag = dec_varint(&c);
		uint32_t field_number = (uint32_t)(tag >> 3);
		uint8_t  wt           = (uint8_t)(tag & 0x07);

		/* Linear scan over plan->fields. n_fields is typically small;
		 * tag-keyed dispatch table is a future optimization. */
		pb_plan_field *f = NULL;
		for (int i = 0; i < plan->n_fields; i++) {
			if (plan->fields[i].field_number == field_number) {
				f = &plan->fields[i];
				break;
			}
		}

		/* Out-of-scope shapes for 3c: repeated, map, message. Skip
		 * by wire type so 3d/3e/3i can replace this branch later. */
		if (f == NULL
		    || f->repeated
		    || f->kind == PB_KIND_MAP
		    || f->kind == PB_KIND_MESSAGE) {
			dec_skip(&c, wt);
			continue;
		}

		dec_push_one(&c, f);  /* stack: ..., names, value */

		/* result[name] = value. Field index within plan = pointer
		 * arithmetic against plan->fields[0]; +1 for the 1-based
		 * field_names_ref convention used elsewhere. */
		int field_idx_1based = (int)(f - plan->fields) + 1;
		lua_rawgeti(L, names_idx, field_idx_1based);  /* push name */
		lua_insert(L, -2);                            /* name, value */
		lua_rawset(L, result_idx);                    /* result[name] = value */
	}

	/* Drop names ref; leave result on top. */
	lua_pop(L, 1);
	return 1;
}

/* ---------------------------------------------------------------- *
 *  Module entry.                                                    *
 * ---------------------------------------------------------------- */



@@ 1157,6 1432,7 @@ static const struct luaL_Reg c_runtime_methods[] = {
	{"plan_has_override", plan_has_override},
	{"plan_sub_plan",   plan_sub_plan},
	{"encode",          encode_lua},
	{"decode",          decode_lua},
	{NULL, NULL},
};


A test/c_runtime_decode_test.lua => test/c_runtime_decode_test.lua +178 -0
@@ 0,0 1,178 @@
-- Test for bd-mz6 / ra6 3c: C-side scalar decode.
--
-- Only runs when PB_ENABLE_C=1 is set in the environment AND the C
-- runtime module is loadable. Otherwise the group is skipped, mirroring
-- the gate on c_runtime_encode_test.lua.
--
-- Acceptance per bd-mz6:
--   Person decode round-trip works for the bytes produced by 3b; the
--   decoded table is shape-identical to pure-Lua decode for the same
--   input bytes (both codegen modes).

local t = require('luatest')
local ffi = require('ffi')

local pb = require('pb')
local c_runtime = pb.c_runtime

local function skip_if_no_c()
    if c_runtime == nil then
        t.skip('PB_ENABLE_C not set or pb.c_runtime not available')
    end
end

-- mode=full is the byte-equal reference for both encode and decode.
local full_hello

for _, mode in ipairs({'full', 'runtime'}) do
    local g = t.group('c_runtime_decode.' .. mode)
    local hello

    g.before_all(function()
        skip_if_no_c()
        hello = require(mode .. '.hello.hello_pb')
        full_hello = require('full.hello.hello_pb')
    end)

    g.before_each(skip_if_no_c)

    -- ---------- Acceptance per bd-mz6 ----------

    function g.test_acceptance_person_round_trip_from_3b_bytes()
        local msg = {
            name = 'x',
            age = 42,
            balance = -7,
            user_id = 0xDEADBEEFCAFEBABEULL,
            weight_kg = 3.14,
        }
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        local bytes = c_runtime.encode(plan, msg)
        local c_decoded = c_runtime.decode(plan, bytes)
        local lua_decoded = full_hello.Person_decode(bytes)
        t.assert_equals(c_decoded, lua_decoded,
            'C decode matches mode=full pure-Lua decode shape-for-shape')
    end

    -- ---------- Per-kind coverage ----------

    function g.test_empty_input_produces_empty_table()
        local plan = c_runtime.compile_plan(hello.Address_descriptor)
        t.assert_equals(c_runtime.decode(plan, ''), {})
    end

    function g.test_address_strings_and_int32()
        local plan = c_runtime.compile_plan(hello.Address_descriptor)
        local msg = {street = 'Main', city = 'Springfield', zip = 12345}
        local bytes = full_hello.Address_encode(msg)
        t.assert_equals(c_runtime.decode(plan, bytes),
                        full_hello.Address_decode(bytes))
    end

    function g.test_proto3_optional_empty_string_present()
        -- Address.apartment is proto3-optional; presence beats default.
        -- 3b emits the tag for apartment='' (optional bypasses suppression),
        -- so decode must surface the empty string in the result table.
        local plan = c_runtime.compile_plan(hello.Address_descriptor)
        local bytes = full_hello.Address_encode({apartment = ''})
        local lua_decoded = full_hello.Address_decode(bytes)
        local c_decoded = c_runtime.decode(plan, bytes)
        t.assert_equals(c_decoded, lua_decoded)
        t.assert_equals(c_decoded.apartment, '')
    end

    function g.test_double_negative_zero_decodes()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        -- LuaJIT constant-folds the literal -0.0 to +0.0, so build the
        -- sign-bit-set zero at runtime via a cdata round-trip. Bytes
        -- encoded from this value must round-trip to a value whose 1/x
        -- is -inf — the only way to distinguish -0.0 from +0.0 in Lua.
        local neg_zero = ffi.new('double[1]', 0)[0] * -1
        local bytes = full_hello.Person_encode({weight_kg = neg_zero})
        t.assert(#bytes > 0, '-0.0 should not be zero-suppressed')
        local c_decoded = c_runtime.decode(plan, bytes)
        t.assert_equals(1 / c_decoded.weight_kg, -math.huge)
    end

    function g.test_enum_as_number_value()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        local bytes = full_hello.Person_encode({status = 2})  -- ERROR
        local c_decoded = c_runtime.decode(plan, bytes)
        local lua_decoded = full_hello.Person_decode(bytes)
        t.assert_equals(c_decoded, lua_decoded)
        t.assert_equals(c_decoded.status, 2)
    end

    function g.test_fixed64_returns_uint64_cdata()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        local msg = {user_id = ffi.new('uint64_t', 0x123456789ABCDEF0)}
        local bytes = full_hello.Person_encode(msg)
        local c_decoded = c_runtime.decode(plan, bytes)
        t.assert_equals(type(c_decoded.user_id), 'cdata')
        t.assert_equals(c_decoded.user_id, msg.user_id)
    end

    function g.test_sint32_negative_round_trip()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        local bytes = full_hello.Person_encode({balance = -1})
        t.assert_equals(c_runtime.decode(plan, bytes),
                        full_hello.Person_decode(bytes))
        bytes = full_hello.Person_encode({balance = 0x7fffffff})
        t.assert_equals(c_runtime.decode(plan, bytes),
                        full_hello.Person_decode(bytes))
    end

    function g.test_bytes_field_preserves_high_bytes()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        local bytes = full_hello.Person_encode({avatar = '\x00\x01\xff\xfe'})
        local c_decoded = c_runtime.decode(plan, bytes)
        t.assert_equals(c_decoded.avatar, '\x00\x01\xff\xfe')
    end

    function g.test_skips_repeated_and_message_tags()
        -- 3c scope: repeated/map/message tags are skipped over by wire
        -- type rather than decoded (3d/3e/3i extend this). The result
        -- table contains only the singular scalar fields, matching what
        -- a 3b-encoded input would have produced anyway.
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        -- Build bytes via the full Lua codec, then decode through C.
        local rich = {
            name = 'x',
            emails = {'a@b'},                 -- repeated string
            address = {street = 'Main'},      -- message
            lucky_numbers = {1, 2, 3},        -- repeated packed
            ages_by_nickname = {alice = 30},  -- map
        }
        local bytes = full_hello.Person_encode(rich)
        local c_decoded = c_runtime.decode(plan, bytes)
        t.assert_equals(c_decoded, {name = 'x'})
    end

    function g.test_skips_unknown_field_tags()
        -- Append a synthetic unknown-field tag (id=999, wire varint=0)
        -- to a valid encoding. The C decoder should skip it, not error.
        local good = full_hello.Person_encode({name = 'x'})
        -- field 999, wire 0 => tag varint = (999<<3) | 0 = 7992 => 0xF8 0x3E
        local unknown_tag = '\xf8\x3e\x05'   -- tag + varint value 5
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        local c_decoded = c_runtime.decode(plan, good .. unknown_tag)
        t.assert_equals(c_decoded, {name = 'x'})
    end

    function g.test_wkt_override_rejected()
        -- has_override plans don't expose field arrays; mirror 3b's gate.
        local plan = c_runtime.compile_plan(pb.wkt.Timestamp_descriptor)
        t.assert_error_msg_contains('override', function()
            c_runtime.decode(plan, '')
        end)
    end

    function g.test_truncated_input_errors()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        -- Tag for field 1 (string) followed by length-prefix without the
        -- string bytes that the length claims.
        t.assert_error_msg_contains('truncated', function()
            c_runtime.decode(plan, '\x0a\x05ab')
        end)
    end
end