Skip to content

Commit

Permalink
feat(sqp): Add support for SQP metrics (#30)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
milonoir authored Oct 17, 2022
1 parent 8d1c41f commit 38dc672
Show file tree
Hide file tree
Showing 21 changed files with 318 additions and 152 deletions.
14 changes: 0 additions & 14 deletions lib/svrquery/clienttest/mock.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package clienttest

import (
"fmt"
"io/ioutil"
"path/filepath"
"testing"
Expand Down Expand Up @@ -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
}
3 changes: 3 additions & 0 deletions lib/svrquery/protocol/sqp/consts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
14 changes: 12 additions & 2 deletions lib/svrquery/protocol/sqp/enums.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,6 +28,7 @@ const (
Uint32
Uint64
String
Float32
)

// Request Types
Expand All @@ -40,4 +49,5 @@ const (
ServerRules
PlayerInfo
TeamInfo
Metrics
)
5 changes: 3 additions & 2 deletions lib/svrquery/protocol/sqp/enums_string.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions lib/svrquery/protocol/sqp/enums_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
103 changes: 45 additions & 58 deletions lib/svrquery/protocol/sqp/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
46 changes: 28 additions & 18 deletions lib/svrquery/protocol/sqp/query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}{
{
Expand All @@ -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,
Expand All @@ -60,6 +53,11 @@ func TestQuery(t *testing.T) {
chunks: TeamInfo,
f: testQueryTeamInfoSinglePacket,
},
{
name: "metrics",
chunks: Metrics,
f: testQueryMetricsSinglePacket,
},
}

buf := &bytes.Buffer{}
Expand All @@ -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)
})
Expand Down Expand Up @@ -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])
}
5 changes: 5 additions & 0 deletions lib/svrquery/protocol/sqp/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file added lib/svrquery/protocol/sqp/testdata/metrics_response
Binary file not shown.
13 changes: 13 additions & 0 deletions lib/svrquery/protocol/sqp/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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.
Expand Down
12 changes: 10 additions & 2 deletions lib/svrquery/protocol/sqp/value.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 38dc672

Please sign in to comment.