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
- Variables
- func AliasFromPath(pkgPath string) string
- func CSNamespaceOf(module, pkgPath string) string
- func DeriveGoPackage(dir string) (string, error)
- func EmitWireNamesCS(res Result) string
- func EnsureImage(ctx context.Context, o GenOptions) (string, error)
- func GoImportPath(goPackage string) string
- func GoPackageName(goPackage string) string
- func ProtoPackageOf(module, pkgPath string) (string, string)
- func RenderGoWire(msgs []Message, mirrored map[string]Message) (string, error)
- func RunBufCSharp(ctx context.Context, tag string, protos []ProtoFileOut) (string, error)
- func RunBufGo(ctx context.Context, tag string, protos []ProtoFileOut) (string, error)
- type Field
- type GenOptions
- type Guidance
- type LoadError
- type Message
- type Ownership
- type ProtoFileOut
- type Result
- type Scalar
- type Target
- type TypeRef
- type Violation
Constants ¶
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).
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).
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).
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`.
const WireFileName = "wire.gen.go"
WireFileName is the per-package wire file RenderGoWire emits.
Variables ¶
var Categories = []string{ CatDuplicateName, CatDuplicateWireName, CatUnserializable, CatUndeclared, CatSlice, CatMap, CatPointer, CatInterface, CatUnsupportedType, }
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
GoImportPath returns the import-path half of a DeriveGoPackage result ("<import-path>;<pkg>" -> "<import-path>").
func GoPackageName ¶
GoPackageName returns the package-name half of a DeriveGoPackage result ("<import-path>;<pkg>" -> "<pkg>").
func ProtoPackageOf ¶
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 ¶
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 ¶
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.
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 ¶
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 ¶
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 ¶
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.
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 ¶
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 ¶
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.
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 ¶
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 ¶
CommandCount returns the number of top-level commands discovered.
func (Result) ComponentCount ¶
ComponentCount returns the number of top-level components discovered.
func (Result) EventCount ¶
EventCount returns the number of top-level events discovered.
func (Result) ResolveGen ¶
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 ¶
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 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 ¶
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.