~bigbes/tarantool

tarantool-protobuf

e875519ea07d2f9bb86d2a68baccefdd53f3d0f3 — Eugene Blikh 2 months ago 6e7835a
c_runtime: repeated + packed scalar encode/decode (ra6 3e)

Add repeated dispatch to the C-runtime encode/decode loop. Encode
side: encode_repeated_field walks Lua arrays via lua_objlen + per-
index rawgeti, dispatches on element kind. Packed numerics build
their payload in a stack-backed sub-buffer then emit `tag(LEN) +
varint(len) + body`; unpacked emit `tag + value` per element via
encode_one_field with force_emit=1 to bypass zero-suppression;
strings/bytes flow through the same path (never packable); repeated
messages reuse encode_submessage_field per element.

Decode side: per-field stack-slot cache (list_stack_idx[]) tied to
list_count[] avoids the per-element lua_getfield(result, name) round
trip the c-accel spike measured at 2x slower at 100KB. On first hit
for a repeated field we lua_createtable + write result[name] AND dup-
push the list onto the stack; subsequent hits lua_rawseti through the
cached absolute stack index. Lists stay valid across recursive sub-
message decodes because each child decode_body cleans up its own
scratch back to the caller's frame.

Packed/unpacked symmetry on read: a wt==LEN payload for any packable
scalar is decoded as a packed blob regardless of the schema's packed
flag, and a per-element-tagged stream is decoded element-by-element
even on a schema that defaults to packed — per proto3 reader rules.

New test/proto/c_repeated.proto fixture carries packed + explicit-
unpacked + repeated string/bytes + repeated message branches. The
encode and decode tests round-trip at 10/100/1000 elements per
branch. The two existing "skip repeated and map" marker tests
collapse to "skip map" — only map fields remain out of scope for
3e (bd-asz / 3h lands them next). 854 → 896 passing tests.

