From e3ff49190cac9bd723d87fa56b65d7f85b72d262 Mon Sep 17 00:00:00 2001 From: mlange-42 Date: Thu, 13 Nov 2025 12:28:43 +0100 Subject: [PATCH 1/2] Binary entity (de)-serialization --- CHANGELOG.md | 6 ++++++ ecs/entity.go | 22 ++++++++++++++++++++++ ecs/entity_test.go | 20 ++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdfa84e3..72389c5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [[unpublished]](https://github.com/mlange-42/ark/compare/v0.6.4...v0.6.5) + +### Features + +- Provides binary serialization and de-serialization of entities for networking (#453) + ## [[v0.6.4]](https://github.com/mlange-42/ark/compare/v0.6.3...v0.6.4) ### Bugfixes diff --git a/ecs/entity.go b/ecs/entity.go index f7f30726..7b6e45ca 100644 --- a/ecs/entity.go +++ b/ecs/entity.go @@ -1,7 +1,9 @@ package ecs import ( + "encoding/binary" "encoding/json" + "fmt" "reflect" ) @@ -82,6 +84,26 @@ func (e *Entity) UnmarshalJSON(data []byte) error { return nil } +// MarshalBinary returns a binary representation of the entity, for serialization and networking purposes. +func (e *Entity) MarshalBinary() []byte { + buf := make([]byte, 8) + binary.BigEndian.PutUint32(buf[0:4], uint32(e.id)) + binary.BigEndian.PutUint32(buf[4:8], e.gen) + return buf +} + +// UnmarshalBinary into an entity. +// +// For serialization and networking purposes only. Do not use this to create entities! +func (e *Entity) UnmarshalBinary(data []byte) error { + if len(data) != 8 { + return fmt.Errorf("invalid data length: expected 8 bytes, got %d", len(data)) + } + e.id = entityID(binary.BigEndian.Uint32(data[0:4])) + e.gen = binary.BigEndian.Uint32(data[4:8]) + return nil +} + // entityIndex denotes an entity's location by table and row index. type entityIndex struct { table tableID diff --git a/ecs/entity_test.go b/ecs/entity_test.go index 2cdf8e99..0bdf300e 100644 --- a/ecs/entity_test.go +++ b/ecs/entity_test.go @@ -50,3 +50,23 @@ func TestEntityMarshal(t *testing.T) { err = e2.UnmarshalJSON([]byte("pft")) expectNotNil(t, err) } + +func TestEntityMarshalBinary(t *testing.T) { + e := Entity{2, 3} + + binData, err := json.Marshal(&e) + if err != nil { + t.Fatal(err) + } + + e2 := Entity{} + err = json.Unmarshal(binData, &e2) + if err != nil { + t.Fatal(err) + } + + expectEqual(t, e2, e) + + err = e2.UnmarshalJSON(make([]byte, 9)) + expectNotNil(t, err) +} From ce9e765bb2bf42be01bfaf9ce44e4755386e59ec Mon Sep 17 00:00:00 2001 From: mlange-42 Date: Thu, 13 Nov 2025 12:57:12 +0100 Subject: [PATCH 2/2] fix interface, implement appender, add benchmarks --- ecs/entity.go | 12 ++++- ecs/entity_test.go | 110 ++++++++++++++++++++++++++++++++++++++------- 2 files changed, 105 insertions(+), 17 deletions(-) diff --git a/ecs/entity.go b/ecs/entity.go index 7b6e45ca..011a99a6 100644 --- a/ecs/entity.go +++ b/ecs/entity.go @@ -85,11 +85,19 @@ func (e *Entity) UnmarshalJSON(data []byte) error { } // MarshalBinary returns a binary representation of the entity, for serialization and networking purposes. -func (e *Entity) MarshalBinary() []byte { +func (e *Entity) MarshalBinary() (data []byte, err error) { buf := make([]byte, 8) binary.BigEndian.PutUint32(buf[0:4], uint32(e.id)) binary.BigEndian.PutUint32(buf[4:8], e.gen) - return buf + return buf, nil +} + +// AppendBinary appends the binary representation of the entity to the given slice, +// for serialization and networking purposes. +func (e *Entity) AppendBinary(buf []byte) ([]byte, error) { + buf = binary.BigEndian.AppendUint32(buf, uint32(e.id)) + buf = binary.BigEndian.AppendUint32(buf, e.gen) + return buf, nil } // UnmarshalBinary into an entity. diff --git a/ecs/entity_test.go b/ecs/entity_test.go index 0bdf300e..35f7b5dc 100644 --- a/ecs/entity_test.go +++ b/ecs/entity_test.go @@ -1,7 +1,9 @@ package ecs import ( + "encoding" "encoding/json" + "runtime" "testing" ) @@ -34,16 +36,15 @@ func TestReservedEntities(t *testing.T) { func TestEntityMarshal(t *testing.T) { e := Entity{2, 3} + var _ json.Marshaler = &e + var _ json.Unmarshaler = &e + jsonData, err := json.Marshal(&e) - if err != nil { - t.Fatal(err) - } + expectNil(t, err) e2 := Entity{} err = json.Unmarshal(jsonData, &e2) - if err != nil { - t.Fatal(err) - } + expectNil(t, err) expectEqual(t, e2, e) @@ -54,19 +55,98 @@ func TestEntityMarshal(t *testing.T) { func TestEntityMarshalBinary(t *testing.T) { e := Entity{2, 3} - binData, err := json.Marshal(&e) - if err != nil { - t.Fatal(err) - } + var _ encoding.BinaryMarshaler = &e + var _ encoding.BinaryUnmarshaler = &e + var _ encoding.BinaryAppender = &e + + binData, err := e.MarshalBinary() + expectNil(t, err) e2 := Entity{} - err = json.Unmarshal(binData, &e2) - if err != nil { - t.Fatal(err) - } + err = e2.UnmarshalBinary(binData) + expectNil(t, err) + expectEqual(t, 8, len(binData)) expectEqual(t, e2, e) - err = e2.UnmarshalJSON(make([]byte, 9)) + err = e2.UnmarshalBinary(make([]byte, 9)) expectNotNil(t, err) + + e = Entity{4, 5} + binData, err = e.AppendBinary(binData) + expectNil(t, err) + expectEqual(t, 16, len(binData)) + + err = e2.UnmarshalBinary(binData[8:]) + expectNil(t, err) + expectEqual(t, e2, e) + + err = e2.UnmarshalBinary(binData[:8]) + expectNil(t, err) + expectEqual(t, e2, Entity{2, 3}) +} + +func BenchmarkEntityMarshalBinary_1000(b *testing.B) { + w := NewWorld() + + entities := make([]Entity, 0, 1000) + w.NewEntities(1000, func(e Entity) { + entities = append(entities, e) + }) + + var binData []byte + loop := func() { + for _, e := range entities { + binData, _ = e.MarshalBinary() + } + } + for b.Loop() { + loop() + } + + runtime.KeepAlive(binData) +} + +func BenchmarkEntityAppendBinary_1000(b *testing.B) { + w := NewWorld() + + entities := make([]Entity, 0, 1000) + w.NewEntities(1000, func(e Entity) { + entities = append(entities, e) + }) + + binData := make([]byte, 0, 8000) + loop := func() { + binData = binData[:0] + for _, e := range entities { + binData, _ = e.AppendBinary(binData) + } + } + for b.Loop() { + loop() + } + + runtime.KeepAlive(binData) +} + +func BenchmarkEntityUnmarshalBinary_1000(b *testing.B) { + w := NewWorld() + + entities := make([][]byte, 0, 1000) + w.NewEntities(1000, func(e Entity) { + binData, _ := e.MarshalBinary() + entities = append(entities, binData) + }) + + var entity Entity + loop := func() { + for _, e := range entities { + _ = entity.UnmarshalBinary(e) + } + } + for b.Loop() { + loop() + } + + runtime.KeepAlive(entity) }