diff options
Diffstat (limited to '')
| -rw-r--r-- | client/quake/client.go | 107 | ||||
| -rw-r--r-- | client/quake/commands.go | 237 | ||||
| -rw-r--r-- | client/quake/move.go | 30 | ||||
| -rw-r--r-- | client/quake/net.go | 144 | ||||
| -rw-r--r-- | client/quake/option.go | 87 |
5 files changed, 605 insertions, 0 deletions
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 + } +} |
