sdkgen

package
v1.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 29, 2026 License: LGPL-3.0 Imports: 22 Imported by: 0

README

sdkgen (maintainer notes)

Audience: whoever maintains or extends this generator. Developer-facing usage is in world sdk generate --help; the type/authoring rules are enforced by the lint (it names the fix per violation).

What it does

Reads a World Engine backend's Go wire types — commands, events, components, and system events — and produces typed, reflection-free serialization for Go and C#:

                        .proto --(protoc/buf in Docker)--> proto structs (Go) + C# classes (gen/)
Go wire structs  --→  /
  (discover roles,    \
   classify, lint)     wire.gen.go (in-place) --→ ToProto/FromProto (struct ⇄ proto message)
                                                  + SizeWire/AppendWire (every type)
                                                  + MarshalWire/UnmarshalWire (top-level types)
  • C#: the protoc-generated class (reflection-free, IL2CPP-safe) is used directly.
  • Go: the hand-written struct stays as-is (only its Name() method). Generated methods convert it to/from the protoc-generated proto message and (de)serialize via protobuf — no runtime reflection.

The engine contract: schema.Serializable

Every top-level wire type satisfies world-engine's single interface (pkg/cardinal/internal/schema):

type Serializable interface {
    Name() string
    MarshalWire() []byte
    UnmarshalWire([]byte) (any, error)
}

That is the released engine (v0.16.x). The snapshot rework (world-engine branch Anthony/rework-snapshot-type) swaps MarshalWire for SizeWire() int and AppendWire([]byte) []byte. The generator emits all four, so a backend builds against either.

The methods are emitted on the type (value receivers), so the type is Serializable — there is no codec struct, no registry, and no init()/blank-import. UnmarshalWire proto-unmarshals then FromProtos, returning the value as any (callers assert the concrete type). A type that hasn't been generated is missing these methods, so it fails to compile — the LSP flags it.

MarshalWire is derived: c.AppendWire(make([]byte, 0, c.SizeWire())). It used to be proto.Marshal(c.ToProto()), a second encoder that had to agree with the first byte for byte. Once the rework lands it stays as a convenience and drops off the interface.

UnmarshalWire keeps its error: it decodes bytes from storage or the network, where truncation and corruption are conditions rather than bugs.

The runtime dispatches by concrete type (the command queue and ECS column are generic over T), never by a name lookup — so there is nothing to register.

The direct encoders: SizeWire / AppendWire
SizeWire() int             // the exact encoded size
AppendWire([]byte) []byte  // appends exactly that many bytes

The reworked ECS column snapshots through these: one pass sizes every component of every entity, a second writes them all into one buffer, and neither allocates. The pair is emitted for every message, nested and mirrored included, because a parent encodes a nested field through the field's own pair. AppendWire never sizes: a nested message's length prefix is a one-byte placeholder patched in after its body (wireLenPrefix), so the append pass is a single walk. The bytes are the ones proto.Marshal(c.ToProto()) produces, so UnmarshalWire decodes either and the encoding rules are protobuf-go's, not ours — see the header of emit_wire_direct.go for the list. Decode is unchanged.

UTF-8. SizeWire panics on a string field holding bytes that are not valid UTF-8, via the emitted wireStringSize helper. proto3 forbids such a string and every decoder rejects the payload, so writing one produces a snapshot that cannot be restored. proto.Marshal made the same check, and the sizing pass runs over the whole world before a byte is appended — so the panic fails the write instead of committing a file that only fails later, at restore. A bytes field is not checked; it carries arbitrary octets by definition.

Kinds and targets

A type's kind comes from the WithX field a system declares it through — the only signal that a plain struct is a wire type (and one the runtime needs anyway):

Kind Discovered via Targets
command RegisterCommand[T](), SendToShard(T) Go + C#
event RegisterEvent[T]() Go + C#
component RegisterComponent[T]() Go + C#
system event RegisterSystemEvent[T]() Go only (in-process; the client never sees it)

