From 38dc672d4b5705aa4ccc337aee01806f7561041b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mil=C3=A1n=20Boleradszki?= Date: Mon, 17 Oct 2022 09:52:50 +0100 Subject: [PATCH] feat(sqp): Add support for SQP metrics (#30) Deprecate multi-packet support as no known implementations use it. Unity SDKs don't support it either - Add new Float32 data type - Add new MetricsChunk type - Aupport metrics query in svrsample --- lib/svrquery/clienttest/mock.go | 14 -- lib/svrquery/protocol/sqp/consts.go | 3 + lib/svrquery/protocol/sqp/enums.go | 14 +- lib/svrquery/protocol/sqp/enums_string.go | 5 +- lib/svrquery/protocol/sqp/enums_test.go | 5 + lib/svrquery/protocol/sqp/query.go | 103 +++++------ lib/svrquery/protocol/sqp/query_test.go | 46 +++-- lib/svrquery/protocol/sqp/reader.go | 5 + .../protocol/sqp/testdata/info_multi_request | Bin 8 -> 0 bytes .../sqp/testdata/info_multi_response_000 | Bin 21 -> 0 bytes .../sqp/testdata/info_multi_response_001 | Bin 31 -> 0 bytes .../protocol/sqp/testdata/metrics_request | Bin 0 -> 8 bytes .../protocol/sqp/testdata/metrics_response | Bin 0 -> 40 bytes lib/svrquery/protocol/sqp/types.go | 13 ++ lib/svrquery/protocol/sqp/value.go | 12 +- lib/svrquery/protocol/sqp/value_test.go | 8 + lib/svrsample/common/interfaces.go | 1 + lib/svrsample/protocol/sqp/metrics.go | 29 ++++ lib/svrsample/protocol/sqp/server_info.go | 26 +-- lib/svrsample/protocol/sqp/sqp.go | 22 ++- lib/svrsample/protocol/sqp/sqp_test.go | 164 ++++++++++++++---- 21 files changed, 318 insertions(+), 152 deletions(-) delete mode 100644 lib/svrquery/protocol/sqp/testdata/info_multi_request delete mode 100644 lib/svrquery/protocol/sqp/testdata/info_multi_response_000 delete mode 100644 lib/svrquery/protocol/sqp/testdata/info_multi_response_001 create mode 100644 lib/svrquery/protocol/sqp/testdata/metrics_request create mode 100644 lib/svrquery/protocol/sqp/testdata/metrics_response create mode 100644 lib/svrsample/protocol/sqp/metrics.go diff --git a/lib/svrquery/clienttest/mock.go b/lib/svrquery/clienttest/mock.go index dce910f..35b7288 100644 --- a/lib/svrquery/clienttest/mock.go +++ b/lib/svrquery/clienttest/mock.go @@ -1,7 +1,6 @@ package clienttest import ( - "fmt" "io/ioutil" "path/filepath" "testing" @@ -51,16 +50,3 @@ func LoadData(t *testing.T, fileParts ...string) []byte { require.NoError(t, err) return d } - -func LoadMultiData(t *testing.T, files int, fileParts ...string) [][]byte { - pkts := make([][]byte, files) - file := fileParts[len(fileParts)-1] - for i := range pkts { - fileParts[len(fileParts)-1] = fmt.Sprintf("%s_%03d", file, i) - d, err := ioutil.ReadFile(filepath.Join(fileParts...)) - require.NoError(t, err) - pkts[i] = d - require.NoError(t, err) - } - return pkts -} diff --git a/lib/svrquery/protocol/sqp/consts.go b/lib/svrquery/protocol/sqp/consts.go index 92d2fbd..1397bd3 100644 --- a/lib/svrquery/protocol/sqp/consts.go +++ b/lib/svrquery/protocol/sqp/consts.go @@ -7,4 +7,7 @@ const ( // Version is the query protocol version this client uses. Version = uint16(1) + + // MaxMetrics is the maximum number of metrics supported in a request. + MaxMetrics = byte(25) ) diff --git a/lib/svrquery/protocol/sqp/enums.go b/lib/svrquery/protocol/sqp/enums.go index 9212ba4..e3f2723 100644 --- a/lib/svrquery/protocol/sqp/enums.go +++ b/lib/svrquery/protocol/sqp/enums.go @@ -7,10 +7,18 @@ type DataType byte // Size returns the DataTypes size in bytes, or -1 if unknown func (dt DataType) Size() int { - if dt > Uint64 { + switch dt { + case Byte: + return 1 + case Uint16: + return 2 + case Uint32, Float32: + return 4 + case Uint64: + return 8 + default: return -1 } - return 1 << dt } // Supported types for response fields @@ -20,6 +28,7 @@ const ( Uint32 Uint64 String + Float32 ) // Request Types @@ -40,4 +49,5 @@ const ( ServerRules PlayerInfo TeamInfo + Metrics ) diff --git a/lib/svrquery/protocol/sqp/enums_string.go b/lib/svrquery/protocol/sqp/enums_string.go index e230d23..c677d90 100644 --- a/lib/svrquery/protocol/sqp/enums_string.go +++ b/lib/svrquery/protocol/sqp/enums_string.go @@ -13,11 +13,12 @@ func _() { _ = x[Uint32-2] _ = x[Uint64-3] _ = x[String-4] + _ = x[Float32-5] } -const _DataType_name = "ByteUint16Uint32Uint64String" +const _DataType_name = "ByteUint16Uint32Uint64StringFloat32" -var _DataType_index = [...]uint8{0, 4, 10, 16, 22, 28} +var _DataType_index = [...]uint8{0, 4, 10, 16, 22, 28, 35} func (i DataType) String() string { if i >= DataType(len(_DataType_index)-1) { diff --git a/lib/svrquery/protocol/sqp/enums_test.go b/lib/svrquery/protocol/sqp/enums_test.go index 43827d3..9f30d98 100644 --- a/lib/svrquery/protocol/sqp/enums_test.go +++ b/lib/svrquery/protocol/sqp/enums_test.go @@ -33,6 +33,11 @@ func TestDataType_Size(t *testing.T) { dt: String, want: -1, }, + { + name: "Float32 size", + dt: Float32, + want: 4, + }, { name: "Unknown size", dt: DataType(99), diff --git a/lib/svrquery/protocol/sqp/query.go b/lib/svrquery/protocol/sqp/query.go index 0b457c9..a5b3146 100644 --- a/lib/svrquery/protocol/sqp/query.go +++ b/lib/svrquery/protocol/sqp/query.go @@ -85,18 +85,16 @@ func (q *queryer) readQueryHeader() (uint16, byte, byte, uint16, error) { } var curPkt, lastPkt byte - curPkt, err = q.reader.ReadByte() - if err != nil { + if curPkt, err = q.reader.ReadByte(); err != nil { return 0, 0, 0, 0, err } - lastPkt, err = q.reader.ReadByte() - if err != nil { + if lastPkt, err = q.reader.ReadByte(); err != nil { return 0, 0, 0, 0, err } - pktLen, err := q.reader.ReadUint16() - if err != nil { + var pktLen uint16 + if pktLen, err = q.reader.ReadUint16(); err != nil { return 0, 0, 0, 0, err } @@ -108,21 +106,18 @@ func (q *queryer) readQueryHeader() (uint16, byte, byte, uint16, error) { } func (q *queryer) readQuery(requestedChunks byte) (*QueryResponse, error) { - version, curPkt, lastPkt, pktLen, err := q.readQueryHeader() + // Multi-packet streams are not supported. + version, _, _, pktLen, err := q.readQueryHeader() if err != nil { return nil, err } - if lastPkt == 0 && curPkt == 0 { - // If the header says the body is empty, we should just return now - if pktLen == 0 { - return &QueryResponse{Version: version, Address: q.c.Address()}, nil - } - - return q.readQuerySinglePacket(q.reader, version, requestedChunks, uint32(pktLen)) + // If the header says the body is empty, we should just return now + if pktLen == 0 { + return &QueryResponse{Version: version, Address: q.c.Address()}, nil } - return q.readQueryMultiPacket(version, curPkt, lastPkt, requestedChunks, pktLen) + return q.readQuerySinglePacket(q.reader, version, requestedChunks, uint32(pktLen)) } func (q *queryer) readQuerySinglePacket(r *packetReader, version uint16, requestedChunks byte, pktLen uint32) (*QueryResponse, error) { @@ -157,6 +152,13 @@ func (q *queryer) readQuerySinglePacket(r *packetReader, version uint16, request l -= qr.TeamInfo.ChunkLength + uint32(Uint32.Size()) } + if requestedChunks&Metrics > 0 { + if err := q.readQueryMetrics(qr, r); err != nil { + return nil, err + } + l -= qr.Metrics.ChunkLength + uint32(Uint32.Size()) + } + if l > 0 { // If we have extra bytes remaining, we assume they are new fields from a future // query version and discard them. @@ -420,58 +422,43 @@ func (q *queryer) readQueryTeamInfo(qr *QueryResponse, r *packetReader) (err err return nil } -func (q *queryer) readQueryMultiPacket(version uint16, curPkt, lastPkt, requestedChunks byte, pktLen uint16) (*QueryResponse, error) { - // Setup our array of packet bodies - expectedPkts := lastPkt + 1 - multiPkt := make(map[byte][]byte, expectedPkts) - totalPktLen := uint32(pktLen) +func (q *queryer) readQueryMetrics(qr *QueryResponse, r *packetReader) (err error) { + qr.Metrics = &MetricsChunk{} - // Handle this first packet - multiPkt[curPkt] = make([]byte, pktLen) - n, err := q.reader.Read(multiPkt[curPkt]) - if err != nil { - return nil, err - } else if uint16(n) != pktLen { - return nil, NewErrMalformedPacketf("expected packet length of %v, but read %v bytes", pktLen, n) + if qr.Metrics.ChunkLength, err = r.ReadUint32(); err != nil { + return err } + l := int64(qr.Metrics.ChunkLength) - // Remember the challengeID so that we can verify each packet we are reading is - // part of this multi-packet response - challengeID := q.challengeID - - // Handle each subsequent packet until we have all of the ones we need - for len(multiPkt) != int(expectedPkts) { - version, curPkt, lastPkt, pktLen, err = q.readQueryHeader() - if err != nil { - return nil, err - } + qr.Metrics.MetricCount, err = r.ReadByte() + if err != nil { + return err + } + l -= int64(Byte.Size()) - // If this packet isn't part of the multi-packet response we are expecting, discard it - if q.challengeID != challengeID { - if _, err := io.CopyN(ioutil.Discard, q.reader, int64(pktLen)); err != nil { - return nil, err - } - continue - } + if qr.Metrics.MetricCount > MaxMetrics { + return NewErrMalformedPacketf("metric count cannot be greater than %v, but got %v", MaxMetrics, qr.Metrics.MetricCount) + } - totalPktLen += uint32(pktLen) - multiPkt[curPkt] = make([]byte, pktLen) - n, err := q.reader.Read(multiPkt[curPkt]) + qr.Metrics.Metrics = make([]float32, qr.Metrics.MetricCount) + for i := 0; i < int(qr.Metrics.MetricCount) && l > 0; i++ { + qr.Metrics.Metrics[i], err = r.ReadFloat32() if err != nil { - return nil, err - } else if uint16(n) != pktLen { - return nil, NewErrMalformedPacketf("expected packet length of %v, but read %v bytes", pktLen, n) + return err } + l -= int64(Float32.Size()) } - // Now recombine the packets into the right order. - // Sure, this could be more efficient by not using a map before - // and instead just inserting the packets into the correct place - // in a slice using splicing, but for now this is easier. - buf := &bytes.Buffer{} - for curPkt = 0; curPkt <= lastPkt; curPkt++ { - buf.Write(multiPkt[curPkt]) + if l < 0 { + // If we have read more bytes than expected, the packet is malformed + return NewErrMalformedPacketf("expected chunk length of %v, but have %v bytes remaining", qr.Metrics.ChunkLength, l) + } else if l > 0 { + // If we have extra bytes remaining, we assume they are new fields from a future + // query version and discard them + if _, err = io.CopyN(ioutil.Discard, r, l); err != nil { + return err + } } - return q.readQuerySinglePacket(newPacketReader(buf), version, requestedChunks, totalPktLen) + return nil } diff --git a/lib/svrquery/protocol/sqp/query_test.go b/lib/svrquery/protocol/sqp/query_test.go index 57b03ed..1cc5ef7 100644 --- a/lib/svrquery/protocol/sqp/query_test.go +++ b/lib/svrquery/protocol/sqp/query_test.go @@ -26,7 +26,6 @@ func TestQuery(t *testing.T) { cases := []struct { name string chunks byte - multi int f func(t *testing.T, challengeID uint32, c *queryer) }{ { @@ -39,12 +38,6 @@ func TestQuery(t *testing.T) { chunks: ServerInfo, f: testQueryServerInfoSinglePacketMalformed, }, - { - name: "info_multi", - chunks: ServerInfo, - multi: 2, - f: testQueryServerInfoMultiPacket, - }, { name: "rules", chunks: ServerRules, @@ -60,6 +53,11 @@ func TestQuery(t *testing.T) { chunks: TeamInfo, f: testQueryTeamInfoSinglePacket, }, + { + name: "metrics", + chunks: Metrics, + f: testQueryMetricsSinglePacket, + }, } buf := &bytes.Buffer{} @@ -82,17 +80,9 @@ func TestQuery(t *testing.T) { testSetChallenge(req, chalResp) m.On("Write", req).Return(len(req), nil).Once() - if tc.multi > 0 { - pkts := clienttest.LoadMultiData(t, tc.multi, testDir, tc.name+"_response") - for _, resp := range pkts { - testSetChallenge(resp, chalResp) - m.On("Read", mock.AnythingOfType("[]uint8")).Return(resp, nil).Once() - } - } else { - resp := clienttest.LoadData(t, testDir, tc.name+"_response") - testSetChallenge(resp, chalResp) - m.On("Read", mock.AnythingOfType("[]uint8")).Return(resp, nil).Once() - } + resp := clienttest.LoadData(t, testDir, tc.name+"_response") + testSetChallenge(resp, chalResp) + m.On("Read", mock.AnythingOfType("[]uint8")).Return(resp, nil).Once() tc.f(t, cid, c) }) @@ -218,3 +208,23 @@ func testQueryTeamInfoSinglePacket(t *testing.T, challengeID uint32, c *queryer) require.Equal(t, uint64(72057594037927938), qr.TeamInfo.Teams[1]["field4"].Uint64()) require.Equal(t, "STRING", qr.TeamInfo.Teams[1]["field5"].String()) } + +func testQueryMetricsSinglePacket(t *testing.T, challengeID uint32, c *queryer) { + r, err := c.Query() + require.NoError(t, err, "query request should not have failed") + qr := r.(*QueryResponse) + + require.Equal(t, challengeID, c.challengeID, "expected correct challenge id") + + require.NotNil(t, qr, "expected query response") + require.NotNil(t, qr.Metrics, "expected metrics") + + require.Equal(t, byte(6), qr.Metrics.MetricCount) + require.Len(t, qr.Metrics.Metrics, int(qr.Metrics.MetricCount)) + require.Equal(t, float32(1), qr.Metrics.Metrics[0]) + require.Equal(t, float32(0), qr.Metrics.Metrics[1]) + require.Equal(t, float32(3.14159), qr.Metrics.Metrics[2]) + require.Equal(t, float32(55.57), qr.Metrics.Metrics[3]) + require.Equal(t, float32(438.2522), qr.Metrics.Metrics[4]) + require.Equal(t, float32(-123.456), qr.Metrics.Metrics[5]) +} diff --git a/lib/svrquery/protocol/sqp/reader.go b/lib/svrquery/protocol/sqp/reader.go index 80c0312..ceb2be2 100644 --- a/lib/svrquery/protocol/sqp/reader.go +++ b/lib/svrquery/protocol/sqp/reader.go @@ -38,6 +38,11 @@ func (pr *packetReader) ReadByte() (v byte, err error) { return v, binary.Read(pr, binary.BigEndian, &v) } +// ReadFloat32 returns a float32 from the underlying reader +func (pr *packetReader) ReadFloat32() (v float32, err error) { + return v, binary.Read(pr, binary.BigEndian, &v) +} + // ReadString returns a string and the number of bytes representing it (len byte + len) from the underlying reader func (pr *packetReader) ReadString() (int64, string, error) { // Read the first byte as the length of the string diff --git a/lib/svrquery/protocol/sqp/testdata/info_multi_request b/lib/svrquery/protocol/sqp/testdata/info_multi_request deleted file mode 100644 index a6b29b21349729ad94f1a1749d0868ba55556883..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8 PcmZQ%U|?WmU}OXU02crS diff --git a/lib/svrquery/protocol/sqp/testdata/info_multi_response_000 b/lib/svrquery/protocol/sqp/testdata/info_multi_response_000 deleted file mode 100644 index 554c6cba2eb01bff74456c31746c971fa8d79a50..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21 acmZQ%U|<9yMn(p%k~Bs`=G?>r7DfOMVFF?R diff --git a/lib/svrquery/protocol/sqp/testdata/info_multi_response_001 b/lib/svrquery/protocol/sqp/testdata/info_multi_response_001 deleted file mode 100644 index 34574108d13af7a4908bc2591b7ad4fc1cbba3b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31 jcmZQ%U|<9xMg|cE1_mhxRt7H4+)9Pw)S|M~BIaZO9#RBO diff --git a/lib/svrquery/protocol/sqp/testdata/metrics_request b/lib/svrquery/protocol/sqp/testdata/metrics_request new file mode 100644 index 0000000000000000000000000000000000000000..3c7eb2168b18f87f867c0c8406e7bbc52afa5197 GIT binary patch literal 8 PcmZQ%U|?WmU=#oV044ww literal 0 HcmV?d00001 diff --git a/lib/svrquery/protocol/sqp/testdata/metrics_response b/lib/svrquery/protocol/sqp/testdata/metrics_response new file mode 100644 index 0000000000000000000000000000000000000000..e63b74fb20bd2eea49f1aa3d82e248256198081a GIT binary patch literal 40 qcmZQ%U|?VbLIws|AeLmaZvaw2;NZ!B!70vto%3x4k3-*HRssM{r3jn= literal 0 HcmV?d00001 diff --git a/lib/svrquery/protocol/sqp/types.go b/lib/svrquery/protocol/sqp/types.go index 1454bb3..adbd5af 100644 --- a/lib/svrquery/protocol/sqp/types.go +++ b/lib/svrquery/protocol/sqp/types.go @@ -49,6 +49,18 @@ func (tic *TeamInfoChunk) MarshalJSON() ([]byte, error) { return json.Marshal(tic.Teams) } +// MetricsChunk is the response chunk for metrics data +type MetricsChunk struct { + ChunkLength uint32 `json:"-"` + MetricCount byte `json:"-"` + Metrics []float32 +} + +// MarshalJSON returns the JSON representation of the metrics +func (mc *MetricsChunk) MarshalJSON() ([]byte, error) { + return json.Marshal(mc.Metrics) +} + // QueryResponse is the combined response to a query request type QueryResponse struct { Version uint16 `json:"version"` @@ -57,6 +69,7 @@ type QueryResponse struct { ServerRules *ServerRulesChunk `json:"server_rules,omitempty"` PlayerInfo *PlayerInfoChunk `json:"player_info,omitempty"` TeamInfo *TeamInfoChunk `json:"team_info,omitempty"` + Metrics *MetricsChunk `json:"metrics,omitempty"` } // MaxClients returns the maximum number of clients. diff --git a/lib/svrquery/protocol/sqp/value.go b/lib/svrquery/protocol/sqp/value.go index fef6bbc..fa874ff 100644 --- a/lib/svrquery/protocol/sqp/value.go +++ b/lib/svrquery/protocol/sqp/value.go @@ -41,7 +41,7 @@ func NewDynamicValueWithType(r *packetReader, dt DataType) (int64, *DynamicValue func dynamicValue(dv *DynamicValue, dt DataType, r *packetReader) (int64, error) { var err error - dv.Type = DataType(dt) + dv.Type = dt switch dv.Type { case Byte: dv.Value, err = r.ReadByte() @@ -59,6 +59,9 @@ func dynamicValue(dv *DynamicValue, dt DataType, r *packetReader) (int64, error) var count int64 count, dv.Value, err = r.ReadString() return count, err + case Float32: + dv.Value, err = r.ReadFloat32() + return int64(Float32.Size()), err } return 0, ErrUnknownDataType(dv.Type) @@ -89,10 +92,15 @@ func (dv *DynamicValue) String() string { return dv.Value.(string) } +// Float32 returns the value as a float32 +func (dv *DynamicValue) Float32() float32 { + return dv.Value.(float32) +} + // MarshalJSON returns the json marshalled version of the dynamic value func (dv *DynamicValue) MarshalJSON() ([]byte, error) { switch dv.Type { - case Byte, Uint16, Uint32, Uint64, String: + case Byte, Uint16, Uint32, Uint64, String, Float32: return json.Marshal(dv.Value) } return nil, ErrUnknownDataType(dv.Type) diff --git a/lib/svrquery/protocol/sqp/value_test.go b/lib/svrquery/protocol/sqp/value_test.go index b510f4a..30e9af6 100644 --- a/lib/svrquery/protocol/sqp/value_test.go +++ b/lib/svrquery/protocol/sqp/value_test.go @@ -56,6 +56,14 @@ func TestDynamicValue_MarshalJSON(t *testing.T) { }, want: []byte(`"a string"`), }, + { + name: "float32 value", + fields: fields{ + Type: Float32, + Value: float32(3.14159), + }, + want: []byte(`3.14159`), + }, { name: "unknown type value", fields: fields{ diff --git a/lib/svrsample/common/interfaces.go b/lib/svrsample/common/interfaces.go index 261dea2..ba1e6cd 100644 --- a/lib/svrsample/common/interfaces.go +++ b/lib/svrsample/common/interfaces.go @@ -14,4 +14,5 @@ type QueryState struct { GameType string Map string Port uint16 + Metrics []float32 } diff --git a/lib/svrsample/protocol/sqp/metrics.go b/lib/svrsample/protocol/sqp/metrics.go new file mode 100644 index 0000000..e16184e --- /dev/null +++ b/lib/svrsample/protocol/sqp/metrics.go @@ -0,0 +1,29 @@ +package sqp + +import ( + "github.com/multiplay/go-svrquery/lib/svrsample/common" +) + +// Metrics holds the Metrics chunk data. +type Metrics struct { + Count byte + Values []float32 +} + +// Size returns the number of bytes QueryResponder will use on the wire. +func (m Metrics) Size() uint32 { + return uint32(1 + len(m.Values)*4) +} + +// MetricsFromQueryState converts metrics data in common.QueryState to Metrics. +func MetricsFromQueryState(qs common.QueryState) *Metrics { + l := len(qs.Metrics) + m := &Metrics{ + Count: byte(l), + } + + m.Values = make([]float32, l) + copy(m.Values, qs.Metrics) + + return m +} diff --git a/lib/svrsample/protocol/sqp/server_info.go b/lib/svrsample/protocol/sqp/server_info.go index 6c1c512..fed15db 100644 --- a/lib/svrsample/protocol/sqp/server_info.go +++ b/lib/svrsample/protocol/sqp/server_info.go @@ -15,19 +15,7 @@ type ServerInfo struct { Port uint16 } -// QueryStateToServerInfo converts proto.QueryState to ServerInfo. -func QueryStateToServerInfo(qs common.QueryState) ServerInfo { - return ServerInfo{ - CurrentPlayers: uint16(qs.CurrentPlayers), - MaxPlayers: uint16(qs.MaxPlayers), - ServerName: qs.ServerName, - GameType: qs.GameType, - GameMap: qs.Map, - Port: qs.Port, - } -} - -// Size returns the number of bytes sqpServerInfo will use on the wire. +// Size returns the number of bytes QueryResponder will use on the wire. func (si ServerInfo) Size() uint32 { return uint32( 2 + // CurrentPlayers @@ -39,3 +27,15 @@ func (si ServerInfo) Size() uint32 { 2, // Port ) } + +// ServerInfoFromQueryState converts server info data in common.QueryState to ServerInfo. +func ServerInfoFromQueryState(qs common.QueryState) *ServerInfo { + return &ServerInfo{ + CurrentPlayers: uint16(qs.CurrentPlayers), + MaxPlayers: uint16(qs.MaxPlayers), + ServerName: qs.ServerName, + GameType: qs.GameType, + GameMap: qs.Map, + Port: qs.Port, + } +} diff --git a/lib/svrsample/protocol/sqp/sqp.go b/lib/svrsample/protocol/sqp/sqp.go index 15f0932..e30e8ab 100644 --- a/lib/svrsample/protocol/sqp/sqp.go +++ b/lib/svrsample/protocol/sqp/sqp.go @@ -32,8 +32,10 @@ type queryWireFormat struct { CurrentPacketNum byte LastPacketNum byte PayloadLength uint16 - ServerInfoLength uint32 - ServerInfo ServerInfo + ServerInfoLength *uint32 + ServerInfo *ServerInfo + MetricsLength *uint32 + Metrics *Metrics } // NewQueryResponder returns creates a new responder capable of responding @@ -112,6 +114,8 @@ func (q *QueryResponder) handleQuery(clientAddress string, buf []byte) ([]byte, requestedChunks := buf[7] wantsServerInfo := requestedChunks&0x1 == 1 + wantsMetrics := requestedChunks&0x10 == 16 + f := queryWireFormat{ Header: 1, Challenge: expectedChallenge.(uint32), @@ -121,9 +125,17 @@ func (q *QueryResponder) handleQuery(clientAddress string, buf []byte) ([]byte, resp := bytes.NewBuffer(nil) if wantsServerInfo { - f.ServerInfo = QueryStateToServerInfo(q.state) - f.ServerInfoLength = f.ServerInfo.Size() - f.PayloadLength += uint16(f.ServerInfoLength) + 4 + f.ServerInfo = ServerInfoFromQueryState(q.state) + size := f.ServerInfo.Size() + f.ServerInfoLength = &size + f.PayloadLength += uint16(*f.ServerInfoLength) + 4 + } + + if wantsMetrics { + f.Metrics = MetricsFromQueryState(q.state) + size := f.Metrics.Size() + f.MetricsLength = &size + f.PayloadLength += uint16(*f.MetricsLength) + 4 } if err := common.WireWrite(resp, q.enc, f); err != nil { diff --git a/lib/svrsample/protocol/sqp/sqp_test.go b/lib/svrsample/protocol/sqp/sqp_test.go index ae40ca1..b721fa8 100644 --- a/lib/svrsample/protocol/sqp/sqp_test.go +++ b/lib/svrsample/protocol/sqp/sqp_test.go @@ -2,54 +2,142 @@ package sqp import ( "bytes" + "encoding/binary" "testing" "github.com/multiplay/go-svrquery/lib/svrsample/common" "github.com/stretchr/testify/require" ) -func Test_Respond(t *testing.T) { - q, err := NewQueryResponder(common.QueryState{ +func TestSQPServer(t *testing.T) { + testcases := []struct { + name string + serverInfo bool + metrics bool + expPayload []byte + }{ + { + name: "empty", + expPayload: []byte{}, + }, + { + name: "server_info_only", + serverInfo: true, + expPayload: []byte{ + 0x0, 0x0, 0x0, 0xa, // chunk length + 0x0, 0x1, // current players + 0x0, 0x2, // max players + 0x0, // server name length + 0x0, // game type length + 0x0, // build ID length + 0x0, // map length + 0x0, 0x0, // port + }, + }, + { + name: "metrics_only", + metrics: true, + expPayload: []byte{ + 0x00, 0x00, 0x00, 0x19, // chunk length + 0x06, // metric count + 0x3f, 0x80, 0x00, 0x00, // metric 1 + 0x00, 0x00, 0x00, 0x00, // metric 2 + 0x40, 0x49, 0x0f, 0xd0, // metric 3 + 0x42, 0x5e, 0x47, 0xae, // metric 4 + 0x43, 0xdb, 0x20, 0x48, // metric 5 + 0xc2, 0xf6, 0xe9, 0x79, // metric 6 + }, + }, + { + name: "server_info_and_metrics", + serverInfo: true, + metrics: true, + expPayload: []byte{ + // server info + 0x0, 0x0, 0x0, 0xa, // chunk length + 0x0, 0x1, // current players + 0x0, 0x2, // max players + 0x0, // server name length + 0x0, // game type length + 0x0, // build ID length + 0x0, // map length + 0x0, 0x0, // port + + // metrics + 0x00, 0x00, 0x00, 0x19, // chunk length + 0x06, // metric count + 0x3f, 0x80, 0x00, 0x00, // metric 1 + 0x00, 0x00, 0x00, 0x00, // metric 2 + 0x40, 0x49, 0x0f, 0xd0, // metric 3 + 0x42, 0x5e, 0x47, 0xae, // metric 4 + 0x43, 0xdb, 0x20, 0x48, // metric 5 + 0xc2, 0xf6, 0xe9, 0x79, // metric 6 + }, + }, + } + + addr := "client-addr:65534" + state := common.QueryState{ CurrentPlayers: 1, MaxPlayers: 2, - }) + Metrics: []float32{1, 0, 3.14159, 55.57, 438.2522, -123.456}, + } + + q, err := NewQueryResponder(state) require.NoError(t, err) require.NotNil(t, q) - addr := "client-addr:65534" + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + // Challenge packet + resp, err := q.Respond(addr, []byte{0, 0, 0, 0, 0}) + require.NoError(t, err) + require.Equal(t, byte(0), resp[0]) - // Challenge packet - resp, err := q.Respond(addr, []byte{0, 0, 0, 0, 0}) - require.NoError(t, err) - require.Equal(t, byte(0), resp[0]) - - // Query packet - resp, err = q.Respond( - addr, - bytes.Join( - [][]byte{ - {1}, - resp[1:5], // challenge - {0, 1}, // SQP version - {1}, // Request chunks (server info only) - }, - nil, - ), - ) - require.NoError(t, err) - require.Equal( - t, - bytes.Join( - [][]byte{ - {1}, - resp[1:5], - resp[5:7], - {0}, - {0}, - {0x0, 0xe, 0x0, 0x0, 0x0, 0xa, 0x0, 0x1, 0x0, 0x2, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, - }, - nil, - ), - resp, - ) + // Requested chunks + var chunks byte + if tc.serverInfo { + chunks |= 0x1 + } + if tc.metrics { + chunks |= 0x10 + } + + // Query packet + resp, err = q.Respond( + addr, + bytes.Join( + [][]byte{ + {1}, // query request + resp[1:5], // challenge + {0, 1}, // SQP version + {chunks}, // Request chunks + }, + nil, + ), + ) + require.NoError(t, err) + + // convert packet length to []byte + pl := make([]byte, 2) + binary.BigEndian.PutUint16(pl, uint16(len(tc.expPayload))) + + require.Equal( + t, + bytes.Join( + [][]byte{ + {1}, // query response + resp[1:5], // challenge + resp[5:7], // SQP version + {0}, // current packet + {0}, // last packet + pl, // packet length + tc.expPayload, // payload (chunks data) + }, + nil, + ), + resp, + ) + }) + } }