diff --git a/.gitignore b/.gitignore index f1c181e..f4aba4c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out + +# OSX Junk +.DS_Store \ No newline at end of file diff --git a/lib/svrsample/protocol/tf2/encoder.go b/lib/svrsample/protocol/tf2/encoder.go new file mode 100644 index 0000000..9efd559 --- /dev/null +++ b/lib/svrsample/protocol/tf2/encoder.go @@ -0,0 +1,21 @@ +package tf2 + +import ( + "bytes" + "encoding/binary" +) + +type ( + // encoder is a struct which implements proto.WireEncoder + encoder struct{} +) + +// WriteString writes a string to the provided buffer. +func (e *encoder) WriteString(resp *bytes.Buffer, s string) error { + return binary.Write(resp, binary.LittleEndian, []byte(s+"\x00")) +} + +// Write writes arbitrary data to the provided buffer. +func (e *encoder) Write(resp *bytes.Buffer, v interface{}) error { + return binary.Write(resp, binary.LittleEndian, v) +} diff --git a/lib/svrsample/protocol/tf2/tf2.go b/lib/svrsample/protocol/tf2/tf2.go new file mode 100644 index 0000000..8a07620 --- /dev/null +++ b/lib/svrsample/protocol/tf2/tf2.go @@ -0,0 +1,171 @@ +package tf2 + +import ( + "bytes" + "runtime" + + "github.com/multiplay/go-svrquery/lib/svrsample/common" +) + +type ( + QueryResponder struct { + enc *encoder + extended bool + state common.QueryState + version int8 + } + + instanceInfo struct { + Retail byte + InstanceType byte + ClientDLLCRC uint32 + NetProtocol uint16 + RandomServerID uint64 + } + + instanceInfoV8 struct { + Retail byte + InstanceType byte + ClientDLLCRC uint32 + NetProtocol uint16 + HealthFlags uint32 + RandomServerID uint32 + } + + performanceInfo struct { + AverageFrameTime float32 + MaxFrameTime float32 + AverageUserCommandTime float32 + MaxUserCommandTime float32 + } + + matchState struct { + Phase byte + MaxRounds byte + MaxRoundsIMC byte + MaxRoundsMilitia byte + TimeLimitSeconds uint16 + TimePassedSeconds uint16 + MaxScore uint16 + Team byte + } + + queryWireFormat struct { + Header int32 + ResponseType byte + Version int8 + InstanceInfo *instanceInfo + InstanceInfoV8 *instanceInfoV8 + BuildName *string + Datacenter *string + GameMode *string + Port uint16 + Platform string + PlaylistVersion string + PlaylistNum uint32 + PlaylistName string + PlatformNum *byte + NumClients uint8 + MaxClients uint8 + Map string + PerformanceInfo *performanceInfo + TeamsLeftWithPlayersNum *uint16 + MatchState *matchState + EOP uint64 + } +) + +const ( + notApplicable = "n/a" +) + +// NewQueryResponder returns creates a new responder capable of responding +// to tf2-formatted queries. +func NewQueryResponder(state common.QueryState, version int8, extended bool) (common.QueryResponder, error) { + q := &QueryResponder{ + enc: &encoder{}, + extended: extended, + state: state, + version: version, + } + return q, nil +} + +// Respond writes a query response to the requester in the tf2 wire protocol. +func (q *QueryResponder) Respond(_ string, _ []byte) ([]byte, error) { + resp := bytes.NewBuffer(nil) + responseFlag := byte(80) + dc := "dc" + + if q.extended { + responseFlag = byte(78) + } + + f := queryWireFormat{ + Header: -1, + ResponseType: responseFlag, + Version: q.version, + Port: q.state.Port, + Platform: runtime.GOOS, + PlaylistVersion: notApplicable, + PlaylistName: notApplicable, + NumClients: uint8(q.state.CurrentPlayers), + MaxClients: uint8(q.state.MaxPlayers), + Map: q.state.Map, + } + + if q.version > 1 { + f.BuildName = &q.state.ServerName + f.Datacenter = &dc + f.GameMode = &q.state.GameType + } + + if q.version > 2 { + f.MatchState = &matchState{ + Team: 255, // Team information omitted + } + } + + // Performance Info + if q.version > 4 { + f.PerformanceInfo = &performanceInfo{ + AverageFrameTime: 1.2, + MaxFrameTime: 3.4, + AverageUserCommandTime: 5.6, + MaxUserCommandTime: 7.8, + } + } + + if q.version > 5 { + i := uint16(0) + f.TeamsLeftWithPlayersNum = &i + } + + if q.version > 6 { + i := byte(0) + f.PlatformNum = &i + } + + if q.version > 7 { + f.InstanceInfoV8 = &instanceInfoV8{ + HealthFlags: uint32( + 1<<0 | // Packet Loss In + 1<<1 | // Packet Loss Out + 1<<2 | // Packet Choked In + 1<<3 | // Packet Choked Out + 1<<4 | // Slow Server Frames + 1<<5 | // Hitching + 1<<6, // DOS + ), + RandomServerID: 123456, + } + } else if q.version > 1 { + f.InstanceInfo = &instanceInfo{RandomServerID: 123456} + } + + if err := common.WireWrite(resp, q.enc, f); err != nil { + return nil, err + } + + return resp.Bytes(), nil +} diff --git a/lib/svrsample/protocol/tf2/tf2_test.go b/lib/svrsample/protocol/tf2/tf2_test.go new file mode 100644 index 0000000..79ad335 --- /dev/null +++ b/lib/svrsample/protocol/tf2/tf2_test.go @@ -0,0 +1,104 @@ +package tf2 + +import ( + "bytes" + "runtime" + "testing" + + "github.com/multiplay/go-svrquery/lib/svrquery/protocol" + "github.com/multiplay/go-svrquery/lib/svrquery/protocol/titanfall" + "github.com/multiplay/go-svrquery/lib/svrsample/common" + "github.com/stretchr/testify/require" +) + +type ( + // queryerFromBytes is a queryer which responds with data in the provided + // byte buffer. + queryerFromBytes struct { + *bytes.Buffer + } +) + +func (q queryerFromBytes) Close() error { + return nil +} + +func (q queryerFromBytes) Key() string { + return "" +} + +func (q queryerFromBytes) Address() string { + return "" +} + +func Test_Respond(t *testing.T) { + q, err := NewQueryResponder(common.QueryState{ + CurrentPlayers: 1, + MaxPlayers: 2, + ServerName: "my server", + GameType: "game type", + Map: "my map", + Port: 8080, + }, 1, false) + require.NoError(t, err) + require.NotNil(t, q) + + resp, err := q.Respond("", nil) + require.NoError(t, err) + require.Equal(t, []byte{0xff, 0xff, 0xff, 0xff, 0x50, 0x1, 0x90, 0x1f, 0x64, 0x61, 0x72, 0x77, 0x69, 0x6e, 0x0, 0x6e, 0x2f, 0x61, 0x0, 0x0, 0x0, 0x0, 0x0, 0x6e, 0x2f, 0x61, 0x0, 0x1, 0x2, 0x6d, 0x79, 0x20, 0x6d, 0x61, 0x70, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, resp) +} + +func Test_Respond_tf2e_v8(t *testing.T) { + q, err := NewQueryResponder(common.QueryState{ + CurrentPlayers: 1, + MaxPlayers: 2, + ServerName: "my server", + GameType: "game type", + Map: "my map", + Port: 8080, + }, 8, true) + require.NoError(t, err) + require.NotNil(t, q) + + data, err := q.Respond("", nil) + require.NoError(t, err) + + p, err := protocol.Get("tf2e-v8") + require.NoError(t, err) + + client := p(&queryerFromBytes{ + bytes.NewBuffer(data), + }) + resp, err := client.Query() + require.NoError(t, err) + require.Equal(t, &titanfall.Info{ + Header: titanfall.Header{ + Prefix: -1, + Command: 78, + Version: 8, + }, + InstanceInfoV8: titanfall.InstanceInfoV8{ + HealthFlags: 127, + RandomServerID: 123456, + }, + BuildName: "my server", + Datacenter: "multiplay-dc", + GameMode: "game type", + BasicInfo: titanfall.BasicInfo{ + Port: 8080, + Platform: runtime.GOOS, + PlaylistName: "n/a", + PlaylistVersion: "n/a", + NumClients: 1, + MaxClients: 2, + Map: "my map", + PlatformPlayers: map[string]uint8{}, + }, + PerformanceInfo: titanfall.PerformanceInfo{ + AverageFrameTime: 1.2, + MaxFrameTime: 3.4, + AverageUserCommandTime: 5.6, + MaxUserCommandTime: 7.8, + }, + }, resp) +} diff --git a/lib/svrsample/query.go b/lib/svrsample/query.go index 8cb019e..04737a7 100644 --- a/lib/svrsample/query.go +++ b/lib/svrsample/query.go @@ -5,8 +5,8 @@ import ( "fmt" "github.com/multiplay/go-svrquery/lib/svrsample/common" - "github.com/multiplay/go-svrquery/lib/svrsample/protocol/sqp" + "github.com/multiplay/go-svrquery/lib/svrsample/protocol/tf2" ) var ( @@ -19,6 +19,14 @@ func GetResponder(proto string, state common.QueryState) (common.QueryResponder, switch proto { case "sqp": return sqp.NewQueryResponder(state) + case "tf2": + return tf2.NewQueryResponder(state, 1, false) + case "tf2e": + return tf2.NewQueryResponder(state, 3, true) + case "tf2e-v7": + return tf2.NewQueryResponder(state, 7, true) + case "tf2e-v8": + return tf2.NewQueryResponder(state, 8, true) } return nil, fmt.Errorf("%w: %s", ErrProtoNotFound, proto) }