Post-codec, kind no longer changes the emitted code — every kind gets identical wire methods. It survives only as the target policy (kindTargets). A type can play several roles (e.g. an event that's also a system event): it is generated once, and its Targets is the union of every role's targets (unionTargets) — not a clash. targetsFor also filters by locality: a type defined in a dependency ships its own Go wire code, so only its missing C# is (re)generated here.

Pipeline (sdkgen.go)

  • Discover(dir) — loads packages via go/packages, walks the full import graph (so types in imported packages, including plugins in other modules, are seen). Collects each WithX roster and enqueues every T under its kind; enqueue records the role set per type and pulls in nested field types transitively.
  • discoverGenericFieldTypes — finds every T in a cardinal.<generic>[T] field (matched structurally; engine-agnostic, no world-engine import). discoverSentCommandTypes adds send-only commands (SendCommand/SendToShard), which the receive-side scan misses.
  • collectWireNames — extracts the wire name from each type's Name() string (AST: the returned string literal; carries the version, e.g. "move.v2").
  • buildMessage — one struct → one proto message: Kind is a top-level role if the type has one (else nested), Targets is the union, fields numbered by declaration order.
  • reportIssues / checkDuplicates — post-discovery integrity checks decided once every message is known (duplicate proto full name; duplicate command wire name).
  • reportOrphans — completeness net: a local type with a Name() badge that's in no roster and reached as no nested field is flagged (res.Orphans) — it looks like a wire type but was never wired, so it'd be silently omitted. Surfaced in the report's ORPHANS section. (The roster is what discovers a type; the badge is what the dev intends — the diff catches a forgotten WithX.)

The type map (classify — edit this to support more types)

  • classify(t) — shape dispatch: slice → classifySlice, map → classifyMap, else classifySingle. Before any of those, world-engine's immutable.Slice[T] is recognised by origin (engineSliceElem) and goes to classifyEngineSlice, since every struct rule would otherwise refuse it: it has no exported field.
  • classifyEngineSlice — immutable.Slice[T] → the wire shape []T would have (repeated, or bytes for a byte element), flagged Field.Slice. The element goes through the slice rules, and shapeCategory/notData look through the Slice, so a pointer inside it is reported as a pointer. A Slice inside another repeated field (Slice[Slice[T]], [N]Slice[T]) is refused like [][]T.
  • scalarOf — Go scalar → proto scalar (add a scalar here). uint8→uint32, int8/16→int32, int/uint→64-bit.
  • classifySingle — scalar; named struct → nested message (enqueued); time.Time → google.protobuf.Timestamp; *struct/*scalar via classifyPointer (nullable message / proto3 optional); interface → errAny.
  • classifySlice/classifyMap — []byte→bytes, []T→repeated; map keys must be a non-float scalar, values can't be a collection.

A field that isn't cleanly representable is refused, and generation stops. There is one tier: every finding blocks. The category picks which fix the report offers, and refusedCategory chooses it from the type — three outcomes, asked in this order:

  • unserializable — not data at all, so no encoding of any format could carry it: chan/func/unsafe.Pointer, a method interface, or a non-empty struct with no exported fields (nothing crosses the wire, so generating it would emit an empty message and drop the value; an empty struct{} carries nothing to lose and is allowed). notData walks the type, because the reason is often below the surface — []chan int is refused for the chan, not for the slice.
  • its shape — data reached by address, so the shape names the fix: slice, map, pointer, interface.
  • unsupported-type — data held inline that this generator has no mapping for: uintptr, complex64/complex128. Named for who is declining rather than for protobuf's capability, since proto has no multi-dimensional array type either and fixed arrays are carried anyway by flattening them. A complex could ride as a {re, im} message; an address means nothing on the far side.

Plus the duplicate-name / duplicate-wire-name collisions, which are about names rather than fields.

Separately from representability, every field that DOES classify is checked for whether it can be stored. A field holding a reference is reported under the Go type that causes it — slice, map, pointer, interface — since the bytes of such a field are not the whole value and an ECS column copy would share memory with the live world. [N]T is the fix and is first-class: dimensions flatten to one repeated field, and [N]byte becomes proto bytes.

For a length that is genuinely unbounded the fix is world-engine's immutable.Slice[T] (pkg/immutable). It travels exactly as []T would, and it is not a finding, because nothing can write through it: the backing array is unexported and every reader returns a copy, so a column copy sharing memory with the live world is harmless. Its element is held to every rule a field is.

That holds only while the ELEMENT is representable. [2][]int32 and [4]map[string]int32 are arrays of a collection, which protobuf cannot express any more than it can the element alone, so they are refused — reported under the element's shape (slice, map) rather than as an array. Fixing the element is what makes the array typed.

The test is whether a field copies cleanly: assigning the component gives you your own value, so a write through one copy is never visible through another. That is narrower than "holds no pointer", and the difference is string. A string header carries a pointer to heap bytes exactly as a slice header does, and it is allowed — nothing can write through it, so no copy can affect another. Arrays reach the same place from the other side, by copying their contents outright, and stay freely mutable. Two mechanisms, one property:

a := [3]int{1, 2, 3}; b := a; b[0] = 99 // a stays [1 2 3] — b is your own
c := []int{1, 2, 3};  d := c; d[0] = 99 // c becomes [99 2 3] — d is the same array

So neither immutability, boundedness, nor heap placement is the rule, and each of those admits the wrong set: string is unbounded, heap-backed and pointer-carrying and passes; [N]T is mutable and passes. What actually breaks is that a query hands back a copy, so within one struct c.Name = x touches only the caller's copy while c.Items[0] = x writes into the stored component — adjacent lines, opposite behaviour, nothing at the call site to tell them apart.

One finding per field, named after the shape. An earlier split between "proto cannot express this" and "a column cannot hold this" reported the same field twice under two names, for one defect with one fix.

There is no non-blocking tier. A field protobuf cannot express is refused rather than carried as opaque JSON bytes with a warning, so a finding has one consequence and needs nothing to qualify it. The category is decided by the TYPE, never by what encoding/json happens to accept: deciding it by encodability files a map[float64]V under unserializable, when it is a map and wants a map's fix.

Types from another module

Two cases, decided by whether that module ran the generator itself.

It did — its wire.gen.go exists, so its generated Go type does too. The field is stamped with the owner's go_package and the .proto is written but left out of --path, so buf resolves it without generating: protoc-gen-go emits an import of their package rather than a second copy of the type. One definition, and the owner's own ToProto is assignable here. Every plugin type takes this path.

It did not — there is nothing to import, so the type is mirrored: its message is emitted into this schema and its converters as free functions in the referencing package, since Go forbids declaring methods on a foreign type. The walk continues into its own fields to whatever depth they go; d.seenKind terminates it (import cycles are irrelevant — type A struct{ B *A } is legal in one package).

A type reached as a registered type rather than a field — RegisterCommand[T]() — takes neither path: it goes to externalTop, where its Go comes from the owner and only the C# it does not ship is generated here.

An embedded struct is not special-cased: it flows through classify like any other field (its field name is the embedded type's name), so it nests as a sub-message — the ToProto/FromProto composition follows the Go structure, no flattening. A cyclic embed (valid Go via pointers) becomes a recursive proto message (dedup stops discovery from looping).

Each violation names its fix (categoryGuidance), surfaced verbatim by the lint. To add a supported type, extend scalarOf or classify*; to reject one with guidance, return an error whose text names the fix.

The wire layer (emit_wire.go)

RenderGoWire emits each package's wire.gen.go:

  • Per message: exported ToProto/FromProto (struct ⇄ the protoc-generated proto message). Value receivers, both — FromProto returns a populated copy (func (c X) FromProto(p) X { ...; return c }), keeping the method set all-value (its hand-written Name() must be a value receiver under a generic value constraint, and Go/ recvcheck forbid mixing value and pointer receivers). Decode is c = c.FromProto(&p); a nested field is c.F = c.F.FromProto(p.F). Exported so a type in one package can convert a nested type defined in another package of the same module.
  • Per message: SizeWire/AppendWire, the allocation-free direct encoders (emit_wire_direct.go). Nested types get them too — their parent encodes through them. Mirrored types get free functions (mirror<Alias><Name>SizeWire/AppendWire). Package-level helpers (sizeWireTimestamp/appendWireTimestamp, wireBool, wireStringSize, wireLenPrefix) are written once per file when a field needs them, recorded like imports (wireGen.helper).
  • Per top-level type: MarshalWire/UnmarshalWire (proto bytes). Nested types do not get these — they compose into their parent's message and never cross the wire standalone.
  • The gen (proto) package is referenced through the alias threaded from --go-out, never a literal "gen", so any output dir name works.
  • A Field.Slice field (immutable.Slice[T]) reads through All in ToProto (Clone for bytes) and is rebuilt with SliceOf in FromProto. The elements collect in a per-field items<Name> slice first, because a Slice cannot be appended to in place. The file imports the declaring package under Field.SliceImport.

protoc/buf in Docker

The .proto is compiled by buf (protoc-gen-go + C# backends) inside a pinned Docker image, built on demand from the embedded buf/Dockerfile and cached by version tag — so the build cost is one-time. Invoked by shelling out to the docker CLI (os/exec); works with whatever that CLI targets. Two passes filter by target (see buf.go):

  • RunBufGo — Go only (buf.gen.go.yaml), over every locally-defined type including system events (engine-internal, Go-only).
  • RunBufCSharp — C# only (buf.gen.cs.yaml), over the client-facing subset (system events excluded) plus dependency types the client needs but that ship no C#.
  • EnsureImage — preflights docker on PATH and bakes plugin versions in via build args (--go-version/--csharp-version).

Storing the schema (--proto-out)

Debugging only, slated for deprecation. The emitted .proto is normally handed to buf and discarded. Pass --proto-out DIR to also persist it (mirroring each file's module-relative path), which is how you read the wire contract by hand. It is not a build input: the export holds only this backend's own files — a dependency's schema is left out while the import line naming it stays — so it does not compile standalone and cannot feed buf breaking. Consumers do not need it either: a module that imports these types rebuilds their schema from the Go source in the module cache. Requires --go-out — the stored schema's go_package option is derived from it.

Field numbering

Declaration order (no pb tags). Safe under the append-only rule: add fields at the bottom; reorder/remove/retype = breaking = a new vN via Name(). A lock file (pin-number-by-name, reserve deletions) is the future hardening for when shipped clients or persisted data make version skew possible.

Tests

sdkgen_test.go / emit_wire_test.go are self-contained: each case writes a small source into a temp module (with a minimal cardinal shim), runs Discover / RenderGoWire, and asserts inline — no committed fixtures, no golden files.

  • TestProtoTypeMapping — the Go→proto type matrix (widening, repeated, map).
  • TestProtoNested — nested struct, pointer, and repeated-message emission.
  • TestMultiRole — a type wired under two roles generates once with unioned targets (not a clash).
  • TestProtoEmbedded / TestProtoEmbeddedRecursive — an embedded struct nests as a sub-message; a cyclic embed nests recursively (no violation).
  • TestViolations — every rejected type reports the expected fix.
  • TestRenderGoWireDirect_* — SizeWire/AppendWire emission per field shape (emit_wire_direct_test.go). Byte-exactness against proto.Marshal is not checked here (nothing compiles the output).
  • TestRenderGoWire_* — MarshalWire/ToProto emission, the gen alias threaded (not hardcoded), and the output free of any RegisterCommandCodec/codec vestige.

protoc compilation itself (the C#/Go output) is exercised end-to-end against the buf-in-Docker path, not by these unit tests.

Documentation

Overview

Package sdkgen reads command structs from a World Engine backend (Go source) and produces typed serde. Discovery is shared; each field is classified once into a structured model, from which two outputs are derived:

  • Go (shard side): MarshalWire/UnmarshalWire and SizeWire/AppendWire methods on the command struct, emitted over protowire (see emit_wire.go, emit_wire_direct.go). Keeps the hand-written struct as the type; no separate generated type.
  • C#/proto (client side): a .proto (EmitProto) compiled by buf+protoc in Docker (see buf.go).

Both speak the protobuf wire format with the same field numbers, so Go and C# are wire-compatible. Go types with no proto representation (time.Time, interface{}, ...) are reported as violations with the standard alternative.

Index

Constants

View Source
const (
	DefaultGoVersion     = "v1.36.11" // protocolbuffers/go
	DefaultCSharpVersion = "33.2"     // protocolbuffers/csharp == protoc release
)

Default plugin versions — mirror proto/buf.gen.yaml. Overridable via the CLI flags (--go-version / --csharp-version).

View Source
const (
	KindNested      = ""
	KindCommand     = "command"
	KindEvent       = "event"
	KindComponent   = "component"
	KindSystemEvent = "system_event"
)

Message kinds. Commands, events, components, and system events are top-level wire types (each gets the generated wire methods); a struct reached only as a nested field is KindNested (no wire name — just ToProto/FromProto, carried inside its parent's proto file).

View Source
const (
	CatDuplicateName     = "duplicate-name"      // same proto message name in two packages
	CatDuplicateWireName = "duplicate-wire-name" // same Name() wire string across two commands
	CatUnserializable    = "unserializable"      // chan/func/unsafe/method-interface — not data
	CatUndeclared        = "undeclared"          // passed to a cardinal generic, declared through no roster

	CatSlice     = "slice"     // []T, []byte, or an array of them
	CatMap       = "map"       // map[K]V
	CatPointer   = "pointer"   // *T, or an optional scalar
	CatInterface = "interface" // any / interface{}
	// CatUnsupportedType is data, held inline, that this generator has no mapping for: uintptr, complex.
	// Named for who is declining, not for protobuf's capability — proto has no multi-dimensional array
	// type either, and fixed arrays are carried anyway by flattening them (see classifyArray). A complex
	// could ride as a {re, im} message and a uintptr's bits fit a uint64; neither is carried because an
	// address means nothing on the far side and complex has no agreed cross-language form.
	CatUnsupportedType = "unsupported-type"
)

Issue categories. Every one stops generation; the category picks the guidance shown (see categoryGuidance), so it names the KIND OF FIX the field needs rather than a severity.

A category is named after the Go type that caused it, not after the reason it is a problem. A field has exactly one shape, so it produces exactly one finding — an earlier split across "why proto cannot express this" and "why ECS cannot store this" reported the same field twice under two names and inflated every count.

The field-level set splits three ways: the value is not data at all (CatUnserializable), it is data reached by address (the four shapes), or it is data held inline that this generator does not carry (CatUnsupportedType).

View Source
const WireBuildTag = "sdkgen"

WireBuildTag hides the generated wire layer from discovery. RenderGoWire stamps every wire.gen.go with `//go:build !<tag>`; Discover loads with `-tags=<tag>`, so the file is dropped before it is parsed. Normal `go build` passes no tags and compiles it as usual.

A generator must not parse its own output: that output describes the PREVIOUS shape of the source, so a stale (or unparseable) wire file aborts discovery over code this same run is about to overwrite. Same reason google/wire tags its generated wire_gen.go `!wireinject`.

View Source
const WireFileName = "wire.gen.go"

WireFileName is the per-package wire file RenderGoWire emits.

Variables

Categories is every issue category the generator can report. Declared once so the guidance table and its coverage test cannot drift from the constants.

Functions

func AliasFromPath

func AliasFromPath(pkgPath string) string

AliasFromPath is pkgAlias's rule on the import path alone, for callers that hold a path rather than a package. Two trailing segments, because a single leaf collides constantly — "component" names a package in every plugin.

func CSNamespaceOf

func CSNamespaceOf(module, pkgPath string) string

CSNamespaceOf derives the client SDK namespace for a Go package: <Product>.<Reldir PascalCased>, where Product is the generating module's leaf. This is the old proto-package scheme, kept for the client alone. It stays module-relative and short because a game developer reads these names — "Rampage.Shards.Gameplay.Event", not the module path spelled out. A package outside the module keeps its full import path, which is what the client already sees for plugin types today.

func DeriveGoPackage

func DeriveGoPackage(dir string) (string, error)

DeriveGoPackage computes the protoc-gen-go `go_package` value ("<import-path>;<pkg>") for generated code placed in dir, by reading the nearest enclosing go.mod.

func EmitWireNamesCS

func EmitWireNamesCS(res Result) string

EmitWireNamesCS renders C# partial classes that make each top-level wire type implement IWireNamed, exposing its backend Name() (Message.Wire). protoc output carries only the descriptor name, so this is the C# mirror of the third Serializable member (IMessage already gives Marshal/Unmarshal). One file, grouped by C# namespace; returns "" when there is nothing to emit.

func EnsureImage

func EnsureImage(ctx context.Context, o GenOptions) (string, error)

EnsureImage returns the buf image tag for these versions, building it (from the embedded Dockerfile) only if it isn't already cached. So the cost is one-time per version set; later runs reuse the cached image.

func GoImportPath

func GoImportPath(goPackage string) string

GoImportPath returns the import-path half of a DeriveGoPackage result ("<import-path>;<pkg>" -> "<import-path>").

func GoPackageName

func GoPackageName(goPackage string) string

GoPackageName returns the package-name half of a DeriveGoPackage result ("<import-path>;<pkg>" -> "<pkg>").

func ProtoPackageOf

func ProtoPackageOf(module, pkgPath string) (string, string)

ProtoPackageOf derives the proto package and .proto file path for a Go package.

The PACKAGE is the full Go import path, dotted — the same value whoever generates it. That matters because a type reached from another module has to end up with one identity, not one per consumer: if each backend derived its own name for world-engine's Vec2, every backend would define a different message for the same Go type, and nothing downstream could tell they were the same thing. The import path is already globally unique (it contains the module path), so it gives agreement and collision- freedom from the same string. Note this is NOT the C# namespace — see Message.CSNamespace.

The FILE is module-relative, so it stays short and a package's generated code lands where its source is. A foreign package's file is resolved against ITS owner's module, not the generating one, so the owner and every consumer name the same file for the same type.

No version suffix — this system versions per-type via Name(), so a package version would never increment. Examples, for module github.com/argus-labs/rampage:

.../rampage/shards/gameplay/event  ->  pkg "github_com.argus_labs.rampage.shards.gameplay.event"
                                       file "shards/gameplay/event/event.proto"
.../world-engine/pkg/plugin/lobby/system (a dependency, owner module world-engine)
                                    ->  pkg "github_com.argus_labs.world_engine.pkg.plugin.lobby.system"
                                       file "pkg/plugin/lobby/system/system.proto"

func RenderGoWire

func RenderGoWire(msgs []Message, mirrored map[string]Message) (string, error)

RenderGoWire emits the in-place wire layer for one source package: per message an exported `ToProto` and `FromProto` (struct ↔ the protoc-generated proto message) and the direct encoders `SizeWire`/ `AppendWire` (wire bytes straight from the struct, see emit_wire_direct.go), and for each top-level type (command, event, component, system event) `MarshalWire`/`UnmarshalWire` (proto bytes), so it satisfies schema.Serializable. There is no codec struct, no registry, and no init() — the methods live on the type itself; nested types get no MarshalWire/UnmarshalWire, since they never cross the wire on their own. The hand structs stay plain Go (only their Name method); proto owns the encoding.

The file lives in the type's own package, because Go requires a method's receiver type to be same-package. A type another module owns therefore cannot have methods here at all: its converters are free functions, and mirrored carries them (see wireMirrorFuncs).

Everything the header needs is accumulated while the body is written, never derived from a second walk of the messages. That is the invariant this file is built around: an import is registered by the same call that writes the name requiring it, so the header is a report of what was emitted rather than a guess that has to happen to agree. Call Result.ResolveGen before this — every generated-package path is read off the messages, not recomputed here.

func RunBufCSharp

func RunBufCSharp(ctx context.Context, tag string, protos []ProtoFileOut) (string, error)

RunBufCSharp generates C# only. Used for dependency (plugin) types: the plugin ships its own Go wire code, so regenerating Go would duplicate/collide in the proto registry — only the C# is missing.

func RunBufGo

func RunBufGo(ctx context.Context, tag string, protos []ProtoFileOut) (string, error)

RunBufGo generates Go only. Used for the Go pass over every locally-defined wire type — including system events, which are engine-internal (Go only) and so are excluded from the separate C# run.

Types

type Field

type Field struct {
	Name   string
	Number int

	Kind string // scalar | message | repeated | map

	Scalar   *Scalar // scalar leaf, or repeated-scalar element
	Optional bool    // scalar via pointer (*T) → proto3 `optional`; preserves nil/presence on the wire
	Pointer  bool    // single message via pointer

	// Msg is the message this field carries — a single message, a repeated element, or a fixed-array
	// element. Val is a map value's message. Both are unset for a scalar field. They stay separate
	// references rather than one, because map[string]foreign.T points at two independent types: the value
	// is foreign while the field itself is a map.
	Msg TypeRef
	Val TypeRef

	// Timestamp marks a time.Time field, mapped to the google.protobuf.Timestamp well-known type rather
	// than a generated message. It rides the message/repeated/map plumbing (Kind + Pointer) but swaps the
	// proto type token and the wire conversion (timestamppb.New / AsTime). For a map, the VALUE is the time.
	Timestamp bool

	Key   *Scalar // map key
	ValSc *Scalar // map value scalar (nil if value is a message)
	// ArrayDims holds the dimensions of a fixed-size Go array field, outermost first
	// ([4][8]Vec2 -> {4, 8}). The array flattens to a single repeated field of its base element type
	// in row-major order: the shape is a compile-time constant, so it belongs in the schema (as the
	// shape comment) rather than being re-encoded per message as wrapper rows. Empty for every other
	// field shape.
	ArrayDims []int64

	// Slice marks a world-engine immutable.Slice[T] field. On the wire it is what a []T would be — a
	// repeated field, or bytes for Slice[byte] — so proto emission reads Kind as usual. Only the Go side
	// differs: ToProto reads through All (or Clone), FromProto rebuilds with SliceOf, and the wire file
	// imports the declaring package under SliceImport (an import spec, see importSpecFor).
	Slice       bool
	SliceImport string
}

Field is one field on a message, classified once for both Go and proto output.

func (Field) ArrayCount

func (f Field) ArrayCount() int64

ArrayCount is the total number of elements a fixed array flattens to, i.e. the product of its dimensions. Zero when the field is not a fixed array.

func (Field) ArrayShape

func (f Field) ArrayShape() string

ArrayShape renders the Go array shape for the generated proto comment, e.g. "[4][8]".

type GenOptions

type GenOptions struct {
	GoVersion     string // protoc-gen-go, e.g. v1.36.11
	CSharpVersion string // protoc release for the C# backend, e.g. 33.2
}

GenOptions pins the plugin versions baked into the buf image.

type Guidance

type Guidance struct {
	Proper string
}

Guidance is the suggestion pair shown once per category group in the report: Quick is the fastest change that types (or unblocks) the field; Proper is the idiomatic solution.

func GuidanceFor

func GuidanceFor(category string) Guidance

GuidanceFor returns the easy/proper suggestion pair for an issue category (zero value if unknown).

type LoadError

type LoadError struct {
	Errors []string
}

LoadError reports type-check errors that block discovery and are NOT the expected bootstrap "missing wire method" errors (those are tolerated — the generator is about to create them). These are real problems in the backend the dev must fix before generation can read it; surfaced individually (not collapsed into one opaque wrap) so they're actionable.

func (*LoadError) Error

func (e *LoadError) Error() string

type Message

type Message struct {
	// Own says who generates this message's wire code: this run, another module that already did, or
	// another module that did not, so this run rebuilds it. Every downstream branch reads this rather
	// than reconstructing it, which is what stops proto emission, Go emission and the buf invocation from
	// disagreeing about a type.
	Own Ownership

	// Self is how a file OTHER than this message's own package names and converts it. A non-local message
	// needs it because its converters are written into somebody else's package; for a local message the
	// name is bare there and Self carries that.
	Self TypeRef

	Name string // Go struct name
	Wire string // exact wire name from a static Name(); "" for nested or correlated types
	// Correlated types have a dynamic Name() (`field + "literal"`): they own only WireSuffix, joined at
	// runtime to a prefix from WireField (e.g. RequestID). Mutually exclusive with Wire. Both "" = nested.
	WireSuffix string
	WireField  string
	Kind       string   // primary role: a top-level kind if the type plays one, else KindNested ("")
	Kinds      []string // every role the type is wired under; Targets is the union over these (generate-once)
	Targets    []Target // codegen outputs (Go engine / C# client / …); union of each role's targets; passes filter on this
	Fields     []Field
	Dir        string // filesystem dir of the package this struct is defined in
	PkgName    string // package name of that package
	PkgPath    string // import path of that package (so the codec file can import it)

	// ProtoPkg is the proto package this message lands in — one per Go package, so same-named types in
	// different Go packages never collide. e.g. "rampage.shards.gameplay.event". No version suffix: this
	// system versions per-type via Name() ("move.v2") + append-only fields, so a package-level version
	// would be a redundant axis that never increments. ProtoFile is its .proto path (drives .pb.go landing).
	ProtoPkg  string
	ProtoFile string

	// CSNamespace is the client SDK's namespace for this message. Deliberately NOT derived from
	// ProtoPkg: the proto package has to be globally agreed so two modules name the same type
	// identically, while this only has to be unique and readable inside one client. Tying them
	// together would push the module path into every C# name for no benefit to the game developer.
	CSNamespace string

	// GenImport is the Go import path of the protobuf-generated package holding this message's .pb.go,
	// resolved once here so nothing downstream re-derives it. A foreign package resolves against ITS
	// owner's module, and re-deriving from the generating one named a path nobody had written.
	//
	// For OwnImported it is the owner's, and stamping that as go_package is what makes protoc-gen-go emit
	// an import of their type rather than a second copy. Otherwise it is this run's own output path.
	GenImport string
}

Message is a top-level wire type (command/event/component/system event) or a nested struct.

func MergeMessages

func MergeMessages(groups ...[]Message) []Message

MergeMessages unions message lists, keeping the first copy of any message that appears more than once.

Keyed by proto full name (package + message) rather than Go identity, because that is what a duplicate means to protoc: two `message Vec2` in one `dep.component` is "symbol already defined" no matter which list each came from. A dependency's type genuinely reaches a run twice — as a local field and as a wired top-level type — and emitting each list separately produced two .proto files claiming one path, where only the last survived and every message the loser held vanished with nothing reported.

Earlier groups win, so callers pass the authoritative list first.

type Ownership

type Ownership uint8

Ownership says who generates a type's wire code. It is the single fact the rest of the generator routes on, replacing three overlapping booleans that answered slightly different questions under similar names — one of which doubled as "do not generate this file" and, read for a local package, silently suppressed a backend's entire output.

Exactly one state holds for any type, and every downstream decision follows from it: whether a .proto is generated or only imported, whether conversion is a method or a free function, and whose Go the wire file imports.

const (
	// OwnLocal means this run generates the type's proto and its Go. Conversion is a method on the type.
	OwnLocal Ownership = iota
	// OwnImported means another module owns the type AND already generated for it. Its .proto is emitted only
	// so this schema resolves, stamped with their go_package so protoc-gen-go emits an import of their Go
	// type instead of defining a second one. Conversion is still a method — on the type they generated.
	OwnImported
	// OwnMirrored means another module owns the type and generated nothing, so there is nothing to import and
	// this run rebuilds the message itself. Go forbids declaring methods on a foreign type, so conversion
	// is a pair of free functions in the referencing package.
	OwnMirrored
)

func (Ownership) Foreign

func (o Ownership) Foreign() bool

Foreign reports whether another module owns the type. Distinct from Mirrored: an imported type is foreign but is NOT rebuilt here, and conflating those is what let a local package be treated as one somebody else had already generated.

func (Ownership) Mirrored

func (o Ownership) Mirrored() bool

Mirrored reports whether this run rebuilds the type and emits free-function converters for it.

func (Ownership) String

func (o Ownership) String() string

type ProtoFileOut

type ProtoFileOut struct {
	Path    string
	Content string

	// ImportOnly marks a file that exists so the schema resolves, but must not be generated from: its
	// go_package names another module's package, which already holds the generated type. Generating it
	// would write a second copy of that type into this module and defeat the point of the import.
	ImportOnly bool
}

ProtoFileOut is one generated .proto file: its module-relative path and content.

func EmitProtos

func EmitProtos(module, goOutImport string, msgs []Message) ([]ProtoFileOut, error)

EmitProtos renders one .proto file per Go package (grouped by Message.ProtoFile). Each file declares its own proto package (Message.ProtoPkg) so same-named types in different Go packages never collide; a field whose message type lives in another package is rendered fully qualified and its file imported. module anchors proto-package derivation; goOutImport is the import path of the --go-out module, used for each file's go_package option (empty omits it, e.g. a cs-only run).

type Result

type Result struct {
	Module string // Go module path of the scanned backend
	// OwnerModule maps a package's import path to the module that defines it. A foreign package's .proto
	// file is resolved against ITS owner, not the module being generated, so the owner and every consumer
	// name the same file for the same type.
	OwnerModule map[string]string
	Messages    []Message
	// ExternalMessages are top-level types (and their nested types) discovered in a dependency — e.g. a
	// plugin's commands. The dependency ships its own Go wire code, so we never regenerate Go for these;
	// but it ships no C#, so these are emitted C#-only into the consumer's namespace (see EmitProtos with
	// an empty goOutImport). Kept separate from Messages so the Go path is untouched.
	ExternalMessages []Message
	Violations       []Violation
	// Orphans are local types that carry a Name() badge but are declared nowhere — they look like wire
	// types but were never registered (World.RegisterX[T]()) nor reached as a nested field, and
	// nothing hands them to cardinal, so they're omitted from generation. Surfaced as a report warning.
	Orphans []string
}

Result is the outcome of Discover.

func Discover

func Discover(dir string) (Result, error)

Discover loads packages under dir, finds the wire types registered through cardinal's World.RegisterX[T](), transitively collects nested message types, classifies fields, and lints.

func (Result) CommandCount

func (r Result) CommandCount() int

CommandCount returns the number of top-level commands discovered.

func (Result) ComponentCount

func (r Result) ComponentCount() int

ComponentCount returns the number of top-level components discovered.

func (Result) EventCount

func (r Result) EventCount() int

EventCount returns the number of top-level events discovered.

func (Result) ResolveGen

func (r Result) ResolveGen(goOutImport string)

ResolveGen stamps every message, and every reference to one, with the gen package holding its generated Go. Call it once after Discover, when --go-out is finally known.

This is the carry-rather-than-derive half of the fix. Emission used to work these paths out again at each call site, from the GENERATING module — which is wrong for a foreign package, since that resolves against its own owner — and the result was wire files importing paths nobody had written. Resolving here means the emitter reads a field and there is nothing left to disagree with. The value receiver matches Result's other methods. It still mutates: every write goes through the message slices, whose backing arrays the caller shares.

func (Result) SystemEventCount

func (r Result) SystemEventCount() int

SystemEventCount returns the number of top-level system events discovered.

type Scalar

type Scalar struct {
	Family string // varint | bool | i32 | i64 | string | bytes
	GoType string // Go type for the read-side cast, e.g. "int32"
	Proto  string // proto type token, e.g. "int64", "string", "double", "bytes"
	// Imports are the import specs GoType needs when it names a type from another package — a NAMED
	// scalar such as cardinal.EntityID or json.Number, where the decode-side cast must target the
	// declared type rather than its underlying basic. Recorded beside the spelling that requires them,
	// so the emitter registers the import at the point it writes the cast.
	Imports []string
}

Scalar describes a scalar/string/bytes leaf type.

type Target

type Target int

Target is a codegen output.

const (
	TargetGo     Target = iota // engine wire code (proto .pb.go + MarshalWire/UnmarshalWire + SizeWire/AppendWire)
	TargetCSharp               // Unity / .NET client SDK
)

type TypeRef

type TypeRef struct {
	// Name is the bare type name, shared by the Go struct and the proto message — one flat proto
	// namespace per package, so nothing is qualified here.
	Name string
	// GoType is how to write the type where it is referenced: bare for a type in the referencing file's
	// own package, "<alias>.<Name>" otherwise. Import is the import spec GoType needs, empty when bare.
	GoType string
	Import string
	// PkgPath is the Go import path of the declaring package; Own says who generates its wire code.
	PkgPath string
	Own     Ownership
	// GenImport is the protobuf-generated package holding this type's .pb.go. Filled by Result.ResolveGen
	// once --go-out is known, so the emitter reads it rather than deriving a path per call site — and a
	// foreign type keeps the path resolved against ITS owner instead of the generating module.
	GenImport string
}

TypeRef is a reference to a message type from a place that has to name it — a field, a map value, or a message describing how others name it. Discovery resolves it once; nothing downstream re-derives any part of it.

It exists because these facts are only correct together. The name, the qualified Go spelling, the import that spelling needs, and who generates the type used to be four parallel fields per reference, duplicated again for map values. Every bug in this area was two of them disagreeing.

func (TypeRef) ConvFunc

func (r TypeRef) ConvFunc(dir string) string

ConvFunc names the free-function converter for a mirrored type; dir is "ToProto" or "FromProto".

Named after the source package's import alias rather than its leaf. The leaf alone collides the moment one file mirrors two packages that share it — "component" names a package in every plugin — and the collision lands as a redeclaration in the emitted file, since format.Source only parses. Reusing the alias makes the name unique for free: checkImportAliases already refuses a run whose aliases clash.

func (TypeRef) Key

func (r TypeRef) Key() string

Key identifies the referenced type across packages, matching how Discover keys mirrored messages.

func (TypeRef) Set

func (r TypeRef) Set() bool

Set reports whether the reference points at anything. The zero value is how a scalar field records "no message here".

type Violation

type Violation struct {
	Category string
	Where    string
	GoType   string
	Reason   string
}

Violation is a finding: a field, or a name collision, that stops generation. Every finding is one — there is no second tier, so a finding has exactly one consequence and carries nothing to qualify it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL