From b3d9f44d71a52d8ff89c8baf9adf5329e595665e Mon Sep 17 00:00:00 2001 From: Sergei K Date: Fri, 13 Dec 2013 03:00:30 +0400 Subject: [PATCH] Initial commit --- .gitignore | 3 + CHANGES.rst | 3 + GS1/Classes/Link.uc | 53 +++++ GS1/Classes/Listener.uc | 236 ++++++++++++++++++++++ GS1/Classes/Response.uc | 428 ++++++++++++++++++++++++++++++++++++++++ GS1/Classes/TCPLink.uc | 77 ++++++++ GS1/Classes/UDPLink.uc | 76 +++++++ LICENSE | 19 ++ README.rst | 120 +++++++++++ fabfile/__init__.py | 8 + fabfile/dist.py | 53 +++++ fabfile/server.py | 64 ++++++ fabfile/settings.py | 67 +++++++ fabfile/ucc.py | 80 ++++++++ fabfile/utils.py | 118 +++++++++++ 15 files changed, 1405 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGES.rst create mode 100644 GS1/Classes/Link.uc create mode 100644 GS1/Classes/Listener.uc create mode 100644 GS1/Classes/Response.uc create mode 100644 GS1/Classes/TCPLink.uc create mode 100644 GS1/Classes/UDPLink.uc create mode 100644 LICENSE create mode 100644 README.rst create mode 100644 fabfile/__init__.py create mode 100644 fabfile/dist.py create mode 100644 fabfile/server.py create mode 100644 fabfile/settings.py create mode 100644 fabfile/ucc.py create mode 100644 fabfile/utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9eb28bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/compiled/* +/dist/* +*.html diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 0000000..0d8eb3e --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,3 @@ +1.0.0 (2014-05-15) + +* Initial public release. \ No newline at end of file diff --git a/GS1/Classes/Link.uc b/GS1/Classes/Link.uc new file mode 100644 index 0000000..4f0ed43 --- /dev/null +++ b/GS1/Classes/Link.uc @@ -0,0 +1,53 @@ +interface Link; + +/** + * Copyright (c) 2014 Sergei Khoroshilov + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * Attempt to bind and listen to specified port. + * + * @param int Port + * Port number + * @return void + */ +public function Bind(int Port, Listener Listener); + +/** + * Reply with plain text data. + * + * @param struct'IpAddr' Addr + * Destination address + * @param string Text + * Outgoing plain text data + * @return void + */ +public function Reply(IpDrv.InternetLink.IpAddr Addr, string Text); + +/** + * Dummy interface function. + * Ignore this. + * + * @return bool + */ +public function bool Destroy(); + +/* vim: set ft=java: */ \ No newline at end of file diff --git a/GS1/Classes/Listener.uc b/GS1/Classes/Listener.uc new file mode 100644 index 0000000..70b52d0 --- /dev/null +++ b/GS1/Classes/Listener.uc @@ -0,0 +1,236 @@ +class Listener extends SwatGame.SwatMutator; + +/** + * Copyright (c) 2014 Sergei Khoroshilov + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * The purpose of this class is to initialize instances of + * Link interface implementing classes that that provide unified API + * to their protocol-specific operations (i.e. UDP and TCP). + * + * Listener is able to listen on both UDP and TCP ports simultaneously. + */ + +/** + * Indicate whether listener is enabled + * @default False + * @type bool + */ +var config bool Enabled; + +/** + * Port number to listen on (1-65535) + * @default Join Port + 1 + * @type int + */ +var config int Port; + +/** + * Listener protocol (can be either UDP, TCP or both) + * @default UDP + * @type enum'eProtocol' + */ +var config enum eProtocol +{ + UDP, + TCP, + ALL +} Protocol; + +/** + * Reference to listening links + * @type array + */ +var protected array Links; + +/** + * Response instance + * @type class'Response' + */ +var protected Response Response; + +/** + * Check whether mod is enabled + * If it's not, destroy the instance + * + * @return void + */ +public function PreBeginPlay() +{ + Super.PreBeginPlay(); + + if (!self.Enabled) + { + self.Destroy(); + } +} + +/** + * Initialize the multiprotocol listener + * + * @return void + */ +public function BeginPlay() +{ + Super.BeginPlay(); + // Avoid being initialized on the Entry level + if (Level.Game == None || SwatGameInfo(Level.Game) == None) + { + self.Destroy(); + return; + } + // Listen on the join port +1 if none specified + if (self.Port == 0) + { + self.Port = SwatGameInfo(Level.Game).GetServerPort() + 1; + } + switch (self.Protocol) + { + case UDP: + self.AddLink(Spawn(class'UDPLink')); + break; + case TCP: + self.AddLink(Spawn(class'TCPLink')); + break; + case ALL: + self.AddLink(Spawn(class'UDPLink')); + self.AddLink(Spawn(class'TCPLink')); + break; + default: + self.Destroy(); + return; + } + self.Response = Spawn(class'Response'); + self.Listen(); +} + +/** + * Add a new instance of a Link interface implementing class + * + * @param interface'Link' Link + * @return void + */ +protected function AddLink(Link Link) +{ + self.Links[self.Links.Length] = Link; +} + +/** + * Attempt to listen on the query port on all initialized links + * + * @return void + */ +protected function Listen() +{ + local int i; + + for (i = 0; i < self.Links.Length; i++) + { + self.Links[i].Bind(self.Port, self); + } +} + +/** + * Handle a request + * + * @param interface'Link' Link + * Reference to the interacted link + * @param struct'IpAddr' Addr + * Source address + * @param string Text + * Plain text request data + * @return void + */ +public function OnTextReceived(Link Link, IpDrv.InternetLink.IpAddr Addr, string Text) +{ + // Check whether listener is busy with another request + if (self.Response.IsOccupied()) + { + return; + } + self.Response.Occupy(); + // Parse the request query text.. + switch (class'Utils.StringUtils'.static.Replace(Text, "\\", "")) + { + // ..although, only status is currently supported + case "status": + Response.AddInfo(); + Response.AddPlayers(); + break; + default: + break; + } + // Read one packet a time + while (!self.Response.IsEmpty()) + { + // Return a UTF-8 encoded string + Link.Reply(Addr, class'Utils.UnicodeUtils'.static.EncodeUTF8(self.Response.Read())); + } + // Free the response instance for further use + self.Response.Free(); +} + +/** + * Perform a shutdown whenever a referenced link encounters a failure + * + * @param interface'Link' Link + * @param string Message (optional) + * @return void + */ +public function OnLinkFailure(Link Link, optional string Message) +{ + log(Link $ " failed to listen on " $ self.Port $ " (" $ Message $ ")"); + + self.Destroy(); +} + +/** + * Destroy instances of referenced links + */ +event Destroyed() +{ + while (Links.Length > 0) + { + if (self.Links[0] != None) + { + self.Links[0].Destroy(); + } + self.Links.Remove(0, 1); + } + + if (self.Response != None) + { + self.Response.Destroy(); + self.Response = None; + } + + Super.Destroyed(); +} + +defaultproperties +{ + Enabled=false; + Port=0; + Protocol=UDP; +} + +/* vim: set ft=java: */ \ No newline at end of file diff --git a/GS1/Classes/Response.uc b/GS1/Classes/Response.uc new file mode 100644 index 0000000..5e797dc --- /dev/null +++ b/GS1/Classes/Response.uc @@ -0,0 +1,428 @@ +class Response extends SwatGame.SwatMutator; + +/** + * Copyright (c) 2014 Sergei Khoroshilov + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * This class handles response objects that expose simple public API: + * + * AddInfo() - populates object with server information + * AddPlayers() - populates object with player details + * Read() - read a piece of response formed off previously populated data + * IsEmpty() - tell whether response object is out of data + * + * Every single packet read with a Read() call contains + * a \queryid\%N% fragment with a 1-based number indicating current packet count. + * The last response packet is also provided with a \final\ fragment. + * + * Read more about Gamespy protocol at + */ + +/** + * Although the most reliable udp packet size is believed to be 508, + * the final packet does also contain the extra \final\ string + * @type int + */ +const MAX_PACKET_SIZE = 500; + +/** + * Array of fragments awaiting of being split and read chunk by chunk + * @type array + */ +var protected array Fragments; + +/** + * Current packet count + * @type int + */ +var protected int PacketCount; + +/** + * Indicate whether the response instance is occupied by listener + * @type bool + */ +var protected bool bOccupied; + +/** + * Tell whether the fragments array is out of elements + * + * @return bool + */ +public function bool IsEmpty() +{ + return self.Fragments.Length == 0; +} + +/** + * Return the next piece of response text + * + * @return string + */ +public function string Read() +{ + local int FragmentCount; + local array Packet; + + if (self.IsEmpty()) + { + return ""; + } + // Prepopulate the array with packet information fragments + Packet[Packet.Length] = "queryid"; + Packet[Packet.Length] = string(++self.PacketCount); + // Only pack new fragments if they dont break the limit + while (true) + { + // Stop when exhausted + if (self.IsEmpty()) + { + break; + } + // Dont let the next pair of fragments to pass if the two break the limit + if (++FragmentCount % 2 != 0) + { + if (self.GetPacketSize(Packet) + (Len(self.Fragments[0]) + Len(self.Fragments[1]) + 2) > class'Response'.const.MAX_PACKET_SIZE) + { + break; + } + } + Packet[Packet.Length] = self.Fragments[0]; + // Keep removing acquired fragments untill the array is empty.. + self.Fragments.Remove(0, 1); + } + // Rearrange packet array elements so the queryid token + // and it's value are at the end of the packet + Packet[Packet.Length] = Packet[0]; + Packet[Packet.Length] = Packet[1]; + // Shift the original queryid elements + Packet.Remove(0, 2); + // Add final token in case the array is out of fragments + if (self.IsEmpty()) + { + Packet[Packet.Length] = "final"; + Packet[Packet.Length] = "";; + } + // Split packets always begin with a backslash + return "\\" $ class'Utils.ArrayUtils'.static.Join(Packet, "\\");; +} + +/** + * Extend response with server details + * + * @return void + */ +public function AddInfo() +{ + self.AddPair("hostname", ServerSettings(Level.CurrentServerSettings).ServerName); + self.AddPair("numplayers", SwatGameInfo(Level.Game).NumberOfPlayersForServerBrowser()); + self.AddPair("maxplayers", ServerSettings(Level.CurrentServerSettings).MaxPlayers); + self.AddPair("gametype", SwatGameInfo(Level.Game).GetGameModeName()); + self.AddPair("gamevariant", Level.ModName); + self.AddPair("mapname", Level.Title); + self.AddPair("hostport", SwatGameInfo(Level.Game).GetServerPort()); + self.AddPair("password", Lower(string(Level.Game.GameIsPasswordProtected()))); + self.AddPair("gamever", Level.BuildVersion); + self.AddPair("round", ServerSettings(Level.CurrentServerSettings).RoundNumber+1); + self.AddPair("numrounds", ServerSettings(Level.CurrentServerSettings).NumRounds); + self.AddPair("timeleft", SwatGameReplicationInfo(Level.Game.GameReplicationInfo).RoundTime); + + switch (ServerSettings(Level.CurrentServerSettings).GameType) + { + #if IG_SPEECH_RECOGNITION + case MPM_COOPQMM: + #endif + case MPM_COOP: + case MPM_RapidDeployment: + case MPM_VIPEscort: + self.AddPair("timespecial", SwatGameReplicationInfo(Level.Game.GameReplicationInfo).SpecialTime); + break; + } + + switch (ServerSettings(Level.CurrentServerSettings).GameType) + { + #if IG_SPEECH_RECOGNITION + case MPM_COOPQMM: + #endif + case MPM_COOP: + self.AddCOOPInfo(); + break; + case MPM_RapidDeployment: + self.AddPair("bombsdefused", SwatGameReplicationInfo(Level.Game.GameReplicationInfo).DiffusedBombs); + self.AddPair("bombstotal", SwatGameReplicationInfo(Level.Game.GameReplicationInfo).TotalNumberOfBombs); + // no break + default: + self.AddPair("swatscore", SwatGameInfo(Level.Game).GetTeamFromID(0).NetScoreInfo.GetScore()); + self.AddPair("suspectsscore", SwatGameInfo(Level.Game).GetTeamFromID(1).NetScoreInfo.GetScore()); + self.AddPair("swatwon", SwatGameInfo(Level.Game).GetTeamFromID(0).NetScoreInfo.GetRoundsWon()); + self.AddPair("suspectswon", SwatGameInfo(Level.Game).GetTeamFromID(1).NetScoreInfo.GetRoundsWon()); + break; + } +} + +/** + * Add + * + * @return void + */ +public function AddCOOPInfo() +{ + local int i; + local MissionObjectives Objectives; + local Procedures Procedures; + + Objectives = SwatRepo(Level.GetRepo()).MissionObjectives; + + // "obj_"Objective name => Objective status pair + for (i = 0; i < Objectives.Objectives.Length; i++) + { + // Skip the hidden objective + if (Objectives.Objectives[i].name == 'Automatic_DoNot_Die') + { + continue; + } + self.AddPair("obj_" $ Objectives.Objectives[i].name, SwatGameReplicationInfo(Level.Game.GameReplicationInfo).ObjectiveStatus[i]); + } + + // Add the params "tocreports" and "weaponssecured" + Procedures = SwatRepo(Level.GetRepo()).Procedures; + + for (i = 0; i < Procedures.Procedures.Length; i++) + { + switch (Procedures.Procedures[i].class.name) + { + case 'Procedure_SecureAllWeapons': + self.AddPair("tocreports", Procedures.Procedures[i].Status()); + break; + case 'Procedure_ReportCharactersToTOC': + self.AddPair("weaponssecured", Procedures.Procedures[i].Status()); + break; + } + } +} + +/** + * Append player details to response + * + * @return void + */ +public function AddPlayers() +{ + local int i; + local PlayerController PC; + + foreach DynamicActors(class'PlayerController', PC) + { + self.AddPair("player_" $ i, PC.PlayerReplicationInfo.PlayerName); + self.AddPair("score_" $ i, SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetScore()); + self.AddPair("ping_" $ i, Min(999, PC.PlayerReplicationInfo.Ping)); + self.AddPair("team_" $ i, NetTeam(PC.PlayerReplicationInfo.Team).GetTeamNumber()); + + // COOP status + if (Level.IsCOOPServer) + { + self.AddPair("coopstatus_" $ i, SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).COOPPlayerStatus); + } + + // Kills + if (SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetEnemyKills() > 0) + { + self.AddPair("kills_" $ i, SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetEnemyKills()); + } + + // Deaths + if (SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetTimesDied() > 0) + { + self.AddPair("deaths_" $ i, SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetTimesDied()); + } + + // Team Kills + if (SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetFriendlyKills() > 0) + { + self.AddPair("tkills_" $ i, SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetFriendlyKills()); + } + + // Arrests + if (SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetArrests() > 0) + { + self.AddPair("arrests_" $ i, SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetArrests()); + } + + // Arrested + if (SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetTimesArrested() > 0) + { + self.AddPair("arrested_" $ i, SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetTimesArrested()); + } + + // The VIP + if (SwatGamePlayerController(PC).ThisPlayerIsTheVIP) + { + self.AddPair("vip_" $ i, 1); + } + + // VIP Escapes + if (SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetVIPPlayerEscaped() > 0) + { + self.AddPair("vescaped_" $ i, SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetVIPPlayerEscaped()); + } + + // VIP Captures + if (SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetArrestedVIP() > 0) + { + self.AddPair("arrestedvip_" $ i, SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetArrestedVIP()); + } + + // VIP Rescues + if (SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetUnarrestedVIP() > 0) + { + self.AddPair("unarrestedvip_" $ i, SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetUnarrestedVIP()); + } + + // VIP Kills Valid + if (SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetKilledVIPValid() > 0) + { + self.AddPair("validvipkills_" $ i, SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetKilledVIPValid()); + } + + // VIP Kills Invalid + if (SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetKilledVIPInvalid() > 0) + { + self.AddPair("invalidvipkills_" $ i, SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetKilledVIPInvalid()); + } + + // Bombs Disarmed + if (SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetBombsDiffused() > 0) + { + self.AddPair("bombsdiffused_" $ i, SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetBombsDiffused()); + } + + // RD Crybaby + if (SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetRDCrybaby() > 0) + { + self.AddPair("rdcrybaby_" $ i, SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetRDCrybaby()); + } + + #if IG_SPEECH_RECOGNITION + // SG Crybaby + if (SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetSGCrybaby() > 0) + { + self.AddPair("sgcrybaby_" $ i, SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetSGCrybaby()); + } + + // Case Escapes + if (SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetEscapedSG() > 0) + { + self.AddPair("escapedcase_" $ i, SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetEscapedSG()); + } + + // Case Kills + if (SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetKilledSG() > 0) + { + self.AddPair("killedcase_" $ i, SwatPlayerReplicationInfo(PC.PlayerReplicationInfo).netScoreInfo.GetKilledSG()); + } + #endif + + i++; + } +} + +/** + * Add a new key-value pair + * + * @param string Key + * @param string Value + * @return void + */ +public function AddPair(coerce string Key, coerce string Value) +{ + self.AddFragment(Key); + self.AddFragment(Value); +} + +/** + * Append a new fragment to the list of fragments + * + * @param string Fragment + * @return void + */ +protected function AddFragment(coerce string Fragment) +{ + // Trim long fragments + self.Fragments[self.Fragments.Length] = Left(Fragment, class'Response'.const.MAX_PACKET_SIZE/3); +} + +/** + * Compute and return the length of given packet fragments + * as if they were formed up into a packet using a backslash as the delimiter + * + * @param array Packet + * @return int + */ +static function int GetPacketSize(array Packet) +{ + local int i, Size; + + for (i = 0; i < Packet.Length; i++) + { + Size += Len(Packet[i]); + } + return Size + Packet.Length; +} + +/** + * Tell whether the response instance has been occupied + * + * @return bool + */ +public function bool IsOccupied() +{ + return self.bOccupied; +} + +/** + * Occupy the instance + * + * @return void + */ +public function Occupy() +{ + self.bOccupied = true; +} + +/** + * Free the instance + * + * @return void + */ +public function Free() +{ + self.Fragments.Remove(0, self.Fragments.Length); + self.PacketCount = 0; + self.bOccupied = false; +} + +event Destroyed() +{ + self.Free(); + Super.Destroyed(); +} + +/* vim: set ft=java: */ \ No newline at end of file diff --git a/GS1/Classes/TCPLink.uc b/GS1/Classes/TCPLink.uc new file mode 100644 index 0000000..9e5780f --- /dev/null +++ b/GS1/Classes/TCPLink.uc @@ -0,0 +1,77 @@ +class TCPLink extends IpDrv.TcpLink + implements Link; + +/** + * Copyright (c) 2014 Sergei Khoroshilov + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * Reference to the listener + * @type class'Listener' + */ +var protected Listener Listener; + +/** + * @see Link.Bind + */ +public function Bind(int Port, Listener Listener) +{ + self.Listener = Listener; + + if (self.BindPort(Port, false) > 0) + { + if (self.Listen()) + { + log(self $ " is now listening on " $ Port); + return; + } + } + + self.Listener.OnLinkFailure(self, "failed to bind port"); + self.Destroy(); +} + +/** + * @see Link.Reply + */ +public function Reply(IpAddr Addr, coerce string Text) +{ + self.SendText(Text); +} + +/** + * Call the parent's OnTextReceived delegate whenever a child receives plain text data + * + * @param string Text + * Plain text data + */ +event ReceivedText(string Text) +{ + self.Listener.OnTextReceived(self, self.RemoteAddr, Text); +} + +event Destroyed() +{ + self.Listener = None; + Super.Destroyed(); +} + +/* vim: set ft=java: */ \ No newline at end of file diff --git a/GS1/Classes/UDPLink.uc b/GS1/Classes/UDPLink.uc new file mode 100644 index 0000000..9b833ca --- /dev/null +++ b/GS1/Classes/UDPLink.uc @@ -0,0 +1,76 @@ +class UDPLink extends IpDrv.UdpLink + implements Link; + +/** + * Copyright (c) 2014 Sergei Khoroshilov + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * Reference to the listener + * @type class'Listener' + */ +var protected Listener Listener; + +/** + * @see Link.Bind + */ +public function Bind(int Port, Listener Listener) +{ + self.Listener = Listener; + + if (self.BindPort(Port, false) > 0) + { + log(self $ " is now listening on " $ Port); + return; + } + + self.Listener.OnLinkFailure(self, "failed to bind port"); + self.Destroy(); +} + +/** + * @see Link.Reply + */ +public function Reply(IpAddr Addr, coerce string Text) +{ + self.SendText(Addr, Text); +} + +/** + * Call delegate whenever plain text data is received + * + * @param IpAddr Addr + * Source address + * @param string Text + * Received plain text data + */ +event ReceivedText(IpAddr Addr, string Text) +{ + self.Listener.OnTextReceived(self, Addr, Text); +} + +event Destroyed() +{ + self.Listener = None; + Super.Destroyed(); +} + +/* vim: set ft=java: */ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8f13b40 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2014 Sergei Khoroshilov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..3483e89 --- /dev/null +++ b/README.rst @@ -0,0 +1,120 @@ +swat-gs1 +%%%%%%%% + +:Version: 1.0.0 +:Home page: https://github.com/sergeii/swat-gs1 +:Author: Sergei Khoroshilov +:License: The MIT License (http://opensource.org/licenses/MIT) + +Description +=========== +This package provides `Gamespy Query `_ multiprotocol (both UDP and TCP) listen support for SWAT 4 servers. + +Inspiration +=========== +Initially this package was developed with an intention to provide +an ability for a SWAT 4 server to respond to Gamespy queries utilizing TCP networking, +which has been a major relief for webmasters willing to set up their own server monitoring +software that would avoid using UDP transmission, since it's almost never available on a shared website hosting. + +It has later been tweaked, in order to replace the native query listener +that has gone obsolete due to SWAT 4 Gamespy support shutdown, as there are no other solutions +had been available that the author of this package had been familiar with. +The widespread AMServerQuery listener from the `AMMod package `_ that has been meant to replace +the native listener since the shutdown, does not comply with the +`Gamespy Protocol standard `_, +hence is helpless to a variety of server monitoring software (GameQ, HLSW, etc) that expect a properly formatted buffered UDP response. + +Dependencies +============ +* `Utils `_ *>=1.0.0* + +Installation +============ + +0. Install required packages listed above in the **Dependencies** section. + +1. Download compiled binaries or compile the ``GS1`` package yourself. + + Every release is accompanied by two tar files, each containing a compiled package for a specific game version:: + + swat-gs1.X.Y.Z.swat4.tar.gz + swat-gs1.X.Y.Z.swat4exp.tar.gz + + with `X.Y.Z` being the package version, followed by a game version identifier:: + + swat4 - SWAT 4 1.0-1.1 + swat4exp - SWAT 4: The Stetchkov Syndicate + + Please check the `releases page `_ to get the latest stable package version appropriate to your server game version. + +2. Copy contents of a tar archive into the server's `System` directory. + +3. Open ``Swat4DedicatedServer.ini`` + +4. Navigate to the ``[Engine.GameEngine]`` section. + +5. Comment out or remove completely the following line:: + + ServerActors=IpDrv.MasterServerUplink + + This is ought to free the +1 port (e.g. 10481) that has been occupied by the native Gamespy query listener. + +6. Insert the following line anywhere in the section:: + + ServerActors=GS1.Listener + +7. Add the following section at the bottom of the file:: + + [GS1.Listener] + Enabled=True + +8. The ``Swat4DedicatedServer.ini`` contents should look like this now:: + + [Engine.GameEngine] + EnableDevTools=False + InitialMenuClass=SwatGui.SwatMainMenu + ... + ;ServerActors=IpDrv.MasterServerUplink + ServerActors=GS1.Listener + ... + + [GS1.Listener] + Enabled=True + +9. | Your server is now ready to listen to GameSpy v1 protocol queries on the join port + 1 (e.g. 10481). + | By default, that would be a UDP port, but it is also possible to use TCP protocol as well. + | Please refer to the Properties section below. + +Properties +========== +The ``[GS1.Listener]`` section of ``Swat4DedicatedServer.ini`` accepts the following properties: + +.. list-table:: + :widths: 15 40 10 10 + :header-rows: 1 + + * - Property + - Descripion + - Options + - Default + * - Enabled + - Toggle listener on and off (requires a server restart). + - True/False + - False + * - Port + - Port to listen on. + + By default, listener mimics behaviour of the original gamespy query listener + that binds a port number equals to the join port incremented by 1. + For instance, if a join port was 10480 (default value), the query port would be 10481. + + You are free to set up an explicit port number, in order to avoid the default behaviour. + - 1-65535 + - Join Port+1 + * - Protocol + - Protocol the listen port should be bound with. + + It is possible to listen to both UDP and TCP ports simultaneously. To achieve that, set this option to `ALL`. + - UDP, TCP, ALL + - UDP diff --git a/fabfile/__init__.py b/fabfile/__init__.py new file mode 100644 index 0000000..c39a775 --- /dev/null +++ b/fabfile/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +from fabric.api import env + +from . import (ucc, server, dist) + +env.always_use_pty = False +env.use_ssh_config = True + diff --git a/fabfile/dist.py b/fabfile/dist.py new file mode 100644 index 0000000..1677082 --- /dev/null +++ b/fabfile/dist.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +import os +import glob + +from fabric.api import * +from unipath import Path + +from .utils import here + + +@task +def readme(): + """Generate README and CHANGES html files from their respective rst sources.""" + with here(): + local('rst2html {} {}'.format('CHANGES.rst', 'CHANGES.html')) + local('rst2html {} {}'.format('README.rst', 'README.html')) + +@task +def release(): + """Assemble a release dist package.""" + # create the dist directory + with quiet(): + local('rm -rf {}'.format(env.paths['dist'])) + local('mkdir -p {}'.format(env.paths['dist'])) + # find compiled packages + for (dirpath, dirnames, filenames) in os.walk(env.paths['compiled']): + files = [] + filename = [] + for path in glob.glob(Path(dirpath).child('*.u')): + path = Path(path) + files.append(path) + # filename has not yet been assembled + if not filename: + # get path of a compile package relative to the directory + relpath = Path(os.path.relpath(path, start=env.paths['compiled'])) + if relpath: + # first two components of the assembled dist package name + # are the original swat package name and its version.. + filename = [env.paths['here'].name, env.dist['version']] + for component in relpath.components()[1:-1]: + # also include names of directories the components + # of the relative path + filename.append(component.lower()) + filename.extend(['tar', 'gz']) + if not files: + continue + # tar the following files + files.extend(env.dist['extra']) + with lcd(env.paths['dist']): + local(r'tar -czf "{}" {} '.format( + '.'.join(filename), + ' '.join(['-C "{0.parent}" "{0.name}"'.format(f) for f in files]) + )) \ No newline at end of file diff --git a/fabfile/server.py b/fabfile/server.py new file mode 100644 index 0000000..fc37dfe --- /dev/null +++ b/fabfile/server.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +from fabric.api import * + +from .settings import env +from .utils import here, checkout, edit_ini + + +_kits = env.kits.keys() + +@task +@roles('server') +def all(): + """Set up the compiled packages on a clean server, then launch it.""" + setup() + install() + launch() + +@task +@roles('server') +def setup(): + """Set up a SWAT 4 test server.""" + checkout(env.server['git'], env.server['path']) + +@task +@roles('server') +def install(kits=_kits): + """Install the compiled packages on a test server.""" + with quiet(): + # configure separate servers for every listed kit + for kit in kits: + with cd(env.server['path'].child(env.kits[kit]['content'], 'System')): + # transfer compiled packages + for package, _ in env.ucc['packages']: + put(env.paths['compiled'].child(kit, '{}.u'.format(package)), '.') + # edit Swat4DedicatedServer.ini + with edit_ini(env.kits[kit]['ini']) as ini: + for section, lines in env.server['settings'].items(): + # append extra lines to a section + if section[0] == '+': + ini.append_unique(section[1:], *lines) + # set/replace section + else: + ini.replace(section, *lines) + +@task +@roles('server') +def launch(kits=_kits): + """Run a swat demo server.""" + # configure a separate server for every listed kit + for kit in kits: + puts('Starting {}'.format(env.kits[kit]['server'])) + with cd(env.server['path'].child(env.kits[kit]['content'], 'System')): + run('DISPLAY=:0 screen -d -m wine {}'.format(env.kits[kit]['server'])) + if prompt('Stop the servers?', default='y').lower().startswith('y'): + kill(kits) + +@task +@roles('server') +def kill(kits=_kits): + """Stop all Swat4DedicatedServer(X).exe processes.""" + for kit in kits: + puts('Stopping {}'.format(env.kits[kit]['server'])) + with quiet(): + run('killall {}'.format(env.kits[kit]['server'])) \ No newline at end of file diff --git a/fabfile/settings.py b/fabfile/settings.py new file mode 100644 index 0000000..b9e94d5 --- /dev/null +++ b/fabfile/settings.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +import os + +from unipath import Path +from fabric.api import * + + +env.kits = { + 'swat4': { + 'mod': 'Mod', + 'content': 'Content', + 'server': 'Swat4DedicatedServer.exe', + 'ini': 'Swat4DedicatedServer.ini', + }, + 'swat4exp': { + 'mod': 'ModX', + 'content': 'ContentExpansion', + 'server': 'Swat4XDedicatedServer.exe', + 'ini': 'Swat4XDedicatedServer.ini', + }, +} + +env.roledefs = { + 'ucc': ['vm-ubuntu-swat'], + 'server': ['vm-ubuntu-swat'], +} + +env.paths = { + 'here': Path(os.path.dirname(__file__)).parent, +} +env.paths.update({ + 'dist': env.paths['here'].child('dist'), + 'compiled': env.paths['here'].child('compiled'), +}) + +env.ucc = { + 'path': Path('/home/sergei/swat4ucc/'), + 'git': 'git@home:public/swat4#origin/ucc', + 'packages': ( + ('Utils', 'git@home:swat/swat-utils'), + ('GS1', 'git@home:swat/swat-gs1'), + ), +} + +env.server = { + 'path': Path('/home/sergei/swat4server/'), + 'git': 'git@home:public/swat4#origin/server-coop', + 'settings': { + '+[Engine.GameEngine]': ( + 'ServerActors=Utils.Package', + 'ServerActors=GS1.Listener', + ), + '[GS1.Listener]': ( + 'Enabled=True', + 'Protocol=ALL', + ), + } +} + +env.dist = { + 'version': '1.0.0', + 'extra': ( + env.paths['here'].child('LICENSE'), + env.paths['here'].child('README.html'), + env.paths['here'].child('CHANGES.html'), + ) +} \ No newline at end of file diff --git a/fabfile/ucc.py b/fabfile/ucc.py new file mode 100644 index 0000000..8d421a6 --- /dev/null +++ b/fabfile/ucc.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +from contextlib import contextmanager + +from fabric.api import * +from unipath import Path + +from .settings import env +from .utils import checkout, tmp, edit_ini + +@task +@roles('ucc') +def all(): + """Deploy package source code, compile it and retrieve the compiled binaries.""" + setup() + deploy() + make() + retrieve() + +@task +@roles('ucc') +def setup(): + """Reset the kit's git working tree to match the specified revision.""" + checkout(env.ucc['git'], env.ucc['path']) + +@task +@roles('ucc') +def deploy(): + """Deploy package source code onto virtual machine.""" + with quiet(): + for kit, opts in env.kits.items(): + source_dir = env.ucc['path'].child(opts['mod'], opts['content']) + for package, repo in env.ucc['packages']: + # checkout package repo to a tmp directory + local_repo = tmp() + checkout(repo, local_repo) + # rm existing package source directory + run(r'rm -rf {}'.format(source_dir.child(package))) + # deploy package source code + run(r'cp -r {0} {1}'.format(local_repo.child(package), source_dir)) + with system(kit): + with edit_ini('UCC.ini') as ini: + for package, _ in env.ucc['packages']: + ini.append_unique( + r'[Editor.EditorEngine]', + r'EditPackages={0}'.format(package) + ) + +@task +@roles('ucc') +def make(): + """Run UCC make utility.""" + for kit in env.kits.keys(): + with system(kit) as path: + ucc(path, 'make --nobind') + +@task +@roles('ucc') +def retrieve(): + """Retrieve compiled packages.""" + # retrieve compiled package from a mod's System directory + with quiet(): + local('rm -rf {}'.format(env.paths['compiled'])) + for kit, opts in env.kits.items(): + for package, _ in env.ucc['packages']: + package = '{}.u'.format(package) + remote_package = env.ucc['path'].child(opts['mod'], 'System', package) + local_package = Path(env.paths['compiled'].child(kit, package)) + with quiet(): + local('mkdir -p {}'.format(local_package.parent)) + get(remote_package, local_package) + +def ucc(path, command): + """Run a ucc command.""" + run('wine {0} {1}'.format(path, command)) + +@contextmanager +def system(kit): + """Set CWD and ucc environment variables appropriate to the specified kit branch.""" + with cd(env.ucc['path'].child(env.kits[kit]['mod'], 'System')): + yield env.ucc['path'].child(env.kits[kit]['content'], 'System', 'UCC.exe') \ No newline at end of file diff --git a/fabfile/utils.py b/fabfile/utils.py new file mode 100644 index 0000000..d9351f2 --- /dev/null +++ b/fabfile/utils.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +import uuid +from StringIO import StringIO +from collections import OrderedDict +from contextlib import contextmanager + +from fabric.contrib.files import exists +from unipath import Path + +from .settings import * + + +class IniFile(object): + + def __init__(self, file=None): + super(IniFile, self).__init__() + self.sections = self.parse_ini(file) + + def append(self, section, *lines): + for line in lines: + self.sections.setdefault(section, []).append(line) + + def append_unique(self, section, *lines): + # remove previous occurrences + for line in lines: + try: + self.remove(section, line) + except ValueError: + pass + self.append(section, *lines) + + def replace(self, section, *lines): + self.sections[section] = list(lines) + + def remove(self, section, *lines): + if not lines: + del self.sections[section] + else: + for line in lines: + self.sections[section].remove(line) + + def get_contents(self): + contents = [] + for section, lines in self.sections.items(): + if section: + # prepend 2-n'th section with a new line + if contents: + contents.append('') + contents.append(section) + for line in lines: + contents.append(line) + return '\n'.join(contents) + + @staticmethod + def parse_ini(path): + sections = OrderedDict() + if not path: + return sections + section = None + with open(path, 'rU') as fp: + for line in fp: + line = line.strip() + # a section + if line.startswith('['): + section = line + continue + if line: + # an ordinary non-empty line + sections.setdefault(section, []).append(line) + return sections + +def git(command): + """Run a git command.""" + run('git {}'.format(command)) + +def checkout(remote, local): + """ + Clone a remote repository `remote` to `local` and checkout origin/master. + + If the `remote` argument contains an optional extension `#revision`, + then the specified revision will be checked out instead. + """ + path, revision = (remote.split('#', 1) + [None])[:2] + if not exists(local): + git('clone {} {}'.format(path, local)) + with cd(local): + rev = revision if revision else 'origin/master' + git('fetch origin') + git('checkout --force {}'.format(rev)) + git('reset --hard {}'.format(rev)) # --mixed? + git('clean -fdx') + +def tmp(): + """Return a Path instance for a random generated /tmp file/directory child.""" + return Path('/tmp').child(str(uuid.uuid4())) + +@contextmanager +def here(): + """Set CWD to the fabfile's parent directory.""" + with lcd(env.paths['here']): + yield + +@contextmanager +def edit_ini(path): + """Yield an IniFile object for the given ini remote file path.""" + tmp_file = tmp() + # copy the remote file to the local /tmp dir + get(path, tmp_file) + # get an ini file manager + ini = IniFile(tmp_file) + # let the invoker modify file contents + yield ini + + fobj = StringIO() + fobj.write(ini.get_contents()) + # transfer it back to the remote machine + put(fobj, path) + fobj.close()