bd-jc9
M .beads/interactions.jsonl => .beads/interactions.jsonl +1 -0
@@ 22,3 22,4 @@
{"id":"int-59f687bd","kind":"field_change","created_at":"2026-05-23T09:41:30.983997Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-y1n","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"C-side singular-scalar encode landed in runtime/pb/c/c_runtime.c. New entry pb.c_runtime.encode(plan, msg) -> 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->enum_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."}}
{"id":"int-32f1e096","kind":"field_change","created_at":"2026-05-23T10:00:25.652311Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-mz6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"C-side singular scalar/enum/string/bytes decode. pb.c_runtime.decode(plan, bytes) -> table mirrors mode=full output: int64 family pushes cdata via luaL_pushint64/pushuint64, others Lua number/string/boolean. Linear field_number scan over plan->fields 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."}}
{"id":"int-634d0065","kind":"field_change","created_at":"2026-05-23T10:24:19.417709Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-hwe","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"ra6 3d: sub-message encode/decode (recursion) — refactored encode_lua/decode_lua into reusable encode_body/decode_body, added singular sub-message encode (force-promoted parent heap_idx before recursion to make lua_settop safe) and decode (temporarily shrunk c->len for bounded inner read). 22 new tests cover round-trip, empty sub-message presence, proto3-optional fields, 5-level depth (new test/proto/c_nested.proto), parent-buffer regrow path, truncated-input errors. Full suite 854/854 with PB_ENABLE_C=1."}}
{"id":"int-869e5402","kind":"field_change","created_at":"2026-05-23T10:39:55.349195Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-jc9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}

M .beads/issues.jsonl => .beads/issues.jsonl +1 -1
@@ 1,5 1,5 @@
{"_type":"issue","id":"tarantool-protobuf-exy","title":"ra6 3f: repeated strings + repeated messages (cached stack-idx)","description":"Repeated string fields and repeated message fields. Encode: iterate the Lua array via cached stack idx, write each element (string field or recursive submsg encode). Decode: first hit lazy-creates the result array and stashes its stack index in a per-field slot (sized by plan-\u003en_fields, cap 16 for the spike fixed-size); subsequent hits lua_rawseti directly without re-lookup; one lua_setfield at decode_message exit attaches the array to the result table. This is the SPIKE-VALIDATED PATTERN — naive lazy-getfield is 2x slower at 100KB. Depends on 3d (needs sub-message machinery for repeated messages). Acceptance: Person.emails (repeated string) and Person.friends (repeated Person, self-reference) both round-trip byte-equal to mode=full at 1KB, 10KB, 100KB sizes.","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:52Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:52Z","dependencies":[{"issue_id":"tarantool-protobuf-exy","depends_on_id":"tarantool-protobuf-hwe","type":"blocks","created_at":"2026-05-18T23:22:18Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_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-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":"closed","priority":1,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:33Z","created_by":"Eugene Blikh","updated_at":"2026-05-23T10:39:55Z","started_at":"2026-05-23T10:29:02Z","closed_at":"2026-05-23T10:39:55Z","close_reason":"Closed","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":"closed","priority":1,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:24Z","created_by":"Eugene Blikh","updated_at":"2026-05-23T10:24:19Z","started_at":"2026-05-23T10:11:41Z","closed_at":"2026-05-23T10:24:19Z","close_reason":"ra6 3d: sub-message encode/decode (recursion) — refactored encode_lua/decode_lua into reusable encode_body/decode_body, added singular sub-message encode (force-promoted parent heap_idx before recursion to make lua_settop safe) and decode (temporarily shrunk c-\u003elen for bounded inner read). 22 new tests cover round-trip, empty sub-message presence, proto3-optional fields, 5-level depth (new test/proto/c_nested.proto), parent-buffer regrow path, truncated-input errors. Full suite 854/854 with PB_ENABLE_C=1.","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":"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}

A examples/expected/full/c_repeated/c_repeated_pb.lua => examples/expected/full/c_repeated/c_repeated_pb.lua +737 -0
@@ 0,0 1,737 @@
-- Code generated by protoc-gen-tarantool. DO NOT EDIT.
-- source: c_repeated.proto
-- syntax: proto3
-- package: c_repeated

local pb = require("pb")
local wire = pb.wire
local string_byte = string.byte
local band = bit.band
local rshift = bit.rshift

local M = {}

M.options = {go_package = "tarantoolpb_synthetic/c_repeated"}

-- Pre-declare message descriptors so cross-references resolve.
M.Inner_descriptor = {name = "c_repeated.Inner"}
M.Holder_descriptor = {name = "c_repeated.Holder"}

-- Message: c_repeated.Inner
M.Inner_descriptor.fields = {
    {name="v", id=1, kind='scalar', proto_type="int32"},
    {name="s", id=2, kind='scalar', proto_type="string"},
}
pb.finalize_message(M.Inner_descriptor)
M.Inner_fields = pb.field_names({
    v = "v",
    s = "s",
})

-- Message: c_repeated.Holder
M.Holder_descriptor.fields = {
    {name="packed_int32", id=1, kind='scalar', proto_type="int32", repeated=true, packed=true},
    {name="packed_int64", id=2, kind='scalar', proto_type="int64", repeated=true, packed=true},
    {name="packed_sint32", id=3, kind='scalar', proto_type="sint32", repeated=true, packed=true},
    {name="packed_uint32", id=4, kind='scalar', proto_type="uint32", repeated=true, packed=true},
    {name="packed_fixed32", id=5, kind='scalar', proto_type="fixed32", repeated=true, packed=true},
    {name="packed_fixed64", id=6, kind='scalar', proto_type="fixed64", repeated=true, packed=true},
    {name="packed_double", id=7, kind='scalar', proto_type="double", repeated=true, packed=true},
    {name="packed_float", id=8, kind='scalar', proto_type="float", repeated=true, packed=true},
    {name="packed_bool", id=9, kind='scalar', proto_type="bool", repeated=true, packed=true},
    {name="unpacked_int32", id=20, kind='scalar', proto_type="int32", repeated=true, packed=false, options={packed = false}},
    {name="unpacked_fixed64", id=21, kind='scalar', proto_type="fixed64", repeated=true, packed=false, options={packed = false}},
    {name="unpacked_sint32", id=22, kind='scalar', proto_type="sint32", repeated=true, packed=false, options={packed = false}},
    {name="strings", id=30, kind='scalar', proto_type="string", repeated=true},
    {name="blobs", id=31, kind='scalar', proto_type="bytes", repeated=true},
    {name="messages", id=40, kind='message', message=M.Inner_descriptor, repeated=true},
}
pb.finalize_message(M.Holder_descriptor)
M.Holder_fields = pb.field_names({
    packed_int32 = "packed_int32",
    packed_int64 = "packed_int64",
    packed_sint32 = "packed_sint32",
    packed_uint32 = "packed_uint32",
    packed_fixed32 = "packed_fixed32",
    packed_fixed64 = "packed_fixed64",
    packed_double = "packed_double",
    packed_float = "packed_float",
    packed_bool = "packed_bool",
    unpacked_int32 = "unpacked_int32",
    unpacked_fixed64 = "unpacked_fixed64",
    unpacked_sint32 = "unpacked_sint32",
    strings = "strings",
    blobs = "blobs",
    messages = "messages",
})

-- EmmyLua / lua-language-server type annotations.
-- These are comments — no runtime effect. They give editors
-- autocomplete and type-checking for the generated wrappers.
---@class c_repeated.Inner
---@field v integer
---@field s string

---@class c_repeated.Holder
---@field packed_int32 integer[] @ Packed scalars (proto3 default). One per encoder branch.
---@field packed_int64 integer[]
---@field packed_sint32 integer[]
---@field packed_uint32 integer[]
---@field packed_fixed32 integer[]
---@field packed_fixed64 integer[]
---@field packed_double number[]
---@field packed_float number[]
---@field packed_bool boolean[]
---@field unpacked_int32 integer[] @ Explicit unpacked — emits one tag per element.
---@field unpacked_fixed64 integer[]
---@field unpacked_sint32 integer[]
---@field strings string[] @ Length-delimited element kinds — never packable.
---@field blobs string[]
---@field messages c_repeated.Inner[] @ Repeated sub-message — per-element tag + length-prefix + body.

---@param t? c_repeated.Inner
---@return c_repeated.Inner
function M.Inner_new(t) return t or {} end

---@param t c_repeated.Inner
---@return string
function M.Inner_encode(t)
    if type(t) ~= 'table' then
        error("expected table for c_repeated.Inner, got " .. type(t), 0)
    end
    local out, n = {}, 0
    local v
    -- field 1: v
    v = t.v
    if v ~= nil and v ~= 0 then
        n = n + 1; out[n] = "\x08"
        n = n + 1; out[n] = wire.encode_int32(v)
    end
    -- field 2: s
    v = t.s
    if v ~= nil and v ~= '' then
        n = n + 1; out[n] = "\x12"
        local _len = #v
        if _len < 128 then
            n = n + 1; out[n] = string.char(_len)
        else
            n = n + 1; out[n] = wire.encode_varint(_len)
        end
        n = n + 1; out[n] = v
    end
    local _exts = t._extensions
    if _exts ~= nil then
        local _elist = M.Inner_descriptor.extensions_list
        if _elist ~= nil then
            for _i = 1, #_elist do
                local _ext = _elist[_i]
                local _ev = _exts[_ext.full_name]
                if _ev ~= nil then
                    pb.codec.encode_field(_ext, _ev, out, true)
                end
            end
            n = #out
        end
    end
    local _uf = t._unknown_fields
    if _uf ~= nil and _uf ~= '' then n = n + 1; out[n] = _uf end
    return table.concat(out)
end

---@param b string
---@return c_repeated.Inner
function M.Inner_decode(buf)
    if type(buf) ~= 'string' then
        error("expected string for c_repeated.Inner decode, got " .. type(buf), 0)
    end
    local result = {}
    local pos, len = 1, #buf
    local _uf
    while pos <= len do
        local _tag_start = pos
        local id, wt
        local _b = string_byte(buf, pos)
        if _b ~= nil and _b < 0x80 then
            wt = band(_b, 7)
            if wt >= 6 then error("illegal wire type " .. wt, 0) end
            id = rshift(_b, 3)
            if id == 0 then error("illegal field number 0", 0) end
            pos = pos + 1
        else
            id, wt, pos = wire.decode_tag(buf, pos)
        end
        if id == 1 then
            local val
            val, pos = wire.decode_int32(buf, pos)
            result.v = val
        elseif id == 2 then
            local val
            val, pos = wire.decode_string(buf, pos)
            result.s = val
        else
            local _ebid = M.Inner_descriptor.extensions_by_id
            local _ext = _ebid and _ebid[id] or nil
            if _ext ~= nil then
                pos = pb.codec.decode_extension(_ext, buf, pos, wt, result)
            else
                pos = wire.skip_field(buf, pos, wt, id)
                if _uf == nil then _uf = {} end
                _uf[#_uf + 1] = buf:sub(_tag_start, pos - 1)
            end
        end
    end
    if _uf ~= nil then result._unknown_fields = table.concat(_uf) end
    return result
end

---@param b string
---@return pb.MessageView
function M.Inner_decode_lazy(b) return pb.decode_lazy(M.Inner_descriptor, b) end
---@param t c_repeated.Inner
---@param opts? {single_line: boolean?, indent: string?}
---@return string
function M.Inner_text(t, opts) return pb.text.encode(M.Inner_descriptor, t, opts) end

---@param t? c_repeated.Holder
---@return c_repeated.Holder
function M.Holder_new(t) return t or {} end

---@param t c_repeated.Holder
---@return string
function M.Holder_encode(t)
    if type(t) ~= 'table' then
        error("expected table for c_repeated.Holder, got " .. type(t), 0)
    end
    local out, n = {}, 0
    local v
    -- field 1: packed_int32
    v = t.packed_int32
    if v ~= nil and #v > 0 then
        local parts, m = {}, 0
        for _i = 1, #v do
            m = m + 1; parts[m] = wire.encode_int32(v[_i])
        end
        local _b = table.concat(parts)
        n = n + 1; out[n] = "\x0a"
        local _len = #_b
        if _len < 128 then
            n = n + 1; out[n] = string.char(_len)
        else
            n = n + 1; out[n] = wire.encode_varint(_len)
        end
        n = n + 1; out[n] = _b
    end
    -- field 2: packed_int64
    v = t.packed_int64
    if v ~= nil and #v > 0 then
        local parts, m = {}, 0
        for _i = 1, #v do
            m = m + 1; parts[m] = wire.encode_int64(v[_i])
        end
        local _b = table.concat(parts)
        n = n + 1; out[n] = "\x12"
        local _len = #_b
        if _len < 128 then
            n = n + 1; out[n] = string.char(_len)
        else
            n = n + 1; out[n] = wire.encode_varint(_len)
        end
        n = n + 1; out[n] = _b
    end
    -- field 3: packed_sint32
    v = t.packed_sint32
    if v ~= nil and #v > 0 then
        local parts, m = {}, 0
        for _i = 1, #v do
            m = m + 1; parts[m] = wire.encode_sint32(v[_i])
        end
        local _b = table.concat(parts)
        n = n + 1; out[n] = "\x1a"
        local _len = #_b
        if _len < 128 then
            n = n + 1; out[n] = string.char(_len)
        else
            n = n + 1; out[n] = wire.encode_varint(_len)
        end
        n = n + 1; out[n] = _b
    end
    -- field 4: packed_uint32
    v = t.packed_uint32
    if v ~= nil and #v > 0 then
        local parts, m = {}, 0
        for _i = 1, #v do
            m = m + 1; parts[m] = wire.encode_uint32(v[_i])
        end
        local _b = table.concat(parts)
        n = n + 1; out[n] = "\x22"
        local _len = #_b
        if _len < 128 then
            n = n + 1; out[n] = string.char(_len)
        else
            n = n + 1; out[n] = wire.encode_varint(_len)
        end
        n = n + 1; out[n] = _b
    end
    -- field 5: packed_fixed32
    v = t.packed_fixed32
    if v ~= nil and #v > 0 then
        local parts, m = {}, 0
        for _i = 1, #v do
            m = m + 1; parts[m] = wire.encode_fixed32(v[_i])
        end
        local _b = table.concat(parts)
        n = n + 1; out[n] = "\x2a"
        local _len = #_b
        if _len < 128 then
            n = n + 1; out[n] = string.char(_len)
        else
            n = n + 1; out[n] = wire.encode_varint(_len)
        end
        n = n + 1; out[n] = _b
    end
    -- field 6: packed_fixed64
    v = t.packed_fixed64
    if v ~= nil and #v > 0 then
        local parts, m = {}, 0
        for _i = 1, #v do
            m = m + 1; parts[m] = wire.encode_fixed64(v[_i])
        end
        local _b = table.concat(parts)
        n = n + 1; out[n] = "\x32"
        local _len = #_b
        if _len < 128 then
            n = n + 1; out[n] = string.char(_len)
        else
            n = n + 1; out[n] = wire.encode_varint(_len)
        end
        n = n + 1; out[n] = _b
    end
    -- field 7: packed_double
    v = t.packed_double
    if v ~= nil and #v > 0 then
        local parts, m = {}, 0
        for _i = 1, #v do
            m = m + 1; parts[m] = wire.encode_double(v[_i])
        end
        local _b = table.concat(parts)
        n = n + 1; out[n] = "\x3a"
        local _len = #_b
        if _len < 128 then
            n = n + 1; out[n] = string.char(_len)
        else
            n = n + 1; out[n] = wire.encode_varint(_len)
        end
        n = n + 1; out[n] = _b
    end
    -- field 8: packed_float
    v = t.packed_float
    if v ~= nil and #v > 0 then
        local parts, m = {}, 0
        for _i = 1, #v do
            m = m + 1; parts[m] = wire.encode_float(v[_i])
        end
        local _b = table.concat(parts)
        n = n + 1; out[n] = "\x42"
        local _len = #_b
        if _len < 128 then
            n = n + 1; out[n] = string.char(_len)
        else
            n = n + 1; out[n] = wire.encode_varint(_len)
        end
        n = n + 1; out[n] = _b
    end
    -- field 9: packed_bool
    v = t.packed_bool
    if v ~= nil and #v > 0 then
        local parts, m = {}, 0
        for _i = 1, #v do
            m = m + 1; parts[m] = wire.encode_bool(v[_i])
        end
        local _b = table.concat(parts)
        n = n + 1; out[n] = "\x4a"
        local _len = #_b
        if _len < 128 then
            n = n + 1; out[n] = string.char(_len)
        else
            n = n + 1; out[n] = wire.encode_varint(_len)
        end
        n = n + 1; out[n] = _b
    end
    -- field 20: unpacked_int32
    v = t.unpacked_int32
    if v ~= nil and #v > 0 then
        local _tag = "\xa0\x01"
        for _i = 1, #v do
            n = n + 1; out[n] = _tag
            n = n + 1; out[n] = wire.encode_int32(v[_i])
        end
    end
    -- field 21: unpacked_fixed64
    v = t.unpacked_fixed64
    if v ~= nil and #v > 0 then
        local _tag = "\xa9\x01"
        for _i = 1, #v do
            n = n + 1; out[n] = _tag
            n = n + 1; out[n] = wire.encode_fixed64(v[_i])
        end
    end
    -- field 22: unpacked_sint32
    v = t.unpacked_sint32
    if v ~= nil and #v > 0 then
        local _tag = "\xb0\x01"
        for _i = 1, #v do
            n = n + 1; out[n] = _tag
            n = n + 1; out[n] = wire.encode_sint32(v[_i])
        end
    end
    -- field 30: strings
    v = t.strings
    if v ~= nil and #v > 0 then
        local _tag = "\xf2\x01"
        for _i = 1, #v do
            local _b = v[_i]
            n = n + 1; out[n] = _tag
            local _len = #_b
            if _len < 128 then
                n = n + 1; out[n] = string.char(_len)
            else
                n = n + 1; out[n] = wire.encode_varint(_len)
            end
            n = n + 1; out[n] = _b
        end
    end
    -- field 31: blobs
    v = t.blobs
    if v ~= nil and #v > 0 then
        local _tag = "\xfa\x01"
        for _i = 1, #v do
            local _b = v[_i]
            n = n + 1; out[n] = _tag
            local _len = #_b
            if _len < 128 then
                n = n + 1; out[n] = string.char(_len)
            else
                n = n + 1; out[n] = wire.encode_varint(_len)
            end
            n = n + 1; out[n] = _b
        end
    end
    -- field 40: messages
    v = t.messages
    if v ~= nil and #v > 0 then
        local _tag = "\xc2\x02"
        for _i = 1, #v do
            local _b = M.Inner_encode(v[_i])
            n = n + 1; out[n] = _tag
            local _len = #_b
            if _len < 128 then
                n = n + 1; out[n] = string.char(_len)
            else
                n = n + 1; out[n] = wire.encode_varint(_len)
            end
            n = n + 1; out[n] = _b
        end
    end
    local _exts = t._extensions
    if _exts ~= nil then
        local _elist = M.Holder_descriptor.extensions_list
        if _elist ~= nil then
            for _i = 1, #_elist do
                local _ext = _elist[_i]
                local _ev = _exts[_ext.full_name]
                if _ev ~= nil then
                    pb.codec.encode_field(_ext, _ev, out, true)
                end
            end
            n = #out
        end
    end
    local _uf = t._unknown_fields
    if _uf ~= nil and _uf ~= '' then n = n + 1; out[n] = _uf end
    return table.concat(out)
end

---@param b string
---@return c_repeated.Holder
function M.Holder_decode(buf)
    if type(buf) ~= 'string' then
        error("expected string for c_repeated.Holder decode, got " .. type(buf), 0)
    end
    local result = {}
    local pos, len = 1, #buf
    local _uf
    local _n_packed_int32 = 0
    local _n_packed_int64 = 0
    local _n_packed_sint32 = 0
    local _n_packed_uint32 = 0
    local _n_packed_fixed32 = 0
    local _n_packed_fixed64 = 0
    local _n_packed_double = 0
    local _n_packed_float = 0
    local _n_packed_bool = 0
    local _n_unpacked_int32 = 0
    local _n_unpacked_fixed64 = 0
    local _n_unpacked_sint32 = 0
    local _n_strings = 0
    local _n_blobs = 0
    local _n_messages = 0
    while pos <= len do
        local _tag_start = pos
        local id, wt
        local _b = string_byte(buf, pos)
        if _b ~= nil and _b < 0x80 then
            wt = band(_b, 7)
            if wt >= 6 then error("illegal wire type " .. wt, 0) end
            id = rshift(_b, 3)
            if id == 0 then error("illegal field number 0", 0) end
            pos = pos + 1
        else
            id, wt, pos = wire.decode_tag(buf, pos)
        end
        if id == 1 then
            local list = result.packed_int32
            if list == nil then list = {}; result.packed_int32 = list end
            if wt == 2 then
                local payload
                payload, pos = wire.decode_len(buf, pos)
                local p2, lim = 1, #payload
                while p2 <= lim do
                    local val
                    val, p2 = wire.decode_int32(payload, p2)
                    _n_packed_int32 = _n_packed_int32 + 1; list[_n_packed_int32] = val
                end
            else
                local val
                val, pos = wire.decode_int32(buf, pos)
                _n_packed_int32 = _n_packed_int32 + 1; list[_n_packed_int32] = val
            end
        elseif id == 2 then
            local list = result.packed_int64
            if list == nil then list = {}; result.packed_int64 = list end
            if wt == 2 then
                local payload
                payload, pos = wire.decode_len(buf, pos)
                local p2, lim = 1, #payload
                while p2 <= lim do
                    local val
                    val, p2 = wire.decode_int64(payload, p2)
                    _n_packed_int64 = _n_packed_int64 + 1; list[_n_packed_int64] = val
                end
            else
                local val
                val, pos = wire.decode_int64(buf, pos)
                _n_packed_int64 = _n_packed_int64 + 1; list[_n_packed_int64] = val
            end
        elseif id == 3 then
            local list = result.packed_sint32
            if list == nil then list = {}; result.packed_sint32 = list end
            if wt == 2 then
                local payload
                payload, pos = wire.decode_len(buf, pos)
                local p2, lim = 1, #payload
                while p2 <= lim do
                    local val
                    val, p2 = wire.decode_sint32(payload, p2)
                    _n_packed_sint32 = _n_packed_sint32 + 1; list[_n_packed_sint32] = val
                end
            else
                local val
                val, pos = wire.decode_sint32(buf, pos)
                _n_packed_sint32 = _n_packed_sint32 + 1; list[_n_packed_sint32] = val
            end
        elseif id == 4 then
            local list = result.packed_uint32
            if list == nil then list = {}; result.packed_uint32 = list end
            if wt == 2 then
                local payload
                payload, pos = wire.decode_len(buf, pos)
                local p2, lim = 1, #payload
                while p2 <= lim do
                    local val
                    val, p2 = wire.decode_uint32(payload, p2)
                    _n_packed_uint32 = _n_packed_uint32 + 1; list[_n_packed_uint32] = val
                end
            else
                local val
                val, pos = wire.decode_uint32(buf, pos)
                _n_packed_uint32 = _n_packed_uint32 + 1; list[_n_packed_uint32] = val
            end
        elseif id == 5 then
            local list = result.packed_fixed32
            if list == nil then list = {}; result.packed_fixed32 = list end
            if wt == 2 then
                local payload
                payload, pos = wire.decode_len(buf, pos)
                local p2, lim = 1, #payload
                while p2 <= lim do
                    local val
                    val, p2 = wire.decode_fixed32(payload, p2)
                    _n_packed_fixed32 = _n_packed_fixed32 + 1; list[_n_packed_fixed32] = val
                end
            else
                local val
                val, pos = wire.decode_fixed32(buf, pos)
                _n_packed_fixed32 = _n_packed_fixed32 + 1; list[_n_packed_fixed32] = val
            end
        elseif id == 6 then
            local list = result.packed_fixed64
            if list == nil then list = {}; result.packed_fixed64 = list end
            if wt == 2 then
                local payload
                payload, pos = wire.decode_len(buf, pos)
                local p2, lim = 1, #payload
                while p2 <= lim do
                    local val
                    val, p2 = wire.decode_fixed64(payload, p2)
                    _n_packed_fixed64 = _n_packed_fixed64 + 1; list[_n_packed_fixed64] = val
                end
            else
                local val
                val, pos = wire.decode_fixed64(buf, pos)
                _n_packed_fixed64 = _n_packed_fixed64 + 1; list[_n_packed_fixed64] = val
            end
        elseif id == 7 then
            local list = result.packed_double
            if list == nil then list = {}; result.packed_double = list end
            if wt == 2 then
                local payload
                payload, pos = wire.decode_len(buf, pos)
                local p2, lim = 1, #payload
                while p2 <= lim do
                    local val
                    val, p2 = wire.decode_double(payload, p2)
                    _n_packed_double = _n_packed_double + 1; list[_n_packed_double] = val
                end
            else
                local val
                val, pos = wire.decode_double(buf, pos)
                _n_packed_double = _n_packed_double + 1; list[_n_packed_double] = val
            end
        elseif id == 8 then
            local list = result.packed_float
            if list == nil then list = {}; result.packed_float = list end
            if wt == 2 then
                local payload
                payload, pos = wire.decode_len(buf, pos)
                local p2, lim = 1, #payload
                while p2 <= lim do
                    local val
                    val, p2 = wire.decode_float(payload, p2)
                    _n_packed_float = _n_packed_float + 1; list[_n_packed_float] = val
                end
            else
                local val
                val, pos = wire.decode_float(buf, pos)
                _n_packed_float = _n_packed_float + 1; list[_n_packed_float] = val
            end
        elseif id == 9 then
            local list = result.packed_bool
            if list == nil then list = {}; result.packed_bool = list end
            if wt == 2 then
                local payload
                payload, pos = wire.decode_len(buf, pos)
                local p2, lim = 1, #payload
                while p2 <= lim do
                    local val
                    val, p2 = wire.decode_bool(payload, p2)
                    _n_packed_bool = _n_packed_bool + 1; list[_n_packed_bool] = val
                end
            else
                local val
                val, pos = wire.decode_bool(buf, pos)
                _n_packed_bool = _n_packed_bool + 1; list[_n_packed_bool] = val
            end
        elseif id == 20 then
            local list = result.unpacked_int32
            if list == nil then list = {}; result.unpacked_int32 = list end
            if wt == 2 then
                local payload
                payload, pos = wire.decode_len(buf, pos)
                local p2, lim = 1, #payload
                while p2 <= lim do
                    local val
                    val, p2 = wire.decode_int32(payload, p2)
                    _n_unpacked_int32 = _n_unpacked_int32 + 1; list[_n_unpacked_int32] = val
                end
            else
                local val
                val, pos = wire.decode_int32(buf, pos)
                _n_unpacked_int32 = _n_unpacked_int32 + 1; list[_n_unpacked_int32] = val
            end
        elseif id == 21 then
            local list = result.unpacked_fixed64
            if list == nil then list = {}; result.unpacked_fixed64 = list end
            if wt == 2 then
                local payload
                payload, pos = wire.decode_len(buf, pos)
                local p2, lim = 1, #payload
                while p2 <= lim do
                    local val
                    val, p2 = wire.decode_fixed64(payload, p2)
                    _n_unpacked_fixed64 = _n_unpacked_fixed64 + 1; list[_n_unpacked_fixed64] = val
                end
            else
                local val
                val, pos = wire.decode_fixed64(buf, pos)
                _n_unpacked_fixed64 = _n_unpacked_fixed64 + 1; list[_n_unpacked_fixed64] = val
            end
        elseif id == 22 then
            local list = result.unpacked_sint32
            if list == nil then list = {}; result.unpacked_sint32 = list end
            if wt == 2 then
                local payload
                payload, pos = wire.decode_len(buf, pos)
                local p2, lim = 1, #payload
                while p2 <= lim do
                    local val
                    val, p2 = wire.decode_sint32(payload, p2)
                    _n_unpacked_sint32 = _n_unpacked_sint32 + 1; list[_n_unpacked_sint32] = val
                end
            else
                local val
                val, pos = wire.decode_sint32(buf, pos)
                _n_unpacked_sint32 = _n_unpacked_sint32 + 1; list[_n_unpacked_sint32] = val
            end
        elseif id == 30 then
            local list = result.strings
            if list == nil then list = {}; result.strings = list end
            local val
            val, pos = wire.decode_string(buf, pos)
            _n_strings = _n_strings + 1; list[_n_strings] = val
        elseif id == 31 then
            local list = result.blobs
            if list == nil then list = {}; result.blobs = list end
            local val
            val, pos = wire.decode_bytes(buf, pos)
            _n_blobs = _n_blobs + 1; list[_n_blobs] = val
        elseif id == 40 then
            local list = result.messages
            if list == nil then list = {}; result.messages = list end
            local payload
            payload, pos = wire.decode_len(buf, pos)
            _n_messages = _n_messages + 1; list[_n_messages] = M.Inner_decode(payload)
        else
            local _ebid = M.Holder_descriptor.extensions_by_id
            local _ext = _ebid and _ebid[id] or nil
            if _ext ~= nil then
                pos = pb.codec.decode_extension(_ext, buf, pos, wt, result)
            else
                pos = wire.skip_field(buf, pos, wt, id)
                if _uf == nil then _uf = {} end
                _uf[#_uf + 1] = buf:sub(_tag_start, pos - 1)
            end
        end
    end
    if _uf ~= nil then result._unknown_fields = table.concat(_uf) end
    return result
end

---@param b string
---@return pb.MessageView
function M.Holder_decode_lazy(b) return pb.decode_lazy(M.Holder_descriptor, b) end
---@param t c_repeated.Holder
---@param opts? {single_line: boolean?, indent: string?}
---@return string
function M.Holder_text(t, opts) return pb.text.encode(M.Holder_descriptor, t, opts) end

return M

A examples/expected/runtime/c_repeated/c_repeated_pb.lua => examples/expected/runtime/c_repeated/c_repeated_pb.lua +126 -0
@@ 0,0 1,126 @@
-- Code generated by protoc-gen-tarantool. DO NOT EDIT.
-- source: c_repeated.proto
-- syntax: proto3
-- package: c_repeated

local pb = require("pb")
local wire = pb.wire
local string_byte = string.byte
local band = bit.band
local rshift = bit.rshift

local M = {}

M.options = {go_package = "tarantoolpb_synthetic/c_repeated"}

-- Pre-declare message descriptors so cross-references resolve.
M.Inner_descriptor = {name = "c_repeated.Inner"}
M.Holder_descriptor = {name = "c_repeated.Holder"}

-- Message: c_repeated.Inner
M.Inner_descriptor.fields = {
    {name="v", id=1, kind='scalar', proto_type="int32"},
    {name="s", id=2, kind='scalar', proto_type="string"},
}
pb.finalize_message(M.Inner_descriptor)
M.Inner_fields = pb.field_names({
    v = "v",
    s = "s",
})

-- Message: c_repeated.Holder
M.Holder_descriptor.fields = {
    {name="packed_int32", id=1, kind='scalar', proto_type="int32", repeated=true, packed=true},
    {name="packed_int64", id=2, kind='scalar', proto_type="int64", repeated=true, packed=true},
    {name="packed_sint32", id=3, kind='scalar', proto_type="sint32", repeated=true, packed=true},
    {name="packed_uint32", id=4, kind='scalar', proto_type="uint32", repeated=true, packed=true},
    {name="packed_fixed32", id=5, kind='scalar', proto_type="fixed32", repeated=true, packed=true},
    {name="packed_fixed64", id=6, kind='scalar', proto_type="fixed64", repeated=true, packed=true},
    {name="packed_double", id=7, kind='scalar', proto_type="double", repeated=true, packed=true},
    {name="packed_float", id=8, kind='scalar', proto_type="float", repeated=true, packed=true},
    {name="packed_bool", id=9, kind='scalar', proto_type="bool", repeated=true, packed=true},
    {name="unpacked_int32", id=20, kind='scalar', proto_type="int32", repeated=true, packed=false, options={packed = false}},
    {name="unpacked_fixed64", id=21, kind='scalar', proto_type="fixed64", repeated=true, packed=false, options={packed = false}},
    {name="unpacked_sint32", id=22, kind='scalar', proto_type="sint32", repeated=true, packed=false, options={packed = false}},
    {name="strings", id=30, kind='scalar', proto_type="string", repeated=true},
    {name="blobs", id=31, kind='scalar', proto_type="bytes", repeated=true},
    {name="messages", id=40, kind='message', message=M.Inner_descriptor, repeated=true},
}
pb.finalize_message(M.Holder_descriptor)
M.Holder_fields = pb.field_names({
    packed_int32 = "packed_int32",
    packed_int64 = "packed_int64",
    packed_sint32 = "packed_sint32",
    packed_uint32 = "packed_uint32",
    packed_fixed32 = "packed_fixed32",
    packed_fixed64 = "packed_fixed64",
    packed_double = "packed_double",
    packed_float = "packed_float",
    packed_bool = "packed_bool",
    unpacked_int32 = "unpacked_int32",
    unpacked_fixed64 = "unpacked_fixed64",
    unpacked_sint32 = "unpacked_sint32",
    strings = "strings",
    blobs = "blobs",
    messages = "messages",
})

-- EmmyLua / lua-language-server type annotations.
-- These are comments — no runtime effect. They give editors
-- autocomplete and type-checking for the generated wrappers.
---@class c_repeated.Inner
---@field v integer
---@field s string

---@class c_repeated.Holder
---@field packed_int32 integer[] @ Packed scalars (proto3 default). One per encoder branch.
---@field packed_int64 integer[]
---@field packed_sint32 integer[]
---@field packed_uint32 integer[]
---@field packed_fixed32 integer[]
---@field packed_fixed64 integer[]
---@field packed_double number[]
---@field packed_float number[]
---@field packed_bool boolean[]
---@field unpacked_int32 integer[] @ Explicit unpacked — emits one tag per element.
---@field unpacked_fixed64 integer[]
---@field unpacked_sint32 integer[]
---@field strings string[] @ Length-delimited element kinds — never packable.
---@field blobs string[]
---@field messages c_repeated.Inner[] @ Repeated sub-message — per-element tag + length-prefix + body.

---@param t? c_repeated.Inner
---@return c_repeated.Inner
function M.Inner_new(t) return t or {} end
---@param t c_repeated.Inner
---@return string
function M.Inner_encode(t) return pb.encode(M.Inner_descriptor, t) end
---@param b string
---@return c_repeated.Inner
function M.Inner_decode(b) return pb.decode(M.Inner_descriptor, b) end
---@param b string
---@return pb.MessageView
function M.Inner_decode_lazy(b) return pb.decode_lazy(M.Inner_descriptor, b) end
---@param t c_repeated.Inner
---@param opts? {single_line: boolean?, indent: string?}
---@return string
function M.Inner_text(t, opts) return pb.text.encode(M.Inner_descriptor, t, opts) end

---@param t? c_repeated.Holder
---@return c_repeated.Holder
function M.Holder_new(t) return t or {} end
---@param t c_repeated.Holder
---@return string
function M.Holder_encode(t) return pb.encode(M.Holder_descriptor, t) end
---@param b string
---@return c_repeated.Holder
function M.Holder_decode(b) return pb.decode(M.Holder_descriptor, b) end
---@param b string
---@return pb.MessageView
function M.Holder_decode_lazy(b) return pb.decode_lazy(M.Holder_descriptor, b) end
---@param t c_repeated.Holder
---@param opts? {single_line: boolean?, indent: string?}
---@return string
function M.Holder_text(t, opts) return pb.text.encode(M.Holder_descriptor, t, opts) end

return M

M runtime/pb/c/c_runtime.c => runtime/pb/c/c_runtime.c +299 -30
@@ 990,19 990,24 @@ enum_value_at(lua_State *L, pb_plan_field *f, int idx)
	return (int32_t)to_int64_at(L, idx);
}

/* Encode one singular scalar/enum/string field. Returns 1 if bytes
 * were written, 0 if the value collapsed to its proto3 default and
 * was suppressed. Zero-suppression skipped for proto3-optional. */
/* Encode one scalar/enum/string field. Returns 1 if bytes were
 * written, 0 if the value collapsed to its proto3 default and was
 * suppressed. Zero-suppression is skipped for proto3-optional AND
 * when `force_emit` is non-zero (used by repeated-unpacked, where
 * every element must reach the wire regardless of value). */
static int
encode_one_field(lua_State *L, enc_buf *b, pb_plan_field *f, int val_idx)
encode_one_field(lua_State *L, enc_buf *b, pb_plan_field *f, int val_idx,
                 int force_emit)
{
	int suppress = !force_emit && !f->optional;

	switch (f->kind) {
	case PB_KIND_INT32:
	case PB_KIND_INT64:
	case PB_KIND_UINT32:
	case PB_KIND_UINT64: {
		uint64_t u = to_uint64_at(L, val_idx);
		if (!f->optional && u == 0) return 0;
		if (suppress && u == 0) return 0;
		ebuf_reserve(L, b, f->tag_len + 10);
		ebuf_put_tag(b, f);
		ebuf_put_varint(b, u);


@@ 1010,7 1015,7 @@ encode_one_field(lua_State *L, enc_buf *b, pb_plan_field *f, int val_idx)
	}
	case PB_KIND_SINT32: {
		int32_t s = to_int32_at(L, val_idx);
		if (!f->optional && s == 0) return 0;
		if (suppress && s == 0) return 0;
		ebuf_reserve(L, b, f->tag_len + 5);
		ebuf_put_tag(b, f);
		ebuf_put_varint(b, zigzag32(s));


@@ 1018,7 1023,7 @@ encode_one_field(lua_State *L, enc_buf *b, pb_plan_field *f, int val_idx)
	}
	case PB_KIND_SINT64: {
		int64_t s = to_int64_at(L, val_idx);
		if (!f->optional && s == 0) return 0;
		if (suppress && s == 0) return 0;
		ebuf_reserve(L, b, f->tag_len + 10);
		ebuf_put_tag(b, f);
		ebuf_put_varint(b, zigzag64(s));


@@ 1026,7 1031,7 @@ encode_one_field(lua_State *L, enc_buf *b, pb_plan_field *f, int val_idx)
	}
	case PB_KIND_BOOL: {
		int truthy = lua_toboolean(L, val_idx);
		if (!f->optional && !truthy) return 0;
		if (suppress && !truthy) return 0;
		ebuf_reserve(L, b, f->tag_len + 1);
		ebuf_put_tag(b, f);
		ebuf_put_byte(b, truthy ? 1 : 0);


@@ 1036,7 1041,7 @@ encode_one_field(lua_State *L, enc_buf *b, pb_plan_field *f, int val_idx)
	case PB_KIND_SFIXED32: {
		uint64_t u = to_uint64_at(L, val_idx);
		uint32_t u32 = (uint32_t)u;
		if (!f->optional && u32 == 0) return 0;
		if (suppress && u32 == 0) return 0;
		ebuf_reserve(L, b, f->tag_len + 4);
		ebuf_put_tag(b, f);
		ebuf_put_fixed32(b, u32);


@@ 1045,7 1050,7 @@ encode_one_field(lua_State *L, enc_buf *b, pb_plan_field *f, int val_idx)
	case PB_KIND_FIXED64:
	case PB_KIND_SFIXED64: {
		uint64_t u = to_uint64_at(L, val_idx);
		if (!f->optional && u == 0) return 0;
		if (suppress && u == 0) return 0;
		ebuf_reserve(L, b, f->tag_len + 8);
		ebuf_put_tag(b, f);
		ebuf_put_fixed64(b, u);


@@ 1056,7 1061,7 @@ encode_one_field(lua_State *L, enc_buf *b, pb_plan_field *f, int val_idx)
		uint32_t u = f32_to_u32((float)d);
		/* +0.0 -> u==0 (skip); -0.0 -> u==0x80000000 (emit). Matches the
		 * Lua-side `(v ~= 0 or 1/v == -math.huge)` guard. */
		if (!f->optional && u == 0) return 0;
		if (suppress && u == 0) return 0;
		ebuf_reserve(L, b, f->tag_len + 4);
		ebuf_put_tag(b, f);
		ebuf_put_fixed32(b, u);


@@ 1065,7 1070,7 @@ encode_one_field(lua_State *L, enc_buf *b, pb_plan_field *f, int val_idx)
	case PB_KIND_DOUBLE: {
		double d = lua_tonumber(L, val_idx);
		uint64_t u = f64_to_u64(d);
		if (!f->optional && u == 0) return 0;
		if (suppress && u == 0) return 0;
		ebuf_reserve(L, b, f->tag_len + 8);
		ebuf_put_tag(b, f);
		ebuf_put_fixed64(b, u);


@@ 1073,7 1078,7 @@ encode_one_field(lua_State *L, enc_buf *b, pb_plan_field *f, int val_idx)
	}
	case PB_KIND_ENUM: {
		int32_t e = enum_value_at(L, f, val_idx);
		if (!f->optional && e == 0) return 0;
		if (suppress && e == 0) return 0;
		ebuf_reserve(L, b, f->tag_len + 10);
		ebuf_put_tag(b, f);
		/* enum is wire-equivalent to int32: sign-extend to uint64 then varint. */


@@ 1086,7 1091,7 @@ encode_one_field(lua_State *L, enc_buf *b, pb_plan_field *f, int val_idx)
			luaL_error(L, "string/bytes field requires a string value");
		size_t len;
		const char *s = lua_tolstring(L, val_idx, &len);
		if (!f->optional && len == 0) return 0;
		if (suppress && len == 0) return 0;
		ebuf_reserve(L, b, f->tag_len + 10 + len);
		ebuf_put_tag(b, f);
		ebuf_put_varint(b, (uint64_t)len);


@@ 1095,13 1100,161 @@ encode_one_field(lua_State *L, enc_buf *b, pb_plan_field *f, int val_idx)
		return 1;
	}
	default:
		/* Repeated/map/message are 3d/3e scope — silently skip. */
		/* Map is 3h scope; message singular goes through
		 * encode_submessage_field; repeated dispatches at the
		 * field-walk level. */
		return 0;
	}
}

/* Encode one element of a packed scalar/enum/bool field into `b` —
 * raw value bytes only, no tag. Mirrors encode_one_field's per-kind
 * value emission with suppression always off. Length-delimited kinds
 * (STRING/BYTES/MESSAGE) are not packable per spec — caller must
 * dispatch them elsewhere. */
static void
encode_packed_element_at(lua_State *L, enc_buf *b, pb_plan_field *f,
                         int val_idx)
{
	switch (f->kind) {
	case PB_KIND_INT32:
	case PB_KIND_INT64:
	case PB_KIND_UINT32:
	case PB_KIND_UINT64: {
		uint64_t u = to_uint64_at(L, val_idx);
		ebuf_reserve(L, b, 10);
		ebuf_put_varint(b, u);
		return;
	}
	case PB_KIND_SINT32: {
		int32_t s = to_int32_at(L, val_idx);
		ebuf_reserve(L, b, 5);
		ebuf_put_varint(b, zigzag32(s));
		return;
	}
	case PB_KIND_SINT64: {
		int64_t s = to_int64_at(L, val_idx);
		ebuf_reserve(L, b, 10);
		ebuf_put_varint(b, zigzag64(s));
		return;
	}
	case PB_KIND_BOOL: {
		int truthy = lua_toboolean(L, val_idx);
		ebuf_reserve(L, b, 1);
		ebuf_put_byte(b, truthy ? 1 : 0);
		return;
	}
	case PB_KIND_FIXED32:
	case PB_KIND_SFIXED32: {
		uint64_t u = to_uint64_at(L, val_idx);
		ebuf_reserve(L, b, 4);
		ebuf_put_fixed32(b, (uint32_t)u);
		return;
	}
	case PB_KIND_FIXED64:
	case PB_KIND_SFIXED64: {
		uint64_t u = to_uint64_at(L, val_idx);
		ebuf_reserve(L, b, 8);
		ebuf_put_fixed64(b, u);
		return;
	}
	case PB_KIND_FLOAT: {
		double d = lua_tonumber(L, val_idx);
		ebuf_reserve(L, b, 4);
		ebuf_put_fixed32(b, f32_to_u32((float)d));
		return;
	}
	case PB_KIND_DOUBLE: {
		double d = lua_tonumber(L, val_idx);
		ebuf_reserve(L, b, 8);
		ebuf_put_fixed64(b, f64_to_u64(d));
		return;
	}
	case PB_KIND_ENUM: {
		int32_t e = enum_value_at(L, f, val_idx);
		ebuf_reserve(L, b, 10);
		ebuf_put_varint(b, (uint64_t)(int64_t)e);
		return;
	}
	default:
		luaL_error(L, "kind %d not packable", (int)f->kind);
	}
}

/* Forward decls for the recursive encode pair. */
static void encode_body(lua_State *L, enc_buf *b, pb_plan *plan, int msg_idx);
static void encode_submessage_field(lua_State *L, enc_buf *b, pb_plan *plan,
                                    pb_plan_field *f, int val_idx);

/* Encode a repeated field's elements into `b`. Dispatches on element
 * kind and the `packed` plan flag:
 *   - packed scalar/enum/bool → single tag(LEN) + varint(len) + tight
 *     payload built in a stack-backed sub-buffer
 *   - unpacked scalar/enum/bool → per-element tag(elem_wire) + value
 *   - string/bytes → per-element tag(LEN) + varint(len) + bytes
 *     (string/bytes are never packable per spec)
 *   - message → per-element tag(LEN) + len-prefix + nested body via
 *     encode_submessage_field
 *
 * Empty arrays produce nothing — proto3 wire spec treats an absent
 * repeated field and an empty one identically.
 *
 * Sub-buffer cleanup follows encode_submessage_field's contract: the
 * parent's heap is force-established before allocating the sub-buffer
 * so the parent's heap_idx sits below saved_top and survives the final
 * lua_settop. */
static void
encode_repeated_field(lua_State *L, enc_buf *b, pb_plan *plan,
                      pb_plan_field *f, int val_idx)
{
	val_idx = abs_idx(L, val_idx);
	int n = (int)lua_objlen(L, val_idx);
	if (n == 0)
		return;

	if (f->kind == PB_KIND_MESSAGE) {
		for (int i = 1; i <= n; i++) {
			lua_rawgeti(L, val_idx, i);
			int elem = lua_gettop(L);
			encode_submessage_field(L, b, plan, f, elem);
			lua_pop(L, 1);
		}
		return;
	}

	if (f->packed) {
		if (b->heap_idx == 0)
			ebuf_grow(L, b, 1);
		int saved_top = lua_gettop(L);

		enc_buf sub;
		ebuf_init(&sub);
		for (int i = 1; i <= n; i++) {
			lua_rawgeti(L, val_idx, i);
			encode_packed_element_at(L, &sub, f, lua_gettop(L));
			lua_pop(L, 1);
		}

		ebuf_reserve(L, b, f->tag_len + 10 + sub.used);
		ebuf_put_tag(b, f);
		ebuf_put_varint(b, (uint64_t)sub.used);
		if (sub.used > 0)
			ebuf_put_bytes(b, ebuf_base(&sub), sub.used);

		lua_settop(L, saved_top);
		return;
	}

	/* Unpacked: per-element tag + value. string/bytes flow through
	 * encode_one_field too — its STRING/BYTES branch already emits
	 * `tag + varint(len) + bytes`, which is exactly the unpacked
	 * length-delimited element shape. */
	for (int i = 1; i <= n; i++) {
		lua_rawgeti(L, val_idx, i);
		encode_one_field(L, b, f, lua_gettop(L), /* force_emit */ 1);
		lua_pop(L, 1);
	}
}

/* Encode one singular sub-message field into the parent buffer `b`.
 * Lifecycle / stack-management contract:


@@ 1173,9 1326,7 @@ encode_body(lua_State *L, enc_buf *b, pb_plan *plan, int msg_idx)
	for (int i = 0; i < plan->n_fields; i++) {
		pb_plan_field *f = &plan->fields[i];

		/* Scope for now: singular scalar/enum/string/bytes/message.
		 * Repeated and map are 3e/3h territory — still skipped. */
		if (f->repeated) continue;
		/* Map dispatches in 3h. */
		if (f->kind == PB_KIND_MAP) continue;

		lua_rawgeti(L, names_idx, i + 1); /* push field name */


@@ 1187,10 1338,15 @@ encode_body(lua_State *L, enc_buf *b, pb_plan *plan, int msg_idx)
			continue;
		}

		if (f->kind == PB_KIND_MESSAGE) {
		if (f->repeated) {
			if (lua_type(L, val_idx) != LUA_TTABLE)
				luaL_error(L,
					"repeated field requires a table value");
			encode_repeated_field(L, b, plan, f, val_idx);
		} else if (f->kind == PB_KIND_MESSAGE) {
			encode_submessage_field(L, b, plan, f, val_idx);
		} else {
			encode_one_field(L, b, f, val_idx);
			encode_one_field(L, b, f, val_idx, /* force_emit */ 0);
		}
		lua_pop(L, 1);
	}


@@ 1468,6 1624,27 @@ decode_submessage_field(dec_ctx *c, pb_plan_field *f, int sub_plans_idx)
	c->len = saved_len;
}

/* Return 1 if the field's element wire type is varint/i32/i64 — i.e.
 * the field is eligible for packed encoding. Length-delimited kinds
 * (STRING/BYTES/MESSAGE) and MAP are never packable. Used on decode
 * to detect a wt==LEN payload for a repeated scalar field as a packed
 * blob even when the schema declares packed=false (proto3 readers MUST
 * accept both). */
static inline int
field_is_packable(const pb_plan_field *f)
{
	switch (f->kind) {
	case PB_KIND_STRING:
	case PB_KIND_BYTES:
	case PB_KIND_MESSAGE:
	case PB_KIND_MAP:
	case PB_KIND_NONE:
		return 0;
	default:
		return 1;
	}
}

static void
decode_body(dec_ctx *c, pb_plan *plan, int result_idx)
{


@@ 1494,6 1671,26 @@ decode_body(dec_ctx *c, pb_plan *plan, int result_idx)
	}
	sub_plans_idx = lua_gettop(L);

	/* Per-field stack-slot cache for repeated fields. On first hit for
	 * a given field, we lua_createtable + store into result[name] and
	 * dup-push the table onto the stack; subsequent hits reuse the
	 * cached absolute stack index and lua_rawseti the new element
	 * directly. Avoids the per-element lua_getfield(result, name) round
	 * trip that the c-accel spike measured at 2x slower at 100KB.
	 *
	 * list_count[i] tracks length without calling lua_objlen per append
	 * — a Lua-side O(log n) probe that adds up fast on the 1000-element
	 * acceptance path.
	 *
	 * VLA size guarded against n_fields == 0 (UB for zero-length VLA).
	 * Both arrays live on the C stack; n_fields is bounded by message
	 * shape — for any realistic schema this is well under 1KB. */
	int vla_n = plan->n_fields > 0 ? plan->n_fields : 1;
	int list_stack_idx[vla_n];
	int list_count[vla_n];
	memset(list_stack_idx, 0, sizeof(list_stack_idx));
	memset(list_count, 0, sizeof(list_count));

	while (c->pos < c->len) {
		uint64_t tag = dec_varint(c);
		uint32_t field_number = (uint32_t)(tag >> 3);


@@ 1502,39 1699,111 @@ decode_body(dec_ctx *c, pb_plan *plan, int result_idx)
		/* Linear scan over plan->fields. n_fields is typically small;
		 * tag-keyed dispatch table is a future optimization. */
		pb_plan_field *f = NULL;
		int f_idx = -1;
		for (int i = 0; i < plan->n_fields; i++) {
			if (plan->fields[i].field_number == field_number) {
				f = &plan->fields[i];
				f_idx = i;
				break;
			}
		}

		/* Out-of-scope shapes for current bd-hwe: repeated, map.
		 * 3e / 3h will replace this skip. */
		if (f == NULL || f->repeated || f->kind == PB_KIND_MAP) {
		/* Unknown tag, or map (3h territory). */
		if (f == NULL || f->kind == PB_KIND_MAP) {
			dec_skip(c, wt);
			continue;
		}

		/* ------------------------------------------------------ *
		 *  Repeated dispatch                                      *
		 * ------------------------------------------------------ */
		if (f->repeated) {
			/* Lazy-create the list table on first hit. */
			int list_idx = list_stack_idx[f_idx];
			if (list_idx == 0) {
				lua_createtable(L, 0, 0);
				/* Stack: ..., new_list. Dup, write name → list
				 * into result, leave list on top as our cache. */
				lua_pushvalue(L, -1);
				lua_rawgeti(L, names_idx, f_idx + 1);
				lua_insert(L, -2);            /* name, list_copy */
				lua_rawset(L, result_idx);    /* result[name] = list */
				list_idx = lua_gettop(L);
				list_stack_idx[f_idx] = list_idx;
				list_count[f_idx] = 0;
			}

			/* Repeated message: per-element length-delimited body. */
			if (f->kind == PB_KIND_MESSAGE) {
				if (wt != PB_WIRE_LEN)
					luaL_error(L,
						"repeated message field %d expected wire 2, got %d",
						(int)field_number, (int)wt);
				decode_submessage_field(c, f, sub_plans_idx);
				/* Stack top is the decoded sub-table. */
				list_count[f_idx]++;
				lua_rawseti(L, list_idx, list_count[f_idx]);
				continue;
			}

			/* Packed payload: a single LEN-prefixed blob carrying
			 * N elements end-to-end. Proto3 readers must accept a
			 * packed payload for any packable scalar regardless of
			 * the schema's packed flag — that's why this check is
			 * `field_is_packable`, not `f->packed`. */
			if (wt == PB_WIRE_LEN && field_is_packable(f)) {
				uint64_t plen = dec_varint(c);
				if (c->len - c->pos < plen)
					luaL_error(L,
						"truncated packed payload for field %d",
						(int)field_number);
				size_t saved_len = c->len;
				c->len = c->pos + (size_t)plen;
				while (c->pos < c->len) {
					dec_push_one(c, f);
					list_count[f_idx]++;
					lua_rawseti(L, list_idx, list_count[f_idx]);
				}
				if (c->pos != c->len)
					luaL_error(L,
						"packed payload underflow for field %d",
						(int)field_number);
				c->len = saved_len;
				continue;
			}

			/* Unpacked single element. */
			dec_push_one(c, f);
			list_count[f_idx]++;
			lua_rawseti(L, list_idx, list_count[f_idx]);
			continue;
		}

		/* ------------------------------------------------------ *
		 *  Singular dispatch                                      *
		 * ------------------------------------------------------ */
		if (f->kind == PB_KIND_MESSAGE) {
			decode_submessage_field(c, f, sub_plans_idx);
			/* stack: ..., names, sub_plans, sub_result */
			int field_idx_1based = (int)(f - plan->fields) + 1;
			lua_rawgeti(L, names_idx, field_idx_1based);
			/* stack: ..., names, sub_plans, [lists...], sub_result */
			lua_rawgeti(L, names_idx, f_idx + 1);
			lua_insert(L, -2);            /* name, sub_result */
			lua_rawset(L, result_idx);    /* result[name] = sub_result */
			continue;
		}

		dec_push_one(c, f);  /* stack: ..., names, sub_plans, value */
		dec_push_one(c, f);  /* stack: ..., names, sub_plans, [lists...], value */

		int field_idx_1based = (int)(f - plan->fields) + 1;
		lua_rawgeti(L, names_idx, field_idx_1based);
		lua_rawgeti(L, names_idx, f_idx + 1);
		lua_insert(L, -2);                /* name, value */
		lua_rawset(L, result_idx);        /* result[name] = value */
	}

	lua_pop(L, 2); /* sub_plans, names */
	/* Pop everything we pushed: per-field list tables (one per repeated
	 * field that appeared), then sub_plans and names. Walk list_stack_idx
	 * to count list-table pushes — equals lua_gettop(L) - sub_plans_idx. */
	int top = lua_gettop(L);
	int to_pop = top - names_idx + 1;
	lua_pop(L, to_pop);
}

static int

M test/c_runtime_decode_test.lua => test/c_runtime_decode_test.lua +157 -6
@@ 129,20 129,26 @@ for _, mode in ipairs({'full', 'runtime'}) do
        t.assert_equals(c_decoded.avatar, '\x00\x01\xff\xfe')
    end

    function g.test_skips_repeated_and_map_tags()
        -- 3d scope: singular message decoded into a sub-table.
        -- Repeated and map remain skipped (3e/3h replace this).
    function g.test_skips_map_tags()
        -- 3e scope: singular message + repeated now decode. Map tags
        -- (synthetic entry sub-messages) remain skipped — 3h replaces
        -- the gate.
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        local rich = {
            name = 'x',
            emails = {'a@b'},                 -- repeated string (skip)
            emails = {'a@b'},                 -- repeated string (decode)
            address = {street = 'Main'},      -- message (decode)
            lucky_numbers = {1, 2, 3},        -- repeated packed (skip)
            lucky_numbers = {1, 2, 3},        -- repeated packed (decode)
            ages_by_nickname = {alice = 30},  -- map (skip)
        }
        local bytes = full_hello.Person_encode(rich)
        local c_decoded = c_runtime.decode(plan, bytes)
        t.assert_equals(c_decoded, {name = 'x', address = {street = 'Main'}})
        t.assert_equals(c_decoded, {
            name = 'x',
            emails = {'a@b'},
            address = {street = 'Main'},
            lucky_numbers = {1, 2, 3},
        })
    end

    -- ---------- Sub-message decode (bd-hwe / ra6 3d) ----------


@@ 228,4 234,149 @@ for _, mode in ipairs({'full', 'runtime'}) do
            c_runtime.decode(plan, '\x0a\x05ab')
        end)
    end

    -- ---------- Repeated + packed decode (bd-jc9 / ra6 3e) ----------

    function g.test_acceptance_lucky_numbers_packed_int32_round_trip()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        local msg = {lucky_numbers = {1, 2, 3, 4, 5, -1, 0x7fffffff}}
        local bytes = full_hello.Person_encode(msg)
        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.lucky_numbers, {1, 2, 3, 4, 5, -1, 0x7fffffff})
    end

    function g.test_repeated_string_round_trip()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        local msg = {emails = {'a@b', 'c@d', '', 'last'}}
        local bytes = full_hello.Person_encode(msg)
        t.assert_equals(c_runtime.decode(plan, bytes),
                        full_hello.Person_decode(bytes))
    end

    function g.test_repeated_message_self_reference_round_trip()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        local msg = {
            name = 'root',
            friends = {
                {name = 'alice', age = 30},
                {name = 'bob', friends = {{name = 'carol'}}},
                {},
            },
        }
        local bytes = full_hello.Person_encode(msg)
        t.assert_equals(c_runtime.decode(plan, bytes),
                        full_hello.Person_decode(bytes))
    end

    function g.test_packed_payload_accepted_for_unpacked_schema()
        -- Wire-format invariant: proto3 decoders accept a packed payload
        -- for ANY packable scalar, regardless of the schema's packed flag.
        -- Build a synthetic packed payload for Holder.unpacked_int32 and
        -- confirm the C decoder concatenates its elements correctly.
        local cr = require(mode .. '.c_repeated.c_repeated_pb')
        local full_cr = require('full.c_repeated.c_repeated_pb')
        local wire = require('pb.wire')
        -- field 20, wire LEN: tag = (20<<3)|2 = 162 = 0xA2 0x01
        local payload = wire.encode_varint(1) .. wire.encode_varint(2)
                     .. wire.encode_varint(3)
        local bytes = '\xa2\x01' .. wire.encode_varint(#payload) .. payload
        local plan = c_runtime.compile_plan(cr.Holder_descriptor)
        local c_decoded = c_runtime.decode(plan, bytes)
        local lua_decoded = full_cr.Holder_decode(bytes)
        t.assert_equals(c_decoded, lua_decoded)
        t.assert_equals(c_decoded.unpacked_int32, {1, 2, 3})
    end

    function g.test_unpacked_payload_accepted_for_packed_schema()
        -- Symmetric: a per-element-tagged stream for Holder.packed_int32
        -- must also concatenate. Build it by hand.
        local cr = require(mode .. '.c_repeated.c_repeated_pb')
        local full_cr = require('full.c_repeated.c_repeated_pb')
        local wire = require('pb.wire')
        -- field 1, wire VARINT: tag = (1<<3)|0 = 8 = 0x08
        local elem = function(v)
            return '\x08' .. wire.encode_varint(v)
        end
        local bytes = elem(7) .. elem(8) .. elem(9)
        local plan = c_runtime.compile_plan(cr.Holder_descriptor)
        local c_decoded = c_runtime.decode(plan, bytes)
        local lua_decoded = full_cr.Holder_decode(bytes)
        t.assert_equals(c_decoded, lua_decoded)
        t.assert_equals(c_decoded.packed_int32, {7, 8, 9})
    end

    local function counts() return {10, 100, 1000} end

    function g.test_fixture_packed_int32_round_trip_at_counts()
        local cr = require(mode .. '.c_repeated.c_repeated_pb')
        local full_cr = require('full.c_repeated.c_repeated_pb')
        local plan = c_runtime.compile_plan(cr.Holder_descriptor)
        for _, n in ipairs(counts()) do
            local arr = {}
            for i = 1, n do arr[i] = i end
            local bytes = full_cr.Holder_encode({packed_int32 = arr})
            t.assert_equals(c_runtime.decode(plan, bytes),
                            full_cr.Holder_decode(bytes),
                            ('packed_int32 n=%d'):format(n))
        end
    end

    function g.test_fixture_unpacked_scalars_round_trip_at_counts()
        local cr = require(mode .. '.c_repeated.c_repeated_pb')
        local full_cr = require('full.c_repeated.c_repeated_pb')
        local plan = c_runtime.compile_plan(cr.Holder_descriptor)
        for _, n in ipairs(counts()) do
            local i32, sx, fx = {}, {}, {}
            for i = 1, n do
                i32[i] = i
                sx[i] = -i
                fx[i] = ffi.new('uint64_t', i)
            end
            local bytes = full_cr.Holder_encode({
                unpacked_int32 = i32,
                unpacked_sint32 = sx,
                unpacked_fixed64 = fx,
            })
            t.assert_equals(c_runtime.decode(plan, bytes),
                            full_cr.Holder_decode(bytes),
                            ('unpacked scalars n=%d'):format(n))
        end
    end

    function g.test_fixture_repeated_messages_round_trip_at_counts()
        local cr = require(mode .. '.c_repeated.c_repeated_pb')
        local full_cr = require('full.c_repeated.c_repeated_pb')
        local plan = c_runtime.compile_plan(cr.Holder_descriptor)
        for _, n in ipairs(counts()) do
            local arr = {}
            for i = 1, n do arr[i] = {v = i, s = 'name' .. i} end
            local bytes = full_cr.Holder_encode({messages = arr})
            t.assert_equals(c_runtime.decode(plan, bytes),
                            full_cr.Holder_decode(bytes),
                            ('messages n=%d'):format(n))
        end
    end

    function g.test_fixture_mixed_round_trip()
        local cr = require(mode .. '.c_repeated.c_repeated_pb')
        local full_cr = require('full.c_repeated.c_repeated_pb')
        local plan = c_runtime.compile_plan(cr.Holder_descriptor)
        local p, u, s, m = {}, {}, {}, {}
        for i = 1, 100 do
            p[i] = i
            u[i] = -i
            s[i] = 'k' .. i
            m[i] = {v = i, s = 's' .. i}
        end
        local bytes = full_cr.Holder_encode({
            packed_int32 = p,
            unpacked_int32 = u,
            strings = s,
            messages = m,
        })
        t.assert_equals(c_runtime.decode(plan, bytes),
                        full_cr.Holder_decode(bytes))
    end
end

M test/c_runtime_encode_test.lua => test/c_runtime_encode_test.lua +186 -7
@@ 144,19 144,24 @@ for _, mode in ipairs({'full', 'runtime'}) do
                        full_hello.Person_encode(msg))
    end

    function g.test_skips_repeated_and_map_fields()
        -- 3d scope: singular message now encoded. Repeated and map
        -- are still silently skipped — 3e/3h will replace this.
    function g.test_skips_map_fields()
        -- 3e scope: singular message + repeated (packed and unpacked)
        -- now encode. Map fields remain silently skipped — 3h replaces
        -- the gate.
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        local msg = {
            name = 'x',
            emails = {'a@b'},                 -- repeated string (skip)
            emails = {'a@b'},                 -- repeated string (encode)
            address = {street = 'Main'},      -- message (encode)
            lucky_numbers = {1, 2, 3},        -- repeated packed (skip)
            lucky_numbers = {1, 2, 3},        -- repeated packed (encode)
            ages_by_nickname = {alice = 30},  -- map (skip)
        }
        local expected = full_hello.Person_encode(
            {name = 'x', address = {street = 'Main'}})
        local expected = full_hello.Person_encode({
            name = 'x',
            emails = {'a@b'},
            address = {street = 'Main'},
            lucky_numbers = {1, 2, 3},
        })
        t.assert_equals(c_runtime.encode(plan, msg), expected)
    end



@@ 244,4 249,178 @@ for _, mode in ipairs({'full', 'runtime'}) do
            c_runtime.encode(plan, {seconds = 1})
        end)
    end

    -- ---------- Repeated + packed (bd-jc9 / ra6 3e) ----------

    -- Acceptance per bd-jc9: Person.lucky_numbers (packed int32) round-
    -- trips byte-equal, and a fixture mixing packed + unpacked at 10/
    -- 100/1000 elements matches mode=full byte-for-byte.

    function g.test_acceptance_lucky_numbers_packed_int32()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        local msg = {lucky_numbers = {1, 2, 3, 4, 5, -1, 0x7fffffff}}
        t.assert_equals(c_runtime.encode(plan, msg),
                        full_hello.Person_encode(msg))
    end

    function g.test_repeated_empty_array_omits_field()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        -- Empty repeated must be wire-equivalent to absent.
        t.assert_equals(c_runtime.encode(plan, {lucky_numbers = {}}), '')
        t.assert_equals(c_runtime.encode(plan, {emails = {}}), '')
    end

    function g.test_repeated_emits_zero_elements_no_suppression()
        -- Unlike singular scalars, repeated elements are NOT zero-
        -- suppressed — every element reaches the wire.
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        local msg = {lucky_numbers = {0, 0, 0}}
        local bytes = c_runtime.encode(plan, msg)
        t.assert_equals(bytes, full_hello.Person_encode(msg))
        t.assert(#bytes > 0, 'zero elements still emit')
    end

    function g.test_repeated_string_per_element_tag()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        local msg = {emails = {'a@b', 'c@d', '', 'long' .. string.rep('x', 200)}}
        t.assert_equals(c_runtime.encode(plan, msg),
                        full_hello.Person_encode(msg))
    end

    function g.test_repeated_message_self_reference()
        -- Person.friends is `repeated Person` — a self-referencing
        -- sub-message exercising recursive plan dispatch via the
        -- c_plan stash for cycle-breaking.
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        local msg = {
            name = 'root',
            friends = {
                {name = 'alice', age = 30},
                {name = 'bob', friends = {{name = 'carol'}}},
                {},  -- empty friend → tag + len(0) per proto3 presence
            },
        }
        t.assert_equals(c_runtime.encode(plan, msg),
                        full_hello.Person_encode(msg))
    end

    function g.test_repeated_non_table_value_errors()
        local plan = c_runtime.compile_plan(hello.Person_descriptor)
        t.assert_error_msg_contains('repeated', function()
            c_runtime.encode(plan, {lucky_numbers = 'not an array'})
        end)
    end

    -- ---------- c_repeated fixture: every dispatch branch ----------

    local function counts() return {10, 100, 1000} end

    function g.test_fixture_packed_int32_at_counts()
        local cr = require(mode .. '.c_repeated.c_repeated_pb')
        local full_cr = require('full.c_repeated.c_repeated_pb')
        local plan = c_runtime.compile_plan(cr.Holder_descriptor)
        for _, n in ipairs(counts()) do
            local arr = {}
            for i = 1, n do arr[i] = i end
            local msg = {packed_int32 = arr}
            t.assert_equals(c_runtime.encode(plan, msg),
                            full_cr.Holder_encode(msg),
                            ('packed_int32 n=%d'):format(n))
        end
    end

    function g.test_fixture_packed_all_numeric_kinds()
        local cr = require(mode .. '.c_repeated.c_repeated_pb')
        local full_cr = require('full.c_repeated.c_repeated_pb')
        local plan = c_runtime.compile_plan(cr.Holder_descriptor)
        local msg = {
            packed_int32   = {1, -1, 0x7fffffff},
            packed_int64   = {ffi.new('int64_t', 1), ffi.new('int64_t', -1)},
            packed_sint32  = {-3, 0, 3},
            packed_uint32  = {7, 8, 9},
            packed_fixed32 = {100, 200},
            packed_fixed64 = {ffi.new('uint64_t', 0x1234567890ABCDEFULL)},
            packed_double  = {1.5, -2.25, 0},
            packed_float   = {0.5, -0.25},
            packed_bool    = {true, false, true, true, false},
        }
        t.assert_equals(c_runtime.encode(plan, msg),
                        full_cr.Holder_encode(msg))
    end

    function g.test_fixture_unpacked_scalars_at_counts()
        local cr = require(mode .. '.c_repeated.c_repeated_pb')
        local full_cr = require('full.c_repeated.c_repeated_pb')
        local plan = c_runtime.compile_plan(cr.Holder_descriptor)
        for _, n in ipairs(counts()) do
            local i32, sx, fx = {}, {}, {}
            for i = 1, n do
                i32[i] = i
                sx[i] = -i
                fx[i] = ffi.new('uint64_t', i)
            end
            local msg = {
                unpacked_int32 = i32,
                unpacked_sint32 = sx,
                unpacked_fixed64 = fx,
            }
            t.assert_equals(c_runtime.encode(plan, msg),
                            full_cr.Holder_encode(msg),
                            ('unpacked scalars n=%d'):format(n))
        end
    end

    function g.test_fixture_repeated_strings_and_bytes()
        local cr = require(mode .. '.c_repeated.c_repeated_pb')
        local full_cr = require('full.c_repeated.c_repeated_pb')
        local plan = c_runtime.compile_plan(cr.Holder_descriptor)
        for _, n in ipairs(counts()) do
            local s, b = {}, {}
            for i = 1, n do
                s[i] = 'str' .. tostring(i)
                b[i] = string.char(i % 256) .. '\0\xff'
            end
            local msg = {strings = s, blobs = b}
            t.assert_equals(c_runtime.encode(plan, msg),
                            full_cr.Holder_encode(msg),
                            ('strings/blobs n=%d'):format(n))
        end
    end

    function g.test_fixture_repeated_messages_at_counts()
        local cr = require(mode .. '.c_repeated.c_repeated_pb')
        local full_cr = require('full.c_repeated.c_repeated_pb')
        local plan = c_runtime.compile_plan(cr.Holder_descriptor)
        for _, n in ipairs(counts()) do
            local arr = {}
            for i = 1, n do arr[i] = {v = i, s = 'name' .. i} end
            local msg = {messages = arr}
            t.assert_equals(c_runtime.encode(plan, msg),
                            full_cr.Holder_encode(msg),
                            ('messages n=%d'):format(n))
        end
    end

    function g.test_fixture_mixed_packed_and_unpacked()
        -- All branches simultaneously: packed + unpacked + string/bytes
        -- + message, on the same message instance.
        local cr = require(mode .. '.c_repeated.c_repeated_pb')
        local full_cr = require('full.c_repeated.c_repeated_pb')
        local plan = c_runtime.compile_plan(cr.Holder_descriptor)
        local p, u, s, m = {}, {}, {}, {}
        for i = 1, 100 do
            p[i] = i
            u[i] = -i
            s[i] = 'k' .. i
            m[i] = {v = i, s = 's' .. i}
        end
        local msg = {
            packed_int32 = p,
            unpacked_int32 = u,
            strings = s,
            messages = m,
        }
        t.assert_equals(c_runtime.encode(plan, msg),
                        full_cr.Holder_encode(msg))
    end
end

A test/proto/c_repeated.proto => test/proto/c_repeated.proto +43 -0
@@ 0,0 1,43 @@
syntax = "proto3";

package c_repeated;

// Fixture for bd-jc9 (ra6 3e): repeated + packed C-runtime acceptance.
//
// Carries packed (proto3 default), explicit [packed=false], length-
// delimited (string/bytes — never packable), and repeated sub-message
// shapes so the C runtime exercises every repeated dispatch branch
// against the same mode=full reference.
//
// Field numbers cluster by encoding shape so a hand-decode of a small
// encoded payload stays readable when debugging fixture drift.

message Inner {
  int32  v = 1;
  string s = 2;
}

message Holder {
  // Packed scalars (proto3 default). One per encoder branch.
  repeated int32   packed_int32   = 1;
  repeated int64   packed_int64   = 2;
  repeated sint32  packed_sint32  = 3;
  repeated uint32  packed_uint32  = 4;
  repeated fixed32 packed_fixed32 = 5;
  repeated fixed64 packed_fixed64 = 6;
  repeated double  packed_double  = 7;
  repeated float   packed_float   = 8;
  repeated bool    packed_bool    = 9;

  // Explicit unpacked — emits one tag per element.
  repeated int32   unpacked_int32   = 20 [packed = false];
  repeated fixed64 unpacked_fixed64 = 21 [packed = false];
  repeated sint32  unpacked_sint32  = 22 [packed = false];

  // Length-delimited element kinds — never packable.
  repeated string strings = 30;
  repeated bytes  blobs   = 31;

  // Repeated sub-message — per-element tag + length-prefix + body.
  repeated Inner messages = 40;
}