Repository: summertriangle-dev/acb.py (language: python)

Issue:
Crossplatform & Build Workflow
Hello,
when I tried to build your project I noticed that your speedup didn't compile on Windows,
so I made a [simple fix](https://github.com/summertriangle-dev/acb.py/compare/py-package...K0lb3:py-package?expand=1#diff-10404f8e40c8687618f6f4ad90e9f021ac3f5fca9365f7a6d51e5df859059c1dR8) for that and added an automated build workflow with tests to check if it works for all systems.


You can also use that workflow to auto-publish the module as well as precompiled wheels and eggs on pypy by removing the commenting of [Deploy](https://github.com/summertriangle-dev/acb.py/compare/py-package...K0lb3:py-package?expand=1#diff-ee49282f461b4c8ad179f79dd5bcdf93124561074c64a771366caf93e99b9320R75)  while setting a token in the github project environment.



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
new file mode 100644
index 0000000..f68d0ea
--- /dev/null
+++ b/.github/workflows/python-package.yml
@@ -0,0 +1,96 @@
+name: CI
+
+on: [push, pull_request]
+
+jobs:
+  deploy:
+    strategy:
+      fail-fast: false
+      matrix:
+        os: ['macOS-latest', 'windows-latest']
+        python-version: ['3.9', '3.8', '3.7', '3.6']
+        compiler: ['gcc']
+        architecture: ['x86', 'x64']
+        include:
+          - os: 'ubuntu-latest'
+            python-version: '3.9'
+            compiler: 'gcc'
+
+    timeout-minutes: 30
+
+    runs-on: ${{ matrix.os }}
+    name: ${{ matrix.os }} ${{ matrix.architecture }} - ${{ matrix.python-version }}
+
+    steps:
+      - uses: actions/checkout@v2
+
+      # Setup for Windows - installs the correct python architecture, x86 / x64
+      - name: Set up Python (Win) ${{ matrix.python-version }} ${{ matrix.architecture }}
+        uses: actions/setup-python@v1
+        with:
+          python-version: ${{ matrix.python-version }}
+          architecture: ${{ matrix.architecture }}
+        if: runner.os == 'Windows'
+
+      # Setup for Mac & Linux, both don't support architecture selection without using specific versions
+      - name: Set up Python (Non-Win) ${{ matrix.python-version }}
+        uses: actions/setup-python@v1
+        with:
+          python-version: ${{ matrix.python-version }}
+        if: runner.os != 'Windows'
+
+      # Display the versions
+      - name: Show runner information
+        run: |
+          python --version
+          pip --version
+
+      # Installs the dependencies, add yours here
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install --upgrade setuptools wheel pytest twine
+
+      # Syntax check
+      - name: Lint with flake8
+        run: |
+          pip install flake8
+          # stop the build if there are Python syntax errors or undefined names
+          flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
+          # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
+          flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+
+      # Build
+      - name: Build package
+        run: python setup.py build
+
+      # Install Local
+      - name: Install package
+        run: python setup.py install --user
+
+      # Run Tests, have to be in a file with test in name in a folder named tests with functions with test in the name
+      - name: Run tests
+        run: pytest -v -s
+
+      # Create wheels for deployment
+      - name: Build wheels
+        run: python setup.py sdist bdist_wheel --skip-build
+        if: runner.os != 'Linux' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
+    
+      - name: Build wheels (manylinux)
+        uses: RalfG/python-wheels-manylinux-build@v0.3.3-manylinux2010_x86_64
+        if: runner.os == 'Linux' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
+
+      # Deploy
+      - name: Publish
+        shell: bash
+        if: success() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
+        env:
+          TWINE_USERNAME: __token__
+          TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
+        run: |
+          if [ $(uname) == 'Linux' ]; then 
+            twine upload dist/*-manylinux* --skip-existing
+          else
+            twine upload dist/* --skip-existing
+          fi
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9464b80
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,97 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+.hypothesis/
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# pyenv
+.python-version
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..e0383c5
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,44 @@
+Copyright (c) 2016 - 2021, The Holy Constituency of the Summer Triangle.
+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 program is based on code from VGMToolbox.
+Copyright (c) 2009
+
+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.
diff --git a/README.md b/README.md
index 3ee0a3d..2b6942d 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,10 @@ HCA decryption is based on the 2ch HCA decoder. Thanks also to Headcrabbed who d
 Usage:
 
 ```sh
-python3 acb.py somefile.acb output
+python3 setup.py install
+python3 -m acb somefile.acb output
+# equivalent
+acbextract somefile.acb output
 ```
 
 You can also pass `--disarm-with=key1,key2` to have the library decrypt (but not decode) files for you. The key format
diff --git a/acb.py b/acb.py
deleted file mode 100755
index 5576eac..0000000
--- a/acb.py
+++ /dev/null
@@ -1,268 +0,0 @@
-#!/usr/bin/env python3
-# acb.py: For all your ACB extracting needs
-
-# Copyright (c) 2016, The Holy Constituency of the Summer Triangle.
-# 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 program is based on code from VGMToolbox.
-# Copyright (c) 2009
-#
-# 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.
-
-import io
-import math
-import struct
-import sys
-import itertools
-import os
-import re
-from collections import namedtuple as T
-
-try:
-    from .utf import UTFTable, R
-    from .disarm import DisarmContext
-except ImportError:
-    from utf import UTFTable, R
-    from disarm import DisarmContext
-
-WAVEFORM_ENCODE_TYPE_ADX          = 0
-WAVEFORM_ENCODE_TYPE_HCA          = 2
-WAVEFORM_ENCODE_TYPE_VAG          = 7
-WAVEFORM_ENCODE_TYPE_ATRAC3       = 8
-WAVEFORM_ENCODE_TYPE_BCWAV        = 9
-WAVEFORM_ENCODE_TYPE_NINTENDO_DSP = 13
-
-wave_type_ftable = {
-    WAVEFORM_ENCODE_TYPE_ADX          : ".adx",
-    WAVEFORM_ENCODE_TYPE_HCA          : ".hca",
-    WAVEFORM_ENCODE_TYPE_VAG          : ".at3",
-    WAVEFORM_ENCODE_TYPE_ATRAC3       : ".vag",
-    WAVEFORM_ENCODE_TYPE_BCWAV        : ".bcwav",
-    WAVEFORM_ENCODE_TYPE_NINTENDO_DSP : ".dsp"}
-
-track_t = T("track_t", ("cue_id", "name", "memory_wav_id", "external_wav_id", "enc_type", "is_stream"))
-
-class TrackList(object):
-    def __init__(self, utf):
-        cue_handle = io.BytesIO(utf.rows[0]["CueTable"])
-        nam_handle = io.BytesIO(utf.rows[0]["CueNameTable"])
-        wav_handle = io.BytesIO(utf.rows[0]["WaveformTable"])
-        syn_handle = io.BytesIO(utf.rows[0]["SynthTable"])
-
-        cues = UTFTable(cue_handle)
-        nams = UTFTable(nam_handle)
-        wavs = UTFTable(wav_handle)
-        syns = UTFTable(syn_handle)
-
-        self.tracks = []
-
-        name_map = {}
-        for row in nams.rows:
-            name_map[row["CueIndex"]] = row["CueName"]
-
-        for ind, row in enumerate(cues.rows):
-            if row["ReferenceType"] not in {3, 8}:
-                raise RuntimeError("ReferenceType {0} not implemented.".format(row["ReferenceType"]))
-
-            r_data = syns.rows[row["ReferenceIndex"]]["ReferenceItems"]
-            a, b = struct.unpack(">HH", r_data)
-
-            wav_id = wavs.rows[b].get("Id")
-            if wav_id is None:
-                wav_id = wavs.rows[b]["MemoryAwbId"]
-            extern_wav_id = wavs.rows[b]["StreamAwbId"]
-            enc = wavs.rows[b]["EncodeType"]
-            is_stream = wavs.rows[b]["Streaming"]
-
-            self.tracks.append(track_t(row["CueId"], name_map.get(ind, "UNKNOWN"), wav_id,
-                extern_wav_id, enc, is_stream))
-
-def align(n):
-    def _align(number):
-        return (number + n - 1) & ~(n - 1)
-    return _align
-
-afs2_file_ent_t = T("afs2_file_ent_t", ("cue_id", "offset", "size"))
-
-class AFSArchive(object):
-    def __init__(self, file):
-        buf = R(file)
-
-        magic = buf.uint32_t()
-        if magic != 0x41465332:
-            raise ValueError("bad magic")
-
-        version = buf.bytes(4)
-        file_count = buf.le_uint32_t()
-
-        if version[0] >= 0x02:
-            self.alignment = buf.le_uint16_t()
-            self.mix_key = buf.le_uint16_t()
-        else:
-            self.alignment = buf.le_uint32_t()
-            self.mix_key = None
-
-        #print("afs2:", file_count, "files in ar")
-        #print("afs2: aligned to", self.alignment, "bytes")
-
-        self.offset_size = version[1]
-        self.offset_mask = int("FF" * self.offset_size, 16)
-        #print("afs2: a file offset is", self.offset_size, "bytes")
-
-        self.files = []
-        self.create_file_entries(buf, file_count)
-        self.src = buf
-
-    def create_file_entries(self, buf, file_count):
-        buf.seek(0x10)
-        read_cue_ids = struct.Struct("<" + ("H" * file_count))
-        if self.offset_size == 2:
-            read_raw_offs = struct.Struct("<" + ("H" * (file_count + 1)))
-        else:
-            read_raw_offs = struct.Struct("<" + ("I" * (file_count + 1)))
-
-        # read all in one go
-        cue_ids = buf.struct(read_cue_ids)
-        raw_offs = buf.struct(read_raw_offs)
-        # apply the mask
-        unaligned_offs = tuple(map(lambda x: x & self.offset_mask, raw_offs))
-        aligned_offs = tuple(map(align(self.alignment), unaligned_offs))
-        offsets_for_length_calculating = unaligned_offs[1:]
-        lengths = itertools.starmap(
-            lambda my_offset, next_offset: next_offset - my_offset,
-            zip(aligned_offs, offsets_for_length_calculating))
-
-        self.files = list(itertools.starmap(afs2_file_ent_t, zip(cue_ids, aligned_offs, lengths)))
-
-    def file_data_for_cue_id(self, cue_id, rw=False):
-        for f in self.files:
-            if f.cue_id == cue_id:
-                if rw:
-                    buf = bytearray(f.size)
-                    self.src.bytesinto(buf, at=f.offset)
-                    return buf
-                else:
-                    return self.src.bytes(f.size, at=f.offset)
-        else:
-            raise ValueError("id {0} not found in archive".format(cue_id))
-
-def find_awb(path):
-    return re.sub(r"\.acb$", ".awb", path)
-
-def name_gen_default(track):
-     return "{0}{1}".format(track.name, wave_type_ftable.get(track.enc_type, track.enc_type))
-
-def extract_acb(acb_file, target_dir, extern_awb=None, hca_keys=None, name_gen=name_gen_default, no_unmask=False):
-    if isinstance(acb_file, str):
-        with open(acb_file, "rb") as _acb_file:
-            utf = UTFTable(_acb_file)
-    else:
-        utf = UTFTable(acb_file)
-
-    cue = TrackList(utf)
-
-    disarmer_mem = None
-    disarmer_ext = None
-    memory_data_source = None
-    extern_data_source = None
-    external_awb = None
-
-    if len(utf.rows[0]["AwbFile"]) > 0:
-        embedded_awb = io.BytesIO(utf.rows[0]["AwbFile"])
-        memory_data_source = AFSArchive(embedded_awb)
-
-    if any(track.is_stream for track in cue.tracks):
-        extern_name = extern_awb or find_awb(acb_file)
-        try:
-            external_awb = open(extern_name, "rb")
-        except FileNotFoundError:
-            print("Error: At least one track requests streaming, but an external AWB could not be found.",
-                file=sys.stderr)
-            print("Specify the external AWB with --awb.",
-                file=sys.stderr)
-            sys.exit(1)
-        extern_data_source = AFSArchive(external_awb)
-
-    if hca_keys:
-        if memory_data_source:
-            disarmer_mem = DisarmContext(hca_keys, memory_data_source.mix_key)
-        if extern_data_source:
-            disarmer_ext = DisarmContext(hca_keys, extern_data_source.mix_key)
-
-    for track in cue.tracks:
-        print(track)
-        name = name_gen(track)
-
-        with open(os.path.join(target_dir, name_gen(track)), "wb") as named_out_file:
-            if track.is_stream:
-                wid = track.external_wav_id
-                data_source = extern_data_source
-                disarmer = disarmer_ext
-            else:
-                wid = track.memory_wav_id
-                data_source = memory_data_source
-                disarmer = disarmer_mem
-
-            if hca_keys:
-                hca_buf = data_source.file_data_for_cue_id(wid, rw=True)
-                disarmer.disarm(hca_buf, no_unmask)
-                named_out_file.write(hca_buf)
-            else:
-                named_out_file.write(data_source.file_data_for_cue_id(wid))
-
-    if external_awb:
-        external_awb.close()
-
-def main():
-    import argparse
-
-    parser = argparse.ArgumentParser()
-    parser.add_argument("--disarm-with", help="decrypt HCAs with provided keys")
-    parser.add_argument("--awb", help="use file as the external AWB")
-    parser.add_argument("--no-unmask", action="store_true", default=False,
-        help="don't unmask segment names (requires --disarm-with)")
-    parser.add_argument("acb_file", help="input ACB file")
-    parser.add_argument("output_dir", help="directory to place output files in")
-
-    args = parser.parse_args()
-
-    os.makedirs(args.output_dir, 0o755, exist_ok=True)
-    extract_acb(args.acb_file, args.output_dir, args.awb, args.disarm_with, no_unmask=args.no_unmask)
-
-if __name__ == '__main__':
-    main()
diff --git a/acb/__init__.py b/acb/__init__.py
new file mode 100644
index 0000000..9822b40
--- /dev/null
+++ b/acb/__init__.py
@@ -0,0 +1,4 @@
+from .acb import ACBFile, AFSArchive, TrackList, afs2_file_ent_t, track_t
+
+# old API
+from .acb import find_awb, name_gen_default, extract_acb
diff --git a/acb/__main__.py b/acb/__main__.py
new file mode 100644
index 0000000..72e2f88
--- /dev/null
+++ b/acb/__main__.py
@@ -0,0 +1,23 @@
+import argparse
+import os
+
+from acb import extract_acb
+
+def main():
+    import argparse
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--disarm-with", help="decrypt HCAs with provided keys")
+    parser.add_argument("--awb", help="use file as the external AWB")
+    parser.add_argument("--no-unmask", action="store_true", default=False,
+        help="don't unmask segment names (requires --disarm-with)")
+    parser.add_argument("acb_file", help="input ACB file")
+    parser.add_argument("output_dir", help="directory to place output files in")
+
+    args = parser.parse_args()
+
+    os.makedirs(args.output_dir, 0o755, exist_ok=True)
+    extract_acb(args.acb_file, args.output_dir, args.awb, args.disarm_with, no_unmask=args.no_unmask)
+
+if __name__ == '__main__':
+    main()
diff --git a/acb/acb.py b/acb/acb.py
new file mode 100755
index 0000000..250c887
--- /dev/null
+++ b/acb/acb.py
@@ -0,0 +1,353 @@
+# acb.py: For all your ACB extracting needs
+
+# Copyright (c) 2016, The Holy Constituency of the Summer Triangle.
+# 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 program is based on code from VGMToolbox.
+# Copyright (c) 2009
+#
+# 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.
+
+import io
+import math
+import struct
+import sys
+import itertools
+import os
+import re
+from collections import namedtuple as T
+from typing import Optional, Union, BinaryIO, Tuple, List, Callable
+
+from .utf import UTFTable, R
+from .disarm import DisarmContext
+
+WAVEFORM_ENCODE_TYPE_ADX          = 0
+WAVEFORM_ENCODE_TYPE_HCA          = 2
+WAVEFORM_ENCODE_TYPE_VAG          = 7
+WAVEFORM_ENCODE_TYPE_ATRAC3       = 8
+WAVEFORM_ENCODE_TYPE_BCWAV        = 9
+WAVEFORM_ENCODE_TYPE_NINTENDO_DSP = 13
+
+wave_type_ftable = {
+    WAVEFORM_ENCODE_TYPE_ADX          : ".adx",
+    WAVEFORM_ENCODE_TYPE_HCA          : ".hca",
+    WAVEFORM_ENCODE_TYPE_VAG          : ".vag",
+    WAVEFORM_ENCODE_TYPE_ATRAC3       : ".at3",
+    WAVEFORM_ENCODE_TYPE_BCWAV        : ".bcwav",
+    WAVEFORM_ENCODE_TYPE_NINTENDO_DSP : ".dsp"}
+
+track_t = T("track_t", ("cue_id", "name", "memory_wav_id", "external_wav_id", "enc_type", "is_stream"))
+
+class TrackList(object):
+    def __init__(self, utf):
+        cue_handle = io.BytesIO(utf.rows[0]["CueTable"])
+        nam_handle = io.BytesIO(utf.rows[0]["CueNameTable"])
+        wav_handle = io.BytesIO(utf.rows[0]["WaveformTable"])
+        syn_handle = io.BytesIO(utf.rows[0]["SynthTable"])
+
+        cues = UTFTable(cue_handle)
+        nams = UTFTable(nam_handle)
+        wavs = UTFTable(wav_handle)
+        syns = UTFTable(syn_handle)
+
+        self.tracks: List[track_t] = []
+
+        name_map = {}
+        for row in nams.rows:
+            name_map[row["CueIndex"]] = row["CueName"]
+
+        for ind, row in enumerate(cues.rows):
+            if row["ReferenceType"] not in {3, 8}:
+                raise RuntimeError("ReferenceType {0} not implemented.".format(row["ReferenceType"]))
+
+            r_data = syns.rows[row["ReferenceIndex"]]["ReferenceItems"]
+            a, b = struct.unpack(">HH", r_data)
+
+            wav_id = wavs.rows[b].get("Id")
+            if wav_id is None:
+                wav_id = wavs.rows[b]["MemoryAwbId"]
+            extern_wav_id = wavs.rows[b]["StreamAwbId"]
+            enc = wavs.rows[b]["EncodeType"]
+            is_stream = wavs.rows[b]["Streaming"]
+
+            self.tracks.append(track_t(row["CueId"], name_map.get(ind, "UNKNOWN"), wav_id,
+                extern_wav_id, enc, is_stream))
+
+def align(n):
+    def _align(number):
+        return (number + n - 1) & ~(n - 1)
+    return _align
+
+afs2_file_ent_t = T("afs2_file_ent_t", ("cue_id", "offset", "size"))
+
+class AFSArchive(object):
+    def __init__(self, file):
+        buf = R(file)
+
+        magic = buf.uint32_t()
+        if magic != 0x41465332:
+            raise ValueError("bad magic")
+
+        version = buf.bytes(4)
+        file_count = buf.le_uint32_t()
+
+        if version[0] >= 0x02:
+            self.alignment = buf.le_uint16_t()
+            self.mix_key = buf.le_uint16_t()
+        else:
+            self.alignment = buf.le_uint32_t()
+            self.mix_key = None
+
+        #print("afs2:", file_count, "files in ar")
+        #print("afs2: aligned to", self.alignment, "bytes")
+
+        self.offset_size = version[1]
+        self.offset_mask = int("FF" * self.offset_size, 16)
+        #print("afs2: a file offset is", self.offset_size, "bytes")
+
+        self.files = []
+        self.create_file_entries(buf, file_count)
+        self.src = buf
+
+    def create_file_entries(self, buf, file_count):
+        buf.seek(0x10)
+        read_cue_ids = struct.Struct("<" + ("H" * file_count))
+        if self.offset_size == 2:
+            read_raw_offs = struct.Struct("<" + ("H" * (file_count + 1)))
+        else:
+            read_raw_offs = struct.Struct("<" + ("I" * (file_count + 1)))
+
+        # read all in one go
+        cue_ids = buf.struct(read_cue_ids)
+        raw_offs = buf.struct(read_raw_offs)
+        # apply the mask
+        unaligned_offs = tuple(map(lambda x: x & self.offset_mask, raw_offs))
+        aligned_offs = tuple(map(align(self.alignment), unaligned_offs))
+        offsets_for_length_calculating = unaligned_offs[1:]
+        lengths = itertools.starmap(
+            lambda my_offset, next_offset: next_offset - my_offset,
+            zip(aligned_offs, offsets_for_length_calculating))
+
+        self.files = list(itertools.starmap(afs2_file_ent_t, zip(cue_ids, aligned_offs, lengths)))
+
+    def file_data_for_cue_id(self, cue_id, rw=False):
+        for f in self.files:
+            if f.cue_id == cue_id:
+                if rw:
+                    buf = bytearray(f.size)
+                    self.src.bytesinto(buf, at=f.offset)
+                    return buf
+                else:
+                    return self.src.bytes(f.size, at=f.offset)
+        else:
+            raise ValueError("id {0} not found in archive".format(cue_id))
+
+AnyFile = Union[str, os.PathLike, BinaryIO]
+Uninitialized = object()
+
+def _get_file_obj(name: AnyFile) -> Tuple[BinaryIO, bool]:
+    if isinstance(name, (str, os.PathLike)):
+        return open(name, "rb"), True
+    else:
+        return name, False
+
+class ACBFile(object):
+    """ Represents an ACB file.
+
+        You should call the close() method to release the underlying file objects
+        when finished, or use the object as a context manager.
+        Tracks can be accessed using .track_list together with get_track_data().
+
+        Constructor arguments:
+        - acb_file: Path to the file, or an open file object in binary mode.
+        - extern_awb: Path to the streaming AWB file, if needed.
+        - hca_keys: HCA keys in string format. Can be one of:
+            "0xLOWBYTES,0xHIGHBYTES", "0xHIGHBYTESLOWBYTES", or None.
+            "0xLOWBYTES,0xHIGHBYTES" is equivalent to invoking hca_decoder
+            with arguments "-a LOWBYTES -b HIGHBYTES".
+            If None, HCA files will not be decrypted.
+    """
+    def __init__(self, acb_file: AnyFile, extern_awb: Optional[AnyFile] = None, hca_keys: Optional[str] = None):
+        self.acb_handle, self.acb_handle_owned = _get_file_obj(acb_file)
+        
+        if extern_awb is None:
+            self.awb_handle = None
+            self.awb_handle_owned = False
+        else:
+            self.awb_handle, self.awb_handle_owned = _get_file_obj(extern_awb)
+                
+        utf = UTFTable(self.acb_handle)
+        if len(utf.rows[0]["AwbFile"]) > 0:
+            self.embedded_awb = AFSArchive(io.BytesIO(utf.rows[0]["AwbFile"]))
+        else:
+            self.embedded_awb = None # type: ignore
+
+        if self.awb_handle:
+            self.external_awb = AFSArchive(self.awb_handle)
+        else:
+            self.external_awb = None # type: ignore
+
+        self.hca_keys = hca_keys
+        self.embedded_disarm: Optional[DisarmContext] = Uninitialized # type: ignore
+        self.external_disarm: Optional[DisarmContext] = Uninitialized # type: ignore
+
+        self.track_list = TrackList(utf)
+        self.closed = False
+    
+    def get_embedded_disarm(self) -> Optional[DisarmContext]:
+        if self.embedded_disarm is Uninitialized:
+            if self.hca_keys:
+                self.embedded_disarm = DisarmContext(self.hca_keys, self.embedded_awb.mix_key)
+            else:
+                self.embedded_disarm = None
+
+        return self.embedded_disarm
+    
+    def get_external_disarm(self) -> Optional[DisarmContext]:
+        if self.external_disarm is Uninitialized:
+            if self.hca_keys:
+                self.external_disarm = DisarmContext(self.hca_keys, self.external_awb.mix_key)
+            else:
+                self.external_disarm = None
+        return self.external_disarm
+
+    def get_track_data(self, track: track_t, disarm: Optional[bool] = None, unmask: bool = True) -> bytearray:
+        """ Gets encoded audio data as a bytearray.
+
+            Arguments:
+            - track: The track to get data for, from .track_list.
+            - disarm: Whether to decrypt HCA data before returning it.
+                The default action is to decrypt if hca_keys were passed when creating
+                the ACBFile. You can pass False to skip decryption, or True to force
+                decryption. 
+                ValueError is raised you force decryption and keys were not passed.
+            - unmask: Whether to remove XOR masking from HCA header tags. 
+                This only has an effect if decryption is enabled, whether implicitly or
+                explicitly.
+        """
+        if self.closed:
+            raise ValueError("ACBFile is closed")
+
+        if track.is_stream:
+            buf = self.external_awb.file_data_for_cue_id(track.external_wav_id, rw=True)
+            disarmer = self.get_external_disarm()
+        else:
+            buf = self.embedded_awb.file_data_for_cue_id(track.memory_wav_id, rw=True)
+            disarmer = self.get_embedded_disarm()
+
+        if disarm is True and not disarmer:
+            raise ValueError(
+                "Disarm was explicitly requested, but no keys were provided. "
+                "Either remove the disarm= argument from the call to get_track_data, "
+                "or provide keys using the hca_keys= argument to ACBFile."
+            )
+    
+        if disarm is None:
+            disarm = (disarmer is not None)
+
+        if disarm and disarmer:
+            disarmer.disarm(buf, not unmask)
+
+        return buf
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, type, value, traceback):
+        self.close()
+
+    def close(self):
+        """ Close any open files held by ACBFile. If you passed file objects
+            instead of paths when creating the ACBFile instance, they will
+            not be closed.
+
+            Can be called multiple times; subsequent calls will have no effect.
+        """
+        if self.acb_handle_owned:
+            self.acb_handle.close()
+            self.acb_handle_owned = False
+        if self.awb_handle_owned:
+            self.awb_handle.close()
+            self.awb_handle_owned = False
+        self.closed = True
+
+    def __del__(self):
+        self.close()
+
+
+def find_awb(path):
+    return re.sub(r"\.acb$", ".awb", path)
+
+def name_gen_default(track):
+    return "{0}{1}".format(track.name, wave_type_ftable.get(track.enc_type, track.enc_type))
+
+def extract_acb(
+    acb_file: AnyFile,
+    target_dir: str,
+    extern_awb: Optional[AnyFile] = None,
+    hca_keys: Optional[str] = None,
+    name_gen: Callable[[track_t], str] = name_gen_default,
+    no_unmask: bool = False
+):
+    """ Oneshot file extraction API. Dumps all tracks from a file into the
+        named output directory.
+
+        Arguments:
+        - acb_file: Path to the file, or an open file object in binary mode.
+        - target_dir: Path to the destination directory. Must already exist.
+        - extern_awb: Path to the streaming AWB file, if needed.
+        - hca_keys: Same as ACBFile's hca_keys argument.
+        - name_gen: A callable taking the track_t object and returning a 
+            destination filename. Should not return absolute paths, as
+            they will be prefixed with the target_dir.
+        - no_unmask: See ACBFile.get_track_data's unmask argument. For 
+            compatibility reasons, the meaning of this flag is reversed;
+            i.e. True will result in unmasking being disabled.
+    """
+    if isinstance(acb_file, str) and extern_awb is None:
+        awb_name = find_awb(acb_file)
+        if os.path.exists(awb_name):
+            extern_awb = awb_name
+
+    with ACBFile(acb_file, extern_awb=extern_awb, hca_keys=hca_keys) as acb:
+        for track in acb.track_list.tracks:
+            print(track)
+            name = name_gen(track)
+
+            with open(os.path.join(target_dir, name), "wb") as out_file:
+                out_file.write(acb.get_track_data(track))
diff --git a/disarm.py b/acb/disarm.py
similarity index 98%
rename from disarm.py
rename to acb/disarm.py
index 559f3c3..d2dd4d9 100644
--- a/disarm.py
+++ b/acb/disarm.py
@@ -85,9 +85,9 @@ def init_table1(cls) -> bytearray:
 
     def __init__(self, keyspec: str, header_key: int = None):
         if "," in keyspec:
-            keya, keyb = keyspec.split(",")
-            keya = int(keya, 16)
-            keyb = int(keyb, 16)
+            keya_, keyb_ = keyspec.split(",")
+            keya = int(keya_, 16)
+            keyb = int(keyb_, 16)
 
             if header_key:
                 keylong = mix_header_key(keya | (keyb << 32), header_key)
diff --git a/utf.py b/acb/utf.py
similarity index 93%
rename from utf.py
rename to acb/utf.py
index a04d93f..40d7383 100644
--- a/utf.py
+++ b/acb/utf.py
@@ -50,37 +50,33 @@
 from collections import namedtuple as T
 import sys
 
-if sys.version_info.major < 3:
-    BYTE_ZERO = "\x00"
-    def JOIN_BYTE_ARRAY(sr):
-        return b"".join(sr)
-else:
-    BYTE_ZERO = 0
-    def JOIN_BYTE_ARRAY(sr):
-        return bytes(sr)
+BYTE_ZERO = 0
+def JOIN_BYTE_ARRAY(sr):
+    return bytes(sr)
+
+def readfunc(fmt):
+    a = struct.Struct(fmt)
+    b = a.size
+    def f(f, at=None):
+        if at is not None:
+            back = f.tell()
+            f.seek(at)
+            d = a.unpack(f.read(b))[0]
+            f.seek(back)
+            return d
+        else:
+            return a.unpack(f.read(b))[0]
+
+    return f
+
+def latebinder(f):
+    return lambda s: f(s.f)
 
 class R(object):
     """ file reader based on types """
     def __init__(self, file):
         self.f = file
 
-    def readfunc(fmt):
-        a = struct.Struct(fmt)
-        b = a.size
-        def f(f, at=None):
-            if at is not None:
-                back = f.tell()
-                f.seek(at)
-                d = a.unpack(f.read(b))[0]
-                f.seek(back)
-                return d
-            else:
-                return a.unpack(f.read(b))[0]
-        return f
-
-    def latebinder(f):
-        return lambda s: f(s.f)
-
     int8_t    = latebinder(readfunc(">b"))
     uint8_t   = latebinder(readfunc(">B"))
     int16_t   = latebinder(readfunc(">h"))
diff --git a/fast_sub/module.c b/fast_sub/module.c
index 1049044..9c7fa46 100644
--- a/fast_sub/module.c
+++ b/fast_sub/module.c
@@ -5,6 +5,14 @@
 #include <stdint.h>
 #include <stdlib.h>
 
+// htons for win and mac
+#if defined(WIN32) || defined(_WIN32) || defined(__WIN32__) || defined(__NT__)
+    #pragma comment(lib, "Ws2_32.lib")
+    #include "Winsock2.h"
+#else
+    #include <arpa/inet.h>
+#endif
+
 static PyObject *disarm_block_fast(PyObject *self, PyObject *args);
 static PyObject *apply_checksum(PyObject *self, PyObject *args);
 
diff --git a/fast_sub/setup.py b/fast_sub/setup.py
deleted file mode 100644
index a505508..0000000
--- a/fast_sub/setup.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from setuptools import setup, Extension
-
-setup(
-    name="acb_speedup",
-    version="1.0.0",
-    description="Speedups for disarm_actual",
-    ext_modules=[
-        Extension(
-            "_acb_speedup", sources=["module.c"], py_limited_api=True
-        )
-    ],
-)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..b5a3c46
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,6 @@
+[build-system]
+requires = [
+    "setuptools>=42",
+    "wheel"
+]
+build-backend = "setuptools.build_meta"
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..1bbcb3d
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,21 @@
+[metadata]
+name = acb-py
+version = 1.1.0
+author = The Holy Constituency of the Summer Triangle
+author_email = summertriangle.dev@gmail.com
+description = Library for reading/extracting ACB files
+long_description = file: README.md
+long_description_content_type = text/markdown
+url = https://github.com/summertriangle-dev/acb.py
+classifiers =
+    Programming Language :: Python :: 3
+    License :: OSI Approved :: MIT License
+    Operating System :: OS Independent
+    Topic :: System :: Archiving
+
+[options]
+packages = find:
+python_requires = >=3.6
+
+[options.entry_points]
+console_scripts = acbextract=acb.__main__:main
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..fa19c6c
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,18 @@
+import sys
+from setuptools import setup, Extension
+
+
+def main():
+    args = dict(
+        ext_modules=[
+            Extension(
+                "_acb_speedup", sources=["fast_sub/module.c"], py_limited_api=True
+            )
+        ],
+    )
+
+    setup(**args)
+
+
+if __name__ == "__main__":
+    main()


Test patch:
diff --git a/tests/test_main.py b/tests/test_main.py
new file mode 100644
index 0000000..151212b
--- /dev/null
+++ b/tests/test_main.py
@@ -0,0 +1,6 @@
+import os
+from acb import acb, disarm
+
+
+def test_speedup_compilation():
+    assert(disarm._acb_speedup)