Kaynağa Gözat

Release 1.5

- Remove networking
- Re-add round win/lose
- Fix cx_freeze build
kiwec 4 yıl önce
ebeveyn
işleme
069d3824c4

+ 2 - 5
.gitignore

@@ -1,10 +1,7 @@
 __pycache__
 .mypy_cache
-venv
 .vscode
 
 build/
-*.dll
-
-cache/*
-!cache/.gitkeep
+dist/
+csgo_quake_sounds.egg-info/

+ 0 - 11
BUILDING.md

@@ -1,11 +0,0 @@
-### Building
-
-The following commands are run using Python 3.6.7 or older.
-
-* Run the following :
-
-* `python setup.py install --user`
-
-* `pip install cx_Freeze`
-
-* Run `python setup.py build`

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2020 Wolf Clement
+
+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.

+ 18 - 6
README.md

@@ -16,18 +16,30 @@ Yes.
 
 * How do I add my own sounds ?
 
-Yes. Just drop them in the corresponding `sounds` folder.
+Drop your sounds in the corresponding `sounds` folder. Feel free to remove the ones you don't like, too.
 
-As of right now, only OPUS files are supported. More formats will come soon.
-
-Feel free to remove the ones you don't like, too.
+Please keep in mind that only OPUS files are supported.
 
 ### Running
 
 You probably want to use the [installer](https://github.com/kiwec/csgo-quake-sounds/releases/latest).
 
-However, if you want to try the latest version or host your own server, follow the instructions in [RUNNING.md](https://github.com/kiwec/csgo-quake-sounds/blob/master/RUNNING.md).
+However, if you want to try the latest version, execute these commands :
+
+* `git clone https://github.com/kiwec/csgo-quake-sounds.git && cd csgo-quake-sounds`
+
+* `python setup.py install --user`
+
+Then, run it :
+
+* `python main.py`
 
 ### Building
 
-If you want to build your own installer, follow the instructions in [BUILDING.md](https://github.com/kiwec/csgo-quake-sounds/blob/master/BUILDING.md).
+Run the following commands :
+
+* `python setup.py install --user`
+
+* `pip install cx_Freeze`
+
+* Run `python build.py build`

+ 0 - 17
RUNNING.md

@@ -1,17 +0,0 @@
-### Client
-
-* `git clone https://github.com/kiwec/csgo-quake-sounds.git && cd csgo-quake-sounds`
-
-* `python setup.py install --user`
-
-Then, run it :
-
-* `python main.py`
-
-Please note that your version of Python should be 3.6 or higher.
-
-### Server
-
-* Optional: edit config.ini
-
-* `python3 server.py`

+ 0 - 24
UNLICENSE

@@ -1,24 +0,0 @@
-This is free and unencumbered software released into the public domain.
-
-Anyone is free to copy, modify, publish, use, compile, sell, or
-distribute this software, either in source code form or as a compiled
-binary, for any purpose, commercial or non-commercial, and by any
-means.
-
-In jurisdictions that recognize copyright laws, the author or authors
-of this software dedicate any and all copyright interest in the
-software to the public domain. We make this dedication for the benefit
-of the public at large and to the detriment of our heirs and
-successors. We intend this dedication to be an overt act of
-relinquishment in perpetuity of all present and future rights to this
-software under copyright law.
-
-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 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.
-
-For more information, please refer to <http://unlicense.org/>

+ 28 - 0
build.py

@@ -0,0 +1,28 @@
+import sys
+from cx_Freeze import setup, Executable  # type: ignore
+from typing import Dict
+
+buildOptions: Dict = dict(
+    packages=["aiofiles", "pyogg", "openal", "wx", "wxasync"],
+    excludes=["tkinter"],
+    include_files=[
+        "sounds",
+        "gamestate_integration_ccs.cfg",
+        "icon.ico",
+        "config.ini",
+    ],
+    optimize=2,
+    include_msvcr=True,
+)
+
+base = "Win32GUI" if sys.platform == "win32" else None
+
+executables = [Executable("main.py", base=base, targetName="csgo-custom-sounds.exe")]
+
+setup(
+    name="csgo-custom-sounds",
+    version="1.5",
+    description="Play custom sounds via Gamestate Integration",
+    options=dict(build_exe=buildOptions),
+    executables=executables,
+)

+ 8 - 222
client.py

@@ -1,84 +1,16 @@
-import asyncio
-import os
-import packel
-import wx  # type: ignore
-from queue import Empty, Queue, LifoQueue
-from typing import Optional
-from wxasync import StartCoroutine
-
-import config
-from protocol import (
-    protocol,
-    ClientUpdate,
-    ClientSoundRequest,
-    GameEvent,
-    PlaySound,
-    SoundResponse,
-    ServerRoomSounds,
-)
 from sounds import SoundManager
 from state import CSGOState
-from util import print
 
 
 class Client:
-    connected = False
-    reconnect_timeout = 1
-    room_name: Optional[str] = None
-    downloaded = 0
-    download_total = 0
-    uploaded = 0
-    upload_total = 0
-    reader = None
-    writer = None
-
-    packets_to_send: Queue = Queue()
-    sounds_to_download: LifoQueue = LifoQueue()
-    sounds_to_upload: LifoQueue = LifoQueue()
-
     def __init__(self, gui) -> None:
         self.gui = gui
         self.sounds = SoundManager(self)
         self.state = CSGOState(self)
-        StartCoroutine(self.listen, gui)
-        StartCoroutine(self.keepalive, gui)
-
-    def send(self, packet: packel.Packet):
-        self.packets_to_send.put(packet)
-
-    async def client_update(self) -> None:
-        """Thread-safe: Send a packet informing the server of our current state."""
-        if self.room_name is None:
-            if self.writer is not None and not self.writer.is_closing():
-                self.writer.close()
-            return
-
-        packet = ClientUpdate(shard_code=self.room_name)
-
-        with self.state.lock:
-            if (
-                self.state.old_state is not None
-                and self.state.old_state.steamid is not None
-            ):
-                packet.steamid = int(self.state.old_state.steamid)
-
-        download_folder = os.path.join("sounds", "Downloaded")
-        with self.sounds.lock:
-            available_sounds = self.sounds.available_sounds.items()
-        packet.sounds_list = [
-            bytes.fromhex(v)
-            for k, v in available_sounds
-            if not k.startswith(download_folder)
-        ]
-
-        self.send(packet)
 
     async def update_status(self) -> None:
-        if not self.connected:
-            self.gui.SetStatusText("Connecting to sound sync server...")
-            return
         with self.state.lock:
-            if self.state.old_state == None:
+            if self.state.old_state is None:
                 self.gui.SetStatusText("Waiting for CS:GO...")
             elif self.state.old_state.is_ingame:
                 phase = self.state.old_state.phase
@@ -87,163 +19,17 @@ class Client:
                 else:
                     phase = " (%s)" % phase
                 self.gui.SetStatusText(
-                    f'Room "{self.room_name}" - Round {self.state.old_state.current_round}{phase}'
+                    f"Round {self.state.old_state.current_round}{phase}"
                 )
             else:
-                self.gui.SetStatusText('Room "%s" - Not in a match.' % self.room_name)
+                self.gui.SetStatusText("Not in a match.")
 
     async def reload_sounds(self) -> None:
-        """Reloads all sounds. Do not call outside of gui, unless you disable the update sounds button."""
+        """Reloads all sounds.
+
+        Do not call outside of gui, unless you disable the update sounds button first.
+        """
         await self.sounds.reload()
         await self.update_status()
-        await self.client_update()
         self.gui.updateSoundsBtn.Enable()
-
-        # Play round start sound
-        playpacket = PlaySound()
-        playpacket.steamid = 0
-        random_hash = self.sounds.get_random(GameEvent.Type.ROUND_START, None)
-        if random_hash is not None:
-            playpacket.sound_hash = random_hash
-            self.sounds.play(playpacket)
-
-    async def request_sounds(self):
-        try:
-            hash = self.sounds_to_download.get(block=False)
-            if hash not in self.sounds.available_sounds.values():
-                self.send(ClientSoundRequest(sound_hash=hash))
-        except Empty:
-            if self.download_total != 0:
-                await self.update_status()
-                self.download_total = 0
-
-    async def respond_sounds(self) -> None:
-        try:
-            packet = SoundResponse()
-            packet.hash = self.sounds_to_upload.get(block=False)
-            hash = packet.hash.hex()
-            sound_filepath: Optional[str] = None
-            with self.sounds.lock:
-                for filepath, filehash in self.sounds.available_sounds.items():
-                    if filehash == hash:
-                        sound_filepath = filepath
-                        break
-            if sound_filepath is not None:
-                with open(filepath, "rb") as infile:
-                    packet.data = infile.read()
-                self.send(packet)
-        except Empty:
-            if self.upload_total != 0:
-                await self.update_status()
-                self.upload_total = 0
-
-    def handle(self, packet) -> None:
-        print(f"Received {type(packet)}")
-
-        if isinstance(packet, PlaySound):
-            if self.state.is_ingame():
-                if not self.sounds.play(packet):
-                    self.download_total = self.download_total + 1
-                    self.sounds_to_download.put(packet.sound_hash)
-        elif isinstance(packet, ServerRoomSounds):
-            with self.sounds.lock:
-                for hash in packet.available_hashes:
-                    if hash not in self.sounds.available_sounds.values():
-                        self.sounds_to_download.put(hash)
-
-            while True:
-                try:
-                    self.sounds_to_upload.get()
-                except Empty:
-                    break
-            for hash in packet.missing_hashes:
-                self.sounds_to_upload.put(hash)
-                self.upload_total += 1
-        elif isinstance(packet, SoundResponse):
-            # Give some feedback about download status
-            self.gui.SetStatusText(
-                f"Downloading sound {self.downloaded + 1}/{self.download_total}..."
-            )
-            self.sounds.save(packet)
-        else:
-            print("Unhandled packet type!")
-
-    async def keepalive(self):
-        while True:
-            await self.client_update()
-            await asyncio.sleep(10)
-
-    async def connect(self):
-        while self.room_name is None:
-            await asyncio.sleep(0.1)
-
-        while self.writer is None or self.writer.is_closing():
-            # Reset packet queue
-            self.packets_to_send = Queue()
-            self.sounds_to_download = LifoQueue()
-            self.sounds_to_upload = LifoQueue()
-            self.recvbuf = b""
-
-            ip = config.config["Network"].get("ServerIP", "kiwec.net")
-            port = config.config["Network"].getint("ServerPort", 4004)
-            ssl = config.config["Network"].getboolean("SSL", False)
-            if ssl is False:
-                ssl = None
-
-            try:
-                self.reader, self.writer = await asyncio.open_connection(
-                    ip, port, ssl=ssl
-                )
-                self.reconnect_timeout = 1
-                await self.client_update()
-            except ConnectionRefusedError:
-                await asyncio.sleep(self.reconnect_timeout)
-                self.reconnect_timeout *= 2
-                if self.reconnect_timeout > 60:
-                    self.reconnect_timeout = 60
-
-    async def run(self):
-        while True:
-            if not self.state.is_alive():
-                asyncio.gather(self.request_sounds(), self.respond_sounds())
-
-            # Send stuff
-            try:
-                packet = self.packets_to_send.get(timeout=0.1)
-                raw_packet = protocol.serialize(packet)
-                raw_header = len(raw_packet).to_bytes(4, byteorder="big")
-
-                if isinstance(packet, SoundResponse):
-                    self.gui.SetStatusText(
-                        f"Uploading sound {self.uploaded + 1}/{self.upload_total}...)"
-                    )
-
-                self.writer.write(raw_header + raw_packet)
-                await self.writer.drain()
-            except Empty:
-                pass
-
-            # Receive stuff
-            data = await self.reader.read(4)
-            if not data:
-                break
-            self.recvbuf = self.recvbuf + data
-
-            if len(self.recvbuf) >= 4:
-                recv_len = int.from_bytes(self.recvbuf[:4], "big")
-                remaining = (recv_len + 4) - len(self.recvbuf)
-                if remaining > 0:
-                    data = await self.reader.read(remaining)
-                    if not data:
-                        break
-                    self.recvbuf = self.recvbuf + data
-                    remaining = (recv_len + 4) - len(self.recvbuf)
-                if remaining <= 0:
-                    raw_packet = self.recvbuf[4 : recv_len + 4]
-                    self.recvbuf = self.recvbuf[recv_len + 4 :]
-                    self.handle(protocol.deserialize(raw_packet))
-
-    async def listen(self):
-        while True:
-            await self.connect()
-            await self.run()
+        self.sounds.play("Round start")

+ 0 - 8
config.ini

@@ -1,12 +1,4 @@
 [Sounds]
 preferheadshots = False
 volume = 50
-room = troule
-
-[Network]
-serverip = 127.0.0.1
-serverport = 4004
-certfile = certfile.crt
-keyfile = keyfile.key
-ssl = False
 

+ 4 - 2
config.py

@@ -1,12 +1,14 @@
 import configparser
 
 config = configparser.ConfigParser()
-config.read('config.ini')
+config.read("config.ini")
+
 
 def saveCfg():
-    with open('config.ini', 'w') as outfile:
+    with open("config.ini", "w") as outfile:
         config.write(outfile, space_around_delimiters=True)
 
+
 def set(section, option, val):
     config.set(section, option, str(val))
     saveCfg()

+ 27 - 69
gui.py

@@ -1,12 +1,10 @@
-import asyncio
 import subprocess
-import wx
-import wx.adv
-from wxasync import AsyncBind, StartCoroutine
+import wx  # type: ignore
+import wx.adv  # type: ignore
+from wxasync import AsyncBind, StartCoroutine  # type: ignore
 
 import client
 import config
-from protocol import GameEvent, PlaySound
 
 
 class TaskbarIcon(wx.adv.TaskBarIcon):
@@ -15,7 +13,7 @@ class TaskbarIcon(wx.adv.TaskBarIcon):
         self.frame = frame
         self.SetIcon(wx.Icon("icon.ico"))
         self.Bind(wx.adv.EVT_TASKBAR_LEFT_DOWN, self.OnLeftClick)
-    
+
     def OnLeftClick(self, evt):
         self.frame.Show()
         self.frame.Restore()
@@ -27,18 +25,20 @@ class MainFrame(wx.Frame):
         self.panel = wx.Panel(self)
         self.SetIcon(wx.Icon("icon.ico"))
 
-        # Client needs self.shardCodeIpt
-        friends_zone = self.make_friends_zone()
-
         self.CreateStatusBar()
         self.SetStatusText("Loading sounds...")
         self.client = client.Client(self)
 
         vbox = wx.BoxSizer(wx.VERTICAL)
         vbox.AddStretchSpacer()
-        vbox.Add(friends_zone, border=5, flag=wx.ALIGN_CENTER_HORIZONTAL | wx.ALL)
-        vbox.Add(self.make_volume_zone(), border=5, flag=wx.ALIGN_CENTER_HORIZONTAL | wx.ALL)
-        vbox.Add(self.make_settings_zone(), border=5, flag=wx.ALIGN_CENTER_HORIZONTAL | wx.ALL)
+        vbox.Add(
+            self.make_volume_zone(), border=5, flag=wx.ALIGN_CENTER_HORIZONTAL | wx.ALL
+        )
+        vbox.Add(
+            self.make_settings_zone(),
+            border=5,
+            flag=wx.ALIGN_CENTER_HORIZONTAL | wx.ALL,
+        )
         vbox.AddStretchSpacer()
         self.panel.SetSizer(vbox)
         self.panel.Layout()
@@ -51,42 +51,22 @@ class MainFrame(wx.Frame):
         self.Show()
 
         StartCoroutine(self.UpdateSounds(None), self)
-        StartCoroutine(self.JoinOrLeaveRoom(None), self)
-    
+
     def make_volume_zone(self):
         with self.client.sounds.lock:
-            self.volumeSlider = wx.Slider(self.panel, value=self.client.sounds.volume, size=(272, 25))
+            self.volumeSlider = wx.Slider(
+                self.panel, value=self.client.sounds.volume, size=(272, 25)
+            )
         AsyncBind(wx.EVT_COMMAND_SCROLL_CHANGED, self.OnVolumeSlider, self.volumeSlider)
 
         volumeZone = wx.StaticBoxSizer(wx.VERTICAL, self.panel, label="Volume")
         volumeZone.Add(self.volumeSlider)
         return volumeZone
 
-    def make_friends_zone(self):
-        self.shardCodeBtn = wx.Button(self.panel, label="Join room")
-        AsyncBind(wx.EVT_BUTTON, self.JoinOrLeaveRoom, self.shardCodeBtn)
-        self.shardCodeIpt = wx.TextCtrl(
-            self.panel,
-            value=config.config['Sounds'].get('Room', ''),
-            size=(164, self.shardCodeBtn.GetMinSize().GetHeight())
-        )
-        self.shardCodeIpt.SetFocus()
-        shardCodeExplanationTxt = wx.StaticText(
-            self.panel,
-            label="In order to hear your teammates' sounds, you\nneed to join the same room."
-        )
-
-        friendsZone = wx.StaticBoxSizer(wx.VERTICAL, self.panel, label="Room")
-        friendsZone.Add(shardCodeExplanationTxt, border=5, flag=wx.LEFT | wx.DOWN)
-        friendsInputZone = wx.BoxSizer(wx.HORIZONTAL)
-        friendsInputZone.Add(self.shardCodeIpt, border=5, flag=wx.ALIGN_CENTER_VERTICAL | wx.ALL)
-        friendsInputZone.Add(self.shardCodeBtn, border=5, flag=wx.ALIGN_CENTER_VERTICAL | wx.ALL)
-        friendsZone.Add(friendsInputZone)
-
-        return friendsZone
-    
     def make_settings_zone(self):
-        self.preferHeadshotsChk = wx.CheckBox(self.panel, label="Prefer headshot sounds over killstreak sounds")
+        self.preferHeadshotsChk = wx.CheckBox(
+            self.panel, label="Prefer headshot sounds over killstreak sounds"
+        )
 
         openSoundDirBtn = wx.Button(self.panel, label="Open sounds directory")
         self.updateSoundsBtn = wx.Button(self.panel, label="Update sounds")
@@ -101,12 +81,14 @@ class MainFrame(wx.Frame):
         settingsBox.Add(self.preferHeadshotsChk, border=5, flag=wx.ALL)
         settingsBox.Add(soundBtns, border=5, flag=wx.ALIGN_CENTER | wx.UP | wx.DOWN)
 
-        preferHeadshots = config.config['Sounds'].getboolean('PreferHeadshots', False)
+        preferHeadshots = config.config["Sounds"].getboolean("PreferHeadshots", False)
         self.preferHeadshotsChk.SetValue(preferHeadshots)
         self.Bind(
             wx.EVT_CHECKBOX,
-            lambda e: config.set('Sounds', 'PreferHeadshots', self.preferHeadshotsChk.Value),
-            self.preferHeadshotsChk
+            lambda e: config.set(
+                "Sounds", "PreferHeadshots", self.preferHeadshotsChk.Value
+            ),
+            self.preferHeadshotsChk,
         )
 
         return settingsBox
@@ -121,42 +103,18 @@ class MainFrame(wx.Frame):
         await self.client.update_status()
 
     async def OnVolumeSlider(self, event):
-        config.set('Sounds', 'Volume', self.volumeSlider.Value)
+        config.set("Sounds", "Volume", self.volumeSlider.Value)
         with self.client.sounds.lock:
             # Volume didn't change
             if self.client.sounds.volume == self.volumeSlider.Value:
                 return
             self.client.sounds.volume = self.volumeSlider.Value
-        playpacket = PlaySound()
-        playpacket.steamid = 0
-        random_hash = self.client.sounds.get_random(GameEvent.Type.HEADSHOT, None)
-        if random_hash is not None:
-            playpacket.sound_hash = random_hash
-            self.client.sounds.play(playpacket)
+        self.client.sounds.play("Headshot")
 
     async def OpenSoundsDir(self, event):
         # TODO linux
         subprocess.Popen('explorer "sounds"')
-    
-    async def JoinOrLeaveRoom(self, event):
-        # Don't join without a room name
-        if self.shardCodeIpt.GetValue() == '':
-            return
 
-        if self.client.room_name is None:
-            self.client.room_name = self.shardCodeIpt.GetValue()
-            config.set('Sounds', 'Room', self.shardCodeIpt.GetValue())
-            await self.client.client_update()
-            self.shardCodeIpt.Disable()
-            self.shardCodeBtn.SetLabel('Leave room')
-            self.shardCodeBtn.Disable()
-            await asyncio.sleep(1)
-            self.shardCodeBtn.Enable()
-        else:
-            self.client.room_name = None
-            self.shardCodeIpt.Enable()
-            self.shardCodeBtn.SetLabel('Join room')
-    
     async def UpdateSounds(self, event):
         self.updateSoundsBtn.Disable()
         StartCoroutine(self.client.reload_sounds, self)
@@ -164,7 +122,7 @@ class MainFrame(wx.Frame):
     async def OnMinimize(self, event):
         if self.IsIconized():
             self.Hide()
-    
+
     async def OnClose(self, event):
         self.taskbarIcon.Destroy()
         self.Destroy()

+ 5 - 5
main.py

@@ -4,11 +4,11 @@ import os
 import wx  # type: ignore  # type: ignore
 from openal import oalInit, oalQuit  # type: ignore
 from shutil import copyfile
-from steamfiles import acf  # type: ignore
-from wxasync import WxAsyncApp
+from wxasync import WxAsyncApp  # type: ignore
 
 # Local files
 import gui
+import steamfiles
 
 
 def get_steam_path() -> str:
@@ -31,7 +31,7 @@ def get_steam_path() -> str:
 def get_csgo_path(steamapps_folder):
     # Get every SteamLibrary folder
     with open(os.path.join(steamapps_folder, "libraryfolders.vdf")) as infile:
-        libraryfolders = acf.load(infile)
+        libraryfolders = steamfiles.load(infile)
     folders = [steamapps_folder]
     i = 1
     while True:
@@ -51,7 +51,7 @@ def get_csgo_path(steamapps_folder):
             appmanifest = os.path.join(folder, "appmanifest_730.acf")
             print(f"Opening appmanifest {appmanifest}...")
             with open(appmanifest) as infile:
-                appmanifest = acf.load(infile)
+                appmanifest = steamfiles.load(infile)
                 installdir = os.path.join(
                     folder, "common", appmanifest["AppState"]["installdir"]
                 )
@@ -79,7 +79,7 @@ def main():
     gui.MainFrame(
         None,
         title="CSGO Custom Sounds",
-        size=wx.Size(320, 340),
+        size=wx.Size(320, 230),
         style=wx.DEFAULT_FRAME_STYLE & ~(wx.RESIZE_BORDER | wx.MAXIMIZE_BOX),
     )
     loop.run_until_complete(app.MainLoop())

+ 0 - 252
poetry.lock

@@ -1,252 +0,0 @@
-[[package]]
-category = "main"
-description = "File support for asyncio."
-name = "aiofiles"
-optional = false
-python-versions = "*"
-version = "0.4.0"
-
-[[package]]
-category = "main"
-description = "NumPy is the fundamental package for array computing with Python."
-marker = "python_version >= \"3.0\""
-name = "numpy"
-optional = false
-python-versions = ">=3.5"
-version = "1.17.4"
-
-[[package]]
-category = "main"
-description = "Packet serialization/deserialization in a Pythonic way."
-name = "packel"
-optional = false
-python-versions = "*"
-version = "1.0.1"
-
-[[package]]
-category = "main"
-description = "Python Imaging Library (Fork)"
-name = "pillow"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
-version = "6.2.1"
-
-[[package]]
-category = "main"
-description = "Protocol Buffers"
-name = "protobuf"
-optional = false
-python-versions = "*"
-version = "3.11.1"
-
-[package.dependencies]
-setuptools = "*"
-six = ">=1.9"
-
-[[package]]
-category = "main"
-description = "Ben Hodgson: A teeny Python library for creating Python dicts from protocol buffers and the reverse. Useful as an intermediate step before serialisation (e.g. to JSON). Kapor: upgrade it to PB3 and PY3, rename it to protobuf3-to-dict"
-name = "protobuf3-to-dict"
-optional = false
-python-versions = "*"
-version = "0.1.5"
-
-[package.dependencies]
-protobuf = ">=2.3.0"
-six = "*"
-
-[[package]]
-category = "main"
-description = "Xiph.org's Ogg Vorbis, Opus and FLAC for Python"
-name = "pyogg"
-optional = false
-python-versions = "*"
-version = "0.6.11a1"
-
-[[package]]
-category = "main"
-description = "OpenAL integration for Python"
-name = "pyopenal"
-optional = false
-python-versions = "*"
-version = "0.7.9a1"
-
-[[package]]
-category = "main"
-description = "Python 2 and 3 compatibility utilities"
-name = "six"
-optional = false
-python-versions = ">=2.6, !=3.0.*, !=3.1.*"
-version = "1.13.0"
-
-[[package]]
-category = "main"
-description = "Python library for parsing the most common Steam file formats."
-name = "steamfiles"
-optional = false
-python-versions = "*"
-version = "0.1.3"
-
-[package.dependencies]
-protobuf = ">=3.0.0b2"
-protobuf3-to-dict = ">=0.1.0"
-
-[package.source]
-reference = "27c355fa24e6422838c0d2704c4bba993e5687f5"
-type = "git"
-url = "https://github.com/leovp/steamfiles.git"
-[[package]]
-category = "main"
-description = "asyncio for wxpython"
-name = "wxasync"
-optional = false
-python-versions = "*"
-version = "0.41"
-
-[package.dependencies]
-wxpython = "*"
-
-[[package]]
-category = "main"
-description = "Cross platform GUI toolkit for Python, \"Phoenix\" version"
-name = "wxpython"
-optional = false
-python-versions = "*"
-version = "4.0.7.post2"
-
-[package.dependencies]
-pillow = "*"
-six = "*"
-
-[package.dependencies.numpy]
-python = ">=3.0"
-version = "*"
-
-[metadata]
-content-hash = "34e755851ec2407efa2e2cd70226ec190fdce28f7627a0fd05d92f932a78b5db"
-python-versions = "^3.7"
-
-[metadata.files]
-aiofiles = [
-    {file = "aiofiles-0.4.0-py3-none-any.whl", hash = "sha256:1e644c2573f953664368de28d2aa4c89dfd64550429d0c27c4680ccd3aa4985d"},
-    {file = "aiofiles-0.4.0.tar.gz", hash = "sha256:021ea0ba314a86027c166ecc4b4c07f2d40fc0f4b3a950d1868a0f2571c2bbee"},
-]
-numpy = [
-    {file = "numpy-1.17.4-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:ede47b98de79565fcd7f2decb475e2dcc85ee4097743e551fe26cfc7eb3ff143"},
-    {file = "numpy-1.17.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:43bb4b70585f1c2d153e45323a886839f98af8bfa810f7014b20be714c37c447"},
-    {file = "numpy-1.17.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c7354e8f0eca5c110b7e978034cd86ed98a7a5ffcf69ca97535445a595e07b8e"},
-    {file = "numpy-1.17.4-cp35-cp35m-win32.whl", hash = "sha256:64874913367f18eb3013b16123c9fed113962e75d809fca5b78ebfbb73ed93ba"},
-    {file = "numpy-1.17.4-cp35-cp35m-win_amd64.whl", hash = "sha256:6ca4000c4a6f95a78c33c7dadbb9495c10880be9c89316aa536eac359ab820ae"},
-    {file = "numpy-1.17.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:75fd817b7061f6378e4659dd792c84c0b60533e867f83e0d1e52d5d8e53df88c"},
-    {file = "numpy-1.17.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:7d81d784bdbed30137aca242ab307f3e65c8d93f4c7b7d8f322110b2e90177f9"},
-    {file = "numpy-1.17.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe39f5fd4103ec4ca3cb8600b19216cd1ff316b4990f4c0b6057ad982c0a34d5"},
-    {file = "numpy-1.17.4-cp36-cp36m-win32.whl", hash = "sha256:e467c57121fe1b78a8f68dd9255fbb3bb3f4f7547c6b9e109f31d14569f490c3"},
-    {file = "numpy-1.17.4-cp36-cp36m-win_amd64.whl", hash = "sha256:8d0af8d3664f142414fd5b15cabfd3b6cc3ef242a3c7a7493257025be5a6955f"},
-    {file = "numpy-1.17.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9679831005fb16c6df3dd35d17aa31dc0d4d7573d84f0b44cc481490a65c7725"},
-    {file = "numpy-1.17.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:acbf5c52db4adb366c064d0b7c7899e3e778d89db585feadd23b06b587d64761"},
-    {file = "numpy-1.17.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:3d52298d0be333583739f1aec9026f3b09fdfe3ddf7c7028cb16d9d2af1cca7e"},
-    {file = "numpy-1.17.4-cp37-cp37m-win32.whl", hash = "sha256:475963c5b9e116c38ad7347e154e5651d05a2286d86455671f5b1eebba5feb76"},
-    {file = "numpy-1.17.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0c0763787133dfeec19904c22c7e358b231c87ba3206b211652f8cbe1241deb6"},
-    {file = "numpy-1.17.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:683828e50c339fc9e68720396f2de14253992c495fdddef77a1e17de55f1decc"},
-    {file = "numpy-1.17.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e2e9d8c87120ba2c591f60e32736b82b67f72c37ba88a4c23c81b5b8fa49c018"},
-    {file = "numpy-1.17.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:a8f67ebfae9f575d85fa859b54d3bdecaeece74e3274b0b5c5f804d7ca789fe1"},
-    {file = "numpy-1.17.4-cp38-cp38-win32.whl", hash = "sha256:0a7a1dd123aecc9f0076934288ceed7fd9a81ba3919f11a855a7887cbe82a02f"},
-    {file = "numpy-1.17.4-cp38-cp38-win_amd64.whl", hash = "sha256:ada4805ed51f5bcaa3a06d3dd94939351869c095e30a2b54264f5a5004b52170"},
-    {file = "numpy-1.17.4.zip", hash = "sha256:f58913e9227400f1395c7b800503ebfdb0772f1c33ff8cb4d6451c06cabdf316"},
-]
-packel = [
-    {file = "packel-1.0.1-py3-none-any.whl", hash = "sha256:8af9bfe9a065ce8a26030dc527eb2125c22df4bc466874183ebab2e6183ece8c"},
-    {file = "packel-1.0.1.tar.gz", hash = "sha256:3c0f88f7dbf13461e021d0701057cbb8a463cdb64b046a565fe655f8849e24ea"},
-    {file = "packel-1.0.1b-py3-none-any.whl", hash = "sha256:a35c8670f65fc870383a29683f47ecf8e3614ed34c72bdc56eb871ec621432ec"},
-]
-pillow = [
-    {file = "Pillow-6.2.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:4ac6148008c169603070c092e81f88738f1a0c511e07bd2bb0f9ef542d375da9"},
-    {file = "Pillow-6.2.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:4aad1b88933fd6dc2846552b89ad0c74ddbba2f0884e2c162aa368374bf5abab"},
-    {file = "Pillow-6.2.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c710fcb7ee32f67baf25aa9ffede4795fd5d93b163ce95fdc724383e38c9df96"},
-    {file = "Pillow-6.2.1-cp27-cp27m-win32.whl", hash = "sha256:e9a3edd5f714229d41057d56ac0f39ad9bdba6767e8c888c951869f0bdd129b0"},
-    {file = "Pillow-6.2.1-cp27-cp27m-win_amd64.whl", hash = "sha256:b1ae48d87f10d1384e5beecd169c77502fcc04a2c00a4c02b85f0a94b419e5f9"},
-    {file = "Pillow-6.2.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:a423c2ea001c6265ed28700df056f75e26215fd28c001e93ef4380b0f05f9547"},
-    {file = "Pillow-6.2.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:9f5529fc02009f96ba95bea48870173426879dc19eec49ca8e08cd63ecd82ddb"},
-    {file = "Pillow-6.2.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:5cc901c2ab9409b4b7ac7b5bcc3e86ac14548627062463da0af3b6b7c555a871"},
-    {file = "Pillow-6.2.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c6414f6aad598364aaf81068cabb077894eb88fed99c6a65e6e8217bab62ae7a"},
-    {file = "Pillow-6.2.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:384b12c9aa8ef95558abdcb50aada56d74bc7cc131dd62d28c2d0e4d3aadd573"},
-    {file = "Pillow-6.2.1-cp35-cp35m-win32.whl", hash = "sha256:248cffc168896982f125f5c13e9317c059f74fffdb4152893339f3be62a01340"},
-    {file = "Pillow-6.2.1-cp35-cp35m-win_amd64.whl", hash = "sha256:285edafad9bc60d96978ed24d77cdc0b91dace88e5da8c548ba5937c425bca8b"},
-    {file = "Pillow-6.2.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:846fa202bd7ee0f6215c897a1d33238ef071b50766339186687bd9b7a6d26ac5"},
-    {file = "Pillow-6.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:7ce80c0a65a6ea90ef9c1f63c8593fcd2929448613fc8da0adf3e6bfad669d08"},
-    {file = "Pillow-6.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e0697b826da6c2472bb6488db4c0a7fa8af0d52fa08833ceb3681358914b14e5"},
-    {file = "Pillow-6.2.1-cp36-cp36m-win32.whl", hash = "sha256:047d9473cf68af50ac85f8ee5d5f21a60f849bc17d348da7fc85711287a75031"},
-    {file = "Pillow-6.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:83792cb4e0b5af480588601467c0764242b9a483caea71ef12d22a0d0d6bdce2"},
-    {file = "Pillow-6.2.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:c9e5ffb910b14f090ac9c38599063e354887a5f6d7e6d26795e916b4514f2c1a"},
-    {file = "Pillow-6.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:4deb1d2a45861ae6f0b12ea0a786a03d19d29edcc7e05775b85ec2877cb54c5e"},
-    {file = "Pillow-6.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:0f66dc6c8a3cc319561a633b6aa82c44107f12594643efa37210d8c924fc1c71"},
-    {file = "Pillow-6.2.1-cp37-cp37m-win32.whl", hash = "sha256:59aa2c124df72cc75ed72c8d6005c442d4685691a30c55321e00ed915ad1a291"},
-    {file = "Pillow-6.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6c1db03e8dff7b9f955a0fb9907eb9ca5da75b5ce056c0c93d33100a35050281"},
-    {file = "Pillow-6.2.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:12c9169c4e8fe0a7329e8658c7e488001f6b4c8e88740e76292c2b857af2e94c"},
-    {file = "Pillow-6.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:27faf0552bf8c260a5cee21a76e031acaea68babb64daf7e8f2e2540745082aa"},
-    {file = "Pillow-6.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:809c19241c14433c5d6135e1b6c72da4e3b56d5c865ad5736ab99af8896b8f41"},
-    {file = "Pillow-6.2.1-cp38-cp38-win32.whl", hash = "sha256:ac4428094b42907aba5879c7c000d01c8278d451a3b7cccd2103e21f6397ea75"},
-    {file = "Pillow-6.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:38950b3a707f6cef09cd3cbb142474357ad1a985ceb44d921bdf7b4647b3e13e"},
-    {file = "Pillow-6.2.1-pp272-pypy_41-win32.whl", hash = "sha256:5a47d2123a9ec86660fe0e8d0ebf0aa6bc6a17edc63f338b73ea20ba11713f12"},
-    {file = "Pillow-6.2.1-pp372-pp372-win32.whl", hash = "sha256:c7be4b8a09852291c3c48d3c25d1b876d2494a0a674980089ac9d5e0d78bd132"},
-    {file = "Pillow-6.2.1.tar.gz", hash = "sha256:bf4e972a88f8841d8fdc6db1a75e0f8d763e66e3754b03006cbc3854d89f1cb1"},
-]
-protobuf = [
-    {file = "protobuf-3.11.1-cp27-cp27m-macosx_10_9_intel.whl", hash = "sha256:34a7270940f86da7a28be466ac541c89b6dbf144a6348b9cf7ac6f56b71006ce"},
-    {file = "protobuf-3.11.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e028fee51c96de4e81924484c77111dfdea14010ecfc906ea5b252209b0c4de6"},
-    {file = "protobuf-3.11.1-cp35-cp35m-macosx_10_9_intel.whl", hash = "sha256:29bd1ed46b2536ad8959401a2f02d2d7b5a309f8e97518e4f92ca6c5ba74dbed"},
-    {file = "protobuf-3.11.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:839bad7d115c77cdff29b488fae6a3ab503ce9a4192bd4c42302a6ea8e5d0f33"},
-    {file = "protobuf-3.11.1-cp35-cp35m-win32.whl", hash = "sha256:c65d135ea2d85d40309e268106dab02d3bea723db2db21c23ecad4163ced210b"},
-    {file = "protobuf-3.11.1-cp35-cp35m-win_amd64.whl", hash = "sha256:38cbc830a4a5ba9956763b0f37090bfd14dd74e72762be6225de2ceac55f4d03"},
-    {file = "protobuf-3.11.1-cp36-cp36m-macosx_10_9_intel.whl", hash = "sha256:c98dea04a1ff41a70aff2489610f280004831798cb36a068013eed04c698903d"},
-    {file = "protobuf-3.11.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0265379852b9e1f76af6d3d3fe4b3c383a595cc937594bda8565cf69a96baabd"},
-    {file = "protobuf-3.11.1-cp36-cp36m-win32.whl", hash = "sha256:934a9869a7f3b0d84eca460e386fba1f7ba2a0c1a120a2648bc41fadf50efd1c"},
-    {file = "protobuf-3.11.1-cp36-cp36m-win_amd64.whl", hash = "sha256:3175d45698edb9a07c1a78a1a4850e674ce8988f20596580158b1d0921d0f057"},
-    {file = "protobuf-3.11.1-cp37-cp37m-macosx_10_9_intel.whl", hash = "sha256:d9049aa194378a426f0b2c784e2054565bf6f754d20fcafdee7102a6250556e8"},
-    {file = "protobuf-3.11.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e88a924b591b06d0191620e9c8aa75297b3111066bb09d49a24bae1054a10c13"},
-    {file = "protobuf-3.11.1-cp37-cp37m-win32.whl", hash = "sha256:c4e90bc27c0691c76e09b5dc506133451e52caee1472b8b3c741b7c912ce43ef"},
-    {file = "protobuf-3.11.1-cp37-cp37m-win_amd64.whl", hash = "sha256:e84ad26fb50091b1ea676403c0dd2bd47663099454aa6d88000b1dafecab0941"},
-    {file = "protobuf-3.11.1-py2.7.egg", hash = "sha256:200b77e51f17fbc1d3049045f5835f60405dec3a00fe876b9b986592e46d908c"},
-    {file = "protobuf-3.11.1-py2.py3-none-any.whl", hash = "sha256:665194f5ad386511ac8d8a0bd57b9ab37b8dd2cd71969458777318e774b9cd46"},
-    {file = "protobuf-3.11.1.tar.gz", hash = "sha256:aecdf12ef6dc7fd91713a6da93a86c2f2a8fe54840a3b1670853a2b7402e77c9"},
-]
-protobuf3-to-dict = [
-    {file = "protobuf3-to-dict-0.1.5.tar.gz", hash = "sha256:1e42c25b5afb5868e3a9b1962811077e492c17557f9c66f0fe40d821375d2b5a"},
-]
-pyogg = [
-    {file = "PyOgg-0.6.11a1-py2.py3-none-win32.whl", hash = "sha256:6f1c7bb9081b43c8368fa26fef22c9eac6f8c7eb2917edc51c64a28e65d107df"},
-    {file = "PyOgg-0.6.11a1-py2.py3-none-win_amd64.whl", hash = "sha256:f34f7f366548f5ee557f4b8314c8b407b147b6ca7b8511e23112d819fef86ce4"},
-    {file = "PyOgg-0.6.11a1.tar.gz", hash = "sha256:0a05d2ece580b4c461d70950bc0ba14d81440d2925c05910851fac9e5d2de69a"},
-]
-pyopenal = [
-    {file = "PyOpenAL-0.7.9a1-py2.py3-none-win32.whl", hash = "sha256:e12cdf85825e8a27b9dbfa561de54cc9f83de9e5d88228f88e78022985845025"},
-    {file = "PyOpenAL-0.7.9a1-py2.py3-none-win_amd64.whl", hash = "sha256:70d2af2c79411c98d23b2964fd0c8bdbebd7bf77a5b976e774d01a17ab45a30f"},
-    {file = "PyOpenAL-0.7.9a1.tar.gz", hash = "sha256:115ed4f71aa4184bda9e9f9c3f7a4a17f392110f133a381c2a375f77fff3cf52"},
-]
-six = [
-    {file = "six-1.13.0-py2.py3-none-any.whl", hash = "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd"},
-    {file = "six-1.13.0.tar.gz", hash = "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"},
-]
-steamfiles = []
-wxasync = [
-    {file = "wxasync-0.41-py3-none-any.whl", hash = "sha256:9b90b99e0d67eb402541efee97d40ec041cdd6886c8a5e91e7785d14e87c1db9"},
-    {file = "wxasync-0.41.tar.gz", hash = "sha256:41daa0422966f714eb97758a0fe5c69e9dcde8217f321953d1982c0795a38995"},
-]
-wxpython = [
-    {file = "wxPython-4.0.7.post2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:202fa6c1d463df46bd6c5bb69e72c5614923577f12183915f4d8c3732fb2e4f4"},
-    {file = "wxPython-4.0.7.post2-cp27-cp27m-win32.whl", hash = "sha256:4c8423f89e0f4a86fcb7c128795f6d9d8904c8b869257a18eb98801b697f4ca8"},
-    {file = "wxPython-4.0.7.post2-cp27-cp27m-win_amd64.whl", hash = "sha256:71b0d22a7c518fe58ea91d4aef9d30662e3c5357e8df08881844ac92488bc97a"},
-    {file = "wxPython-4.0.7.post2-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:6b44923b4517fcbaf40ba0c16a75b674e0726f690e39810d689b340b3baa53b9"},
-    {file = "wxPython-4.0.7.post2-cp35-cp35m-win32.whl", hash = "sha256:ece07f9bfcaa349fac2e02d30cc3888158b971d3dbe6834b5f495980697fccaa"},
-    {file = "wxPython-4.0.7.post2-cp35-cp35m-win_amd64.whl", hash = "sha256:6bce0350e91396cc29e6d51540b94750cb45bae84dc9e7cd8b478c04d0525613"},
-    {file = "wxPython-4.0.7.post2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d951d7cbd260899ea5845beb7760d74148502b6903758cfe02bbd16bb40635c1"},
-    {file = "wxPython-4.0.7.post2-cp36-cp36m-win32.whl", hash = "sha256:f49912dfed3d59277c595e077fbb7b2d95a5df011216fbd7db3996b15bb8e698"},
-    {file = "wxPython-4.0.7.post2-cp36-cp36m-win_amd64.whl", hash = "sha256:74af98a97064f299ab8b4df122c4f4fa73592ec03b9b918694879de93163337d"},
-    {file = "wxPython-4.0.7.post2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:39fbab20a1dcbebedf88a3057c095005f9367a5bef8ac69600fc848e3ca169d2"},
-    {file = "wxPython-4.0.7.post2-cp37-cp37m-win32.whl", hash = "sha256:6b4b3df1c046d864947f83719a5e5cc442d4368c354508ff41466aa196448f94"},
-    {file = "wxPython-4.0.7.post2-cp37-cp37m-win_amd64.whl", hash = "sha256:9e2294feb9d183b8754c1eb9eec920368c3a5d5fad4c4e3362ca2709962ae732"},
-    {file = "wxPython-4.0.7.post2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:dbef25843b79d15812ab0e05e6f2b673eff038233d8ca9087cf3a58fe9f21bff"},
-    {file = "wxPython-4.0.7.post2-cp38-cp38-win32.whl", hash = "sha256:aa6d0097a58096ea40b2d6d1307f60259ba108674a68329690a664910c7b6743"},
-    {file = "wxPython-4.0.7.post2-cp38-cp38-win_amd64.whl", hash = "sha256:ab72ca3a3550e132cb2eef67c978bbb702591a334dfdb37853aeb702ce7071af"},
-    {file = "wxPython-4.0.7.post2.tar.gz", hash = "sha256:5a229e695b64f9864d30a5315e0c1e4ff5e02effede0a07f16e8d856737a0c4e"},
-]

+ 0 - 122
protocol.py

@@ -1,122 +0,0 @@
-import enum
-import packel
-from packel.types import PacketType
-from typing import ClassVar, List, Type
-
-
-### packel extensions ----------------------------------------------------------------
-class Hash(PacketType):
-	is_dynamic_length: ClassVar[bool] = False
-	type: ClassVar[Type] = bytes
-
-	@staticmethod
-	def default() -> bytes:
-		return Hash.serialize(b'')
-
-	@staticmethod
-	def deserialize(b: bytes) -> bytes:
-		return b
-
-	@staticmethod
-	def serialize(v) -> bytes:
-		if len(v) > 64:
-			raise ValueError("Hash longer than 64 bytes.")
-		return v.ljust(64, b'\x00')
-
-class HashList(PacketType):
-	is_dynamic_length: ClassVar[bool] = True
-	type: ClassVar[Type] = List
-
-	@staticmethod
-	def default() -> List[bytes]:
-		return []
-
-	@staticmethod
-	def deserialize(b: bytes) -> List[bytes]:
-		out: List[bytes] = []
-
-		pos = 4
-		nb_hashes = int.from_bytes(b[:4], 'big')
-		for i in range(nb_hashes):
-			out.append(Hash.deserialize(b[pos:pos+64]))
-			pos = pos + 64
-
-		return out
-
-	@staticmethod
-	def serialize(v: List[bytes]) -> bytes:
-		nb_hashes = len(v)
-		out = nb_hashes.to_bytes(4, 'big')
-		for hash in v:
-			out = out + Hash.serialize(hash)
-		return out
-
-
-### Sent to server -------------------------------------------------------------------
-class KeepAlive(packel.Packet):
-	pass
-
-class ClientSoundRequest(packel.Packet):
-	"""Packet sent when the client wants to download a sound."""
-	sound_hash = Hash()
-
-class GameEvent(packel.Packet):
-	class Type(enum.Enum):
-		MVP = 0
-		ROUND_WIN = 1
-		ROUND_LOSE = 2
-		SUICIDE = 3
-		TEAMKILL = 4
-		DEATH = 5
-		FLASH = 6
-		KNIFE = 7
-		HEADSHOT = 8
-		KILL = 9
-		COLLATERAL = 10
-		ROUND_START = 11
-		TIMEOUT = 12
-
-	type = packel.Int32()
-	proposed_sound_hash = Hash()
-	kill_count = packel.Int32()
-
-class ClientUpdate(packel.Packet):
-	steamid = packel.Int64()
-	room_name = packel.String()
-	sounds_list = HashList()
-
-
-### Sent to client -------------------------------------------------------------------
-class ServerRoomSounds(packel.Packet):
-	"""Packet sent when a room's sounds list is updated :
-
-	- When a client joins or leaves a room
-	- When a missing sound was uploaded
-	"""
-	available_hashes = HashList()
-	missing_hashes = HashList()
-
-class PlaySound(packel.Packet):
-	steamid = packel.Int64()
-	sound_hash = Hash()
-
-
-### Sent to both ---------------------------------------------------------------------
-class SoundResponse(packel.Packet):
-	"""Packet sent either by the client or the server, containing a sound file."""
-	data = packel.Bytes()
-	hash = Hash()
-
-
-protocol = packel.Protocol([
-	# Sent to server
-	KeepAlive, ClientUpdate, ClientSoundRequest, GameEvent,
-
-	# Sent to client
-	ServerRoomSounds, PlaySound,
-
-	# Both
-	SoundResponse,
-
-	# vvv New packets go here vvv
-])

+ 0 - 23
pyproject.toml

@@ -1,23 +0,0 @@
-[tool.poetry]
-name = "csgo-quake-sounds"
-version = "1.5.0"
-description = "Play quake sounds via Gamestate Integration"
-authors = ["kiwec"]
-license = "UNLICENSE"
-
-[tool.poetry.dependencies]
-python = "^3.7"
-PyOgg = "=0.6.11a1"
-PyOpenAL = "=0.7.9a1"
-wxasync = "^0.41.0"
-steamfiles = {git = "https://github.com/leovp/steamfiles.git"}
-aiofiles = "^0.4.0"
-packel = "^1.0"
-
-[tool.dephell.main]
-from = {format = "poetry", path = "pyproject.toml"}
-to = {format = "setuppy", path = "setup.py"}
-
-[build-system]
-requires = ["poetry>=0.12"]
-build-backend = "poetry.masonry.api"

+ 0 - 217
server.py

@@ -1,217 +0,0 @@
-import asyncio
-import hashlib
-import os
-import ssl
-import sys
-from time import time
-from typing import Dict, Optional
-
-import config
-from protocol import protocol, ClientUpdate, ClientSoundRequest, GameEvent, PlaySound, SoundResponse, ServerRoomSounds
-from util import get_event_class, small_hash
-
-
-CLIENT_TIMEOUT = 20
-available_sounds = set()
-rooms = {}
-
-
-class Room(object):
-	clients = []
-	last_events: Dict[int, int] = {}
-	available_sounds = set()
-	missing_sounds = set()
-
-	def play(self, steamid, hash):
-		packet = PlaySound()
-		packet.steamid = steamid
-		packet.sound_hash = hash
-
-		print(f'{str(self)} Playing {small_hash(hash)} for {steamid}')
-
-		played = False
-		for client in self.clients:
-			if client.steamid != steamid and client.steamid != 0:
-				played = True
-				print(f'{str(self)} -> {str(client.addr)}')
-				client.send(packet)
-
-		if played is True:
-			print(f'Done playing {small_hash(hash)}')
-		else:
-			print("Whoops, nevermind, this guy is all alone")
-
-	def play_shared(self, event, hash):
-		if event not in self.last_events or self.last_events[event] + 10 < time():
-			self.last_events[event] = time()
-			self.play(0, hash)
-
-
-class Connection(asyncio.Protocol):
-
-	def connection_made(self, transport):
-		self.transport = transport
-		self.peername = transport.get_extra_info('peername')
-		self.buffer = b''
-		self.room_name: Optional[str] = None
-		self.sounds = set()
-		self.steamid = 0
-		print(f"{self.peername} connected.")
-
-	def connection_lost(self, exc):
-		print(f"{self.peername} disconnected.")
-		self.transport.close()
-
-	def data_received(self, data):
-		self.buffer = self.buffer + data
-		self.try_reading_buffer()
-
-	@property
-	def room(self):
-		return None if self.room_name is None else rooms[self.room_name]
-
-	def join_room(self, room_name: str) -> bool:
-		if self.room_name is not None:
-			self.leave_room()
-		if room_name not in rooms:
-			rooms[room_name] = Room()
-		rooms[room_name].clients.append(self)
-		self.room_name = room_name
-
-		for sound in self.sounds:
-			if sound in available_sounds:
-				rooms[room_name].available_sounds.add(sound)
-			else:
-				rooms[room_name].missing_sounds.add(sound)
-
-		return True
-
-	def leave_room(self) -> None:
-		rooms[self.room_name].remove(self)
-		if len(rooms[self.room_name]) == 0:
-			del rooms[self.room_name]
-		self.room_name = None
-
-	def save_sound(self, packet) -> bool:
-		global available_sounds
-
-		verif = hashlib.blake2b()
-		verif.update(packet.data)
-		if packet.hash != verif.digest():
-			print("Hashes do not match, dropping file.")
-			print('packet hash : %s - digest : %s' % (packet.hash.hex(), verif.digest().hex()))
-			return False
-
-		if packet.hash in available_sounds:
-			# Sound already saved
-			print('%s sent %s but we already have it. Ignoring.' % (str(self.addr), small_hash(packet.hash)))
-			return True
-		with open('cache/' + packet.hash.hex(), 'wb') as outfile:
-			outfile.write(packet.data)
-		available_sounds.add(packet.hash)
-		self.sounds.add(packet.hash)
-
-		self.room.missing_sounds.remove(packet.hash)
-		self.room.available_sounds.add(packet.hash)
-
-		print(f'Saved {small_hash(packet.hash)} from {str(self.addr)}')
-		return True
-
-	def try_reading_buffer(self):
-		if len(self.buffer) < 4:
-			return
-
-		packet_len = int.from_bytes(self.buffer[:4], 'big')
-		if packet_len > 3 * 1024 * 1024:
-			print(f"{self.peername} sent a packet over 3 Mb.")
-			self.transport.close()
-			return
-
-		if len(self.buffer) < packet_len + 4:
-			return
-
-		packet = protocol.deserialize(self.buffer[4:packet_len+4])
-		self.buffer = self.buffer[packet_len+4:]
-		print(f"{self.peername} sent a {type(packet)}.")
-
-		if isinstance(packet, ClientUpdate):
-			self.steamid = packet.steamid
-			self.sounds = packet.sounds_list
-			if self.room_name != packet.room_name:
-				self.join_room(packet.room_name)
-
-			self.send(
-				ServerRoomSounds(
-					available_hashes=list(self.room.available_sounds),
-					missing_hashes=list(self.room.missing_sounds)
-				)
-			)
-		elif isinstance(packet, ClientSoundRequest):
-			if packet.sound_hash not in available_sounds:
-				return
-
-			response = SoundResponse()
-			with open('cache/' + packet.sound_hash.hex(), 'rb') as infile:
-				response.data = infile.read()
-				response.hash = packet.sound_hash
-
-			print(f'{self.steamid} is downloading {small_hash(packet.sound_hash)}')
-			self.send(response)
-			print(f'{self.steamid} is done downloading {small_hash(packet.sound_hash)}')
-		elif isinstance(packet, GameEvent):
-			if packet.proposed_sound_hash not in self.room.available_sounds:
-				return
-
-			event_class = get_event_class(packet)
-			steamid = self.steamid if event_class == 'normal' else 0
-			if event_class == 'shared':
-				self.room.play_shared(packet.type, packet.proposed_sound_hash)
-			else:
-				self.room.play(steamid if event_class == 'normal' else 0, packet.proposed_sound_hash)
-		elif isinstance(packet, SoundResponse):
-			self.save_sound(packet)
-		else:
-			print("Unhandled packet type. Ignoring.")
-
-	def send(self, packet):
-		raw_packet = protocol.serialize(packet)
-		raw_header = len(raw_packet).to_bytes(4, byteorder='big')
-		self.transport.write(raw_header + raw_packet)
-
-
-if __name__ == '__main__':
-	# We're using Python 3.7 mostly for asyncio features.
-	if sys.version_info[0] < 3 or sys.version_info[1] < 7:
-		print("Python 3.7+ is required, but you are running Python %d.%d." % (sys.version_info[0], sys.version_info[1]))
-
-	for filename in os.listdir('cache'):
-		# Only add valid files
-		if filename.startswith('.') or not os.path.isfile('cache/' + filename):
-			continue
-		available_sounds.add(bytes.fromhex(filename))
-
-	port = config.config['Network'].getint('ServerPort', 4004)
-	try:
-		sslctx = ssl.SSLContext()
-		sslctx.load_cert_chain(
-			config.config['Network'].get('certfile', 'certfile.crt'),
-			keyfile=config.config['Network'].get('keyfile', 'keyfile.key'),
-		)
-	except (FileNotFoundError, ssl.SSLError) as err:
-		print(f"Failed to initialize SSL : {err}")
-		print(f"Serving without encryption.")
-		sslctx = None
-
-	loop = asyncio.get_event_loop()
-	coro = loop.create_server(Connection, '0.0.0.0', port, ssl=sslctx)
-	server = loop.run_until_complete(coro)
-
-	print(f"Started sound server on {server.sockets[0].getsockname()}")
-	try:
-		loop.run_forever()
-	except KeyboardInterrupt:
-		pass
-
-	server.close()
-	loop.run_until_complete(server.wait_closed())
-	loop.close()

+ 3 - 14
setup.py

@@ -1,21 +1,13 @@
 # -*- coding: utf-8 -*-
-
-# DO NOT EDIT THIS FILE!
-# This file has been autogenerated by dephell <3
-# https://github.com/dephell/dephell
-
 try:
-    from setuptools import setup
+    from setuptools import setup  # type: ignore
 except ImportError:
     from distutils.core import setup
 
-readme = ""
-
 setup(
-    long_description=readme,
-    name="csgo-quake-sounds",
+    name="csgo-custom-sounds",
     version="1.5.0",
-    description="Play quake sounds via Gamestate Integration",
+    description="Play custom sounds via Gamestate Integration",
     python_requires="==3.*,>=3.7.0",
     author="kiwec",
     license="UNLICENSE",
@@ -24,11 +16,8 @@ setup(
     package_data={},
     install_requires=[
         "aiofiles==0.*,>=0.4.0",
-        "packel==1.*,>=1.0.0",
         "pyogg==0.6.11a1",
         "pyopenal==0.7.9a1",
-        "steamfiles",
         "wxasync==0.*,>=0.41.0",
     ],
-    dependency_links=["git+https://github.com/leovp/steamfiles.git#egg=steamfiles"],
 )

+ 30 - 131
sounds.py

@@ -1,18 +1,16 @@
 """Related to sounds"""
 import asyncio
-import hashlib
 import os
 import random
-import wx
+import wx  # type: ignore
+from collections import defaultdict
 from concurrent.futures import ThreadPoolExecutor
 from openal import AL_PLAYING, PYOGG_AVAIL, Buffer, OpusFile, Source  # type: ignore
 from threading import Lock
-from typing import Dict, List, Optional
-from wxasync import StartCoroutine
+from typing import Dict, List
+from wxasync import StartCoroutine  # type: ignore
 
 import config
-from protocol import GameEvent, PlaySound, SoundResponse
-from util import print, get_event_class, small_hash
 
 
 class SoundManager:
@@ -24,46 +22,32 @@ class SoundManager:
         self.lock = Lock()
         self.nb_max_sounds = 0
 
-        # List of available sounds (filepath:hash dict).
-        self.available_sounds: Dict[str, str] = {}
+        # Dict[category:List[sound_data]]
+        self.loaded_sounds: Dict[str, List[Buffer]] = defaultdict(list)
 
-        # List of loaded sounds (hash:sound dict)
-        self.loaded_sounds: Dict[bytes, Buffer] = {}
-
-        self.personal_sounds: List[bytes] = []
-
-        self.volume = config.config["Sounds"].getint("Volume", 50)
+        self.volume: int = config.config["Sounds"].getint("Volume", 50)  # type: ignore
 
     def max_sounds(self) -> int:
         """Returns the number of sounds that will be loaded."""
         max = 0
         for path in os.listdir("sounds"):
-            if path == "Downloaded":
-                continue
             for file in os.listdir(os.path.join("sounds", path)):
                 if file.startswith(".git") or file == "desktop.ini":
                     continue
                 max = max + 1
         return max
 
-    def load(self, filepath: str) -> Optional[Buffer]:
-        hash = hashlib.blake2b()
-        with open(filepath, "rb") as infile:
-            hash.update(infile.read())
-            digest = hash.digest()
-
+    def load(self, category: str, filepath: str) -> None:
         # Can't load files - TODO show error & quit
         if not PYOGG_AVAIL:
-            return None
+            return
 
         with self.lock:
-            self.loaded_sounds[digest] = Buffer(OpusFile(filepath))
-            self.available_sounds[filepath] = digest.hex()
+            self.loaded_sounds[category].append(Buffer(OpusFile(filepath)))
             wx.CallAfter(
                 self.client.gui.SetStatusText,
                 f"Loading sounds... ({len(self.loaded_sounds)}/{self.nb_max_sounds})",
             )
-            return self.loaded_sounds[digest]
 
     async def reload(self) -> None:
         """Reloads the list of available sounds.
@@ -71,75 +55,30 @@ class SoundManager:
         The async logic is a bit complicated here, but it boils down to the following :
         - No more than 5 sounds will be loaded at the same time
         - Every sound is getting loaded in a separate thread, so the GUI is not blocked
-        - We're not waiting using the executor (sync) but by waiting for all tasks to end (async)
+        - We're not waiting using the executor but by waiting for all tasks to end,
+        so the operation stays asynchronous
         """
         with self.lock:
-            self.available_sounds = {}
-            self.loaded_sounds = {}
+            self.loaded_sounds = defaultdict(list)
             self.nb_max_sounds = self.max_sounds()
 
         executor = ThreadPoolExecutor(max_workers=5)
         loop = asyncio.get_running_loop()
-        tasks = []
-        for path in os.listdir("sounds"):
-            for file in os.listdir(os.path.join("sounds", path)):
+        tasks: List = []
+        for category in os.listdir("sounds"):
+            for file in os.listdir(os.path.join("sounds", category)):
                 if file.startswith(".git") or file == "desktop.ini":
                     continue
 
-                filepath = os.path.join("sounds", path, file)
-                if path == "Downloaded":
-                    with self.lock:
-                        self.available_sounds[filepath] = file
-                else:
-                    if os.stat(filepath).st_size > 2 * 1024 * 1024:
-                        dialog = wx.GenericMessageDialog(
-                            self.client.gui,
-                            message=f"File {filepath} is too large (over 2 Mb) and will not be loaded.",
-                            caption="Sound loading error",
-                            style=wx.OK | wx.ICON_ERROR,
-                        )
-                        dialog.ShowModal()
-                        continue
-
-                    tasks.append(loop.run_in_executor(executor, self.load, filepath))
+                filepath = os.path.join("sounds", category, file)
+                tasks.append(
+                    loop.run_in_executor(executor, self.load, category, filepath)
+                )
         executor.shutdown(wait=False)
         await asyncio.gather(*tasks)
-
-    def get_random(self, type, state) -> Optional[bytes]:
-        """Get a sample from its sound name"""
-        if type == GameEvent.Type.MVP:
-            sound = "MVP"
-        elif type == GameEvent.Type.SUICIDE:
-            sound = "Suicide"
-        elif type == GameEvent.Type.TEAMKILL:
-            sound = "Teamkill"
-        elif type == GameEvent.Type.DEATH:
-            sound = "Death"
-        elif type == GameEvent.Type.FLASH:
-            sound = "Flashed"
-        elif type == GameEvent.Type.KNIFE:
-            sound = "Unusual kill"
-        elif type == GameEvent.Type.HEADSHOT:
-            sound = "Headshot"
-        elif type == GameEvent.Type.KILL:
-            sound = f"{state.round_kills} kills"
-        elif type == GameEvent.Type.COLLATERAL:
-            sound = "Collateral"
-        elif type == GameEvent.Type.ROUND_START:
-            sound = "Round start"
-        elif type == GameEvent.Type.TIMEOUT:
-            sound = "Timeout"
-
-        with self.lock:
-            sounds = self.available_sounds.items()
-        sound_path = os.path.join("sounds", sound)
-        collection: List[bytes] = [
-            bytes.fromhex(v) for k, v in sounds if k.startswith(sound_path)
-        ]
-        if len(collection) > 0:
-            return random.choice(collection)
-        print(f'[!] No available samples for action "{sound}".')
-        return None
+        wx.CallAfter(
+            self.client.gui.SetStatusText, f"{self.nb_max_sounds} sounds loaded.",
+        )
 
     async def _play(self, sound) -> None:
         """Play sound from its file path."""
@@ -153,56 +92,16 @@ class SoundManager:
             # Don't end the thread until the sound finished playing
             await asyncio.sleep(1)
 
-    def play(self, packet: PlaySound) -> bool:
-        """Tries playing a sound from a PlaySound packet.
+    def play(self, sound_name: str) -> bool:
+        """Tries playing a sound by its name.
 
         Returns True if the sound was played successfully.
         """
-        if str(packet.steamid) != self.playerid and packet.steamid != 0:
-            return True
-
         with self.lock:
-            sound: Optional[Buffer] = None
-            try:
-                sound = self.loaded_sounds[packet.sound_hash]
-            except KeyError:
-                filepath = os.path.join("sounds", "Downloaded", packet.sound_hash.hex())
-                if filepath in self.available_sounds.keys():
-                    sound = self.load_sync(filepath)
-
-            if sound is None:
-                print(f"[!] Sound {small_hash(packet.sound_hash)} not found.")
-                return False
-            else:
+            if len(self.loaded_sounds[sound_name]) > 0:
+                sound = random.choice(self.loaded_sounds[sound_name])
                 StartCoroutine(self._play(sound), self.client.gui)
                 return True
-
-    def save(self, packet: SoundResponse) -> None:
-        filepath = os.path.join("sounds", "Downloaded", packet.hash.hex())
-        with open(filepath, "wb") as outfile:
-            outfile.write(packet.data)
-        filename = packet.hash.hex()
-        with self.lock:
-            self.available_sounds[filepath] = filename
-        print(f"Finished downloading {small_hash(packet.hash)}.")
-
-    def send(self, update_type, state) -> None:
-        """Sends a sound to play for everybody"""
-        if self.client == None:
-            return
-
-        hash = self.get_random(update_type, state)
-        if hash is not None:
-            packet = GameEvent()
-            packet.update = update_type
-            packet.proposed_sound_hash = hash
-            packet.kill_count = int(state.round_kills)
-            packet.round = int(state.current_round)
-            self.client.send(packet)
-
-            # Normal event : play without waiting for server
-            if get_event_class(packet) == "normal":
-                playpacket = PlaySound()
-                playpacket.steamid = 0
-                playpacket.sound_hash = hash
-                self.play(playpacket)
+            else:
+                print(f"[!] No sound found for '{sound_name}'.")
+                return False

+ 0 - 0
cache/.gitkeep → sounds/Round lose/.gitkeep


BIN
sounds/Round win/noenemiesleft.opus


BIN
sounds/Round win/onarollbrag1.opus


BIN
sounds/Round win/onarollbrag10.opus


BIN
sounds/Round win/onarollbrag11.opus


BIN
sounds/Round win/onarollbrag12.opus


BIN
sounds/Round win/onarollbrag13.opus


BIN
sounds/Round win/onarollbrag2.opus


BIN
sounds/Round win/onarollbrag3.opus


BIN
sounds/Round win/onarollbrag4.opus


BIN
sounds/Round win/onarollbrag5.opus


BIN
sounds/Round win/onarollbrag6.opus


BIN
sounds/Round win/onarollbrag7.opus


BIN
sounds/Round win/onarollbrag8.opus


BIN
sounds/Round win/onarollbrag9.opus


BIN
sounds/Round win/radiobotcheer1.opus


BIN
sounds/Round win/radiobotcheer2.opus


+ 26 - 41
state.py

@@ -4,7 +4,6 @@ from threading import Lock, Thread
 from http.server import BaseHTTPRequestHandler, HTTPServer
 
 import config
-from protocol import GameEvent
 
 
 class PlayerState:
@@ -84,7 +83,7 @@ class PlayerState:
 
         self.valid = True
 
-    def compare(self, old_state):
+    def compare(self, old_state) -> None:
         # Init state without playing sounds
         if not old_state or not old_state.valid:
             return
@@ -111,7 +110,7 @@ class PlayerState:
 
         # Play timeout music
         if self.phase == "freezetime" and self.play_timeout:
-            self.sounds.send(GameEvent.Type.TIMEOUT, self)
+            self.sounds.play("Timeout")
             self.play_timeout = False
 
         # Reset state when switching players (used for MVPs)
@@ -121,9 +120,12 @@ class PlayerState:
 
         # Play round start, win, lose, MVP
         if self.is_local_player and self.mvps == old_state.mvps + 1:
-            self.sounds.send(GameEvent.Type.MVP, self)
-        elif self.phase == "live" and self.phase != old_state.phase:
-            self.sounds.send(GameEvent.Type.ROUND_START, self)
+            self.sounds.play("MVP")
+        elif self.phase != old_state.phase:
+            if self.phase == "over" and self.mvps == old_state.mvps:
+                self.sounds.play("Round win" if self.won_round else "Round lose")
+            elif self.phase == "live":
+                self.sounds.play("Round start")
 
         # Don't play player-triggered sounds below this ##########
         if not self.is_local_player:
@@ -133,49 +135,41 @@ class PlayerState:
         # Lost kills - either teamkilled or suicided
         if self.total_kills < old_state.total_kills:
             if self.total_deaths == old_state.total_deaths + 1:
-                self.sounds.send(GameEvent.Type.SUICIDE, self)
+                self.sounds.play("Suicide")
             elif self.total_deaths == old_state.total_deaths:
-                self.sounds.send(GameEvent.Type.TEAMKILL, self)
+                self.sounds.play("Teamkill")
         # Didn't suicide or teamkill -> check if player just died
         elif self.total_deaths == old_state.total_deaths + 1:
-            self.sounds.send(GameEvent.Type.DEATH, self)
+            self.sounds.play("Death")
 
         # Player got flashed
         if self.flash_opacity > 150 and self.flash_opacity > old_state.flash_opacity:
-            self.sounds.send(GameEvent.Type.FLASH, self)
+            self.sounds.play("Flashed")
 
         # Player killed someone
         if self.round_kills == old_state.round_kills + 1:
             # Kill with knife equipped
             if self.is_knife_active:
-                self.sounds.send(GameEvent.Type.KNIFE, self)
+                self.sounds.play("Unusual kill")
             # Kill with weapon equipped
             else:
-                # Headshot
+                # Prefer playing "Headshot" over "x kills"
+                prefer_headshots = config.config["Sounds"].getboolean(  # type: ignore
+                    "PreferHeadshots", False
+                )
+
                 if self.round_headshots == old_state.round_headshots + 1:
-                    # Headshot override : always play Headshot
-                    prefer_headshots = config.config["Sounds"].getboolean(
-                        "PreferHeadshots", False
-                    )
                     if prefer_headshots:
-                        self.sounds.send(GameEvent.Type.HEADSHOT, self)
-                        return
-                    # No headshot override : do not play over double kills, etc
-                    if self.round_kills < 2 or self.round_kills > 5:
-                        self.sounds.send(GameEvent.Type.HEADSHOT, self)
-
-                # Killstreaks, headshotted or not
-                if self.round_kills == 2:
-                    self.sounds.send(GameEvent.Type.KILL, self)
-                elif self.round_kills == 3:
-                    self.sounds.send(GameEvent.Type.KILL, self)
-                elif self.round_kills == 4:
-                    self.sounds.send(GameEvent.Type.KILL, self)
-                elif self.round_kills == 5:
-                    self.sounds.send(GameEvent.Type.KILL, self)
+                        self.sounds.play("Headshot")
+                    else:
+                        self.sounds.play(
+                            f"{self.round_kills} kills"
+                        ) or self.sounds.play("Headshot")
+                else:
+                    self.sounds.play(f"{self.round_kills} kills")
         # Player killed multiple players
         elif self.round_kills > old_state.round_kills:
-            self.sounds.send(GameEvent.Type.COLLATERAL, self)
+            self.sounds.play("Collateral")
 
 
 class CSGOState:
@@ -205,19 +199,10 @@ class CSGOState:
 
     def update(self, json):
         """Update the entire game state"""
-        should_update_client = False
         with self.lock:
             newstate = PlayerState(json, self.client.sounds)
-
-            if self.old_state == None or self.old_state.steamid != newstate.steamid:
-                should_update_client = True
-
             newstate.compare(self.old_state)
             self.old_state = newstate
-        if self.client != None:
-            self.client.update_status()
-            if should_update_client:
-                self.client.client_update()
 
 
 class PostHandler(BaseHTTPRequestHandler):

+ 69 - 0
steamfiles.py

@@ -0,0 +1,69 @@
+"""Parses steam ACF files.
+
+Straight up ripped from https://github.com/leovp/steamfiles.
+(I was having issues building with cx_Freeze)
+"""
+
+
+def loads(data, wrapper=dict):
+    """
+    Loads ACF content into a Python object.
+    :param data: An UTF-8 encoded content of an ACF file.
+    :param wrapper: A wrapping object for key-value pairs.
+    :return: An Ordered Dictionary with ACF data.
+    """
+    if not isinstance(data, str):
+        raise TypeError("can only load a str as an ACF but got " + type(data).__name__)
+
+    parsed = wrapper()
+    current_section = parsed
+    sections = []
+
+    lines = (line.strip() for line in data.splitlines())
+
+    for line in lines:
+        try:
+            key, value = line.split(None, 1)
+            key = key.replace('"', "").lstrip()
+            value = value.replace('"', "").rstrip()
+        except ValueError:
+            if line == "{":
+                # Initialize the last added section.
+                current_section = _prepare_subsection(parsed, sections, wrapper)
+            elif line == "}":
+                # Remove the last section from the queue.
+                sections.pop()
+            else:
+                # Add a new section to the queue.
+                sections.append(line.replace('"', ""))
+            continue
+
+        current_section[key] = value
+
+    return parsed
+
+
+def load(fp, wrapper=dict):
+    """
+    Loads the contents of an ACF file into a Python object.
+    :param fp: A file object.
+    :param wrapper: A wrapping object for key-value pairs.
+    :return: An Ordered Dictionary with ACF data.
+    """
+    return loads(fp.read(), wrapper=wrapper)
+
+
+def _prepare_subsection(data, sections, wrapper):
+    """
+    Creates a subsection ready to be filled.
+    :param data: Semi-parsed dictionary.
+    :param sections: A list of sections.
+    :param wrapper: A wrapping object for key-value pairs.
+    :return: A newly created subsection.
+    """
+    current = data
+    for i in sections[:-1]:
+        current = current[i]
+
+    current[sections[-1]] = wrapper()
+    return current[sections[-1]]

BIN
test.ogg


+ 0 - 148
test.py

@@ -1,148 +0,0 @@
-import asyncio
-import hashlib
-import os
-import random
-import shutil
-import threading
-import unittest
-from unittest.mock import patch, MagicMock, Mock
-from typing import Callable
-
-import sounds
-import server
-from config import config
-from client import Client
-from protocol import GameEvent
-from state import CSGOState
-
-
-class DummyGui:
-	"""There's probably a better way to do this..."""
-	def __init__(self):
-		self.updateSoundsBtn = MagicMock(Enabled=True)
-		self.shardCodeIpt = MagicMock(GetValue=lambda : 'shard_code')
-
-	def SetStatusText(self, *args):
-		pass
-
-
-class PlayerState:
-	is_ingame = True
-	phase = 'live'
-	current_round = 1
-
-	def __init__(self, steamid):
-		self.steamid = steamid
-		self.playerid = steamid
-
-
-class MockState(CSGOState):
-	current_round = 1
-	round_kills = 3
-
-	def __init__(self, client, steamid):
-		self.lock = threading.Lock()
-		self.old_state = PlayerState(steamid)
-		self.client = client
-
-	def is_alive(self) -> bool:
-		return False  # always download/upload sounds
-
-
-class MockClient(Client):
-	"""Simpler client for testing.
-
-	TODO ASYNC REWRITE
-	"""
-
-	def __init__(self, steamid=random.randint(1, 999999999)):
-		self.gui = DummyGui()
-		self.room_name = 'shard_code'
-		self.sounds = SoundManager(self)
-		self.loop = asyncio.get_event_loop()
-
-		# Mock stuff
-		self.sounds.play = Mock()
-		self.state = MockState(self, steamid)
-
-		threading.Thread(target=self.listen, daemon=True).start()
-
-		# Wait for server connection before reloading sounds
-		asyncio.sleep(0.1)
-		await self.reload_sounds()
-
-	def error_callback(self, msg):
-		raise Exception(msg)
-
-
-async def run_server(loop):
-	global server
-	coro = loop.create_server(server.Connection, '127.0.0.1', 4004, ssl=None)
-	server = await loop.run_until_complete(coro)
-
-
-class TestClient(unittest.TestCase):
-	"""Tests for client.py and related code.
-
-	The following tests assume you have at least the default quake sounds in your sound directory.
-	The following tests assume the server is working without any bugs that could carry across tests.
-	"""
-
-	def setUp(self):
-		# Clear cache directory
-		shutil.rmtree('cache')
-		os.mkdir('cache')
-		open('cache/.gitkeep', 'a').close()
-
-		# Run a local sound server
-		config.set('Network', 'ServerIP', '127.0.0.1')
-		config.set('Network', 'ServerPort', '4004')
-		loop = asyncio.new_event_loop()
-		asyncio.create_task(run_server(loop))
-		sleep(1)  # Wait for server to start (shh it's fine)
-
-	def tearDown(self):
-		try:
-			shutil.move('./sounds/Timeout/test.ogg', './test.ogg')
-		except:
-			pass
-
-	def test_receive_sound(self, *args):
-		# Alice will send basic stuff
-		alice = MockClient('123123123')
-		sleep(10)
-		self.assertEqual(alice.sounds.play.call_count, 1)
-
-		# Bob will only receive
-		bob = MockClient('456456456')
-		sleep(1)
-		self.assertEqual(bob.sounds.play.call_count, 1)
-
-		# Charlie will send a custom sound
-		shutil.move('test.ogg', 'sounds/Timeout/test.ogg')
-		charlie = MockClient('789789789')
-		sleep(1)
-
-		self.assertEqual(len(server.rooms[0].clients), 3)
-
-		# Send a sound, and assert it is received by bob
-		alice.sounds.send(GameEvent.Type.COLLATERAL, alice.state)
-		sleep(1)
-		self.assertEqual(bob.sounds.play.call_count, 2)
-
-		# Try MVPs
-		alice.sounds.send(GameEvent.Type.MVP, alice.state)
-		sleep(1)
-		self.assertEqual(bob.sounds.play.call_count, 3)
-		alice.state.current_round = 2
-		alice.sounds.send(GameEvent.Type.MVP, alice.state)
-		sleep(1)
-		self.assertEqual(bob.sounds.play.call_count, 4)
-
-		# Try charlie's custom sound
-		charlie.sounds.send(GameEvent.Type.TIMEOUT, charlie.state)
-		sleep(2)
-		self.assertEqual(bob.sounds.play.call_count, 5)
-
-if __name__ == '__main__':
-	unittest.main()

+ 0 - 40
util.py

@@ -1,40 +0,0 @@
-from threading import Lock
-
-from protocol import GameEvent
-
-rare_events = [
-    GameEvent.Type.MVP,
-    GameEvent.Type.SUICIDE,
-    GameEvent.Type.TEAMKILL,
-    GameEvent.Type.KNIFE,
-    GameEvent.Type.COLLATERAL,
-]
-shared_events = [
-    GameEvent.Type.ROUND_START,
-    GameEvent.Type.TIMEOUT,
-]
-
-# Thread-safe printing
-print_lock = Lock()
-unsafe_print = print
-
-
-def print(*a, **b):
-    with print_lock:
-        unsafe_print(*a, **b)
-
-
-def get_event_class(packet):
-    if packet.type in rare_events:
-        return "rare"
-    if packet.type in shared_events:
-        return "shared"
-    is_kill = packet.type == GameEvent.KILL or packet.type == GameEvent.HEADSHOT
-    if is_kill and packet.kill_count > 3:
-        return "rare"
-    return "normal"
-
-
-def small_hash(hash):
-    hex = hash.hex()
-    return "%s-%s" % (hex[0:4], hex[-4:])