├── .cargo └── config.toml ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .readthedocs.yaml ├── Cargo.toml ├── README.md ├── docs ├── Makefile ├── conf.py ├── index.rst └── source │ ├── peppi_py.frame.rst │ ├── peppi_py.game.rst │ ├── peppi_py.parse.rst │ ├── peppi_py.rst │ └── peppi_py.util.rst ├── pyproject.toml ├── python └── peppi_py │ ├── __init__.py │ ├── frame.py │ ├── game.py │ ├── parse.py │ └── util.py ├── rustfmt.toml ├── src ├── error.rs └── lib.rs ├── tests ├── data │ └── game.slp └── test_peppi_py.py └── time.py /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.x86_64-apple-darwin] 2 | rustflags = [ 3 | "-C", "link-arg=-undefined", 4 | "-C", "link-arg=dynamic_lookup", 5 | ] 6 | 7 | [target.aarch64-apple-darwin] 8 | rustflags = [ 9 | "-C", "link-arg=-undefined", 10 | "-C", "link-arg=dynamic_lookup", 11 | ] 12 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Python 2 | on: 3 | release: 4 | types: [prereleased] 5 | jobs: 6 | macos: 7 | runs-on: macos-latest 8 | strategy: 9 | matrix: 10 | target: [arm64] 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version: 3.12 16 | architecture: arm64 17 | - uses: dtolnay/rust-toolchain@stable 18 | - name: Build wheels 19 | uses: PyO3/maturin-action@v1 20 | with: 21 | target: universal2-apple-darwin 22 | args: --release --out dist --sdist 23 | - name: Install wheels 24 | run: | 25 | pip install dist/peppi_py-*.whl --force-reinstall 26 | python -c "import peppi_py" 27 | - name: Upload wheels 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: wheels-${{ github.job }}-${{ matrix.target }} 31 | path: dist 32 | windows: 33 | runs-on: windows-latest 34 | strategy: 35 | matrix: 36 | target: [x64] 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: actions/setup-python@v5 40 | with: 41 | python-version: 3.12 42 | architecture: ${{ matrix.target }} 43 | - uses: dtolnay/rust-toolchain@stable 44 | - name: Build wheels 45 | uses: PyO3/maturin-action@v1 46 | with: 47 | target: ${{ matrix.target }} 48 | args: --release --out dist 49 | - name: Install wheels 50 | shell: bash 51 | run: | 52 | python -m pip install -U pip 53 | pip install dist/peppi_py-*.whl --force-reinstall 54 | python -c "import peppi_py" 55 | - name: Upload wheels 56 | uses: actions/upload-artifact@v4 57 | with: 58 | name: wheels-${{ github.job }}-${{ matrix.target }} 59 | path: dist 60 | linux: 61 | runs-on: ubuntu-latest 62 | strategy: 63 | matrix: 64 | target: [x86_64] 65 | steps: 66 | - uses: actions/checkout@v4 67 | - uses: actions/setup-python@v5 68 | with: 69 | python-version: 3.12 70 | architecture: x64 71 | - name: Build wheels 72 | uses: PyO3/maturin-action@v1 73 | with: 74 | target: ${{ matrix.target }} 75 | manylinux: auto 76 | args: --release --out dist 77 | - name: Install wheels 78 | run: | 79 | pip install dist/peppi_py-*.whl --force-reinstall 80 | python -c "import peppi_py" 81 | - name: Upload wheels 82 | uses: actions/upload-artifact@v4 83 | with: 84 | name: wheels-${{ github.job }}-${{ matrix.target }} 85 | path: dist 86 | linux-cross: 87 | runs-on: ubuntu-latest 88 | strategy: 89 | matrix: 90 | target: [aarch64] 91 | steps: 92 | - uses: actions/checkout@v4 93 | - uses: actions/setup-python@v5 94 | with: 95 | python-version: 3.12 96 | - name: Build wheels 97 | uses: PyO3/maturin-action@v1 98 | with: 99 | target: ${{ matrix.target }} 100 | manylinux: auto 101 | args: --release --out dist 102 | - uses: uraimo/run-on-arch-action@v2.7.2 103 | name: Install wheels 104 | with: 105 | arch: ${{ matrix.target }} 106 | distro: ubuntu22.04 107 | githubToken: ${{ github.token }} 108 | install: | 109 | apt-get update 110 | apt-get install -y --no-install-recommends python3 python3-pip python3-dev make cmake clang 111 | pip3 install -U pip 112 | run: | 113 | pip3 install dist/peppi_py-*.whl --force-reinstall 114 | python3 -c "import peppi_py" 115 | - name: Upload wheels 116 | uses: actions/upload-artifact@v4 117 | with: 118 | name: wheels-${{ github.job }}-${{ matrix.target }} 119 | path: dist 120 | release: 121 | name: Release 122 | runs-on: ubuntu-latest 123 | needs: [macos, windows, linux, linux-cross] 124 | steps: 125 | - uses: actions/download-artifact@v4 126 | with: 127 | pattern: wheels-* 128 | merge-multiple: true 129 | - name: Upload release assets 130 | uses: skx/github-action-publish-binaries@master 131 | env: 132 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 133 | with: 134 | args: '*' 135 | - uses: actions/setup-python@v5 136 | with: 137 | python-version: 3.12 138 | - name: Publish to PyPI 139 | env: 140 | TWINE_USERNAME: __token__ 141 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 142 | run: | 143 | pip install --upgrade twine 144 | twine upload --skip-existing * 145 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | __pycache__/ 4 | *.so 5 | docs/_build 6 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-22.04 4 | tools: 5 | python: "3.12" 6 | rust: "1.75" 7 | commands: 8 | - python -mvirtualenv $READTHEDOCS_VIRTUALENV_PATH 9 | - python -m pip install --upgrade --no-cache-dir pip setuptools sphinx readthedocs-sphinx-ext maturin 10 | - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH maturin develop 11 | - cd docs && python -m sphinx -T -b html -d _build/doctrees -D language=en . $READTHEDOCS_OUTPUT/html 12 | 13 | sphinx: 14 | configuration: docs/conf.py 15 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["melkor "] 3 | name = "peppi-py" 4 | version = "0.8.0" 5 | edition = "2021" 6 | 7 | [lib] 8 | name = "peppi_py" 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | arrow2 = "0.17" 13 | peppi = "2.1" 14 | pyo3 = { version = "0.24", features = ["abi3-py310", "extension-module", "generate-import-lib"] } 15 | serde = "1.0" 16 | serde_json = "1.0" 17 | 18 | [profile.release] 19 | opt-level = 3 20 | debug = false 21 | debug-assertions = false 22 | overflow-checks = false 23 | lto = true 24 | codegen-units = 1 25 | incremental = false 26 | rpath = false 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # peppi-py 2 | 3 | [![](https://img.shields.io/pypi/v/peppi-py)](https://pypi.org/project/peppi-py/) 4 | [![](https://img.shields.io/readthedocs/peppi-py)](https://peppi-py.readthedocs.io/en/latest/) 5 | 6 | Python bindings for the [peppi](https://github.com/hohav/peppi) Slippi replay parser, built using [Apache Arrow](https://arrow.apache.org/) and [PyO3](https://pyo3.rs/). 7 | 8 | ## Installation 9 | 10 | ```sh 11 | pip install peppi-py 12 | ``` 13 | 14 | To build from source instead, first install [Rust](https://rustup.rs/). Then: 15 | 16 | ```sh 17 | pip install maturin 18 | maturin develop 19 | ``` 20 | 21 | ## Usage 22 | 23 | peppi-py exposes two functions: 24 | 25 | - `read_slippi(path, skip_frames=False)` 26 | - `read_peppi(path, skip_frames=False)` 27 | 28 | Both of these parse a replay file (`.slp` or `.slpp` respectively) into a [Game](https://peppi-py.readthedocs.io/en/latest/source/peppi_py.game.html#peppi_py.game.Game) object. 29 | 30 | Frame data is stored as a [struct-of-arrays](https://en.wikipedia.org/wiki/AoS_and_SoA) for performance, using [Arrow](https://arrow.apache.org/). So to get the value of an attribute "foo.bar" for the `n`th frame of the game, you'd write `game.frames.foo.bar[n]` instead of `game.frames[n].foo.bar`. See the code example below. 31 | 32 | You can do many other things with Arrow arrays, such as converting them to [numpy](https://numpy.org/) arrays. See the [pyarrow docs](https://arrow.apache.org/docs/python/) for more, particularly the various primitive array types such as [Int8Array](https://arrow.apache.org/docs/python/generated/pyarrow.Int8Array.html). 33 | 34 | Also see the [Slippi replay spec](https://github.com/project-slippi/slippi-wiki/blob/master/SPEC.md) for detailed information about the available fields and their meanings. 35 | 36 | ```python 37 | >>> from peppi_py import read_slippi, read_peppi 38 | >>> game = read_slippi('tests/data/game.slp') 39 | >>> game.metadata 40 | {'startAt': '2018-06-22T07:52:59Z', 'lastFrame': 5085, 'players': {'1': {'characters': {'1': 5209}}, '0': {'characters': {'18': 5209}}}, 'playedOn': 'dolphin'} 41 | >>> game.start 42 | Start(slippi=Slippi(version=(1, 0, 0)), ...) 43 | >>> game.end 44 | End(method=, lras_initiator=None, players=None) 45 | >>> game.frames.ports[0].leader.post.position.x[0] 46 | 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | import os, sys 10 | 11 | project = 'peppi-py' 12 | copyright = '2024, melkor' 13 | author = 'melkor' 14 | 15 | # -- General configuration --------------------------------------------------- 16 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 17 | 18 | extensions = [ 19 | 'sphinx.ext.autodoc', 20 | 'sphinx.ext.linkcode', 21 | ] 22 | 23 | templates_path = ['_templates'] 24 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 25 | 26 | language = 'en' 27 | 28 | sys.path.insert(0, os.path.abspath('..')) 29 | 30 | # -- Options for HTML output ------------------------------------------------- 31 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 32 | 33 | html_theme = 'alabaster' 34 | html_static_path = ['_static'] 35 | 36 | def linkcode_resolve(domain, info): 37 | if domain != 'py': 38 | return None 39 | if not (module := info['module']): 40 | return None 41 | filename = module.replace('.', '/') 42 | if module == 'peppi_py': 43 | filename += '/__init__' 44 | return f'https://github.com/hohav/peppi-py/blob/main/{filename}.py' 45 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | peppi-py API docs 2 | ================= 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | source/peppi_py 8 | 9 | 10 | Indices and tables 11 | ================== 12 | 13 | * :ref:`genindex` 14 | * :ref:`modindex` 15 | * :ref:`search` 16 | -------------------------------------------------------------------------------- /docs/source/peppi_py.frame.rst: -------------------------------------------------------------------------------- 1 | peppi\_py.frame module 2 | ====================== 3 | 4 | .. automodule:: peppi_py.frame 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/peppi_py.game.rst: -------------------------------------------------------------------------------- 1 | peppi\_py.game module 2 | ===================== 3 | 4 | .. automodule:: peppi_py.game 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/peppi_py.parse.rst: -------------------------------------------------------------------------------- 1 | peppi\_py.parse module 2 | ====================== 3 | 4 | .. automodule:: peppi_py.parse 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/peppi_py.rst: -------------------------------------------------------------------------------- 1 | peppi-py package 2 | ================= 3 | 4 | Module contents 5 | --------------- 6 | 7 | .. automodule:: peppi_py 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | Submodules 13 | ---------- 14 | 15 | .. toctree:: 16 | :maxdepth: 4 17 | 18 | peppi_py.frame 19 | peppi_py.game 20 | peppi_py.parse 21 | peppi_py.util 22 | -------------------------------------------------------------------------------- /docs/source/peppi_py.util.rst: -------------------------------------------------------------------------------- 1 | peppi\_py.util module 2 | ===================== 3 | 4 | .. automodule:: peppi_py.util 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "peppi-py" 3 | dynamic = ["version"] 4 | requires-python = ">= 3.10" 5 | dependencies = [ 6 | "pyarrow~=17.0", 7 | "inflection~=0.5", 8 | ] 9 | 10 | [build-system] 11 | build-backend = "maturin" 12 | requires = ["maturin>=1.0,<2.0", "pyarrow~=17.0"] 13 | 14 | [tool.maturin] 15 | python-source = "python" 16 | module-name = "peppi_py._peppi" 17 | 18 | [pytest] 19 | testpaths = "tests" 20 | -------------------------------------------------------------------------------- /python/peppi_py/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from dataclasses import dataclass 3 | from ._peppi import read_peppi as _read_peppi, read_slippi as _read_slippi 4 | from .game import End, Game, Start 5 | from .parse import dc_from_json, frames_from_sa 6 | 7 | def read_peppi(path: str, skip_frames: bool=False) -> Game: 8 | g = _read_peppi(path, skip_frames) 9 | return Game(dc_from_json(Start, g.start), dc_from_json(End, g.end), g.metadata, frames_from_sa(g.frames)) 10 | 11 | def read_slippi(path: str, skip_frames: bool=False) -> Game: 12 | g = _read_slippi(path, skip_frames) 13 | return Game(dc_from_json(Start, g.start), dc_from_json(End, g.end), g.metadata, frames_from_sa(g.frames)) 14 | -------------------------------------------------------------------------------- /python/peppi_py/frame.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from pyarrow.lib import Int8Array, Int16Array, Int32Array, Int64Array, UInt8Array, UInt16Array, UInt32Array, UInt64Array, FloatArray, DoubleArray 3 | from .util import _repr 4 | 5 | @dataclass(slots=True) 6 | class End: 7 | __repr__ = _repr 8 | latest_finalized_frame: Int32Array | None = None 9 | 10 | @dataclass(slots=True) 11 | class Position: 12 | __repr__ = _repr 13 | x: FloatArray 14 | y: FloatArray 15 | 16 | @dataclass(slots=True) 17 | class Start: 18 | __repr__ = _repr 19 | random_seed: UInt32Array 20 | scene_frame_counter: UInt32Array | None = None 21 | 22 | @dataclass(slots=True) 23 | class TriggersPhysical: 24 | __repr__ = _repr 25 | l: FloatArray 26 | r: FloatArray 27 | 28 | @dataclass(slots=True) 29 | class Velocities: 30 | __repr__ = _repr 31 | self_x_air: FloatArray 32 | self_y: FloatArray 33 | knockback_x: FloatArray 34 | knockback_y: FloatArray 35 | self_x_ground: FloatArray 36 | 37 | @dataclass(slots=True) 38 | class Velocity: 39 | __repr__ = _repr 40 | x: FloatArray 41 | y: FloatArray 42 | 43 | @dataclass(slots=True) 44 | class Item: 45 | __repr__ = _repr 46 | type: UInt16Array 47 | state: UInt8Array 48 | direction: FloatArray 49 | velocity: Velocity 50 | position: Position 51 | damage: UInt16Array 52 | timer: FloatArray 53 | id: UInt32Array 54 | misc: tuple[UInt8Array, UInt8Array, UInt8Array, UInt8Array] | None = None 55 | owner: Int8Array | None = None 56 | 57 | @dataclass(slots=True) 58 | class Post: 59 | __repr__ = _repr 60 | character: UInt8Array 61 | state: UInt16Array 62 | position: Position 63 | direction: FloatArray 64 | percent: FloatArray 65 | shield: FloatArray 66 | last_attack_landed: UInt8Array 67 | combo_count: UInt8Array 68 | last_hit_by: UInt8Array 69 | stocks: UInt8Array 70 | state_age: FloatArray | None = None 71 | state_flags: tuple[UInt8Array, UInt8Array, UInt8Array, UInt8Array, UInt8Array] | None = None 72 | misc_as: FloatArray | None = None 73 | airborne: UInt8Array | None = None 74 | ground: UInt16Array | None = None 75 | jumps: UInt8Array | None = None 76 | l_cancel: UInt8Array | None = None 77 | hurtbox_state: UInt8Array | None = None 78 | velocities: Velocities | None = None 79 | hitlag: FloatArray | None = None 80 | animation_index: UInt32Array | None = None 81 | 82 | @dataclass(slots=True) 83 | class Pre: 84 | __repr__ = _repr 85 | random_seed: UInt32Array 86 | state: UInt16Array 87 | position: Position 88 | direction: FloatArray 89 | joystick: Position 90 | cstick: Position 91 | triggers: FloatArray 92 | buttons: UInt32Array 93 | buttons_physical: UInt16Array 94 | triggers_physical: TriggersPhysical 95 | raw_analog_x: Int8Array | None = None 96 | percent: FloatArray | None = None 97 | raw_analog_y: Int8Array | None = None 98 | 99 | @dataclass(slots=True) 100 | class Data: 101 | __repr__ = _repr 102 | pre: Pre 103 | post: Post 104 | 105 | @dataclass(slots=True) 106 | class PortData: 107 | __repr__ = _repr 108 | leader: Data 109 | follower: Data | None = None 110 | 111 | @dataclass(slots=True) 112 | class Frame: 113 | __repr__ = _repr 114 | id: object 115 | ports: tuple[PortData] 116 | -------------------------------------------------------------------------------- /python/peppi_py/game.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import IntEnum 3 | from .frame import Frame 4 | from .util import _repr 5 | 6 | class Port(IntEnum): 7 | P1 = 0 8 | P2 = 1 9 | P3 = 2 10 | P4 = 3 11 | 12 | class PlayerType(IntEnum): 13 | HUMAN = 0 14 | CPU = 1 15 | DEMO = 2 16 | 17 | class Language(IntEnum): 18 | JAPANESE = 0 19 | ENGLISH = 1 20 | 21 | class DashBack(IntEnum): 22 | UCF = 1 23 | ARDUINO = 2 24 | 25 | class ShieldDrop(IntEnum): 26 | UCF = 1 27 | ARDUINO = 2 28 | 29 | class EndMethod(IntEnum): 30 | UNRESOLVED = 0 31 | TIME = 1 32 | GAME = 2 33 | RESOLVED = 3 34 | NO_CONTEST = 7 35 | 36 | @dataclass(slots=True) 37 | class Scene: 38 | __repr__ = _repr 39 | major: int 40 | minor: int 41 | 42 | @dataclass(slots=True) 43 | class Match: 44 | __repr__ = _repr 45 | id: str 46 | game: int 47 | tiebreaker: int 48 | 49 | @dataclass(slots=True) 50 | class Slippi: 51 | __repr__ = _repr 52 | version: tuple[int, int, int] 53 | 54 | @dataclass(slots=True) 55 | class Netplay: 56 | __repr__ = _repr 57 | name: str 58 | code: str 59 | suid: str | None = None 60 | 61 | @dataclass(slots=True) 62 | class Team: 63 | __repr__ = _repr 64 | color: int 65 | shade: int 66 | 67 | @dataclass(slots=True) 68 | class Ucf: 69 | __repr__ = _repr 70 | dash_back: DashBack | None 71 | shield_drop: ShieldDrop | None 72 | 73 | @dataclass(slots=True) 74 | class Player: 75 | __repr__ = _repr 76 | port: Port 77 | character: int 78 | type: PlayerType 79 | stocks: int 80 | costume: int 81 | team: Team | None 82 | handicap: int 83 | bitfield: int 84 | cpu_level: int | None 85 | offense_ratio: float 86 | defense_ratio: float 87 | model_scale: float 88 | ucf: Ucf | None = None 89 | name_tag: str | None = None 90 | netplay: Netplay | None = None 91 | 92 | @dataclass(slots=True) 93 | class Start: 94 | __repr__ = _repr 95 | slippi: Slippi 96 | bitfield: tuple[int, int, int, int] 97 | is_raining_bombs: bool 98 | is_teams: bool 99 | item_spawn_frequency: int 100 | self_destruct_score: int 101 | stage: int 102 | timer: int 103 | item_spawn_bitfield: tuple[int, int, int, int, int] 104 | damage_ratio: float 105 | players: tuple[Player, ...] 106 | random_seed: int 107 | is_pal: bool | None = None 108 | is_frozen_ps: bool | None = None 109 | scene: Scene | None = None 110 | language: Language | None = None 111 | match: Match | None = None 112 | 113 | @dataclass(slots=True) 114 | class PlayerEnd: 115 | __repr__ = _repr 116 | port: Port 117 | placement: int 118 | 119 | @dataclass(slots=True) 120 | class End: 121 | __repr__ = _repr 122 | method: EndMethod 123 | lras_initiator: Port | None = None 124 | players: tuple[PlayerEnd, ...] | None = None 125 | 126 | @dataclass(slots=True) 127 | class Game: 128 | __repr__ = _repr 129 | start: Start 130 | end: End 131 | metadata: dict 132 | frames: Frame | None 133 | -------------------------------------------------------------------------------- /python/peppi_py/parse.py: -------------------------------------------------------------------------------- 1 | import sys, types, typing 2 | import pyarrow 3 | import dataclasses as dc 4 | from inflection import underscore 5 | from enum import Enum 6 | from .frame import Data, Frame, PortData 7 | 8 | def _repr(x): 9 | if isinstance(x, pyarrow.Array): 10 | s = ', '.join(repr(v.as_py()) for v in x[:3]) 11 | if len(x) > 3: 12 | s += ', ...' 13 | return f'[{s}]' 14 | elif isinstance(x, tuple): 15 | s = ', '.join(_repr(v) for v in x) 16 | return f'({s})' 17 | elif dc.is_dataclass(x): 18 | s = ', '.join(f'{f.name}={_repr(getattr(x, f.name))}' for f in dc.fields(type(x))) 19 | return f'{type(x).__name__}({s})' 20 | else: 21 | return repr(x) 22 | 23 | def unwrap_union(cls): 24 | if typing.get_origin(cls) is types.UnionType: 25 | return typing.get_args(cls)[0] 26 | else: 27 | return cls 28 | 29 | def field_from_sa(cls, arr): 30 | if arr is None: 31 | return None 32 | cls = unwrap_union(cls) 33 | if dc.is_dataclass(cls): 34 | return dc_from_sa(cls, arr) 35 | elif typing.get_origin(cls) is tuple: 36 | return tuple_from_sa(cls, arr) 37 | else: 38 | return arr 39 | 40 | def arr_field(arr, dc_field): 41 | try: 42 | return arr.field(dc_field.name) 43 | except KeyError: 44 | if dc_field.default is dc.MISSING: 45 | raise 46 | else: 47 | return dc_field.default 48 | 49 | def dc_from_sa(cls, arr): 50 | return cls(*(field_from_sa(f.type, arr_field(arr, f)) for f in dc.fields(cls))) 51 | 52 | def tuple_from_sa(cls, arr): 53 | return tuple((field_from_sa(t, arr.field(str(idx))) for (idx, t) in enumerate(typing.get_args(cls)))) 54 | 55 | def frames_from_sa(arrow_frames): 56 | if arrow_frames is None: 57 | return None 58 | ports = [] 59 | port_arrays = arrow_frames.field('ports') 60 | for p in ('P1', 'P2', 'P3', 'P4'): 61 | try: port = port_arrays.field(p) 62 | except KeyError: continue 63 | leader = dc_from_sa(Data, port.field('leader')) 64 | try: follower = dc_from_sa(Data, port.field('follower')) 65 | except KeyError: follower = None 66 | ports.append(PortData(leader, follower)) 67 | return Frame(arrow_frames.field('id'), tuple(ports)) 68 | 69 | def field_from_json(cls, json): 70 | if json is None: 71 | return None 72 | cls = unwrap_union(cls) 73 | if dc.is_dataclass(cls): 74 | return dc_from_json(cls, json) 75 | elif typing.get_origin(cls) is tuple: 76 | return tuple_from_json(cls, json) 77 | elif issubclass(cls, Enum): 78 | return cls[underscore(json).upper()] 79 | else: 80 | return json 81 | 82 | def dc_from_json(cls, json): 83 | return cls(*(field_from_json(f.type, json.get(f.name)) for f in dc.fields(cls))) 84 | 85 | def tuple_from_json(cls, json): 86 | child_cls = typing.get_args(cls)[0] 87 | 88 | if type(json) is dict: 89 | items = json.items() 90 | else: 91 | items = enumerate(json) 92 | 93 | return tuple((field_from_json(child_cls, val) for (idx, val) in items)) 94 | -------------------------------------------------------------------------------- /python/peppi_py/util.py: -------------------------------------------------------------------------------- 1 | import pyarrow 2 | import dataclasses as dc 3 | 4 | def _repr(x): 5 | if isinstance(x, pyarrow.Array): 6 | s = ', '.join(repr(v.as_py()) for v in x[:3]) 7 | if len(x) > 3: 8 | s += ', ...' 9 | return f'[{s}]' 10 | elif isinstance(x, tuple): 11 | s = ', '.join(_repr(v) for v in x) 12 | return f'({s})' 13 | elif dc.is_dataclass(x): 14 | s = ', '.join(f'{f.name}={_repr(getattr(x, f.name))}' for f in dc.fields(type(x))) 15 | return f'{type(x).__name__}({s})' 16 | else: 17 | return repr(x) 18 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | hard_tabs = true 2 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use peppi::io::Error as PeppiError; 2 | use pyo3::prelude::PyErr; 3 | use std::{error, fmt, io}; 4 | 5 | #[derive(Debug)] 6 | pub enum PyO3ArrowError { 7 | ArrowError(arrow2::error::Error), 8 | IoError(io::Error), 9 | PeppiError(PeppiError), 10 | PythonError(PyErr), 11 | JsonError(serde_json::Error), 12 | } 13 | 14 | impl fmt::Display for PyO3ArrowError { 15 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 16 | use PyO3ArrowError::*; 17 | match *self { 18 | ArrowError(ref e) => e.fmt(f), 19 | IoError(ref e) => e.fmt(f), 20 | PeppiError(ref e) => e.fmt(f), 21 | PythonError(ref e) => e.fmt(f), 22 | JsonError(ref e) => e.fmt(f), 23 | } 24 | } 25 | } 26 | 27 | impl error::Error for PyO3ArrowError { 28 | fn source(&self) -> Option<&(dyn error::Error + 'static)> { 29 | use PyO3ArrowError::*; 30 | match *self { 31 | ArrowError(ref e) => Some(e), 32 | IoError(ref e) => Some(e), 33 | PeppiError(ref e) => Some(e), 34 | PythonError(ref e) => Some(e), 35 | JsonError(ref e) => Some(e), 36 | } 37 | } 38 | } 39 | 40 | impl From for PyO3ArrowError { 41 | fn from(err: arrow2::error::Error) -> PyO3ArrowError { 42 | PyO3ArrowError::ArrowError(err) 43 | } 44 | } 45 | 46 | impl From for PyO3ArrowError { 47 | fn from(err: io::Error) -> PyO3ArrowError { 48 | PyO3ArrowError::IoError(err) 49 | } 50 | } 51 | 52 | impl From for PyO3ArrowError { 53 | fn from(err: PyErr) -> PyO3ArrowError { 54 | PyO3ArrowError::PythonError(err) 55 | } 56 | } 57 | 58 | impl From for PyO3ArrowError { 59 | fn from(err: PeppiError) -> PyO3ArrowError { 60 | PyO3ArrowError::PeppiError(err) 61 | } 62 | } 63 | 64 | impl From for PyO3ArrowError { 65 | fn from(err: serde_json::Error) -> PyO3ArrowError { 66 | PyO3ArrowError::JsonError(err) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use arrow2::array::{Array, StructArray}; 2 | use pyo3::{exceptions::PyOSError, ffi::Py_uintptr_t, prelude::*, types::PyDict, wrap_pyfunction}; 3 | use std::{fs, io}; 4 | 5 | use peppi::frame::PortOccupancy; 6 | use peppi::game::{Start, ICE_CLIMBERS}; 7 | use peppi::io::peppi::de::Opts as PeppiReadOpts; 8 | use peppi::io::slippi::de::Opts as SlippiReadOpts; 9 | 10 | mod error; 11 | use error::PyO3ArrowError; 12 | 13 | fn to_py_via_json( 14 | json: &Bound, 15 | x: &T, 16 | ) -> Result, PyO3ArrowError> { 17 | Ok(json 18 | .call_method1("loads", (serde_json::to_string(x)?,))? 19 | .extract()?) 20 | } 21 | 22 | fn to_py_via_arrow<'py>( 23 | pyarrow: &Bound<'py, PyModule>, 24 | arr: StructArray, 25 | ) -> Result, PyO3ArrowError> { 26 | let data_type = arr.data_type().clone(); 27 | let array_ptr = &arrow2::ffi::export_array_to_c(arr.boxed()) as *const _; 28 | let schema_ptr = 29 | &arrow2::ffi::export_field_to_c(&arrow2::datatypes::Field::new("frames", data_type, false)) 30 | as *const _; 31 | Ok(pyarrow 32 | .getattr("Array")? 33 | .call_method1( 34 | "_import_from_c", 35 | (array_ptr as Py_uintptr_t, schema_ptr as Py_uintptr_t), 36 | )? 37 | .into_pyobject(pyarrow.py()) 38 | .unwrap()) 39 | } 40 | 41 | #[pyclass(get_all, set_all)] 42 | pub struct Game { 43 | pub start: Py, 44 | pub end: Py, 45 | pub metadata: Py, 46 | pub hash: Option, 47 | pub frames: Option, 48 | } 49 | 50 | fn port_occupancy(start: &Start) -> Vec { 51 | start 52 | .players 53 | .iter() 54 | .map(|p| PortOccupancy { 55 | port: p.port, 56 | follower: p.character == ICE_CLIMBERS, 57 | }) 58 | .collect() 59 | } 60 | 61 | fn _read_slippi( 62 | py: Python, 63 | path: String, 64 | parse_opts: SlippiReadOpts, 65 | ) -> Result, PyO3ArrowError> { 66 | let pyarrow = py.import("pyarrow")?; 67 | let json = py.import("json")?; 68 | let game = peppi::io::slippi::read( 69 | &mut io::BufReader::new(fs::File::open(path)?), 70 | Some(&parse_opts), 71 | )?; 72 | 73 | Ok(Bound::new( 74 | py, 75 | Game { 76 | start: to_py_via_json(&json, &game.start)?, 77 | end: to_py_via_json(&json, &game.end)?, 78 | metadata: to_py_via_json(&json, &game.metadata)?, 79 | hash: game.hash, 80 | frames: match parse_opts.skip_frames { 81 | true => None, 82 | _ => Some( 83 | to_py_via_arrow( 84 | &pyarrow, 85 | game.frames.into_struct_array( 86 | game.start.slippi.version, 87 | &port_occupancy(&game.start), 88 | ), 89 | )? 90 | .into(), 91 | ), 92 | }, 93 | }, 94 | )?) 95 | } 96 | 97 | fn _read_peppi( 98 | py: Python, 99 | path: String, 100 | parse_opts: PeppiReadOpts, 101 | ) -> Result, PyO3ArrowError> { 102 | let pyarrow = py.import("pyarrow")?; 103 | let json = py.import("json")?; 104 | let game = peppi::io::peppi::read( 105 | &mut io::BufReader::new(fs::File::open(path)?), 106 | Some(&parse_opts), 107 | )?; 108 | 109 | Ok(Bound::new( 110 | py, 111 | Game { 112 | start: to_py_via_json(&json, &game.start)?, 113 | end: to_py_via_json(&json, &game.end)?, 114 | metadata: to_py_via_json(&json, &game.metadata)?, 115 | hash: game.hash, 116 | frames: match parse_opts.skip_frames { 117 | true => None, 118 | _ => Some( 119 | to_py_via_arrow( 120 | &pyarrow, 121 | game.frames.into_struct_array( 122 | game.start.slippi.version, 123 | &port_occupancy(&game.start), 124 | ), 125 | )? 126 | .into(), 127 | ), 128 | }, 129 | }, 130 | )?) 131 | } 132 | 133 | #[pyfunction] 134 | #[pyo3(signature = (path, skip_frames = false))] 135 | fn read_slippi(py: Python, path: String, skip_frames: bool) -> PyResult> { 136 | _read_slippi( 137 | py, 138 | path, 139 | SlippiReadOpts { 140 | skip_frames, 141 | ..Default::default() 142 | }, 143 | ) 144 | .map_err(|e| PyOSError::new_err(e.to_string())) 145 | } 146 | 147 | #[pyfunction] 148 | #[pyo3(signature = (path, skip_frames = false))] 149 | fn read_peppi(py: Python, path: String, skip_frames: bool) -> PyResult> { 150 | _read_peppi( 151 | py, 152 | path, 153 | PeppiReadOpts { 154 | skip_frames, 155 | ..Default::default() 156 | }, 157 | ) 158 | .map_err(|e| PyOSError::new_err(e.to_string())) 159 | } 160 | 161 | #[pymodule] 162 | #[pyo3(name = "_peppi")] 163 | fn peppi_py(m: &Bound) -> PyResult<()> { 164 | m.add_class::()?; 165 | m.add_function(wrap_pyfunction!(read_slippi, m)?)?; 166 | m.add_function(wrap_pyfunction!(read_peppi, m)?)?; 167 | Ok(()) 168 | } 169 | -------------------------------------------------------------------------------- /tests/data/game.slp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hohav/peppi-py/220a7a1ecb2b3b09b8360d044d04453ec4ef9fe5/tests/data/game.slp -------------------------------------------------------------------------------- /tests/test_peppi_py.py: -------------------------------------------------------------------------------- 1 | from math import isclose 2 | from pathlib import Path 3 | from peppi_py import read_slippi, read_peppi 4 | from peppi_py.game import * 5 | 6 | def test_basic_game(): 7 | game = read_slippi(Path(__file__).parent.joinpath('data/game.slp').as_posix()) 8 | 9 | assert game.metadata == { 10 | 'startAt': '2018-06-22T07:52:59Z', 11 | 'lastFrame': 5085, 12 | 'playedOn': 'dolphin', 13 | 'players': { 14 | '0': {'characters': {'18': 5209}}, # Marth 15 | '1': {'characters': {'1': 5209}}, # Fox 16 | }, 17 | } 18 | 19 | assert game.start == Start( 20 | slippi=Slippi( 21 | version=(1, 0, 0), 22 | ), 23 | bitfield=(50, 1, 134, 76), 24 | is_raining_bombs=False, 25 | is_teams=False, 26 | item_spawn_frequency=-1, 27 | self_destruct_score=-1, 28 | stage=8, # Yoshi's Story 29 | timer=480, 30 | item_spawn_bitfield=(255, 255, 255, 255, 255), 31 | damage_ratio=1.0, 32 | players=( 33 | Player( 34 | port=Port.P1, 35 | character=9, # Marth 36 | type=PlayerType.HUMAN, 37 | stocks=4, 38 | costume=3, 39 | team=None, 40 | handicap=9, 41 | bitfield=192, 42 | cpu_level=None, 43 | offense_ratio=1.0, 44 | defense_ratio=1.0, 45 | model_scale=1.0, 46 | ucf=Ucf( 47 | dash_back=None, 48 | shield_drop=None, 49 | ), 50 | name_tag=None, 51 | netplay=None, 52 | ), 53 | Player( 54 | port=Port.P2, 55 | character=2, # Fox 56 | type=PlayerType.CPU, 57 | stocks=4, 58 | costume=0, 59 | team=None, 60 | handicap=9, 61 | bitfield=64, 62 | cpu_level=1, 63 | offense_ratio=1.0, 64 | defense_ratio=1.0, 65 | model_scale=1.0, 66 | ucf=Ucf( 67 | dash_back=None, 68 | shield_drop=None, 69 | ), 70 | name_tag=None, 71 | netplay=None, 72 | ), 73 | ), 74 | random_seed=3803194226, 75 | is_pal=None, 76 | is_frozen_ps=None, 77 | scene=None, 78 | language=None, 79 | match=None, 80 | ) 81 | 82 | assert game.end == End( 83 | method=EndMethod.RESOLVED, 84 | lras_initiator=None, 85 | players=None, 86 | ) 87 | 88 | assert len(game.frames.id) == 5209 89 | p1 = game.frames.ports[0].leader.pre 90 | p2 = game.frames.ports[1].leader.pre 91 | assert p1.position.x[1000].as_py() == 56.818748474121094 92 | assert p1.position.y[1000].as_py() == -18.6373291015625 93 | assert p2.position.x[1000].as_py() == 42.195167541503906 94 | assert p2.position.y[1000].as_py() == 9.287015914916992 95 | -------------------------------------------------------------------------------- /time.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys, peppi_py, timeit 3 | 4 | peppi_py.game(sys.argv[1]) # warm up 5 | print(timeit.timeit(lambda: peppi_py.game(sys.argv[1]), number=10)) 6 | --------------------------------------------------------------------------------