aboutsummaryrefslogtreecommitdiffstats
path: root/client
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--client/client.go13
-rw-r--r--client/quake/client.go107
-rw-r--r--client/quake/commands.go237
-rw-r--r--client/quake/move.go30
-rw-r--r--client/quake/net.go144
-rw-r--r--client/quake/option.go87
6 files changed, 618 insertions, 0 deletions
diff --git a/client/client.go b/client/client.go
new file mode 100644
index 0000000..d9fed98
--- /dev/null
+++ b/client/client.go
@@ -0,0 +1,13 @@
+package client
+
+import (
+ "github.com/osm/quake/packet"
+ "github.com/osm/quake/packet/command"
+)
+
+type Client interface {
+ Connect(addrPort string) error
+ Enqueue([]command.Command)
+ HandleFunc(func(packet.Packet) []command.Command)
+ Quit()
+}
diff --git a/client/quake/client.go b/client/quake/client.go
new file mode 100644
index 0000000..7b48e6c
--- /dev/null
+++ b/client/quake/client.go
@@ -0,0 +1,107 @@
+package quake
+
+import (
+ "errors"
+ "log"
+ "net"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/osm/quake/client"
+ "github.com/osm/quake/common/context"
+ "github.com/osm/quake/common/rand"
+ "github.com/osm/quake/common/sequencer"
+ "github.com/osm/quake/packet"
+ "github.com/osm/quake/packet/command"
+ "github.com/osm/quake/packet/command/stringcmd"
+ "github.com/osm/quake/protocol"
+)
+
+var (
+ ErrNoName = errors.New("no name supplied")
+ ErrNoTeam = errors.New("no team supplied")
+)
+
+const connectReadDeadline = time.Duration(13)
+
+type Client struct {
+ conn *net.UDPConn
+ logger *log.Logger
+ ctx *context.Context
+ cmdsMu sync.Mutex
+ cmds []command.Command
+ seq *sequencer.Sequencer
+ handlers []func(packet.Packet) []command.Command
+
+ addrPort string
+ qPort uint16
+
+ serverCount int32
+ readDeadline time.Duration
+
+ clientVersion string
+ isSpectator bool
+ mapName string
+ name string
+ ping int16
+ team string
+ bottomColor byte
+ topColor byte
+
+ fteEnabled bool
+ fteExtensions uint32
+ fte2Enabled bool
+ fte2Extensions uint32
+ mvdEnabled bool
+ mvdExtensions uint32
+ zQuakeEnabled bool
+ zQuakeExtensions uint32
+}
+
+func New(name, team string, opts ...Option) (client.Client, error) {
+ if name == "" {
+ return nil, ErrNoName
+ }
+
+ if team == "" {
+ return nil, ErrNoTeam
+ }
+
+ c := &Client{
+ ctx: context.New(context.WithProtocolVersion(protocol.VersionQW)),
+ logger: log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime),
+ seq: sequencer.New(),
+ readDeadline: connectReadDeadline,
+ qPort: rand.Uint16(),
+ name: name,
+ ping: 999,
+ team: team,
+ }
+
+ for _, opt := range opts {
+ opt(c)
+ }
+
+ c.seq.SetPing(c.ping)
+
+ return c, nil
+}
+
+func (c *Client) Enqueue(cmds []command.Command) {
+ c.cmdsMu.Lock()
+ c.cmds = append(c.cmds, cmds...)
+ c.cmdsMu.Unlock()
+}
+
+func (c *Client) HandleFunc(h func(h packet.Packet) []command.Command) {
+ c.handlers = append(c.handlers, h)
+}
+
+func (c *Client) Quit() {
+ c.cmds = append(c.cmds, &stringcmd.Command{String: "drop"})
+
+ if c.seq.GetState() == sequencer.Connected {
+ time.Sleep(time.Duration(c.ping) * time.Millisecond)
+ }
+}
diff --git a/client/quake/commands.go b/client/quake/commands.go
new file mode 100644
index 0000000..a416753
--- /dev/null
+++ b/client/quake/commands.go
@@ -0,0 +1,237 @@
+package quake
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/osm/quake/common/args"
+ "github.com/osm/quake/common/infostring"
+ "github.com/osm/quake/common/sequencer"
+ "github.com/osm/quake/packet/command"
+ "github.com/osm/quake/packet/command/connect"
+ "github.com/osm/quake/packet/command/download"
+ "github.com/osm/quake/packet/command/ip"
+ "github.com/osm/quake/packet/command/modellist"
+ "github.com/osm/quake/packet/command/s2cchallenge"
+ "github.com/osm/quake/packet/command/s2cconnection"
+ "github.com/osm/quake/packet/command/serverdata"
+ "github.com/osm/quake/packet/command/soundlist"
+ "github.com/osm/quake/packet/command/stringcmd"
+ "github.com/osm/quake/packet/command/stufftext"
+ "github.com/osm/quake/protocol"
+ "github.com/osm/quake/protocol/fte"
+ "github.com/osm/quake/protocol/mvd"
+)
+
+func (c *Client) handleServerCommand(cmd command.Command) []command.Command {
+ switch cmd := cmd.(type) {
+ case *s2cchallenge.Command:
+ return c.handleChallenge(cmd)
+ case *s2cconnection.Command:
+ c.seq.SetState(sequencer.Connecting)
+ return c.handleNew()
+ case *stufftext.Command:
+ return c.handleStufftext(cmd)
+ case *soundlist.Command:
+ return c.handleSoundList(cmd)
+ case *modellist.Command:
+ return c.handleModelList(cmd)
+ case *serverdata.Command:
+ c.serverCount = cmd.ServerCount
+ }
+
+ return nil
+}
+
+func (c *Client) handleChallenge(cmd *s2cchallenge.Command) []command.Command {
+ userInfo := infostring.New(
+ infostring.WithKeyValue("name", c.name),
+ infostring.WithKeyValue("team", c.team),
+ infostring.WithKeyValue("topcolor", fmt.Sprintf("%d", c.topColor)),
+ infostring.WithKeyValue("bottomcolor", fmt.Sprintf("%d", c.bottomColor)),
+ )
+
+ if c.addrPort != "" {
+ userInfo.Info = append(userInfo.Info, infostring.Info{
+ Key: "prx",
+ Value: c.addrPort,
+ })
+ }
+
+ if c.clientVersion != "" {
+ userInfo.Info = append(userInfo.Info, infostring.Info{
+ Key: "*client",
+ Value: c.clientVersion,
+ })
+ }
+
+ if c.isSpectator {
+ userInfo.Info = append(userInfo.Info, infostring.Info{
+ Key: "spectator",
+ Value: "1",
+ })
+ }
+
+ if c.zQuakeEnabled {
+ userInfo.Info = append(userInfo.Info, infostring.Info{
+ Key: "*z_ext",
+ Value: fmt.Sprintf("%d", c.zQuakeExtensions),
+ })
+ }
+
+ var extensions []*protocol.Extension
+ for _, ext := range cmd.Extensions {
+ if c.fteEnabled && ext.Version == fte.ProtocolVersion {
+ extensions = append(extensions, ext)
+ } else if c.fte2Enabled && ext.Version == fte.ProtocolVersion2 {
+ extensions = append(extensions, ext)
+ } else if c.mvdEnabled && ext.Version == mvd.ProtocolVersion {
+ extensions = append(extensions, ext)
+ }
+ }
+
+ return []command.Command{
+ &connect.Command{
+ Command: "connect",
+ Version: fmt.Sprintf("%v", c.ctx.GetProtocolVersion()),
+ QPort: c.qPort,
+ ChallengeID: cmd.ChallengeID,
+ UserInfo: userInfo,
+ Extensions: extensions,
+ },
+ }
+}
+
+func (c *Client) handleNew() []command.Command {
+ return []command.Command{&stringcmd.Command{String: "new"}}
+}
+
+func (c *Client) handleStufftext(cmd *stufftext.Command) []command.Command {
+ var cmds []command.Command
+
+ for _, a := range args.Parse(cmd.String) {
+ switch a.Cmd {
+ case "reconnect":
+ c.seq.SetState(sequencer.Connecting)
+ cmds = append(cmds, c.handleNew()...)
+ case "cmd":
+ cmds = append(cmds, c.handleStufftextCmds(a.Args)...)
+ case "packet":
+ cmds = append(cmds, &ip.Command{String: a.Args[1][1 : len(a.Args[1])-1]})
+ case "fullserverinfo":
+ cmds = append(cmds, c.handleFullServerInfo(a.Args[0])...)
+ case "skins":
+ c.readDeadline = time.Duration(c.ping)
+ c.seq.SetState(sequencer.Connected)
+ cmds = append(cmds, &stringcmd.Command{
+ String: fmt.Sprintf("begin %d", c.serverCount),
+ })
+ }
+ }
+
+ return cmds
+}
+
+func (c *Client) handleStufftextCmds(args []string) []command.Command {
+ switch args[0] {
+ case "ack", "prespawn", "spawn":
+ return []command.Command{&stringcmd.Command{String: strings.Join(args, " ")}}
+ case "pext":
+ return c.handleProtocolExtensions()
+ case "new":
+ return c.handleNew()
+ }
+
+ return nil
+}
+
+func (c *Client) handleProtocolExtensions() []command.Command {
+ var fteVersion uint32
+ var fteExtensions uint32
+ var fte2Version uint32
+ var fte2Extensions uint32
+ var mvdVersion uint32
+ var mvdExtensions uint32
+
+ if c.fteEnabled {
+ fteVersion = fte.ProtocolVersion
+ fteExtensions = c.fteExtensions
+ }
+ if c.fte2Enabled {
+ fte2Version = fte.ProtocolVersion2
+ fte2Extensions = c.fte2Extensions
+ }
+ if c.mvdEnabled {
+ mvdVersion = mvd.ProtocolVersion
+ mvdExtensions = c.mvdExtensions
+ }
+
+ return []command.Command{
+ &stringcmd.Command{String: fmt.Sprintf("pext 0x%x 0x%x 0x%x 0x%x 0x%x 0x%x",
+ fteVersion,
+ fteExtensions,
+ fte2Version,
+ fte2Extensions,
+ mvdVersion,
+ mvdExtensions,
+ )},
+ }
+}
+
+func (c *Client) handleFullServerInfo(args string) []command.Command {
+ inf := infostring.Parse(args)
+ c.mapName = inf.Get("map")
+
+ return []command.Command{
+ &stringcmd.Command{String: fmt.Sprintf("soundlist %d 0", c.serverCount)},
+ }
+}
+
+func (c *Client) handleSoundList(cmd *soundlist.Command) []command.Command {
+ nextCmd := "soundlist"
+ if cmd.Index == 0 {
+ nextCmd = "modellist"
+ }
+
+ return []command.Command{
+ &stringcmd.Command{
+ String: fmt.Sprintf("%s %d %d", nextCmd, c.serverCount, cmd.Index),
+ },
+ }
+}
+
+func (c *Client) handleModelList(cmd *modellist.Command) []command.Command {
+ if cmd.Index == 0 {
+ return c.handlePrespawn()
+ }
+
+ return []command.Command{
+ &stringcmd.Command{
+ String: fmt.Sprintf("modellist %d %d", c.serverCount, cmd.Index),
+ },
+ }
+}
+
+func (c *Client) handlePrespawn() []command.Command {
+ hash, ok := protocol.MapChecksum[c.mapName]
+ if !ok {
+ c.logger.Printf("missing map checksum for %s, requesting download", c.mapName)
+
+ // Send download command for the missing map
+ return []command.Command{
+ &download.Command{
+ FTEProtocolExtension: c.fteExtensions,
+ Size16: -1, // -1 signals the server to start sending the file
+ Name: c.mapName, // map filename
+ },
+ }
+ }
+
+ // If hash exists, continue normally
+ return []command.Command{
+ &stringcmd.Command{String: fmt.Sprintf("setinfo pmodel %d", protocol.PlayerModel)},
+ &stringcmd.Command{String: fmt.Sprintf("setinfo emodel %d", protocol.EyeModel)},
+ &stringcmd.Command{String: fmt.Sprintf("prespawn %v 0 %d", c.serverCount, hash)},
+ }
+}
diff --git a/client/quake/move.go b/client/quake/move.go
new file mode 100644
index 0000000..600c1fd
--- /dev/null
+++ b/client/quake/move.go
@@ -0,0 +1,30 @@
+package quake
+
+import (
+ "github.com/osm/quake/packet/command/deltausercommand"
+ "github.com/osm/quake/packet/command/move"
+ "github.com/osm/quake/protocol"
+)
+
+func (c *Client) getMove(seq uint32) *move.Command {
+ mov := move.Command{
+ Null: &deltausercommand.Command{
+ ProtocolVersion: c.ctx.GetProtocolVersion(),
+ Bits: protocol.CMForward | protocol.CMSide | protocol.CMUp | protocol.CMButtons,
+ CMForward16: 0,
+ CMSide16: 0,
+ CMUp16: 0,
+ CMButtons: 0,
+ CMMsec: 13, // ~72hz
+ },
+ Old: &deltausercommand.Command{
+ ProtocolVersion: c.ctx.GetProtocolVersion(),
+ },
+ New: &deltausercommand.Command{
+ ProtocolVersion: c.ctx.GetProtocolVersion(),
+ },
+ }
+
+ mov.Checksum = mov.GetChecksum(seq - 1)
+ return &mov
+}
diff --git a/client/quake/net.go b/client/quake/net.go
new file mode 100644
index 0000000..16c9016
--- /dev/null
+++ b/client/quake/net.go
@@ -0,0 +1,144 @@
+package quake
+
+import (
+ "errors"
+ "net"
+ "syscall"
+ "time"
+
+ "github.com/osm/quake/common/sequencer"
+ "github.com/osm/quake/packet"
+ "github.com/osm/quake/packet/clc"
+ "github.com/osm/quake/packet/command"
+ "github.com/osm/quake/packet/command/connect"
+ "github.com/osm/quake/packet/command/getchallenge"
+ "github.com/osm/quake/packet/command/ip"
+ "github.com/osm/quake/packet/svc"
+)
+
+func (c *Client) Connect(addrPort string) error {
+ addr, err := net.ResolveUDPAddr("udp", addrPort)
+ if err != nil {
+ return err
+ }
+
+ conn, err := net.DialUDP("udp", nil, addr)
+ if err != nil {
+ return err
+ }
+ c.conn = conn
+
+ c.sendChallenge()
+
+ buf := make([]byte, 1024*64)
+ for {
+
+ var isReadTimeout bool
+ var incomingSeq uint32
+ var incomingAck uint32
+ var packet packet.Packet
+ var cmds []command.Command
+
+ if err := c.conn.SetReadDeadline(
+ time.Now().Add(time.Millisecond * c.readDeadline),
+ ); err != nil {
+ c.logger.Printf("unable to set read deadline, %v\n", err)
+ }
+
+ n, _, err := c.conn.ReadFromUDP(buf)
+ if err != nil {
+ if errors.Is(err, syscall.ECONNREFUSED) {
+ c.logger.Printf("lost connection - reconnecting in 5 seqonds")
+ time.Sleep(time.Second * 5)
+ c.sendChallenge()
+ continue
+ }
+
+ if err, ok := err.(net.Error); ok && err.Timeout() {
+ isReadTimeout = true
+ goto process
+ }
+
+ c.logger.Printf("unexpected read error, %v", err)
+ continue
+ }
+
+ packet, err = svc.Parse(c.ctx, buf[:n])
+ if err != nil {
+ c.logger.Printf("error when parsing server data, %v", err)
+ continue
+ }
+
+ switch p := packet.(type) {
+ case *svc.Connectionless:
+ cmds = []command.Command{p.Command}
+ case *svc.GameData:
+ cmds = p.Commands
+ incomingSeq = p.Seq
+ incomingAck = p.Ack
+
+ }
+
+ for _, h := range c.handlers {
+ cmds = append(cmds, h(packet)...)
+ }
+ process:
+ c.processCommands(incomingSeq, incomingAck, cmds, isReadTimeout)
+ }
+}
+
+func (c *Client) processCommands(
+ incomingSeq, incomingAck uint32,
+ serverCmds []command.Command,
+ isReadTmeout bool,
+) {
+ var cmds []command.Command
+
+ for _, serverCmd := range serverCmds {
+ for _, cmd := range c.handleServerCommand(serverCmd) {
+ switch cmd := cmd.(type) {
+ case *connect.Command, *ip.Command:
+ if _, err := c.conn.Write(
+ (&clc.Connectionless{Command: cmd}).Bytes(),
+ ); err != nil {
+ c.logger.Printf("unable to send connectionless command, %v\n", err)
+ }
+ default:
+ cmds = append(cmds, cmd)
+ }
+ }
+ }
+
+ if c.seq.GetState() <= sequencer.Handshake {
+ return
+ }
+
+ c.cmdsMu.Lock()
+ outSeq, outAck, outCmds, err := c.seq.Process(incomingSeq, incomingAck, append(c.cmds, cmds...))
+ c.cmds = []command.Command{}
+ c.cmdsMu.Unlock()
+
+ if err == sequencer.ErrRateLimit {
+ return
+ }
+
+ if _, err := c.conn.Write((&clc.GameData{
+ Seq: outSeq,
+ Ack: outAck,
+ QPort: c.qPort,
+ Commands: append(outCmds, c.getMove(outSeq)),
+ }).Bytes()); err != nil {
+ c.logger.Printf("unable to send game data, %v\n", err)
+ }
+}
+
+func (c *Client) sendChallenge() {
+ c.seq.Reset()
+ c.seq.SetState(sequencer.Handshake)
+ c.readDeadline = connectReadDeadline
+ if _, err := c.conn.Write(
+ (&clc.Connectionless{Command: &getchallenge.Command{}}).Bytes(),
+ ); err != nil {
+ c.logger.Printf("unable to send challenge, %v\n", err)
+ }
+}
diff --git a/client/quake/option.go b/client/quake/option.go
new file mode 100644
index 0000000..9c94fde
--- /dev/null
+++ b/client/quake/option.go
@@ -0,0 +1,87 @@
+package quake
+
+import "log"
+
+type Option func(*Client)
+
+func WithName(name string) Option {
+ return func(c *Client) {
+ c.name = name
+ }
+}
+
+func WithTeam(team string) Option {
+ return func(c *Client) {
+ c.team = team
+ }
+}
+
+func WithSpectator(isSpectator bool) Option {
+ return func(c *Client) {
+ c.isSpectator = isSpectator
+ }
+}
+
+func WithClientVersion(clientVersion string) Option {
+ return func(c *Client) {
+ c.clientVersion = clientVersion
+ }
+}
+
+func WithServerAddr(addrPort string) Option {
+ return func(c *Client) {
+ c.addrPort = addrPort
+ }
+}
+
+func WithPing(ping int16) Option {
+ return func(c *Client) {
+ c.ping = ping
+ }
+}
+
+func WithBottomColor(color byte) Option {
+ return func(c *Client) {
+ c.bottomColor = color
+ }
+}
+
+func WithTopColor(color byte) Option {
+ return func(c *Client) {
+ c.topColor = color
+ }
+}
+
+func WithFTEExtensions(extensions uint32) Option {
+ return func(c *Client) {
+ c.fteEnabled = true
+ c.fteExtensions = extensions
+ }
+}
+
+func WithFTE2Extensions(extensions uint32) Option {
+ return func(c *Client) {
+ c.fte2Enabled = true
+ c.fte2Extensions = extensions
+ }
+}
+
+func WithMVDExtensions(extensions uint32) Option {
+ return func(c *Client) {
+ c.mvdEnabled = true
+ c.mvdExtensions = extensions
+ }
+}
+
+func WithZQuakeExtensions(extensions uint32) Option {
+ return func(c *Client) {
+ c.zQuakeEnabled = true
+ c.zQuakeExtensions = extensions
+ }
+}
+
+func WithLogger(logger *log.Logger) Option {
+ return func(c *Client) {
+ c.logger = logger
+ }
+}