commit c5aca289a5dc480a32af93fef6fb0ff8c8759b89 Author: edmand46 Date: Thu Mar 19 14:52:12 2026 +0300 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ced27ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.cache +.claude +.codex +.DS_Store + +.idea +bin +obj + +Library +Logs +Temp +UserSettings + +ragon diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..f7394a1 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,23 @@ +version: "2" + +linters: + default: none + enable: + - errcheck + - govet + - staticcheck + - unused + - gocritic + - misspell + - prealloc + - unconvert + - unparam + +formatters: + enable: + - goimports + +linters-settings: + goimports: + local-prefixes: + - gorena/server \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bc8a06 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +

+ arpack logo +

+ +# ArPack + +Binary serialization code generator for Go and C#. Define messages once as Go structs — get zero-allocation `Marshal`/`Unmarshal` for Go and `unsafe` pointer-based `Serialize`/`Deserialize` for C#. + +Built for game networking where every byte and allocation matters. + +## Features + +- **Single source of truth** — define messages in Go, generate both Go and C# code +- **Float quantization** — compress `float32`/`float64` to 8 or 16 bits with a `pack` struct tag +- **Boolean packing** — consecutive `bool` fields are packed into single bytes (up to 8 per byte) +- **Enums** — `type Opcode uint16` + `const` block becomes a C# `enum` +- **Nested types, fixed arrays, slices** — full support for complex message structures +- **Cross-language binary compatibility** — Go and C# produce identical wire formats + +## Installation + +```bash +go install edmand46/apack@latest +``` + +## Usage + +```bash +arpack -in messages.go -out-go ./gen -out-cs ../Unity/Assets/Scripts +``` + +| Flag | Description | +|---|---| +| `-in` | Input Go file with struct definitions (required) | +| `-out-go` | Output directory for generated Go code | +| `-out-cs` | Output directory for generated C# code | +| `-cs-namespace` | C# namespace (default: `Arpack.Messages`) | + +At least one of `-out-go` or `-out-cs` is required. + +**Output files:** +- Go: `{name}_gen.go` +- C#: `{Name}.gen.cs` + +## Schema Definition + +Messages are defined as Go structs in a single `.go` file: + +```go +package messages + +// Quantized 3D vector — 6 bytes instead of 12 +type Vector3 struct { + X float32 `pack:"min=-500,max=500,bits=16"` + Y float32 `pack:"min=-500,max=500,bits=16"` + Z float32 `pack:"min=-500,max=500,bits=16"` +} + +// Enum +type Opcode uint16 + +const ( + OpcodeUnknown Opcode = iota + OpcodeAuthorize + OpcodeJoinRoom +) + +type MoveMessage struct { + Position Vector3 // nested type + Velocity [3]float32 // fixed-length array + Waypoints []Vector3 // variable-length slice + PlayerID uint32 + Active bool // 3 consecutive bools → + Visible bool // packed into 1 byte + Ghost bool + Name string +} +``` + +### Supported Types + +| Type | Wire Size | +|---|---| +| `bool` (packed) | 1 bit (up to 8 per byte) | +| `int8`, `uint8` | 1 byte | +| `int16`, `uint16` | 2 bytes | +| `int32`, `uint32`, `float32` | 4 bytes | +| `int64`, `uint64`, `float64` | 8 bytes | +| `string` | 2-byte length prefix + UTF-8 | +| `[N]T` | N × sizeof(T) | +| `[]T` | 2-byte length prefix + N × sizeof(T) | + +### Float Quantization + +Use the `pack` struct tag to compress floats: + +```go +X float32 `pack:"min=-500,max=500,bits=16"` // 2 bytes instead of 4 +Y float32 `pack:"min=0,max=1,bits=8"` // 1 byte instead of 4 +``` + +| Parameter | Description | +|---|---| +| `min` | Minimum expected value | +| `max` | Maximum expected value | +| `bits` | Target size: `8` (uint8) or `16` (uint16) | + +Values are linearly mapped: `encoded = (value - min) / (max - min) * maxUint`. + +## Generated Code + +### Go + +```go +func (m *MoveMessage) Marshal(buf []byte) []byte +func (m *MoveMessage) Unmarshal(data []byte) (int, error) +``` + +`Marshal` appends to the buffer and returns it. `Unmarshal` reads from the buffer and returns bytes consumed. + +### C# + +```csharp +public unsafe int Serialize(byte* buffer) +public static unsafe int Deserialize(byte* buffer, out MoveMessage msg) +``` + +Uses unsafe pointers for zero-copy serialization. Returns bytes written/consumed. + +## Wire Format + +- Little-endian byte order +- No message framing — fields are written in declaration order +- Variable-length fields (`string`, `[]T`) prefixed with `uint16` length +- Booleans packed as bitfields (LSB first, up to 8 per byte) +- Quantized floats stored as `uint8` or `uint16` + +## Running Tests + +```bash +# Unit tests (parser + generator) +go test ./tools/arpack/... + +# End-to-end cross-language tests (requires dotnet SDK) +go test ./tools/arpack/e2e/... +``` diff --git a/cmd/arpack/main.go b/cmd/arpack/main.go new file mode 100644 index 0000000..1ddf833 --- /dev/null +++ b/cmd/arpack/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "edmand46/arpack/generator" + "edmand46/arpack/parser" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "strings" +) + +func main() { + in := flag.String("in", "", "input Go file with struct definitions") + outGo := flag.String("out-go", "", "output directory for generated Go code") + outCS := flag.String("out-cs", "", "output directory for generated C# code") + namespace := flag.String("cs-namespace", "Arpack.Messages", "C# namespace") + flag.Parse() + + if *in == "" { + log.Fatal("arpack: -in is required") + } + if *outGo == "" && *outCS == "" { + log.Fatal("arpack: at least one of -out-go or -out-cs is required") + } + + schema, err := parser.ParseSchemaFile(*in) + if err != nil { + log.Fatalf("arpack: parse error: %v", err) + } + msgs := schema.Messages + if len(msgs) == 0 { + log.Fatalf("arpack: no structs found in %s", *in) + } + + baseName := strings.TrimSuffix(filepath.Base(*in), ".go") + + if *outGo != "" { + pkgName := filepath.Base(*outGo) + if pkgName == "." || pkgName == "" { + pkgName = msgs[0].PackageName + } + + src, err := generator.GenerateGoSchema(schema, pkgName) + if err != nil { + log.Fatalf("arpack: Go generation error: %v", err) + } + + outPath := filepath.Join(*outGo, baseName+"_gen.go") + if err := os.MkdirAll(*outGo, 0755); err != nil { + log.Fatalf("arpack: mkdir %s: %v", *outGo, err) + } + if err := os.WriteFile(outPath, src, 0644); err != nil { + log.Fatalf("arpack: write %s: %v", outPath, err) + } + + fmt.Printf("arpack: wrote %s\n", outPath) + } + + if *outCS != "" { + src, err := generator.GenerateCSharpSchema(schema, *namespace) + if err != nil { + log.Fatalf("arpack: C# generation error: %v", err) + } + + outPath := filepath.Join(*outCS, toTitle(baseName)+".gen.cs") + if err := os.MkdirAll(*outCS, 0755); err != nil { + log.Fatalf("arpack: mkdir %s: %v", *outCS, err) + } + if err := os.WriteFile(outPath, src, 0644); err != nil { + log.Fatalf("arpack: write %s: %v", outPath, err) + } + + fmt.Printf("arpack: wrote %s\n", outPath) + } +} + +func toTitle(s string) string { + return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) +} diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 0000000..f1e7187 --- /dev/null +++ b/e2e/e2e_test.go @@ -0,0 +1,521 @@ +package e2e + +import ( + "bytes" + "edmand46/arpack/generator" + "edmand46/arpack/parser" + "fmt" + "math" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" +) + +const samplePath = "../testdata/sample.go" + +// TestE2E_CrossLanguage гоняет сериализацию в обе стороны: Go → C# и C# → Go. +func TestE2E_CrossLanguage(t *testing.T) { + if _, err := exec.LookPath("dotnet"); err != nil { + t.Skip("dotnet not found, skipping cross-language e2e test") + } + + schema, err := parser.ParseSchemaFile(samplePath) + if err != nil { + t.Fatalf("parse: %v", err) + } + + goSrc, err := generator.GenerateGoSchema(schema, "main") + if err != nil { + t.Fatalf("GenerateGoSchema: %v", err) + } + + csSrc, err := generator.GenerateCSharpSchema(schema, "Ragono.Messages") + if err != nil { + t.Fatalf("GenerateCSharpSchema: %v", err) + } + + goDir := buildGoHarness(t, goSrc) + csDir := buildCSHarness(t, csSrc) + + cases := []struct { + name string + typ string + epsilon float64 + }{ + {"Vector3", "Vector3", 0.02}, // quantized float32 → допустимая погрешность + {"SpawnMessage", "SpawnMessage", 0.02}, // mix: int, nested, []string, []byte + {"MoveMessage", "MoveMessage", 0.02}, // bool bit packing: Active, Visible, Ghost + {"EnvelopeMessage", "EnvelopeMessage", 0}, + } + + for _, tc := range cases { + t.Run("Go_to_CS/"+tc.name, func(t *testing.T) { + hex := runHarness(t, goDir, "go", "ser", tc.typ, "") + out := runHarness(t, csDir, "cs", "deser", tc.typ, hex) + checkOutput(t, tc.typ, out, tc.epsilon) + }) + t.Run("CS_to_Go/"+tc.name, func(t *testing.T) { + hex := runHarness(t, csDir, "cs", "ser", tc.typ, "") + out := runHarness(t, goDir, "go", "deser", tc.typ, hex) + checkOutput(t, tc.typ, out, tc.epsilon) + }) + } +} + +// --- Build helpers --- + +func buildGoHarness(t *testing.T, generatedSrc []byte) string { + t.Helper() + dir := t.TempDir() + + // Читаем sample.go и меняем package на main + sampleSrc, err := os.ReadFile(samplePath) + if err != nil { + t.Fatalf("read sample: %v", err) + } + sampleSrc = bytes.Replace(sampleSrc, []byte("package messages"), []byte("package main"), 1) + + // Generated код уже имеет package main (мы передали "main" в GenerateGo) + write(t, filepath.Join(dir, "messages.go"), sampleSrc) + write(t, filepath.Join(dir, "messages_arpack.go"), generatedSrc) + write(t, filepath.Join(dir, "main.go"), []byte(goHarnessSource)) + write(t, filepath.Join(dir, "go.mod"), []byte("module arpack_e2e\n\ngo 1.21\n")) + + mustRun(t, dir, "go", "build", "-o", "harness", ".") + return dir +} + +func buildCSHarness(t *testing.T, generatedSrc []byte) string { + t.Helper() + dir := t.TempDir() + + write(t, filepath.Join(dir, "Messages.cs"), generatedSrc) + write(t, filepath.Join(dir, "Program.cs"), []byte(csHarnessSource)) + write(t, filepath.Join(dir, "E2EHarness.csproj"), []byte(csProjSource)) + + mustRun(t, dir, "dotnet", "build", "-c", "Release", "-o", "out") + return dir +} + +// --- Harness runners --- + +func runHarness(t *testing.T, dir, lang, op, typ, hexInput string) string { + t.Helper() + var cmd *exec.Cmd + if lang == "go" { + args := []string{op, typ} + if hexInput != "" { + args = append(args, hexInput) + } + cmd = exec.Command(filepath.Join(dir, "harness"), args...) + } else { + args := []string{op, typ} + if hexInput != "" { + args = append(args, hexInput) + } + cmd = exec.Command("dotnet", append([]string{filepath.Join(dir, "out", "E2EHarness.dll")}, args...)...) + } + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s harness %s %s failed: %v\n%s", lang, op, typ, err, out) + } + return strings.TrimSpace(string(out)) +} + +// --- Output verification --- + +// checkOutput парсит key=value вывод и сравнивает с ожидаемыми значениями. +func checkOutput(t *testing.T, typ, output string, epsilon float64) { + t.Helper() + t.Logf("output for %s:\n%s", typ, output) + + kv := parseKV(output) + + switch typ { + case "Vector3": + assertFloat(t, kv, "X", 123.45, epsilon) + assertFloat(t, kv, "Y", -200.0, epsilon) + assertFloat(t, kv, "Z", 0.0, epsilon) + + case "SpawnMessage": + assertInt(t, kv, "EntityID", 42) + assertFloat(t, kv, "Position.X", 10.0, epsilon) + assertFloat(t, kv, "Position.Y", 20.0, epsilon) + assertFloat(t, kv, "Position.Z", 30.0, epsilon) + assertInt(t, kv, "Health", -100) + assertStr(t, kv, "Tags[0]", "hero") + assertStr(t, kv, "Tags[1]", "player") + assertInt(t, kv, "Data[0]", 1) + assertInt(t, kv, "Data[1]", 2) + assertInt(t, kv, "Data[2]", 3) + + case "MoveMessage": + assertInt(t, kv, "PlayerID", 777) + assertStr(t, kv, "Active", "true") + assertStr(t, kv, "Visible", "false") + assertStr(t, kv, "Ghost", "true") + assertStr(t, kv, "Name", "TestPlayer") + case "EnvelopeMessage": + assertInt(t, kv, "Code", 2) + assertInt(t, kv, "Counter", 7) + } +} + +func parseKV(s string) map[string]string { + m := map[string]string{} + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if idx := strings.IndexByte(line, '='); idx >= 0 { + m[line[:idx]] = line[idx+1:] + } + } + return m +} + +func assertFloat(t *testing.T, kv map[string]string, key string, want, eps float64) { + t.Helper() + s, ok := kv[key] + if !ok { + t.Errorf("missing key %q in output", key) + return + } + got, err := strconv.ParseFloat(s, 64) + if err != nil { + t.Errorf("%s: cannot parse %q as float: %v", key, s, err) + return + } + if math.Abs(got-want) > eps { + t.Errorf("%s: got %v, want %v (±%v)", key, got, want, eps) + } +} + +func assertInt(t *testing.T, kv map[string]string, key string, want int64) { + t.Helper() + s, ok := kv[key] + if !ok { + t.Errorf("missing key %q in output", key) + return + } + got, err := strconv.ParseInt(s, 10, 64) + if err != nil { + t.Errorf("%s: cannot parse %q as int: %v", key, s, err) + return + } + if got != want { + t.Errorf("%s: got %d, want %d", key, got, want) + } +} + +func assertStr(t *testing.T, kv map[string]string, key, want string) { + t.Helper() + got, ok := kv[key] + if !ok { + t.Errorf("missing key %q in output", key) + return + } + if got != want { + t.Errorf("%s: got %q, want %q", key, got, want) + } +} + +// --- Utilities --- + +func write(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func mustRun(t *testing.T, dir string, name string, args ...string) { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s %v failed: %v\n%s", name, args, err, out) + } +} + +// --- Go harness source --- + +const goHarnessSource = `package main + +import ( + "encoding/hex" + "fmt" + "os" + "strings" +) + +func main() { + op := os.Args[1] // ser | deser + typ := os.Args[2] // Vector3 | SpawnMessage | ... + + switch op + ":" + typ { + + case "ser:Vector3": + v := Vector3{X: 123.45, Y: -200, Z: 0} + fmt.Println(hex.EncodeToString(v.Marshal(nil))) + + case "deser:Vector3": + data, _ := hex.DecodeString(strings.TrimSpace(os.Args[3])) + var v Vector3 + v.Unmarshal(data) + fmt.Printf("X=%v\nY=%v\nZ=%v\n", v.X, v.Y, v.Z) + + case "ser:SpawnMessage": + msg := SpawnMessage{ + EntityID: 42, + Position: Vector3{X: 10, Y: 20, Z: 30}, + Health: -100, + Tags: []string{"hero", "player"}, + Data: []uint8{1, 2, 3}, + } + fmt.Println(hex.EncodeToString(msg.Marshal(nil))) + + case "deser:SpawnMessage": + data, _ := hex.DecodeString(strings.TrimSpace(os.Args[3])) + var msg SpawnMessage + msg.Unmarshal(data) + fmt.Printf("EntityID=%d\n", msg.EntityID) + fmt.Printf("Position.X=%v\n", msg.Position.X) + fmt.Printf("Position.Y=%v\n", msg.Position.Y) + fmt.Printf("Position.Z=%v\n", msg.Position.Z) + fmt.Printf("Health=%d\n", msg.Health) + for i, tag := range msg.Tags { + fmt.Printf("Tags[%d]=%s\n", i, tag) + } + for i, b := range msg.Data { + fmt.Printf("Data[%d]=%d\n", i, b) + } + + case "ser:MoveMessage": + msg := MoveMessage{ + Position: Vector3{X: 50, Y: -100, Z: 0}, + Velocity: [3]float32{1.5, -2.5, 0}, + Waypoints: []Vector3{{X: 10, Y: 20, Z: 0}}, + PlayerID: 777, + Active: true, + Visible: false, + Ghost: true, + Name: "TestPlayer", + } + fmt.Println(hex.EncodeToString(msg.Marshal(nil))) + + case "deser:MoveMessage": + data, _ := hex.DecodeString(strings.TrimSpace(os.Args[3])) + var msg MoveMessage + msg.Unmarshal(data) + fmt.Printf("PlayerID=%d\n", msg.PlayerID) + fmt.Printf("Active=%v\n", msg.Active) + fmt.Printf("Visible=%v\n", msg.Visible) + fmt.Printf("Ghost=%v\n", msg.Ghost) + fmt.Printf("Name=%s\n", msg.Name) + + case "ser:EnvelopeMessage": + msg := EnvelopeMessage{ + Code: OpcodeJoinRoom, + Counter: 7, + } + fmt.Println(hex.EncodeToString(msg.Marshal(nil))) + + case "deser:EnvelopeMessage": + data, _ := hex.DecodeString(strings.TrimSpace(os.Args[3])) + var msg EnvelopeMessage + msg.Unmarshal(data) + fmt.Printf("Code=%d\n", msg.Code) + fmt.Printf("Counter=%d\n", msg.Counter) + + default: + fmt.Fprintf(os.Stderr, "unknown op:type %s:%s\n", op, typ) + os.Exit(1) + } +} +` + +// --- C# harness source --- + +const csHarnessSource = `using System; +using System.Globalization; +using System.Text; +using Ragono.Messages; + +unsafe class Program +{ + static void Main(string[] args) + { + CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture; + string op = args[0]; // ser | deser + string typ = args[1]; // Vector3 | SpawnMessage | ... + + switch (op + ":" + typ) + { + case "ser:Vector3": + SerVector3(); + break; + case "deser:Vector3": + DeserVector3(args[2]); + break; + case "ser:SpawnMessage": + SerSpawnMessage(); + break; + case "deser:SpawnMessage": + DeserSpawnMessage(args[2]); + break; + case "ser:MoveMessage": + SerMoveMessage(); + break; + case "deser:MoveMessage": + DeserMoveMessage(args[2]); + break; + case "ser:EnvelopeMessage": + SerEnvelopeMessage(); + break; + case "deser:EnvelopeMessage": + DeserEnvelopeMessage(args[2]); + break; + default: + Console.Error.WriteLine($"unknown op:type {op}:{typ}"); + Environment.Exit(1); + break; + } + } + + static unsafe void SerVector3() + { + var msg = new Vector3 { X = 123.45f, Y = -200.0f, Z = 0.0f }; + byte[] buf = new byte[64]; + fixed (byte* ptr = buf) + { + int n = msg.Serialize(ptr); + Console.WriteLine(Convert.ToHexString(buf, 0, n).ToLower()); + } + } + + static unsafe void DeserVector3(string hexStr) + { + byte[] data = Convert.FromHexString(hexStr); + fixed (byte* ptr = data) + { + Vector3.Deserialize(ptr, out Vector3 msg); + Console.WriteLine($"X={msg.X:G9}"); + Console.WriteLine($"Y={msg.Y:G9}"); + Console.WriteLine($"Z={msg.Z:G9}"); + } + } + + static unsafe void SerSpawnMessage() + { + var msg = new SpawnMessage + { + EntityID = 42, + Position = new Vector3 { X = 10.0f, Y = 20.0f, Z = 30.0f }, + Health = -100, + Tags = new string[] { "hero", "player" }, + Data = new byte[] { 1, 2, 3 }, + }; + byte[] buf = new byte[512]; + fixed (byte* ptr = buf) + { + int n = msg.Serialize(ptr); + Console.WriteLine(Convert.ToHexString(buf, 0, n).ToLower()); + } + } + + static unsafe void DeserSpawnMessage(string hexStr) + { + byte[] data = Convert.FromHexString(hexStr); + fixed (byte* ptr = data) + { + SpawnMessage.Deserialize(ptr, out SpawnMessage msg); + Console.WriteLine($"EntityID={msg.EntityID}"); + Console.WriteLine($"Position.X={msg.Position.X:G9}"); + Console.WriteLine($"Position.Y={msg.Position.Y:G9}"); + Console.WriteLine($"Position.Z={msg.Position.Z:G9}"); + Console.WriteLine($"Health={msg.Health}"); + if (msg.Tags != null) + for (int i = 0; i < msg.Tags.Length; i++) + Console.WriteLine($"Tags[{i}]={msg.Tags[i]}"); + if (msg.Data != null) + for (int i = 0; i < msg.Data.Length; i++) + Console.WriteLine($"Data[{i}]={msg.Data[i]}"); + } + } + + static unsafe void SerMoveMessage() + { + var msg = new MoveMessage + { + Position = new Vector3 { X = 50.0f, Y = -100.0f, Z = 0.0f }, + Velocity = new float[] { 1.5f, -2.5f, 0.0f }, + Waypoints = new Vector3[] { new Vector3 { X = 10.0f, Y = 20.0f, Z = 0.0f } }, + PlayerID = 777, + Active = true, + Visible = false, + Ghost = true, + Name = "TestPlayer", + }; + byte[] buf = new byte[512]; + fixed (byte* ptr = buf) + { + int n = msg.Serialize(ptr); + Console.WriteLine(Convert.ToHexString(buf, 0, n).ToLower()); + } + } + + static unsafe void DeserMoveMessage(string hexStr) + { + byte[] data = Convert.FromHexString(hexStr); + fixed (byte* ptr = data) + { + MoveMessage.Deserialize(ptr, out MoveMessage msg); + Console.WriteLine($"PlayerID={msg.PlayerID}"); + Console.WriteLine($"Active={msg.Active.ToString().ToLower()}"); + Console.WriteLine($"Visible={msg.Visible.ToString().ToLower()}"); + Console.WriteLine($"Ghost={msg.Ghost.ToString().ToLower()}"); + Console.WriteLine($"Name={msg.Name}"); + } + } + + static unsafe void SerEnvelopeMessage() + { + var msg = new EnvelopeMessage + { + Code = Opcode.JoinRoom, + Counter = 7, + }; + byte[] buf = new byte[64]; + fixed (byte* ptr = buf) + { + int n = msg.Serialize(ptr); + Console.WriteLine(Convert.ToHexString(buf, 0, n).ToLower()); + } + } + + static unsafe void DeserEnvelopeMessage(string hexStr) + { + byte[] data = Convert.FromHexString(hexStr); + fixed (byte* ptr = data) + { + EnvelopeMessage.Deserialize(ptr, out EnvelopeMessage msg); + Console.WriteLine($"Code={(ushort)msg.Code}"); + Console.WriteLine($"Counter={msg.Counter}"); + } + } +} +` + +var csProjSource = fmt.Sprintf(` + + Exe + net9.0 + true + enable + disable + + +`) diff --git a/generator/csharp.go b/generator/csharp.go new file mode 100644 index 0000000..398e16d --- /dev/null +++ b/generator/csharp.go @@ -0,0 +1,478 @@ +package generator + +import ( + "edmand46/arpack/parser" + "fmt" + "strings" +) + +// GenerateCSharp генерирует C# unsafe код для списка сообщений. +// namespace — пространство имён (например, "Ragono.Messages"). +func GenerateCSharp(messages []parser.Message, namespace string) ([]byte, error) { + return GenerateCSharpSchema(parser.Schema{Messages: messages}, namespace) +} + +// GenerateCSharpSchema генерирует C# код для полной схемы, включая enum-ы. +func GenerateCSharpSchema(schema parser.Schema, namespace string) ([]byte, error) { + messages := schema.Messages + var b strings.Builder + + b.WriteString("// arpack \n") + b.WriteString("// Code generated by arpack. DO NOT EDIT.\n") + b.WriteString("#pragma warning disable CS8500\n\n") + + b.WriteString("using System;\n") + if needsTextEncoding(messages) { + b.WriteString("using System.Text;\n") + } + b.WriteString("\n") + + fmt.Fprintf(&b, "namespace %s\n{\n", namespace) + + enumNames := make(map[string]struct{}, len(schema.Enums)) + for _, enum := range schema.Enums { + enumNames[enum.Name] = struct{}{} + } + + wroteSection := false + for _, enum := range schema.Enums { + writeCSharpEnum(&b, enum) + b.WriteString("\n") + wroteSection = true + } + + for i, msg := range messages { + if err := writeCSharpMessage(&b, msg, enumNames); err != nil { + return nil, fmt.Errorf("message %s: %w", msg.Name, err) + } + if i < len(messages)-1 { + b.WriteString("\n") + } else if wroteSection { + // leave a single blank line between the last enum and the first struct only + } + } + + b.WriteString("}\n") + return []byte(b.String()), nil +} + +func writeCSharpEnum(b *strings.Builder, enum parser.Enum) { + fmt.Fprintf(b, " public enum %s : %s\n {\n", enum.Name, csharpEnumBaseType(enum)) + for i, value := range enum.Values { + fmt.Fprintf(b, " %s = %s", csharpEnumValueName(enum.Name, value.Name), value.Value) + if i < len(enum.Values)-1 { + b.WriteString(",") + } + b.WriteString("\n") + } + b.WriteString(" }\n") +} + +func writeCSharpMessage(b *strings.Builder, msg parser.Message, enumNames map[string]struct{}) error { + segs := segmentFields(msg.Fields) + + b.WriteString(" public unsafe struct ") + b.WriteString(msg.Name) + b.WriteString("\n {\n") + + // Поля + for _, f := range msg.Fields { + fmt.Fprintf(b, " public %s %s;\n", csharpTypeName(f, enumNames), f.Name) + } + b.WriteString("\n") + + // Serialize + b.WriteString(" public int Serialize(byte* buffer)\n") + b.WriteString(" {\n") + b.WriteString(" byte* ptr = buffer;\n") + for i, seg := range segs { + if seg.single != nil { + if err := writeCSharpSerializeField(b, *seg.single, " ", enumNames); err != nil { + return err + } + } else { + writeCSharpBoolGroupSerialize(b, seg.bools, i, " ") + } + } + b.WriteString(" return (int)(ptr - buffer);\n") + b.WriteString(" }\n\n") + + // Deserialize + fmt.Fprintf(b, " public static int Deserialize(byte* buffer, out %s msg)\n", msg.Name) + b.WriteString(" {\n") + b.WriteString(" byte* ptr = buffer;\n") + b.WriteString(" msg = default;\n") + for i, seg := range segs { + if seg.single != nil { + if err := writeCSharpDeserializeField(b, "msg", *seg.single, " ", enumNames); err != nil { + return err + } + } else { + writeCSharpBoolGroupDeserialize(b, "msg", seg.bools, i, " ") + } + } + b.WriteString(" return (int)(ptr - buffer);\n") + b.WriteString(" }\n") + + b.WriteString(" }\n") + return nil +} + +func writeCSharpBoolGroupSerialize(b *strings.Builder, bools []parser.Field, groupIdx int, indent string) { + varName := fmt.Sprintf("_boolByte%d", groupIdx) + fmt.Fprintf(b, "%sbyte %s = 0;\n", indent, varName) + for bit, f := range bools { + fmt.Fprintf(b, "%sif (%s) %s |= (byte)(1 << %d);\n", indent, f.Name, varName, bit) + } + fmt.Fprintf(b, "%s*ptr = %s; ptr++;\n", indent, varName) +} + +func writeCSharpBoolGroupDeserialize(b *strings.Builder, recv string, bools []parser.Field, groupIdx int, indent string) { + varName := fmt.Sprintf("_boolByte%d", groupIdx) + fmt.Fprintf(b, "%sbyte %s = *ptr; ptr++;\n", indent, varName) + for bit, f := range bools { + fmt.Fprintf(b, "%s%s.%s = (%s & (1 << %d)) != 0;\n", indent, recv, f.Name, varName, bit) + } +} + +// --- Serialize --- + +func writeCSharpSerializeField(b *strings.Builder, f parser.Field, indent string, enumNames map[string]struct{}) error { + switch f.Kind { + case parser.KindPrimitive: + return writeCSharpSerializePrimitive(b, f.Name, f, indent, enumNames) + case parser.KindNested: + fmt.Fprintf(b, "%sptr += %s.Serialize(ptr);\n", indent, f.Name) + case parser.KindFixedArray: + iVar := "_i" + f.Name + fmt.Fprintf(b, "%sfor (int %s = 0; %s < %d; %s++)\n%s{\n", + indent, iVar, iVar, f.FixedLen, iVar, indent) + elemField := parser.Field{ + Name: f.Name + "[" + iVar + "]", + Kind: f.Elem.Kind, + Primitive: f.Elem.Primitive, + NamedType: f.Elem.NamedType, + Quant: f.Elem.Quant, + TypeName: f.Elem.TypeName, + Elem: f.Elem.Elem, + FixedLen: f.Elem.FixedLen, + } + if err := writeCSharpSerializeField(b, elemField, indent+" ", enumNames); err != nil { + return err + } + fmt.Fprintf(b, "%s}\n", indent) + case parser.KindSlice: + fmt.Fprintf(b, "%s*(ushort*)ptr = (ushort)(%s?.Length ?? 0); ptr += 2;\n", indent, f.Name) + fmt.Fprintf(b, "%sif (%s != null)\n%s{\n", indent, f.Name, indent) + iVar := "_i" + f.Name + fmt.Fprintf(b, "%s for (int %s = 0; %s < %s.Length; %s++)\n%s {\n", + indent, iVar, iVar, f.Name, iVar, indent) + elemField := parser.Field{ + Name: f.Name + "[" + iVar + "]", + Kind: f.Elem.Kind, + Primitive: f.Elem.Primitive, + NamedType: f.Elem.NamedType, + Quant: f.Elem.Quant, + TypeName: f.Elem.TypeName, + Elem: f.Elem.Elem, + FixedLen: f.Elem.FixedLen, + } + if err := writeCSharpSerializeField(b, elemField, indent+" ", enumNames); err != nil { + return err + } + fmt.Fprintf(b, "%s }\n%s}\n", indent, indent) + } + return nil +} + +func writeCSharpSerializePrimitive( + b *strings.Builder, + access string, + f parser.Field, + indent string, + enumNames map[string]struct{}, +) error { + if f.Quant != nil { + return writeCSharpSerializeQuant(b, access, f, indent) + } + valueExpr := csharpSerializeValueExpr(access, f, enumNames) + switch f.Primitive { + case parser.KindFloat32: + fmt.Fprintf(b, "%s*(float*)ptr = %s; ptr += 4;\n", indent, valueExpr) + case parser.KindFloat64: + fmt.Fprintf(b, "%s*(double*)ptr = %s; ptr += 8;\n", indent, valueExpr) + case parser.KindInt8: + fmt.Fprintf(b, "%s*(sbyte*)ptr = %s; ptr += 1;\n", indent, valueExpr) + case parser.KindUint8: + fmt.Fprintf(b, "%s*ptr = %s; ptr += 1;\n", indent, valueExpr) + case parser.KindBool: + fmt.Fprintf(b, "%s*ptr = (byte)(%s ? 1 : 0); ptr += 1;\n", indent, valueExpr) + case parser.KindInt16: + fmt.Fprintf(b, "%s*(short*)ptr = %s; ptr += 2;\n", indent, valueExpr) + case parser.KindUint16: + fmt.Fprintf(b, "%s*(ushort*)ptr = %s; ptr += 2;\n", indent, valueExpr) + case parser.KindInt32: + fmt.Fprintf(b, "%s*(int*)ptr = %s; ptr += 4;\n", indent, valueExpr) + case parser.KindUint32: + fmt.Fprintf(b, "%s*(uint*)ptr = %s; ptr += 4;\n", indent, valueExpr) + case parser.KindInt64: + fmt.Fprintf(b, "%s*(long*)ptr = %s; ptr += 8;\n", indent, valueExpr) + case parser.KindUint64: + fmt.Fprintf(b, "%s*(ulong*)ptr = %s; ptr += 8;\n", indent, valueExpr) + case parser.KindString: + // UTF-8: uint16 byteCount + bytes + lenVar := "_slen" + sanitizeVarName(access) + fmt.Fprintf(b, "%sint %s = %s != null ? Encoding.UTF8.GetByteCount(%s) : 0;\n", + indent, lenVar, valueExpr, valueExpr) + fmt.Fprintf(b, "%s*(ushort*)ptr = (ushort)%s; ptr += 2;\n", indent, lenVar) + fmt.Fprintf(b, "%sif (%s != null && %s > 0)\n%s{\n", indent, valueExpr, lenVar, indent) + fmt.Fprintf(b, "%s fixed (char* _chars%s = %s)\n%s {\n", + indent, sanitizeVarName(access), valueExpr, indent) + fmt.Fprintf(b, "%s Encoding.UTF8.GetBytes(_chars%s, %s.Length, ptr, %s);\n", + indent, sanitizeVarName(access), valueExpr, lenVar) + fmt.Fprintf(b, "%s }\n", indent) + fmt.Fprintf(b, "%s}\n", indent) + fmt.Fprintf(b, "%sptr += %s;\n", indent, lenVar) + } + return nil +} + +func writeCSharpSerializeQuant(b *strings.Builder, access string, f parser.Field, indent string) error { + q := f.Quant + maxUint := q.MaxUint() + if q.Bits == 8 { + fmt.Fprintf(b, "%s*ptr = (byte)((%s - (%gf)) / (%gf - (%gf)) * %gf); ptr += 1;\n", + indent, access, q.Min, q.Max, q.Min, maxUint) + } else { + fmt.Fprintf(b, "%s*(ushort*)ptr = (ushort)((%s - (%gf)) / (%gf - (%gf)) * %gf); ptr += 2;\n", + indent, access, q.Min, q.Max, q.Min, maxUint) + } + return nil +} + +// --- Deserialize --- + +func writeCSharpDeserializeField( + b *strings.Builder, + recv string, + f parser.Field, + indent string, + enumNames map[string]struct{}, +) error { + access := recv + "." + f.Name + switch f.Kind { + case parser.KindPrimitive: + return writeCSharpDeserializePrimitive(b, access, f, indent, enumNames) + case parser.KindNested: + fmt.Fprintf(b, "%sptr += %s.Deserialize(ptr, out %s);\n", indent, f.TypeName, access) + case parser.KindFixedArray: + iVar := "_i" + f.Name + fmt.Fprintf(b, "%s%s = new %s[%d];\n", indent, access, csharpTypeName(*f.Elem, enumNames), f.FixedLen) + fmt.Fprintf(b, "%sfor (int %s = 0; %s < %d; %s++)\n%s{\n", + indent, iVar, iVar, f.FixedLen, iVar, indent) + elemField := parser.Field{ + Name: f.Name + "[" + iVar + "]", + Kind: f.Elem.Kind, + Primitive: f.Elem.Primitive, + NamedType: f.Elem.NamedType, + Quant: f.Elem.Quant, + TypeName: f.Elem.TypeName, + Elem: f.Elem.Elem, + FixedLen: f.Elem.FixedLen, + } + if err := writeCSharpDeserializeField(b, recv, elemField, indent+" ", enumNames); err != nil { + return err + } + fmt.Fprintf(b, "%s}\n", indent) + case parser.KindSlice: + lenVar := "_len" + sanitizeVarName(f.Name) + fmt.Fprintf(b, "%sint %s = *(ushort*)ptr; ptr += 2;\n", indent, lenVar) + fmt.Fprintf(b, "%s%s = new %s[%s];\n", indent, access, csharpTypeName(*f.Elem, enumNames), lenVar) + iVar := "_i" + sanitizeVarName(f.Name) + fmt.Fprintf(b, "%sfor (int %s = 0; %s < %s; %s++)\n%s{\n", + indent, iVar, iVar, lenVar, iVar, indent) + elemField := parser.Field{ + Name: f.Name + "[" + iVar + "]", + Kind: f.Elem.Kind, + Primitive: f.Elem.Primitive, + NamedType: f.Elem.NamedType, + Quant: f.Elem.Quant, + TypeName: f.Elem.TypeName, + Elem: f.Elem.Elem, + FixedLen: f.Elem.FixedLen, + } + if err := writeCSharpDeserializeField(b, recv, elemField, indent+" ", enumNames); err != nil { + return err + } + fmt.Fprintf(b, "%s}\n", indent) + } + return nil +} + +func writeCSharpDeserializePrimitive( + b *strings.Builder, + access string, + f parser.Field, + indent string, + enumNames map[string]struct{}, +) error { + if f.Quant != nil { + return writeCSharpDeserializeQuant(b, access, f, indent) + } + switch f.Primitive { + case parser.KindFloat32: + fmt.Fprintf(b, "%s%s = %s; ptr += 4;\n", indent, access, + csharpDeserializeValueExpr("*(float*)ptr", f, enumNames)) + case parser.KindFloat64: + fmt.Fprintf(b, "%s%s = %s; ptr += 8;\n", indent, access, + csharpDeserializeValueExpr("*(double*)ptr", f, enumNames)) + case parser.KindInt8: + fmt.Fprintf(b, "%s%s = %s; ptr += 1;\n", indent, access, + csharpDeserializeValueExpr("*(sbyte*)ptr", f, enumNames)) + case parser.KindUint8: + fmt.Fprintf(b, "%s%s = %s; ptr += 1;\n", indent, access, + csharpDeserializeValueExpr("*ptr", f, enumNames)) + case parser.KindBool: + fmt.Fprintf(b, "%s%s = %s; ptr += 1;\n", indent, access, + csharpDeserializeValueExpr("*ptr != 0", f, enumNames)) + case parser.KindInt16: + fmt.Fprintf(b, "%s%s = %s; ptr += 2;\n", indent, access, + csharpDeserializeValueExpr("*(short*)ptr", f, enumNames)) + case parser.KindUint16: + fmt.Fprintf(b, "%s%s = %s; ptr += 2;\n", indent, access, + csharpDeserializeValueExpr("*(ushort*)ptr", f, enumNames)) + case parser.KindInt32: + fmt.Fprintf(b, "%s%s = %s; ptr += 4;\n", indent, access, + csharpDeserializeValueExpr("*(int*)ptr", f, enumNames)) + case parser.KindUint32: + fmt.Fprintf(b, "%s%s = %s; ptr += 4;\n", indent, access, + csharpDeserializeValueExpr("*(uint*)ptr", f, enumNames)) + case parser.KindInt64: + fmt.Fprintf(b, "%s%s = %s; ptr += 8;\n", indent, access, + csharpDeserializeValueExpr("*(long*)ptr", f, enumNames)) + case parser.KindUint64: + fmt.Fprintf(b, "%s%s = %s; ptr += 8;\n", indent, access, + csharpDeserializeValueExpr("*(ulong*)ptr", f, enumNames)) + case parser.KindString: + lenVar := "_slen" + sanitizeVarName(access) + fmt.Fprintf(b, "%sint %s = *(ushort*)ptr; ptr += 2;\n", indent, lenVar) + expr := fmt.Sprintf("%s > 0 ? Encoding.UTF8.GetString(ptr, %s) : string.Empty", lenVar, lenVar) + fmt.Fprintf(b, "%s%s = %s;\n", indent, access, csharpDeserializeValueExpr(expr, f, enumNames)) + fmt.Fprintf(b, "%sptr += %s;\n", indent, lenVar) + } + return nil +} + +func writeCSharpDeserializeQuant(b *strings.Builder, access string, f parser.Field, indent string) error { + q := f.Quant + maxUint := q.MaxUint() + if q.Bits == 8 { + if f.Primitive == parser.KindFloat32 { + fmt.Fprintf(b, "%s%s = (float)(*ptr) / %gf * (%gf - (%gf)) + (%gf); ptr += 1;\n", + indent, access, maxUint, q.Max, q.Min, q.Min) + } else { + fmt.Fprintf(b, "%s%s = (double)(*ptr) / %g * (%g - (%g)) + (%g); ptr += 1;\n", + indent, access, maxUint, q.Max, q.Min, q.Min) + } + } else { + if f.Primitive == parser.KindFloat32 { + fmt.Fprintf(b, "%s%s = (float)(*(ushort*)ptr) / %gf * (%gf - (%gf)) + (%gf); ptr += 2;\n", + indent, access, maxUint, q.Max, q.Min, q.Min) + } else { + fmt.Fprintf(b, "%s%s = (double)(*(ushort*)ptr) / %g * (%g - (%g)) + (%g); ptr += 2;\n", + indent, access, maxUint, q.Max, q.Min, q.Min) + } + } + return nil +} + +func needsTextEncoding(messages []parser.Message) bool { + for _, msg := range messages { + for _, f := range msg.Fields { + if fieldNeedsEncoding(f) { + return true + } + } + } + return false +} + +func fieldNeedsEncoding(f parser.Field) bool { + switch f.Kind { + case parser.KindPrimitive: + return f.Primitive == parser.KindString + case parser.KindFixedArray, parser.KindSlice: + if f.Elem != nil { + return fieldNeedsEncoding(*f.Elem) + } + } + return false +} + +func csharpTypeName(f parser.Field, enumNames map[string]struct{}) string { + switch f.Kind { + case parser.KindPrimitive: + if csharpIsEnumType(f, enumNames) { + return f.NamedType + } + return f.CSharpPrimitiveTypeName() + case parser.KindNested: + return f.TypeName + case parser.KindFixedArray: + return csharpTypeName(*f.Elem, enumNames) + "[]" + case parser.KindSlice: + return csharpTypeName(*f.Elem, enumNames) + "[]" + } + return "unknown" +} + +func csharpSerializeValueExpr(access string, f parser.Field, enumNames map[string]struct{}) string { + if !csharpIsEnumType(f, enumNames) { + return access + } + return "(" + f.CSharpPrimitiveTypeName() + ")" + access +} + +func csharpDeserializeValueExpr(expr string, f parser.Field, enumNames map[string]struct{}) string { + if !csharpIsEnumType(f, enumNames) { + return expr + } + return "(" + f.NamedType + ")(" + expr + ")" +} + +func csharpIsEnumType(f parser.Field, enumNames map[string]struct{}) bool { + if f.NamedType == "" { + return false + } + _, ok := enumNames[f.NamedType] + return ok +} + +func csharpEnumBaseType(enum parser.Enum) string { + field := parser.Field{Primitive: enum.Primitive} + return field.CSharpPrimitiveTypeName() +} + +func csharpEnumValueName(enumName, valueName string) string { + if !strings.HasPrefix(valueName, enumName) || len(valueName) == len(enumName) { + return valueName + } + + suffix := valueName[len(enumName):] + if suffix == "" { + return valueName + } + if suffix[0] == '_' { + suffix = suffix[1:] + } + if suffix == "" { + return valueName + } + + first := suffix[0] + if !((first >= 'A' && first <= 'Z') || (first >= '0' && first <= '9') || first == '_') { + return valueName + } + + return suffix +} diff --git a/generator/generator_test.go b/generator/generator_test.go new file mode 100644 index 0000000..ec38f16 --- /dev/null +++ b/generator/generator_test.go @@ -0,0 +1,382 @@ +package generator + +import ( + "edmand46/arpack/parser" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const samplePath = "../testdata/sample.go" + +func TestGenerateGo_Compiles(t *testing.T) { + msgs, err := parser.ParseFile(samplePath) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + + src, err := GenerateGo(msgs, "messages") + if err != nil { + t.Fatalf("GenerateGo: %v", err) + } + + if len(src) == 0 { + t.Fatal("GenerateGo returned empty output") + } + + // Проверяем что содержит нужные функции + code := string(src) + for _, name := range []string{"Vector3", "MoveMessage", "SpawnMessage", "EnvelopeMessage"} { + if !strings.Contains(code, "func (m *"+name+") Marshal") { + t.Errorf("missing Marshal for %s", name) + } + if !strings.Contains(code, "func (m *"+name+") Unmarshal") { + t.Errorf("missing Unmarshal for %s", name) + } + } + + t.Logf("Generated Go (%d bytes):\n%s", len(src), code) +} + +func TestGenerateGo_RoundTrip(t *testing.T) { + msgs, err := parser.ParseFile(samplePath) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + + src, err := GenerateGo(msgs, "messages") + if err != nil { + t.Fatalf("GenerateGo: %v", err) + } + + // Записываем в temp dir вместе с оригинальными структурами и тестом round-trip + dir := t.TempDir() + + // Копируем testdata/sample.go (определения структур) + sampleSrc, err := os.ReadFile(samplePath) + if err != nil { + t.Fatalf("ReadFile sample: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "messages.go"), sampleSrc, 0644); err != nil { + t.Fatal(err) + } + + // Сохраняем сгенерированный код + if err := os.WriteFile(filepath.Join(dir, "messages_arpack.go"), src, 0644); err != nil { + t.Fatal(err) + } + + // Пишем round-trip тест + roundTrip := `package messages + +import ( + "math" + "testing" +) + +func TestRoundTrip_Vector3(t *testing.T) { + orig := Vector3{X: 123.45, Y: -200.0, Z: 0.0} + buf := orig.Marshal(nil) + var got Vector3 + n, err := got.Unmarshal(buf) + if err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if n != len(buf) { + t.Errorf("consumed %d bytes, want %d", n, len(buf)) + } + // Квантизация 16-бит даёт точность ≈ 1000/65535 ≈ 0.015 + const eps = float32(0.02) + if math.Abs(float64(got.X-orig.X)) > float64(eps) { + t.Errorf("X: got %v, want %v", got.X, orig.X) + } + if math.Abs(float64(got.Y-orig.Y)) > float64(eps) { + t.Errorf("Y: got %v, want %v", got.Y, orig.Y) + } +} + +func TestRoundTrip_SpawnMessage(t *testing.T) { + orig := SpawnMessage{ + EntityID: 42, + Position: Vector3{X: 10, Y: 20, Z: 30}, + Health: -100, + Tags: []string{"hero", "player"}, + Data: []uint8{1, 2, 3}, + } + buf := orig.Marshal(nil) + var got SpawnMessage + _, err := got.Unmarshal(buf) + if err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got.EntityID != orig.EntityID { + t.Errorf("EntityID: got %d, want %d", got.EntityID, orig.EntityID) + } + if got.Health != orig.Health { + t.Errorf("Health: got %d, want %d", got.Health, orig.Health) + } + if len(got.Tags) != len(orig.Tags) { + t.Fatalf("Tags len: got %d, want %d", len(got.Tags), len(orig.Tags)) + } + for i, tag := range orig.Tags { + if got.Tags[i] != tag { + t.Errorf("Tags[%d]: got %q, want %q", i, got.Tags[i], tag) + } + } + if len(got.Data) != len(orig.Data) { + t.Fatalf("Data len: got %d, want %d", len(got.Data), len(orig.Data)) + } +} + +func TestRoundTrip_MoveMessage(t *testing.T) { + orig := MoveMessage{ + Position: Vector3{X: 100, Y: -50, Z: 0}, + Velocity: [3]float32{1.5, -2.5, 0}, + Waypoints: []Vector3{{X: 10, Y: 20, Z: 0}, {X: -10, Y: 0, Z: 100}}, + PlayerID: 999, + Active: true, + Visible: false, + Ghost: true, + Name: "Alice", + } + buf := orig.Marshal(nil) + var got MoveMessage + _, err := got.Unmarshal(buf) + if err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got.PlayerID != orig.PlayerID { + t.Errorf("PlayerID: got %d, want %d", got.PlayerID, orig.PlayerID) + } + if got.Name != orig.Name { + t.Errorf("Name: got %q, want %q", got.Name, orig.Name) + } + if got.Active != orig.Active { + t.Errorf("Active: got %v, want %v", got.Active, orig.Active) + } + if got.Visible != orig.Visible { + t.Errorf("Visible: got %v, want %v", got.Visible, orig.Visible) + } + if got.Ghost != orig.Ghost { + t.Errorf("Ghost: got %v, want %v", got.Ghost, orig.Ghost) + } + if len(got.Waypoints) != len(orig.Waypoints) { + t.Fatalf("Waypoints len: got %d, want %d", len(got.Waypoints), len(orig.Waypoints)) + } +} + +func TestRoundTrip_EnvelopeMessage(t *testing.T) { + orig := EnvelopeMessage{ + Code: OpcodeJoinRoom, + Counter: 7, + } + buf := orig.Marshal(nil) + var got EnvelopeMessage + _, err := got.Unmarshal(buf) + if err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got.Code != orig.Code { + t.Errorf("Code: got %v, want %v", got.Code, orig.Code) + } + if got.Counter != orig.Counter { + t.Errorf("Counter: got %d, want %d", got.Counter, orig.Counter) + } +} +` + if err := os.WriteFile(filepath.Join(dir, "roundtrip_test.go"), []byte(roundTrip), 0644); err != nil { + t.Fatal(err) + } + + // go.mod для temp пакета + goMod := "module messages\n\ngo 1.21\n" + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0644); err != nil { + t.Fatal(err) + } + + // Запускаем go test + cmd := exec.Command("go", "test", "./...") + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("go test failed:\n%s", out) + } + t.Logf("go test output:\n%s", out) +} + +func TestGenerateCSharp_Output(t *testing.T) { + schema, err := parser.ParseSchemaFile(samplePath) + if err != nil { + t.Fatalf("ParseSchemaFile: %v", err) + } + + src, err := GenerateCSharpSchema(schema, "Ragono.Messages") + if err != nil { + t.Fatalf("GenerateCSharpSchema: %v", err) + } + + code := string(src) + + // Проверяем структуру выходного кода + for _, name := range []string{"Vector3", "MoveMessage", "SpawnMessage", "EnvelopeMessage"} { + if !strings.Contains(code, "public unsafe struct "+name) { + t.Errorf("missing struct %s", name) + } + if !strings.Contains(code, "public int Serialize") { + t.Errorf("missing Serialize in %s", name) + } + if !strings.Contains(code, "public static int Deserialize") { + t.Errorf("missing Deserialize in %s", name) + } + } + + // Unsafe паттерны + if !strings.Contains(code, "*(ushort*)ptr") { + t.Error("missing unsafe ushort pointer cast") + } + if !strings.Contains(code, "byte* ptr = buffer") { + t.Error("missing byte* ptr pattern") + } + + // Нет BinaryWriter + if strings.Contains(code, "BinaryWriter") || strings.Contains(code, "BinaryReader") { + t.Error("should not contain BinaryWriter/BinaryReader") + } + if !strings.Contains(code, "public enum Opcode : ushort") { + t.Error("missing Opcode enum") + } + if !strings.Contains(code, "Authorize = 1") || !strings.Contains(code, "JoinRoom = 2") { + t.Error("missing enum values for Opcode") + } + if !strings.Contains(code, "public Opcode Code;") { + t.Error("EnvelopeMessage.Code should use generated enum type") + } + + t.Logf("Generated C# (%d bytes):\n%s", len(src), code) +} + +func TestBoolPacking_GoCode(t *testing.T) { + msgs, err := parser.ParseFile(samplePath) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + + src, err := GenerateGo(msgs, "messages") + if err != nil { + t.Fatalf("GenerateGo: %v", err) + } + code := string(src) + + // Должны быть битовые операции + if !strings.Contains(code, "_boolByte") { + t.Error("missing bool bit-packing variable (_boolByte)") + } + if !strings.Contains(code, "|= 1 <<") { + t.Error("missing bit OR operation for bool packing") + } + if !strings.Contains(code, "&(1<<") { + t.Error("missing bit AND operation for bool unpacking") + } + + // НЕ должно быть per-byte записи для bool-полей из MoveMessage + // (Active, Visible, Ghost упакованы в 1 байт) + if strings.Contains(code, "append(buf, 1)") { + t.Error("should not emit per-byte bool encoding for packed bools") + } +} + +func TestBoolPacking_WireSize(t *testing.T) { + src := `package p +type Flags struct { + A bool + B bool + C bool + D bool + E bool + F bool + G bool + H bool + I bool +} +` + msgs, err := parser.ParseSource(src) + if err != nil { + t.Fatalf("ParseSource: %v", err) + } + // 9 bool: первые 8 → 1 байт, последний → 1 байт = 2 байта + got := packedMinWireSize(msgs[0].Fields) + if got != 2 { + t.Errorf("packedMinWireSize(9 consecutive bools): got %d, want 2", got) + } + + src3 := `package p +type Flags3 struct { + A bool + B bool + C bool +} +` + msgs3, err := parser.ParseSource(src3) + if err != nil { + t.Fatalf("ParseSource: %v", err) + } + // 3 bool → 1 байт + got3 := packedMinWireSize(msgs3[0].Fields) + if got3 != 1 { + t.Errorf("packedMinWireSize(3 consecutive bools): got %d, want 1", got3) + } +} + +func TestBoolPacking_SegmentFields(t *testing.T) { + src := `package p +type Msg struct { + A bool + B bool + ID uint32 + C bool +} +` + msgs, err := parser.ParseSource(src) + if err != nil { + t.Fatalf("ParseSource: %v", err) + } + segs := segmentFields(msgs[0].Fields) + // Ожидаем: [bool-group(A,B)] [single(ID)] [bool-group(C)] + if len(segs) != 3 { + t.Fatalf("expected 3 segments, got %d", len(segs)) + } + if len(segs[0].bools) != 2 { + t.Errorf("seg[0]: expected 2 bools, got %d", len(segs[0].bools)) + } + if segs[1].single == nil || segs[1].single.Name != "ID" { + t.Errorf("seg[1]: expected single field ID") + } + if len(segs[2].bools) != 1 { + t.Errorf("seg[2]: expected 1 bool, got %d", len(segs[2].bools)) + } +} + +func TestBoolPacking_CSharpCode(t *testing.T) { + schema, err := parser.ParseSchemaFile(samplePath) + if err != nil { + t.Fatalf("ParseSchemaFile: %v", err) + } + + src, err := GenerateCSharpSchema(schema, "Ragono.Messages") + if err != nil { + t.Fatalf("GenerateCSharpSchema: %v", err) + } + code := string(src) + + if !strings.Contains(code, "_boolByte") { + t.Error("C#: missing bool bit-packing variable") + } + if !strings.Contains(code, "|= (byte)(1 <<") { + t.Error("C#: missing bit OR for bool packing") + } + if !strings.Contains(code, "& (1 <<") { + t.Error("C#: missing bit AND for bool unpacking") + } +} diff --git a/generator/go.go b/generator/go.go new file mode 100644 index 0000000..cf595fc --- /dev/null +++ b/generator/go.go @@ -0,0 +1,398 @@ +package generator + +import ( + "edmand46/arpack/parser" + "fmt" + "go/format" + "strings" +) + +// GenerateGo генерирует Go-код сериализации для списка сообщений. +// pkgName — имя пакета в котором будет сгенерированный файл. +func GenerateGo(messages []parser.Message, pkgName string) ([]byte, error) { + return GenerateGoSchema(parser.Schema{Messages: messages}, pkgName) +} + +// GenerateGoSchema генерирует Go-код сериализации для полной схемы. +func GenerateGoSchema(schema parser.Schema, pkgName string) ([]byte, error) { + messages := schema.Messages + var b strings.Builder + + b.WriteString("// Code generated by arpack. DO NOT EDIT.\n\n") + fmt.Fprintf(&b, "package %s\n\n", pkgName) + + b.WriteString("import (\n") + b.WriteString("\t\"encoding/binary\"\n") + b.WriteString("\t\"errors\"\n") + if needsMathImport(messages) { + b.WriteString("\t\"math\"\n") + } + b.WriteString(")\n\n") + + for _, msg := range messages { + if err := writeGoMessage(&b, msg); err != nil { + return nil, fmt.Errorf("message %s: %w", msg.Name, err) + } + } + + src := b.String() + formatted, err := format.Source([]byte(src)) + if err != nil { + return []byte(src), fmt.Errorf("go/format: %w\n\nSource:\n%s", err, src) + } + return formatted, nil +} + +func writeGoMessage(b *strings.Builder, msg parser.Message) error { + segs := segmentFields(msg.Fields) + + // Marshal + fmt.Fprintf(b, "func (m *%s) Marshal(buf []byte) []byte {\n", msg.Name) + for i, seg := range segs { + if seg.single != nil { + if err := writeGoMarshalField(b, "m", *seg.single, "\t"); err != nil { + return err + } + } else { + writeGoBoolGroupMarshal(b, "m", seg.bools, i, "\t") + } + } + b.WriteString("\treturn buf\n}\n\n") + + // Unmarshal — возвращает кол-во потреблённых байт + fmt.Fprintf(b, "func (m *%s) Unmarshal(data []byte) (int, error) {\n", msg.Name) + minSize := packedMinWireSize(msg.Fields) + fmt.Fprintf(b, "\tif len(data) < %d {\n", minSize) + fmt.Fprintf(b, "\t\treturn 0, errors.New(\"arpack: buffer too short for %s\")\n", msg.Name) + b.WriteString("\t}\n") + b.WriteString("\toffset := 0\n") + for i, seg := range segs { + if seg.single != nil { + if err := writeGoUnmarshalField(b, "m", *seg.single, "\t"); err != nil { + return err + } + } else { + writeGoBoolGroupUnmarshal(b, "m", seg.bools, i, "\t") + } + } + b.WriteString("\treturn offset, nil\n}\n\n") + + return nil +} + +func writeGoBoolGroupMarshal(b *strings.Builder, recv string, bools []parser.Field, groupIdx int, indent string) { + varName := fmt.Sprintf("_boolByte%d", groupIdx) + fmt.Fprintf(b, "%svar %s uint8\n", indent, varName) + for bit, f := range bools { + fmt.Fprintf(b, "%sif %s.%s { %s |= 1 << %d }\n", indent, recv, f.Name, varName, bit) + } + fmt.Fprintf(b, "%sbuf = append(buf, %s)\n", indent, varName) +} + +func writeGoBoolGroupUnmarshal(b *strings.Builder, recv string, bools []parser.Field, groupIdx int, indent string) { + varName := fmt.Sprintf("_boolByte%d", groupIdx) + fmt.Fprintf(b, "%sif len(data) < offset+1 { return 0, errors.New(\"arpack: buffer too short\") }\n", indent) + fmt.Fprintf(b, "%s%s := data[offset]; offset++\n", indent, varName) + for bit, f := range bools { + expr := fmt.Sprintf("%s&(1<<%d) != 0", varName, bit) + fmt.Fprintf(b, "%s%s.%s = %s\n", indent, recv, f.Name, goUnmarshalValueExpr(expr, f)) + } +} + +// --- Marshal --- + +func writeGoMarshalField(b *strings.Builder, recv string, f parser.Field, indent string) error { + access := recv + "." + f.Name + switch f.Kind { + case parser.KindPrimitive: + return writeGoMarshalPrimitive(b, access, f, indent) + case parser.KindNested: + fmt.Fprintf(b, "%sbuf = %s.Marshal(buf)\n", indent, access) + case parser.KindFixedArray: + fmt.Fprintf(b, "%sfor _i%s := 0; _i%s < %d; _i%s++ {\n", + indent, f.Name, f.Name, f.FixedLen, f.Name) + elemField := parser.Field{ + Name: f.Name + "[_i" + f.Name + "]", + Kind: f.Elem.Kind, + Primitive: f.Elem.Primitive, + NamedType: f.Elem.NamedType, + Quant: f.Elem.Quant, + TypeName: f.Elem.TypeName, + Elem: f.Elem.Elem, + FixedLen: f.Elem.FixedLen, + } + if err := writeGoMarshalField(b, recv, elemField, indent+"\t"); err != nil { + return err + } + fmt.Fprintf(b, "%s}\n", indent) + case parser.KindSlice: + fmt.Fprintf(b, "%sbuf = binary.LittleEndian.AppendUint16(buf, uint16(len(%s)))\n", indent, access) + fmt.Fprintf(b, "%sfor _i%s := range %s {\n", indent, f.Name, access) + elemField := parser.Field{ + Name: f.Name + "[_i" + f.Name + "]", + Kind: f.Elem.Kind, + Primitive: f.Elem.Primitive, + NamedType: f.Elem.NamedType, + Quant: f.Elem.Quant, + TypeName: f.Elem.TypeName, + Elem: f.Elem.Elem, + FixedLen: f.Elem.FixedLen, + } + if err := writeGoMarshalField(b, recv, elemField, indent+"\t"); err != nil { + return err + } + fmt.Fprintf(b, "%s}\n", indent) + } + return nil +} + +func writeGoMarshalPrimitive(b *strings.Builder, access string, f parser.Field, indent string) error { + if f.Quant != nil { + return writeGoMarshalQuant(b, access, f, indent) + } + valueExpr := goMarshalValueExpr(access, f) + switch f.Primitive { + case parser.KindFloat32: + fmt.Fprintf(b, "%sbuf = binary.LittleEndian.AppendUint32(buf, math.Float32bits(%s))\n", indent, valueExpr) + case parser.KindFloat64: + fmt.Fprintf(b, "%sbuf = binary.LittleEndian.AppendUint64(buf, math.Float64bits(%s))\n", indent, valueExpr) + case parser.KindInt8: + fmt.Fprintf(b, "%sbuf = append(buf, uint8(%s))\n", indent, valueExpr) + case parser.KindUint8: + fmt.Fprintf(b, "%sbuf = append(buf, %s)\n", indent, valueExpr) + case parser.KindBool: + fmt.Fprintf(b, "%sif %s { buf = append(buf, 1) } else { buf = append(buf, 0) }\n", indent, valueExpr) + case parser.KindInt16: + fmt.Fprintf(b, "%sbuf = binary.LittleEndian.AppendUint16(buf, uint16(%s))\n", indent, valueExpr) + case parser.KindUint16: + fmt.Fprintf(b, "%sbuf = binary.LittleEndian.AppendUint16(buf, %s)\n", indent, valueExpr) + case parser.KindInt32: + fmt.Fprintf(b, "%sbuf = binary.LittleEndian.AppendUint32(buf, uint32(%s))\n", indent, valueExpr) + case parser.KindUint32: + fmt.Fprintf(b, "%sbuf = binary.LittleEndian.AppendUint32(buf, %s)\n", indent, valueExpr) + case parser.KindInt64: + fmt.Fprintf(b, "%sbuf = binary.LittleEndian.AppendUint64(buf, uint64(%s))\n", indent, valueExpr) + case parser.KindUint64: + fmt.Fprintf(b, "%sbuf = binary.LittleEndian.AppendUint64(buf, %s)\n", indent, valueExpr) + case parser.KindString: + fmt.Fprintf(b, "%sbuf = binary.LittleEndian.AppendUint16(buf, uint16(len(%s)))\n", indent, valueExpr) + fmt.Fprintf(b, "%sbuf = append(buf, %s...)\n", indent, valueExpr) + } + return nil +} + +func writeGoMarshalQuant(b *strings.Builder, access string, f parser.Field, indent string) error { + q := f.Quant + varName := "_q" + sanitizeVarName(access) + valueExpr := goMarshalValueExpr(access, f) + if q.Bits == 8 { + fmt.Fprintf(b, "%s%s := uint8((%s - (%g)) / (%g - (%g)) * %g)\n", + indent, varName, valueExpr, q.Min, q.Max, q.Min, q.MaxUint()) + fmt.Fprintf(b, "%sbuf = append(buf, %s)\n", indent, varName) + } else { + fmt.Fprintf(b, "%s%s := uint16((%s - (%g)) / (%g - (%g)) * %g)\n", + indent, varName, valueExpr, q.Min, q.Max, q.Min, q.MaxUint()) + fmt.Fprintf(b, "%sbuf = binary.LittleEndian.AppendUint16(buf, %s)\n", indent, varName) + } + return nil +} + +// --- Unmarshal --- + +func writeGoUnmarshalField(b *strings.Builder, recv string, f parser.Field, indent string) error { + access := recv + "." + f.Name + switch f.Kind { + case parser.KindPrimitive: + return writeGoUnmarshalPrimitive(b, access, f, indent) + + case parser.KindNested: + nVar := "_n" + sanitizeVarName(f.Name) + fmt.Fprintf(b, "%s%s, _err := %s.Unmarshal(data[offset:])\n", indent, nVar, access) + fmt.Fprintf(b, "%sif _err != nil { return 0, _err }\n", indent) + fmt.Fprintf(b, "%soffset += %s\n", indent, nVar) + + case parser.KindFixedArray: + iVar := "_i" + f.Name + fmt.Fprintf(b, "%sfor %s := 0; %s < %d; %s++ {\n", indent, iVar, iVar, f.FixedLen, iVar) + elemField := parser.Field{ + Name: f.Name + "[" + iVar + "]", + Kind: f.Elem.Kind, + Primitive: f.Elem.Primitive, + NamedType: f.Elem.NamedType, + Quant: f.Elem.Quant, + TypeName: f.Elem.TypeName, + Elem: f.Elem.Elem, + FixedLen: f.Elem.FixedLen, + } + if err := writeGoUnmarshalField(b, recv, elemField, indent+"\t"); err != nil { + return err + } + fmt.Fprintf(b, "%s}\n", indent) + + case parser.KindSlice: + lenVar := "_len" + sanitizeVarName(f.Name) + fmt.Fprintf(b, "%sif len(data) < offset+2 { return 0, errors.New(\"arpack: buffer too short\") }\n", indent) + fmt.Fprintf(b, "%s%s := int(binary.LittleEndian.Uint16(data[offset:]))\n", indent, lenVar) + fmt.Fprintf(b, "%soffset += 2\n", indent) + fmt.Fprintf(b, "%s%s = make(%s, %s)\n", indent, access, f.GoTypeName(), lenVar) + iVar := "_i" + sanitizeVarName(f.Name) + fmt.Fprintf(b, "%sfor %s := 0; %s < %s; %s++ {\n", indent, iVar, iVar, lenVar, iVar) + elemField := parser.Field{ + Name: f.Name + "[" + iVar + "]", + Kind: f.Elem.Kind, + Primitive: f.Elem.Primitive, + NamedType: f.Elem.NamedType, + Quant: f.Elem.Quant, + TypeName: f.Elem.TypeName, + Elem: f.Elem.Elem, + FixedLen: f.Elem.FixedLen, + } + if err := writeGoUnmarshalField(b, recv, elemField, indent+"\t"); err != nil { + return err + } + fmt.Fprintf(b, "%s}\n", indent) + } + return nil +} + +func writeGoUnmarshalPrimitive(b *strings.Builder, access string, f parser.Field, indent string) error { + if f.Quant != nil { + return writeGoUnmarshalQuant(b, access, f, indent) + } + switch f.Primitive { + case parser.KindFloat32: + fmt.Fprintf(b, "%sif len(data) < offset+4 { return 0, errors.New(\"arpack: buffer too short\") }\n", indent) + fmt.Fprintf(b, "%s%s = %s\n", indent, access, goUnmarshalValueExpr("math.Float32frombits(binary.LittleEndian.Uint32(data[offset:]))", f)) + fmt.Fprintf(b, "%soffset += 4\n", indent) + case parser.KindFloat64: + fmt.Fprintf(b, "%sif len(data) < offset+8 { return 0, errors.New(\"arpack: buffer too short\") }\n", indent) + fmt.Fprintf(b, "%s%s = %s\n", indent, access, goUnmarshalValueExpr("math.Float64frombits(binary.LittleEndian.Uint64(data[offset:]))", f)) + fmt.Fprintf(b, "%soffset += 8\n", indent) + case parser.KindInt8: + fmt.Fprintf(b, "%sif len(data) < offset+1 { return 0, errors.New(\"arpack: buffer too short\") }\n", indent) + fmt.Fprintf(b, "%s%s = %s\n", indent, access, goUnmarshalValueExpr("int8(data[offset])", f)) + fmt.Fprintf(b, "%soffset += 1\n", indent) + case parser.KindUint8: + fmt.Fprintf(b, "%sif len(data) < offset+1 { return 0, errors.New(\"arpack: buffer too short\") }\n", indent) + fmt.Fprintf(b, "%s%s = %s\n", indent, access, goUnmarshalValueExpr("data[offset]", f)) + fmt.Fprintf(b, "%soffset += 1\n", indent) + case parser.KindBool: + fmt.Fprintf(b, "%sif len(data) < offset+1 { return 0, errors.New(\"arpack: buffer too short\") }\n", indent) + fmt.Fprintf(b, "%s%s = %s\n", indent, access, goUnmarshalValueExpr("data[offset] != 0", f)) + fmt.Fprintf(b, "%soffset += 1\n", indent) + case parser.KindInt16: + fmt.Fprintf(b, "%sif len(data) < offset+2 { return 0, errors.New(\"arpack: buffer too short\") }\n", indent) + fmt.Fprintf(b, "%s%s = %s\n", indent, access, goUnmarshalValueExpr("int16(binary.LittleEndian.Uint16(data[offset:]))", f)) + fmt.Fprintf(b, "%soffset += 2\n", indent) + case parser.KindUint16: + fmt.Fprintf(b, "%sif len(data) < offset+2 { return 0, errors.New(\"arpack: buffer too short\") }\n", indent) + fmt.Fprintf(b, "%s%s = %s\n", indent, access, goUnmarshalValueExpr("binary.LittleEndian.Uint16(data[offset:])", f)) + fmt.Fprintf(b, "%soffset += 2\n", indent) + case parser.KindInt32: + fmt.Fprintf(b, "%sif len(data) < offset+4 { return 0, errors.New(\"arpack: buffer too short\") }\n", indent) + fmt.Fprintf(b, "%s%s = %s\n", indent, access, goUnmarshalValueExpr("int32(binary.LittleEndian.Uint32(data[offset:]))", f)) + fmt.Fprintf(b, "%soffset += 4\n", indent) + case parser.KindUint32: + fmt.Fprintf(b, "%sif len(data) < offset+4 { return 0, errors.New(\"arpack: buffer too short\") }\n", indent) + fmt.Fprintf(b, "%s%s = %s\n", indent, access, goUnmarshalValueExpr("binary.LittleEndian.Uint32(data[offset:])", f)) + fmt.Fprintf(b, "%soffset += 4\n", indent) + case parser.KindInt64: + fmt.Fprintf(b, "%sif len(data) < offset+8 { return 0, errors.New(\"arpack: buffer too short\") }\n", indent) + fmt.Fprintf(b, "%s%s = %s\n", indent, access, goUnmarshalValueExpr("int64(binary.LittleEndian.Uint64(data[offset:]))", f)) + fmt.Fprintf(b, "%soffset += 8\n", indent) + case parser.KindUint64: + fmt.Fprintf(b, "%sif len(data) < offset+8 { return 0, errors.New(\"arpack: buffer too short\") }\n", indent) + fmt.Fprintf(b, "%s%s = %s\n", indent, access, goUnmarshalValueExpr("binary.LittleEndian.Uint64(data[offset:])", f)) + fmt.Fprintf(b, "%soffset += 8\n", indent) + case parser.KindString: + lenVar := "_slen" + sanitizeVarName(access) + fmt.Fprintf(b, "%sif len(data) < offset+2 { return 0, errors.New(\"arpack: buffer too short\") }\n", indent) + fmt.Fprintf(b, "%s%s := int(binary.LittleEndian.Uint16(data[offset:]))\n", indent, lenVar) + fmt.Fprintf(b, "%soffset += 2\n", indent) + fmt.Fprintf(b, "%sif len(data) < offset+%s { return 0, errors.New(\"arpack: buffer too short\") }\n", indent, lenVar) + fmt.Fprintf(b, "%s%s = %s\n", indent, access, goUnmarshalValueExpr("string(data[offset : offset+"+lenVar+"])", + f)) + fmt.Fprintf(b, "%soffset += %s\n", indent, lenVar) + } + return nil +} + +func writeGoUnmarshalQuant(b *strings.Builder, access string, f parser.Field, indent string) error { + q := f.Quant + varName := "_q" + sanitizeVarName(access) + maxUint := q.MaxUint() + if q.Bits == 8 { + fmt.Fprintf(b, "%sif len(data) < offset+1 { return 0, errors.New(\"arpack: buffer too short\") }\n", indent) + fmt.Fprintf(b, "%s%s := data[offset]\n", indent, varName) + fmt.Fprintf(b, "%soffset += 1\n", indent) + if f.Primitive == parser.KindFloat32 { + expr := fmt.Sprintf("float32(%s) / %g * (%g - (%g)) + (%g)", varName, maxUint, q.Max, q.Min, q.Min) + fmt.Fprintf(b, "%s%s = %s\n", indent, access, goUnmarshalValueExpr(expr, f)) + } else { + expr := fmt.Sprintf("float64(%s) / %g * (%g - (%g)) + (%g)", varName, maxUint, q.Max, q.Min, q.Min) + fmt.Fprintf(b, "%s%s = %s\n", indent, access, goUnmarshalValueExpr(expr, f)) + } + } else { + fmt.Fprintf(b, "%sif len(data) < offset+2 { return 0, errors.New(\"arpack: buffer too short\") }\n", indent) + fmt.Fprintf(b, "%s%s := binary.LittleEndian.Uint16(data[offset:])\n", indent, varName) + fmt.Fprintf(b, "%soffset += 2\n", indent) + if f.Primitive == parser.KindFloat32 { + expr := fmt.Sprintf("float32(%s) / %g * (%g - (%g)) + (%g)", varName, maxUint, q.Max, q.Min, q.Min) + fmt.Fprintf(b, "%s%s = %s\n", indent, access, goUnmarshalValueExpr(expr, f)) + } else { + expr := fmt.Sprintf("float64(%s) / %g * (%g - (%g)) + (%g)", varName, maxUint, q.Max, q.Min, q.Min) + fmt.Fprintf(b, "%s%s = %s\n", indent, access, goUnmarshalValueExpr(expr, f)) + } + } + return nil +} + +func goMarshalValueExpr(access string, f parser.Field) string { + if f.NamedType == "" { + return access + } + return f.GoPrimitiveTypeName() + "(" + access + ")" +} + +func goUnmarshalValueExpr(expr string, f parser.Field) string { + if f.NamedType == "" { + return expr + } + return f.NamedType + "(" + expr + ")" +} + +// sanitizeVarName превращает "m.Pos[_i]" в "_mPos_i". +func sanitizeVarName(s string) string { + var b strings.Builder + for _, c := range s { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') { + b.WriteRune(c) + } else { + b.WriteRune('_') + } + } + return b.String() +} + +func needsMathImport(messages []parser.Message) bool { + for _, msg := range messages { + for _, f := range msg.Fields { + if needsMathField(f) { + return true + } + } + } + return false +} + +func needsMathField(f parser.Field) bool { + switch f.Kind { + case parser.KindPrimitive: + return f.Quant == nil && (f.Primitive == parser.KindFloat32 || f.Primitive == parser.KindFloat64) + case parser.KindFixedArray, parser.KindSlice: + if f.Elem != nil { + return needsMathField(*f.Elem) + } + } + return false +} diff --git a/generator/segment.go b/generator/segment.go new file mode 100644 index 0000000..0095e6d --- /dev/null +++ b/generator/segment.go @@ -0,0 +1,58 @@ +package generator + +import "edmand46/arpack/parser" + +// segment — либо группа bool (1–8 полей → 1 байт), либо одиночное поле. +type segment struct { + bools []parser.Field // non-empty: bool-группа + single *parser.Field // non-nil: любое не-bool поле +} + +// isBoolField возвращает true если поле — нативный bool (не массив, не слайс). +func isBoolField(f parser.Field) bool { + return f.Kind == parser.KindPrimitive && f.Primitive == parser.KindBool +} + +// segmentFields разбивает поля структуры на сегменты. +// Последовательные bool-поля группируются по 8 в один сегмент. +func segmentFields(fields []parser.Field) []segment { + var segs []segment + i := 0 + for i < len(fields) { + if !isBoolField(fields[i]) { + f := fields[i] + segs = append(segs, segment{single: &f}) + i++ + continue + } + // Собираем последовательные bool-поля группами по 8 + for i < len(fields) && isBoolField(fields[i]) { + var group []parser.Field + for i < len(fields) && isBoolField(fields[i]) && len(group) < 8 { + group = append(group, fields[i]) + i++ + } + segs = append(segs, segment{bools: group}) + } + } + return segs +} + +// packedMinWireSize вычисляет минимальный размер буфера с учётом упаковки bool. +func packedMinWireSize(fields []parser.Field) int { + total := 0 + for _, seg := range segmentFields(fields) { + if seg.single != nil { + s := seg.single.WireSize() + if s == -1 { + total += 2 + } else { + total += s + } + } else { + // Группа bool → 1 байт + total += 1 + } + } + return total +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6b25261 --- /dev/null +++ b/go.mod @@ -0,0 +1 @@ +module edmand46/arpack diff --git a/images/logo.png b/images/logo.png new file mode 100644 index 0000000..0b5ee47 Binary files /dev/null and b/images/logo.png differ diff --git a/parser/parser.go b/parser/parser.go new file mode 100644 index 0000000..ee258fd --- /dev/null +++ b/parser/parser.go @@ -0,0 +1,414 @@ +package parser + +import ( + "fmt" + "go/ast" + "go/importer" + goparser "go/parser" + "go/token" + "go/types" + "reflect" + "strconv" + "strings" +) + +// ParseFile парсит Go-файл и возвращает список сообщений. +func ParseFile(path string) ([]Message, error) { + schema, err := ParseSchemaFile(path) + if err != nil { + return nil, err + } + return schema.Messages, nil +} + +// ParseSource парсит исходный код из строки (удобно для тестов). +func ParseSource(src string) ([]Message, error) { + schema, err := ParseSchemaSource(src) + if err != nil { + return nil, err + } + return schema.Messages, nil +} + +// ParseSchemaFile парсит файл и возвращает полную схему: сообщения и enum-ы. +func ParseSchemaFile(path string) (Schema, error) { + fset := token.NewFileSet() + + f, err := goparser.ParseFile(fset, path, nil, 0) + if err != nil { + return Schema{}, fmt.Errorf("parse %s: %w", path, err) + } + + return parseASTFile(fset, f) +} + +// ParseSchemaSource парсит исходник и возвращает полную схему. +func ParseSchemaSource(src string) (Schema, error) { + fset := token.NewFileSet() + + f, err := goparser.ParseFile(fset, "source.go", src, 0) + if err != nil { + return Schema{}, fmt.Errorf("parse source: %w", err) + } + + return parseASTFile(fset, f) +} + +func parseASTFile(fset *token.FileSet, f *ast.File) (Schema, error) { + pkgName := f.Name.Name + + knownStructs := map[string]bool{} + namedPrimitives := map[string]PrimitiveKind{} + var enumOrder []string + + for _, decl := range f.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + + switch t := typeSpec.Type.(type) { + case *ast.StructType: + knownStructs[typeSpec.Name.Name] = true + case *ast.Ident: + primKind, isPrimitive := goPrimitiveKind(t.Name) + if !isPrimitive { + continue + } + namedPrimitives[typeSpec.Name.Name] = primKind + if IsIntegralPrimitive(primKind) { + enumOrder = append(enumOrder, typeSpec.Name.Name) + } + } + } + } + + info, err := typeCheckFile(fset, f) + if err != nil { + return Schema{}, err + } + + schema := Schema{PackageName: pkgName} + enumIndex := make(map[string]int, len(enumOrder)) + for _, name := range enumOrder { + enumIndex[name] = len(schema.Enums) + schema.Enums = append(schema.Enums, Enum{ + Name: name, + Primitive: namedPrimitives[name], + }) + } + + for _, decl := range f.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok { + continue + } + + switch genDecl.Tok { + case token.TYPE: + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + continue + } + + msg, err := parseStruct(pkgName, typeSpec.Name.Name, structType, knownStructs, namedPrimitives) + if err != nil { + return Schema{}, fmt.Errorf("struct %s: %w", typeSpec.Name.Name, err) + } + + schema.Messages = append(schema.Messages, msg) + } + case token.CONST: + if err := parseConstDecls(genDecl, info, enumIndex, &schema); err != nil { + return Schema{}, err + } + } + } + + return schema, nil +} + +func typeCheckFile(fset *token.FileSet, f *ast.File) (*types.Info, error) { + info := &types.Info{ + Defs: make(map[*ast.Ident]types.Object), + } + + cfg := &types.Config{ + Importer: importer.Default(), + } + + if _, err := cfg.Check(f.Name.Name, fset, []*ast.File{f}, info); err != nil { + return nil, fmt.Errorf("typecheck %s: %w", f.Name.Name, err) + } + + return info, nil +} + +func parseConstDecls(genDecl *ast.GenDecl, info *types.Info, enumIndex map[string]int, schema *Schema) error { + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + + for _, name := range valueSpec.Names { + obj, ok := info.Defs[name].(*types.Const) + if !ok { + continue + } + + named, ok := obj.Type().(*types.Named) + if !ok { + continue + } + + idx, ok := enumIndex[named.Obj().Name()] + if !ok { + continue + } + + schema.Enums[idx].Values = append(schema.Enums[idx].Values, EnumValue{ + Name: name.Name, + Value: obj.Val().String(), + }) + } + } + + return nil +} + +func parseStruct( + pkg string, + name string, + st *ast.StructType, + knownStructs map[string]bool, + namedPrimitives map[string]PrimitiveKind, +) (Message, error) { + msg := Message{PackageName: pkg, Name: name} + + for _, astField := range st.Fields.List { + if len(astField.Names) == 0 { + continue // embedded field, пропускаем + } + + var rawTag string + if astField.Tag != nil { + tag := reflect.StructTag(strings.Trim(astField.Tag.Value, "`")) + rawTag = tag.Get("pack") + } + + for _, fieldName := range astField.Names { + field, err := parseFieldType(fieldName.Name, astField.Type, rawTag, knownStructs, namedPrimitives) + if err != nil { + return Message{}, fmt.Errorf("field %s: %w", fieldName.Name, err) + } + + msg.Fields = append(msg.Fields, field) + } + } + return msg, nil +} + +func parseFieldType( + name string, + expr ast.Expr, + rawTag string, + knownStructs map[string]bool, + namedPrimitives map[string]PrimitiveKind, +) (Field, error) { + switch t := expr.(type) { + case *ast.Ident: + return parsePrimitiveOrNested(name, t.Name, rawTag, knownStructs, namedPrimitives) + + case *ast.ArrayType: + if t.Len == nil { + elem, err := parseFieldType("", t.Elt, rawTag, knownStructs, namedPrimitives) + if err != nil { + return Field{}, fmt.Errorf("slice element: %w", err) + } + + return Field{ + Name: name, + Kind: KindSlice, + Elem: &elem, + }, nil + } + + n, err := parseArrayLen(t.Len) + if err != nil { + return Field{}, fmt.Errorf("array length: %w", err) + } + + elem, err := parseFieldType("", t.Elt, rawTag, knownStructs, namedPrimitives) + if err != nil { + return Field{}, fmt.Errorf("array element: %w", err) + } + + return Field{ + Name: name, + Kind: KindFixedArray, + Elem: &elem, + FixedLen: n, + }, nil + + case *ast.StarExpr: + return Field{}, fmt.Errorf("pointer types not supported") + + case *ast.SelectorExpr: + return Field{}, fmt.Errorf("external package types not supported (use only types from the same file)") + } + + return Field{}, fmt.Errorf("unsupported type expression %T", expr) +} + +func parsePrimitiveOrNested( + name string, + typeName string, + rawTag string, + knownStructs map[string]bool, + namedPrimitives map[string]PrimitiveKind, +) (Field, error) { + primKind, isPrimitive := goPrimitiveKind(typeName) + if !isPrimitive { + if namedPrimitive, ok := namedPrimitives[typeName]; ok { + return buildPrimitiveField(name, typeName, namedPrimitive, rawTag) + } + + if !knownStructs[typeName] { + return Field{}, fmt.Errorf("unknown type %q (not a primitive and not defined in the same file)", typeName) + } + return Field{ + Name: name, + Kind: KindNested, + TypeName: typeName, + }, nil + } + + return buildPrimitiveField(name, "", primKind, rawTag) +} + +func buildPrimitiveField(name, namedType string, primKind PrimitiveKind, rawTag string) (Field, error) { + field := Field{ + Name: name, + Kind: KindPrimitive, + Primitive: primKind, + NamedType: namedType, + } + + if rawTag != "" { + if primKind != KindFloat32 && primKind != KindFloat64 { + typeLabel := field.GoTypeName() + return Field{}, fmt.Errorf("arpack tag can only be applied to float32/float64, got %s", typeLabel) + } + quant, err := parseQuantTag(rawTag) + if err != nil { + return Field{}, fmt.Errorf("arpack tag: %w", err) + } + field.Quant = quant + } + + return field, nil +} + +func parseQuantTag(tag string) (*QuantInfo, error) { + info := &QuantInfo{Bits: 16} + parts := strings.Split(tag, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + kv := strings.SplitN(p, "=", 2) + if len(kv) != 2 { + return nil, fmt.Errorf("invalid tag part %q (expected key=value)", p) + } + + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + + switch key { + case "min": + v, err := strconv.ParseFloat(val, 64) + if err != nil { + return nil, fmt.Errorf("min: %w", err) + } + + info.Min = v + case "max": + v, err := strconv.ParseFloat(val, 64) + if err != nil { + return nil, fmt.Errorf("max: %w", err) + } + + info.Max = v + case "bits": + v, err := strconv.Atoi(val) + if err != nil || (v != 8 && v != 16) { + return nil, fmt.Errorf("bits must be 8 or 16, got %q", val) + } + + info.Bits = v + default: + return nil, fmt.Errorf("unknown tag key %q", key) + } + } + + if info.Max <= info.Min { + return nil, fmt.Errorf("max (%.6g) must be greater than min (%.6g)", info.Max, info.Min) + } + + return info, nil +} + +func parseArrayLen(expr ast.Expr) (int, error) { + lit, ok := expr.(*ast.BasicLit) + if !ok { + return 0, fmt.Errorf("array length must be a literal integer constant") + } + + n, err := strconv.Atoi(lit.Value) + if err != nil || n <= 0 { + return 0, fmt.Errorf("array length must be a positive integer, got %q", lit.Value) + } + + return n, nil +} + +func goPrimitiveKind(name string) (PrimitiveKind, bool) { + switch name { + case "float32": + return KindFloat32, true + case "float64": + return KindFloat64, true + case "int8": + return KindInt8, true + case "int16": + return KindInt16, true + case "int32", "int": + return KindInt32, true + case "int64": + return KindInt64, true + case "uint8", "byte": + return KindUint8, true + case "uint16": + return KindUint16, true + case "uint32", "uint": + return KindUint32, true + case "uint64": + return KindUint64, true + case "bool": + return KindBool, true + case "string": + return KindString, true + } + return 0, false +} diff --git a/parser/parser_test.go b/parser/parser_test.go new file mode 100644 index 0000000..4e46e45 --- /dev/null +++ b/parser/parser_test.go @@ -0,0 +1,268 @@ +package parser + +import ( + "testing" +) + +const sampleSource = `package messages + +type Vector3 struct { + X float32 ` + "`" + `pack:"min=-500,max=500,bits=16"` + "`" + ` + Y float32 ` + "`" + `pack:"min=-500,max=500,bits=16"` + "`" + ` + Z float32 ` + "`" + `pack:"min=-500,max=500,bits=16"` + "`" + ` +} + +type MoveMessage struct { + Position Vector3 + Velocity [3]float32 + Waypoints []Vector3 + PlayerID uint32 + Name string + Active bool +} + +type SpawnMessage struct { + ID uint64 + Position Vector3 + Tags [4]string + Data []uint8 +} +` + +const enumSource = `package messages + +type Opcode uint16 + +const ( + OpcodeUnknown Opcode = iota + OpcodeAuthorize + OpcodeJoinRoom +) + +type EnvelopeMessage struct { + Code Opcode + Counter uint8 +} +` + +func TestParseSource_Primitives(t *testing.T) { + msgs, err := ParseSource(sampleSource) + if err != nil { + t.Fatalf("ParseSource: %v", err) + } + if len(msgs) != 3 { + t.Fatalf("expected 3 messages, got %d", len(msgs)) + } +} + +func TestParseSource_Vector3(t *testing.T) { + msgs, err := ParseSource(sampleSource) + if err != nil { + t.Fatalf("ParseSource: %v", err) + } + + v3 := msgs[0] + if v3.Name != "Vector3" { + t.Fatalf("expected Vector3, got %s", v3.Name) + } + if len(v3.Fields) != 3 { + t.Fatalf("expected 3 fields, got %d", len(v3.Fields)) + } + + for _, f := range v3.Fields { + if f.Kind != KindPrimitive { + t.Errorf("field %s: expected KindPrimitive, got %d", f.Name, f.Kind) + } + if f.Primitive != KindFloat32 { + t.Errorf("field %s: expected KindFloat32, got %d", f.Name, f.Primitive) + } + if f.Quant == nil { + t.Errorf("field %s: expected quant info, got nil", f.Name) + continue + } + if f.Quant.Min != -500 || f.Quant.Max != 500 || f.Quant.Bits != 16 { + t.Errorf("field %s: wrong quant info %+v", f.Name, f.Quant) + } + } +} + +func TestParseSource_MoveMessage(t *testing.T) { + msgs, err := ParseSource(sampleSource) + if err != nil { + t.Fatalf("ParseSource: %v", err) + } + + msg := msgs[1] + if msg.Name != "MoveMessage" { + t.Fatalf("expected MoveMessage, got %s", msg.Name) + } + + tests := []struct { + name string + kind FieldKind + typeName string + fixedLen int + elemKind FieldKind + }{ + {"Position", KindNested, "Vector3", 0, 0}, + {"Velocity", KindFixedArray, "", 3, KindPrimitive}, + {"Waypoints", KindSlice, "", 0, KindNested}, + {"PlayerID", KindPrimitive, "", 0, 0}, + {"Name", KindPrimitive, "", 0, 0}, + {"Active", KindPrimitive, "", 0, 0}, + } + + if len(msg.Fields) != len(tests) { + t.Fatalf("expected %d fields, got %d", len(tests), len(msg.Fields)) + } + + for i, tc := range tests { + f := msg.Fields[i] + if f.Name != tc.name { + t.Errorf("[%d] expected field %s, got %s", i, tc.name, f.Name) + } + if f.Kind != tc.kind { + t.Errorf("field %s: expected kind %d, got %d", tc.name, tc.kind, f.Kind) + } + if tc.typeName != "" && f.TypeName != tc.typeName { + t.Errorf("field %s: expected TypeName %s, got %s", tc.name, tc.typeName, f.TypeName) + } + if tc.fixedLen > 0 { + if f.FixedLen != tc.fixedLen { + t.Errorf("field %s: expected FixedLen %d, got %d", tc.name, tc.fixedLen, f.FixedLen) + } + if f.Elem == nil { + t.Errorf("field %s: Elem is nil", tc.name) + } else if f.Elem.Kind != tc.elemKind { + t.Errorf("field %s: Elem.Kind expected %d, got %d", tc.name, tc.elemKind, f.Elem.Kind) + } + } + if tc.kind == KindSlice { + if f.Elem == nil { + t.Errorf("field %s: Elem is nil for slice", tc.name) + } else if f.Elem.Kind != tc.elemKind { + t.Errorf("field %s: Elem.Kind expected %d, got %d", tc.name, tc.elemKind, f.Elem.Kind) + } + } + } +} + +func TestParseSource_SpawnMessage(t *testing.T) { + msgs, err := ParseSource(sampleSource) + if err != nil { + t.Fatalf("ParseSource: %v", err) + } + + msg := msgs[2] + if msg.Name != "SpawnMessage" { + t.Fatalf("expected SpawnMessage, got %s", msg.Name) + } + + // Tags: [4]string + tags := msg.Fields[2] + if tags.Kind != KindFixedArray || tags.FixedLen != 4 { + t.Errorf("Tags: expected KindFixedArray[4], got kind=%d fixedLen=%d", tags.Kind, tags.FixedLen) + } + if tags.Elem == nil || tags.Elem.Primitive != KindString { + t.Errorf("Tags: expected string element") + } + + // Data: []uint8 + data := msg.Fields[3] + if data.Kind != KindSlice { + t.Errorf("Data: expected KindSlice, got %d", data.Kind) + } + if data.Elem == nil || data.Elem.Primitive != KindUint8 { + t.Errorf("Data: expected uint8 element") + } +} + +func TestParseSchemaSource_Enums(t *testing.T) { + schema, err := ParseSchemaSource(enumSource) + if err != nil { + t.Fatalf("ParseSchemaSource: %v", err) + } + + if len(schema.Messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(schema.Messages)) + } + if len(schema.Enums) != 1 { + t.Fatalf("expected 1 enum, got %d", len(schema.Enums)) + } + + enum := schema.Enums[0] + if enum.Name != "Opcode" { + t.Fatalf("expected enum Opcode, got %s", enum.Name) + } + if enum.Primitive != KindUint16 { + t.Fatalf("expected Opcode base kind uint16, got %d", enum.Primitive) + } + if len(enum.Values) != 3 { + t.Fatalf("expected 3 enum values, got %d", len(enum.Values)) + } + if enum.Values[1].Name != "OpcodeAuthorize" || enum.Values[1].Value != "1" { + t.Fatalf("unexpected enum value %#v", enum.Values[1]) + } + + field := schema.Messages[0].Fields[0] + if field.Kind != KindPrimitive { + t.Fatalf("expected EnvelopeMessage.Code to be primitive, got %d", field.Kind) + } + if field.NamedType != "Opcode" { + t.Fatalf("expected named type Opcode, got %q", field.NamedType) + } + if field.Primitive != KindUint16 { + t.Fatalf("expected underlying uint16, got %d", field.Primitive) + } +} + +func TestQuantTag_Errors(t *testing.T) { + cases := []struct { + src string + wantErr bool + }{ + {`package p; type T struct { X float32 ` + "`" + `pack:"min=0,max=100"` + "`" + ` }`, false}, + {`package p; type T struct { X float32 ` + "`" + `pack:"min=100,max=0"` + "`" + ` }`, true}, // max < min + {`package p; type T struct { X float32 ` + "`" + `pack:"min=0,max=100,bits=32"` + "`" + ` }`, true}, // bad bits + {`package p; type T struct { X int32 ` + "`" + `pack:"min=0,max=100"` + "`" + ` }`, true}, // quant на int + {`package p; type T struct { X float32 ` + "`" + `pack:"foo=1"` + "`" + ` }`, true}, // unknown key + } + + for i, tc := range cases { + _, err := ParseSource(tc.src) + if tc.wantErr && err == nil { + t.Errorf("[%d] expected error, got nil", i) + } + if !tc.wantErr && err != nil { + t.Errorf("[%d] unexpected error: %v", i, err) + } + } +} + +func TestWireSize(t *testing.T) { + msgs, err := ParseSource(sampleSource) + if err != nil { + t.Fatalf("ParseSource: %v", err) + } + + v3 := msgs[0] + // Vector3: 3 × uint16 (квантизованные float32) = 6 байт + if v3.MinWireSize() != 6 { + t.Errorf("Vector3.MinWireSize: expected 6, got %d", v3.MinWireSize()) + } + if v3.HasVariableFields() { + t.Errorf("Vector3 should not have variable fields") + } +} + +func TestNestedUnknownType(t *testing.T) { + src := `package p +type Msg struct { + Pos UnknownType +} +` + _, err := ParseSource(src) + if err == nil { + t.Fatal("expected error for unknown nested type, got nil") + } +} diff --git a/parser/types.go b/parser/types.go new file mode 100644 index 0000000..d3b7e35 --- /dev/null +++ b/parser/types.go @@ -0,0 +1,288 @@ +package parser + +// PrimitiveKind — конкретный примитивный тип. +type PrimitiveKind int + +const ( + KindFloat32 PrimitiveKind = iota + KindFloat64 + KindInt8 + KindInt16 + KindInt32 + KindInt64 + KindUint8 + KindUint16 + KindUint32 + KindUint64 + KindBool + KindString +) + +// FieldKind — категория поля. +type FieldKind int + +const ( + KindPrimitive FieldKind = iota // float, int, uint, bool, string + KindNested // ссылка на другой Message + KindFixedArray // [N]T + KindSlice // []T +) + +// QuantInfo описывает квантизацию float → uint8/uint16. +type QuantInfo struct { + Min float64 + Max float64 + Bits int // 8 или 16, default 16 +} + +// MaxUint — максимальное целое значение для данного числа бит. +func (q *QuantInfo) MaxUint() float64 { + if q.Bits == 8 { + return 255 + } + return 65535 +} + +// WireBytes — размер на проводе в байтах. +func (q *QuantInfo) WireBytes() int { + return q.Bits / 8 +} + +// Field — одно поле структуры-сообщения. +type Field struct { + Name string + Kind FieldKind + + // KindPrimitive + Primitive PrimitiveKind + NamedType string + Quant *QuantInfo // nil если нет квантизации + + // KindNested + TypeName string + + // KindFixedArray / KindSlice + Elem *Field + FixedLen int // >0 только для KindFixedArray +} + +// WireSize — размер в байтах на проводе. +// Возвращает -1 для полей переменного размера. +func (f *Field) WireSize() int { + switch f.Kind { + case KindPrimitive: + if f.Quant != nil { + return f.Quant.WireBytes() + } + return primitiveWireSize(f.Primitive) + case KindNested: + return -1 // зависит от конкретного типа, узнаём через Message.MinWireSize + case KindFixedArray: + elemSize := f.Elem.WireSize() + if elemSize == -1 { + return -1 + } + return f.FixedLen * elemSize + case KindSlice: + return -1 // uint16 len + переменная часть + } + return 0 +} + +func primitiveWireSize(k PrimitiveKind) int { + switch k { + case KindFloat32, KindInt32, KindUint32: + return 4 + case KindFloat64, KindInt64, KindUint64: + return 8 + case KindInt16, KindUint16: + return 2 + case KindInt8, KindUint8, KindBool: + return 1 + case KindString: + return -1 + } + return 0 +} + +// IsIntegralPrimitive — подходит ли тип как базовый для enum. +func IsIntegralPrimitive(k PrimitiveKind) bool { + switch k { + case KindInt8, KindInt16, KindInt32, KindInt64, KindUint8, KindUint16, KindUint32, KindUint64: + return true + } + return false +} + +// GoTypeName — имя типа в Go. +func (f *Field) GoTypeName() string { + switch f.Kind { + case KindPrimitive: + if f.NamedType != "" { + return f.NamedType + } + return primitiveGoName(f.Primitive) + case KindNested: + return f.TypeName + case KindFixedArray: + return "[" + itoa(f.FixedLen) + "]" + f.Elem.GoTypeName() + case KindSlice: + return "[]" + f.Elem.GoTypeName() + } + return "unknown" +} + +// CSharpTypeName — имя типа в C#. +func (f *Field) CSharpTypeName() string { + switch f.Kind { + case KindPrimitive: + if f.NamedType != "" { + return f.NamedType + } + return primitiveCSharpName(f.Primitive) + case KindNested: + return f.TypeName + case KindFixedArray: + return f.Elem.CSharpTypeName() + "[]" + case KindSlice: + return f.Elem.CSharpTypeName() + "[]" + } + return "unknown" +} + +func primitiveGoName(k PrimitiveKind) string { + switch k { + case KindFloat32: + return "float32" + case KindFloat64: + return "float64" + case KindInt8: + return "int8" + case KindInt16: + return "int16" + case KindInt32: + return "int32" + case KindInt64: + return "int64" + case KindUint8: + return "uint8" + case KindUint16: + return "uint16" + case KindUint32: + return "uint32" + case KindUint64: + return "uint64" + case KindBool: + return "bool" + case KindString: + return "string" + } + return "unknown" +} + +// GoPrimitiveTypeName — базовый примитивный тип поля в Go. +func (f *Field) GoPrimitiveTypeName() string { + return primitiveGoName(f.Primitive) +} + +func primitiveCSharpName(k PrimitiveKind) string { + switch k { + case KindFloat32: + return "float" + case KindFloat64: + return "double" + case KindInt8: + return "sbyte" + case KindInt16: + return "short" + case KindInt32: + return "int" + case KindInt64: + return "long" + case KindUint8: + return "byte" + case KindUint16: + return "ushort" + case KindUint32: + return "uint" + case KindUint64: + return "ulong" + case KindBool: + return "bool" + case KindString: + return "string" + } + return "unknown" +} + +// CSharpPrimitiveTypeName — базовый примитивный тип поля в C#. +func (f *Field) CSharpPrimitiveTypeName() string { + return primitiveCSharpName(f.Primitive) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + buf := [20]byte{} + pos := len(buf) + for n > 0 { + pos-- + buf[pos] = byte('0' + n%10) + n /= 10 + } + return string(buf[pos:]) +} + +// Message — описание одной структуры-сообщения. +type Message struct { + PackageName string + Name string + Fields []Field +} + +// EnumValue — одно именованное значение enum. +type EnumValue struct { + Name string + Value string +} + +// Enum — enum-подобный тип на основе именованного примитива. +type Enum struct { + Name string + Primitive PrimitiveKind + Values []EnumValue +} + +// Schema — полная модель входного файла. +type Schema struct { + PackageName string + Messages []Message + Enums []Enum +} + +// MinWireSize — минимальный гарантированный размер в байтах. +// Для вложенных типов считается только если размер известен заранее. +// Строки и слайсы считаются как 2 байта (length prefix). +func (m *Message) MinWireSize() int { + total := 0 + for _, f := range m.Fields { + s := f.WireSize() + if s == -1 { + total += 2 // минимум: length prefix + } else { + total += s + } + } + return total +} + +// HasVariableFields — есть ли поля переменного размера. +func (m *Message) HasVariableFields() bool { + for _, f := range m.Fields { + if f.WireSize() == -1 { + return true + } + } + return false +} diff --git a/testdata/sample.go b/testdata/sample.go new file mode 100644 index 0000000..19ad0fc --- /dev/null +++ b/testdata/sample.go @@ -0,0 +1,43 @@ +package messages + +// Vector3 — трёхмерный вектор с квантизацией. +type Vector3 struct { + X float32 `pack:"min=-500,max=500,bits=16"` + Y float32 `pack:"min=-500,max=500,bits=16"` + Z float32 `pack:"min=-500,max=500,bits=16"` +} + +type Opcode uint16 + +const ( + OpcodeUnknown Opcode = iota + OpcodeAuthorize + OpcodeJoinRoom +) + +// MoveMessage содержит всё многообразие поддерживаемых типов. +type MoveMessage struct { + Position Vector3 // вложенный тип + Velocity [3]float32 // фиксированный массив без квантизации + Waypoints []Vector3 // слайс вложенных типов + PlayerID uint32 + // 3 подряд bool → упаковываются в 1 байт + Active bool + Visible bool + Ghost bool + Name string +} + +// SpawnMessage — пример с целочисленными полями и массивами примитивов. +type SpawnMessage struct { + EntityID uint64 + Position Vector3 + Health int16 + Tags []string + Data []uint8 +} + +type EnvelopeMessage struct { + Code Opcode + Counter uint8 +}